feat(custom-widgets): complete Custom JSX v2 (#6390)

This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:48:57 +02:00
1 parent 3a1245cbc5
commit acdfcbb18e
416 files changed
+37853 -18066

No files matched your search

@@ -1,10 +1,12 @@
"use client";
import { useMemo, useState } from "react";
import { Combobox, InputBase, Loader, useCombobox } from "@mantine/core";
import { Avatar, Badge, Combobox, Group, InputBase, Loader, Stack, Text, useCombobox } from "@mantine/core";
import { IconApi } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
@@ -16,6 +18,7 @@ export const WidgetCustomWidgetSelectInput = ({
options,
}: CommonWidgetInputProps<"customWidgetSelect">) => {
const t = useWidgetInputTranslation(kind, property);
const labels = useScopedI18n("widget.customApi.picker");
const form = useFormContext();
const board = useOptionalBoard();
const currentValue = form.values.options[property] as string;
@@ -25,7 +28,10 @@ export const WidgetCustomWidgetSelectInput = ({
);
const [search, setSearch] = useState("");
const definitions = useMemo(() => (data ?? []).map((def) => ({ value: def.id, label: def.name })), [data]);
const definitions = useMemo(
() => (data ?? []).map((definition) => ({ ...definition, value: definition.id, label: definition.name })),
[data],
);
const filteredOptions = useMemo(
() =>
@@ -38,7 +44,6 @@ export const WidgetCustomWidgetSelectInput = ({
);
const selectedLabel = definitions.find((d) => d.value === currentValue)?.label;
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
@@ -47,43 +52,74 @@ export const WidgetCustomWidgetSelectInput = ({
});
return (
<Combobox
store={combobox}
onOptionSubmit={(val) => {
form.setFieldValue(`options.${property}`, val);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
label={t("label")}
description={options.withDescription ? t("description") : undefined}
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
rightSectionPointerEvents="none"
value={combobox.dropdownOpened ? search : (selectedLabel ?? currentValue)}
onChange={(event) => {
setSearch(event.currentTarget.value);
form.setFieldValue(`options.${property}`, event.currentTarget.value);
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
}}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
placeholder={t("label")}
/>
</Combobox.Target>
<Stack gap="sm">
<Combobox
store={combobox}
onOptionSubmit={(val) => {
const definition = definitions.find((candidate) => candidate.value === val);
form.setFieldValue(`options.${property}`, val);
form.setFieldValue("options.configuration", definition?.defaultOptions ?? {});
form.setFieldValue(
"options.configurationVersion",
definition?.updatedAt instanceof Date ? definition.updatedAt.getTime() : Date.now(),
);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
label={t("label")}
description={options.withDescription ? t("description") : undefined}
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
rightSectionPointerEvents="none"
value={combobox.dropdownOpened ? search : (selectedLabel ?? currentValue)}
onChange={(event) => {
setSearch(event.currentTarget.value);
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
}}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
placeholder={t("label")}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{filteredOptions.map((def) => (
<Combobox.Option key={def.value} value={def.value} active={def.value === currentValue}>
{def.label}
</Combobox.Option>
))}
{filteredOptions.length === 0 && !isLoading && <Combobox.Empty>No definitions found</Combobox.Empty>}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
<Combobox.Dropdown>
<Combobox.Options>
<Combobox.Group label={labels("group")}>
{filteredOptions.map((def) => (
<Combobox.Option key={def.value} value={def.value} active={def.value === currentValue}>
<Group wrap="nowrap" gap="sm">
<Avatar src={def.iconUrl} size={36} radius="md" color="blue">
<IconApi size={18} />
</Avatar>
<Stack gap={1} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{def.label}
</Text>
{def.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{def.description}
</Text>
)}
<Group gap={4} mt={2}>
<Badge size="xs" variant="light">
{labels("sources", { count: def.sources.length })}
</Badge>
<Badge size="xs" variant="light" color="gray">
{labels("requests", { count: def.requestCapabilities.length })}
</Badge>
</Group>
</Stack>
</Group>
</Combobox.Option>
))}
</Combobox.Group>
{filteredOptions.length === 0 && !isLoading && <Combobox.Empty>{labels("empty")}</Combobox.Empty>}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
</Stack>
);
};