merge: stack widget modernization on dnd kit
This commit is contained in:
940 files changed
+104475
-33649
No files matched your search
@@ -2,6 +2,7 @@ import type { WidgetOptionType } from "../options";
|
||||
import { WidgetAnchorNoteInput } from "./widget-anchor-note-input";
|
||||
import { WidgetAppInput } from "./widget-app-input";
|
||||
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
|
||||
import { WidgetCustomWidgetConfigurationInput } from "./widget-custom-widget-configuration-input";
|
||||
import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
|
||||
import { WidgetLocationInput } from "./widget-location-input";
|
||||
import { WidgetMultiTextInput } from "./widget-multi-text-input";
|
||||
@@ -18,8 +19,10 @@ import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
|
||||
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
|
||||
import { WidgetIntegrationSelectInput } from "./widget-integration-select-input";
|
||||
import { WidgetIntegrationMultiSelectInput } from "./widget-integration-multi-select-input";
|
||||
import { WidgetInternalInput } from "./widget-internal-input";
|
||||
|
||||
const mapping = {
|
||||
internal: WidgetInternalInput,
|
||||
anchorNote: WidgetAnchorNoteInput,
|
||||
text: WidgetTextInput,
|
||||
location: WidgetLocationInput,
|
||||
@@ -37,6 +40,7 @@ const mapping = {
|
||||
umamiEventNames: WidgetUmamiEventNamesInput,
|
||||
umamiWebsite: WidgetUmamiWebsiteInput,
|
||||
customWidgetSelect: WidgetCustomWidgetSelectInput,
|
||||
customWidgetConfiguration: WidgetCustomWidgetConfigurationInput,
|
||||
integrationSelect: WidgetIntegrationSelectInput,
|
||||
integrationMultiSelect: WidgetIntegrationMultiSelectInput,
|
||||
} satisfies Record<WidgetOptionType, unknown>;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { JsonOption } from "./widget-custom-widget-configuration-input";
|
||||
|
||||
vi.mock("@homarr/custom-widgets/workbench", () => ({
|
||||
CustomWidgetCodeEditor: (props: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
error?: string;
|
||||
onChange(value: string): void;
|
||||
}) => (
|
||||
<textarea
|
||||
id={props.id}
|
||||
aria-label={props.label}
|
||||
aria-invalid={Boolean(props.error)}
|
||||
value={props.value}
|
||||
onChange={(event) => props.onChange(event.currentTarget.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(() => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
async function renderJsonOption(identity: string, value: unknown) {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<JsonOption
|
||||
identity={identity}
|
||||
editorId="settings-json-option"
|
||||
label="Settings"
|
||||
value={value}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function enterEditorValue(editor: HTMLTextAreaElement, value: string) {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
||||
valueSetter?.call(editor, value);
|
||||
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("custom widget JSON option", () => {
|
||||
it("preserves an invalid active draft until its definition or external value changes", async () => {
|
||||
await renderJsonOption("definition-a:settings", { owner: "Alice" });
|
||||
const editor = host.querySelector("textarea");
|
||||
if (!editor) throw new Error("JSON editor was not rendered");
|
||||
expect(editor?.value).toContain("Alice");
|
||||
|
||||
await act(async () => enterEditorValue(editor, '{"owner":'));
|
||||
expect(editor?.value).toBe('{"owner":');
|
||||
|
||||
await renderJsonOption("definition-a:settings", { owner: "Alice" });
|
||||
expect(editor?.value).toBe('{"owner":');
|
||||
|
||||
await renderJsonOption("definition-b:settings", { owner: "Bob" });
|
||||
expect(editor?.value).toContain("Bob");
|
||||
expect(editor?.value).not.toContain("Alice");
|
||||
});
|
||||
|
||||
it("synchronizes a valid external configuration update", async () => {
|
||||
await renderJsonOption("definition-a:settings", { page: 1 });
|
||||
const editor = host.querySelector("textarea");
|
||||
await renderJsonOption("definition-a:settings", { page: 2 });
|
||||
expect(editor?.value).toContain('"page": 2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
ColorInput,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
Select,
|
||||
Slider,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { DateInput, TimeInput } from "@mantine/dates";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import type { CustomWidgetOption } from "@homarr/custom-widgets/core";
|
||||
import { validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
|
||||
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetCustomWidgetConfigurationInput = ({
|
||||
property,
|
||||
}: CommonWidgetInputProps<"customWidgetConfiguration">) => {
|
||||
const form = useFormContext();
|
||||
const labels = useScopedI18n("widget.customApi.configuration");
|
||||
const board = useOptionalBoard();
|
||||
const definitionId = typeof form.values.options.definitionId === "string" ? form.values.options.definitionId : "";
|
||||
const configurationValue = form.values.options[property];
|
||||
const configuration = useMemo(() => (isRecord(configurationValue) ? configurationValue : {}), [configurationValue]);
|
||||
const available = clientApi.customWidget.available.useQuery(
|
||||
{ boardId: board?.id ?? "", currentId: definitionId || undefined },
|
||||
{ enabled: board !== null && Boolean(definitionId) },
|
||||
);
|
||||
const definition = available.data?.find((candidate) => candidate.id === definitionId);
|
||||
const options = isRecord(definition?.options) ? (definition.options as Record<string, CustomWidgetOption>) : null;
|
||||
const issues = useMemo(
|
||||
() => (options ? validateCustomWidgetOptions(options, configuration) : []),
|
||||
[configuration, options],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const path = `options.${property}`;
|
||||
if (issues[0]) form.setFieldError(path, `${issues[0].path}: ${issues[0].message}`);
|
||||
else form.clearFieldError(path);
|
||||
}, [form, issues, property]);
|
||||
|
||||
if (!definitionId || !options) return null;
|
||||
const entries = Object.entries(options);
|
||||
const regular = entries.filter(([, option]) => !option.advanced);
|
||||
const advanced = entries.filter(([, option]) => option.advanced);
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{issues[0] && (
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm">{labels("needsAttention", { message: issues[0].message })}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{regular.map(([name, option]) => (
|
||||
<OptionField
|
||||
key={`${definitionId}:${name}`}
|
||||
option={option}
|
||||
path={`options.${property}.${name}`}
|
||||
configuration={configuration}
|
||||
definitionId={definitionId}
|
||||
/>
|
||||
))}
|
||||
{advanced.length > 0 && (
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="advanced-options">
|
||||
<Accordion.Control>{labels("advancedOptions")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
{advanced.map(([name, option]) => (
|
||||
<OptionField
|
||||
key={`${definitionId}:${name}`}
|
||||
option={option}
|
||||
path={`options.${property}.${name}`}
|
||||
configuration={configuration}
|
||||
definitionId={definitionId}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
function OptionField({
|
||||
option,
|
||||
path,
|
||||
configuration,
|
||||
definitionId,
|
||||
}: {
|
||||
option: CustomWidgetOption;
|
||||
path: string;
|
||||
configuration: Record<string, unknown>;
|
||||
definitionId: string;
|
||||
}) {
|
||||
const form = useFormContext();
|
||||
const input = form.getInputProps(path, { type: option.control === "switch" ? "checkbox" : "input" });
|
||||
const common = { label: option.label, description: option.description };
|
||||
if (option.choicesFrom)
|
||||
return <DynamicOptionField option={option} path={path} configuration={configuration} definitionId={definitionId} />;
|
||||
if (option.control === "switch") return <Switch {...common} {...input} />;
|
||||
if (["number", "duration"].includes(option.control))
|
||||
return <NumberInput {...common} min={option.min} max={option.max} step={option.step} {...input} />;
|
||||
if (option.control === "slider")
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={500}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.description && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
<Slider min={option.min ?? 0} max={option.max ?? 100} step={option.step ?? 1} {...input} />
|
||||
</Stack>
|
||||
);
|
||||
const choices = option.choices?.map((choice) => ({ label: choice.label, value: String(choice.value) })) ?? [];
|
||||
if (option.control === "select")
|
||||
return (
|
||||
<Select
|
||||
{...common}
|
||||
data={choices}
|
||||
value={input.value == null ? null : String(input.value)}
|
||||
onChange={(value) => form.setFieldValue(path, coerceChoice(value, option))}
|
||||
/>
|
||||
);
|
||||
if (option.control === "multiSelect")
|
||||
return (
|
||||
<MultiSelect
|
||||
{...common}
|
||||
data={choices}
|
||||
value={Array.isArray(input.value) ? input.value.map(String) : []}
|
||||
onChange={(value) =>
|
||||
form.setFieldValue(
|
||||
path,
|
||||
value.map((entry) => coerceChoice(entry, option)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (option.control === "textarea") return <Textarea {...common} autosize minRows={3} {...input} />;
|
||||
if (option.control === "color") return <ColorInput {...common} {...input} />;
|
||||
if (option.control === "date") return <DateInput {...common} valueFormat="YYYY-MM-DD" {...input} />;
|
||||
if (option.control === "time") return <TimeInput {...common} {...input} />;
|
||||
if (option.control === "icon") return <IconPicker withAsterisk={false} {...input} />;
|
||||
if (option.control === "timeZone")
|
||||
return <Select {...common} searchable data={Intl.supportedValuesOf("timeZone")} {...input} />;
|
||||
if (option.control === "json")
|
||||
return (
|
||||
<JsonOption
|
||||
identity={`${definitionId}:${path}`}
|
||||
editorId={`${definitionId}-${path}-json-option`}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
value={input.value}
|
||||
onChange={(value) => form.setFieldValue(path, value)}
|
||||
/>
|
||||
);
|
||||
return <TextInput {...common} type={option.control === "url" ? "url" : "text"} {...input} />;
|
||||
}
|
||||
|
||||
function DynamicOptionField({
|
||||
option,
|
||||
path,
|
||||
configuration,
|
||||
definitionId,
|
||||
}: {
|
||||
option: CustomWidgetOption;
|
||||
path: string;
|
||||
configuration: Record<string, unknown>;
|
||||
definitionId: string;
|
||||
}) {
|
||||
const form = useFormContext();
|
||||
const board = useOptionalBoard();
|
||||
const source = option.choicesFrom;
|
||||
if (!source) return null;
|
||||
const query = clientApi.customWidget.optionRequest.useQuery(
|
||||
{
|
||||
boardId: board?.id ?? "",
|
||||
definitionId,
|
||||
requestId: source.request,
|
||||
params: primitiveConfiguration(configuration),
|
||||
},
|
||||
{ enabled: Boolean(board?.id && definitionId && source.request) },
|
||||
);
|
||||
const collection = source.itemsPath ? getByPath(query.data?.data, source.itemsPath) : query.data?.data;
|
||||
const data = (Array.isArray(collection) ? collection : []).flatMap((row) => {
|
||||
const value = getByPath(row, source.valuePath);
|
||||
const label = getByPath(row, source.labelPath);
|
||||
return typeof value === "string" || typeof value === "number"
|
||||
? [{ value: String(value), label: String(label ?? value) }]
|
||||
: [];
|
||||
});
|
||||
const input = form.getInputProps(path);
|
||||
const common = {
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
data,
|
||||
searchable: true,
|
||||
disabled: query.isError,
|
||||
rightSection: query.isFetching ? "…" : undefined,
|
||||
};
|
||||
if (option.control === "multiSelect")
|
||||
return (
|
||||
<MultiSelect
|
||||
{...common}
|
||||
value={Array.isArray(input.value) ? input.value.map(String) : []}
|
||||
onChange={(value) =>
|
||||
form.setFieldValue(
|
||||
path,
|
||||
value.map((entry) => coerceChoice(entry, option)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Select
|
||||
{...common}
|
||||
value={input.value == null ? null : String(input.value)}
|
||||
onChange={(value) => form.setFieldValue(path, coerceChoice(value, option))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const editorMessages: CustomWidgetEditorMessages = {
|
||||
languageJsx: "JSX",
|
||||
languageJson: "JSON",
|
||||
undo: "Undo",
|
||||
redo: "Redo",
|
||||
components: "Components",
|
||||
componentSearch: "Search components",
|
||||
componentEmpty: "No components",
|
||||
componentCount: (count) => `${count} components`,
|
||||
insertStarter: "Insert starter",
|
||||
format: "Format",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
schema: "Schema",
|
||||
schemaTab: "JSON Schema",
|
||||
minimalTab: "Minimal",
|
||||
fullTab: "Full",
|
||||
errors: (count) => `${count} errors`,
|
||||
warnings: (count) => `${count} warnings`,
|
||||
ready: "Ready",
|
||||
position: ({ line, column }) => `Ln ${line}, Col ${column}`,
|
||||
characters: (count, limit) => (limit ? `${count} / ${limit}` : `${count} characters`),
|
||||
diagnosticsTitle: "Diagnostics",
|
||||
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
|
||||
};
|
||||
|
||||
export function JsonOption({
|
||||
identity,
|
||||
editorId,
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
identity: string;
|
||||
editorId: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
value: unknown;
|
||||
onChange(value: unknown): void;
|
||||
}) {
|
||||
const serializedValue = JSON.stringify(value ?? null, null, 2);
|
||||
const [draft, setDraft] = useState(serializedValue);
|
||||
const [error, setError] = useState<string>();
|
||||
const synchronizedValueRef = useRef(serializedValue);
|
||||
const identityRef = useRef(identity);
|
||||
useEffect(() => {
|
||||
if (identityRef.current === identity && synchronizedValueRef.current === serializedValue) return;
|
||||
identityRef.current = identity;
|
||||
synchronizedValueRef.current = serializedValue;
|
||||
setDraft(serializedValue);
|
||||
setError(undefined);
|
||||
}, [identity, serializedValue]);
|
||||
return (
|
||||
<CustomWidgetCodeEditor
|
||||
id={editorId}
|
||||
label={label}
|
||||
description={description}
|
||||
language="json"
|
||||
value={draft}
|
||||
height="160px"
|
||||
error={error}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
try {
|
||||
const parsed = JSON.parse(next) as unknown;
|
||||
synchronizedValueRef.current = JSON.stringify(parsed ?? null, null, 2);
|
||||
onChange(parsed);
|
||||
setError(undefined);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Invalid JSON");
|
||||
}
|
||||
}}
|
||||
messages={editorMessages}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function coerceChoice(value: string | null, option: CustomWidgetOption) {
|
||||
if (value === null) return null;
|
||||
return typeof option.default === "number" ||
|
||||
option.choices?.some((choice) => typeof choice.value === "number" && String(choice.value) === value)
|
||||
? Number(value)
|
||||
: value;
|
||||
}
|
||||
|
||||
function primitiveConfiguration(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, string | number | boolean] =>
|
||||
["string", "number", "boolean"].includes(typeof entry[1]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getByPath(value: unknown, path: string): unknown {
|
||||
return path
|
||||
.replace(/^\$\.?/u, "")
|
||||
.split(".")
|
||||
.filter(Boolean)
|
||||
.reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
available: [] as Array<Record<string, unknown>>,
|
||||
permissions: ["admin"] as string[],
|
||||
setFieldValue: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/core", () => {
|
||||
// oxlint-disable-next-line unicorn/consistent-function-scoping -- the mock factory is hoisted by Vitest
|
||||
const Container = ({ children }: { children?: ReactNode }) => <div>{children}</div>;
|
||||
const Combobox = Object.assign(Container, {
|
||||
Target: Container,
|
||||
Dropdown: Container,
|
||||
Options: Container,
|
||||
Group: Container,
|
||||
Option: Container,
|
||||
Empty: Container,
|
||||
Chevron: () => null,
|
||||
});
|
||||
|
||||
return {
|
||||
Alert: ({ children, title }: { children?: ReactNode; title?: ReactNode }) => (
|
||||
<div role="alert">
|
||||
<strong>{title}</strong>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Anchor: ({ children, href }: { children?: ReactNode; href?: string }) => <a href={href}>{children}</a>,
|
||||
Avatar: Container,
|
||||
Badge: Container,
|
||||
Combobox,
|
||||
Group: Container,
|
||||
InputBase: ({ value }: { value?: string }) => <input aria-label="Custom widget" value={value} readOnly />,
|
||||
Loader: () => null,
|
||||
Stack: Container,
|
||||
Text: Container,
|
||||
useCombobox: () => ({
|
||||
dropdownOpened: false,
|
||||
resetSelectedOption: vi.fn(),
|
||||
closeDropdown: vi.fn(),
|
||||
openDropdown: vi.fn(),
|
||||
updateSelectedOptionIndex: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/api/client", () => ({
|
||||
clientApi: {
|
||||
customWidget: {
|
||||
available: {
|
||||
useQuery: () => ({ data: mocks.available, isLoading: false }),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth/client", () => ({
|
||||
useSession: () => ({ data: { user: { permissions: mocks.permissions } } }),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/context", () => ({
|
||||
useOptionalBoard: () => ({ id: "board-1" }),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string) =>
|
||||
({
|
||||
migrationRequired: "Migration required",
|
||||
migrationDescription: "Copy its redacted prompt to an LLM, then paste the single JSON response into Homarr.",
|
||||
manageMigration: "Open Manage custom widgets",
|
||||
contactAdmin: "Contact an administrator.",
|
||||
})[key] ?? key,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/ui", () => ({ Link: "a" }));
|
||||
|
||||
vi.mock("./form", () => ({
|
||||
useFormContext: () => ({
|
||||
values: { options: { definitionId: "legacy-widget" } },
|
||||
setFieldValue: mocks.setFieldValue,
|
||||
}),
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
mocks.available = [];
|
||||
mocks.permissions = ["admin"];
|
||||
mocks.setFieldValue.mockReset();
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(() => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
async function renderInput() {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WidgetCustomWidgetSelectInput
|
||||
kind="customApi"
|
||||
property="definitionId"
|
||||
options={{ withDescription: false }}
|
||||
initialOptions={{}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("custom widget picker migration status", () => {
|
||||
it("keeps an existing legacy selection visible with migration guidance", async () => {
|
||||
mocks.available = [
|
||||
{
|
||||
id: "legacy-widget",
|
||||
name: "Legacy status",
|
||||
description: "Old API widget",
|
||||
iconUrl: null,
|
||||
sources: [],
|
||||
requestCapabilities: [],
|
||||
defaultOptions: {},
|
||||
updatedAt: new Date(0),
|
||||
migrationRequired: true,
|
||||
},
|
||||
];
|
||||
|
||||
await renderInput();
|
||||
|
||||
expect(host.querySelector("input")?.value).toBe("Legacy status");
|
||||
expect(host.textContent).toContain("Migration required");
|
||||
expect(host.textContent).toContain("Copy its redacted prompt to an LLM");
|
||||
expect(host.querySelector('a[href="/manage/custom-widgets"]')?.textContent).toBe("Open Manage custom widgets");
|
||||
});
|
||||
|
||||
it("tells non-admins to contact an administrator without linking management", async () => {
|
||||
mocks.permissions = ["board-modify-all"];
|
||||
mocks.available = [
|
||||
{
|
||||
id: "legacy-widget",
|
||||
name: "Legacy status",
|
||||
description: "Old API widget",
|
||||
iconUrl: null,
|
||||
sources: [],
|
||||
requestCapabilities: [],
|
||||
defaultOptions: {},
|
||||
updatedAt: new Date(0),
|
||||
migrationRequired: true,
|
||||
},
|
||||
];
|
||||
|
||||
await renderInput();
|
||||
|
||||
expect(host.textContent).toContain("Contact an administrator.");
|
||||
expect(host.querySelector('a[href="/manage/custom-widgets"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Combobox, InputBase, Loader, useCombobox } from "@mantine/core";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Avatar,
|
||||
Badge,
|
||||
Combobox,
|
||||
Group,
|
||||
InputBase,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle, IconApi } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
@@ -13,24 +30,23 @@ export const WidgetCustomWidgetSelectInput = ({
|
||||
property,
|
||||
kind,
|
||||
options,
|
||||
boardId,
|
||||
}: CommonWidgetInputProps<"customWidgetSelect">) => {
|
||||
const t = useWidgetInputTranslation(kind, property);
|
||||
const labels = useScopedI18n("widget.customApi.picker");
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const form = useFormContext();
|
||||
const board = useOptionalBoard();
|
||||
const currentValue = form.values.options[property] as string;
|
||||
const { data, isLoading } = clientApi.customWidget.available.useQuery(
|
||||
{ boardId: boardId ?? "" },
|
||||
{ enabled: Boolean(boardId) },
|
||||
{ boardId: board?.id ?? "", currentId: currentValue || undefined },
|
||||
{ enabled: board !== null },
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const currentValue = form.values.options[property] as string;
|
||||
|
||||
const definitions = useMemo(
|
||||
() =>
|
||||
(data ?? [])
|
||||
.filter((def) => def.enabled || def.id === currentValue)
|
||||
.map((def) => ({ value: def.id, label: def.name })),
|
||||
[data, currentValue],
|
||||
() => (data ?? []).map((definition) => ({ ...definition, value: definition.id, label: definition.name })),
|
||||
[data],
|
||||
);
|
||||
|
||||
const filteredOptions = useMemo(
|
||||
@@ -43,8 +59,8 @@ export const WidgetCustomWidgetSelectInput = ({
|
||||
[definitions, search],
|
||||
);
|
||||
|
||||
const selectedLabel = definitions.find((d) => d.value === currentValue)?.label;
|
||||
|
||||
const selectedDefinition = definitions.find((definition) => definition.value === currentValue);
|
||||
const selectedLabel = selectedDefinition?.label;
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => {
|
||||
combobox.resetSelectedOption();
|
||||
@@ -53,43 +69,104 @@ 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);
|
||||
if (!definition || definition.migrationRequired) return;
|
||||
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}
|
||||
disabled={def.migrationRequired}
|
||||
>
|
||||
<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}>
|
||||
{def.migrationRequired && (
|
||||
<Badge size="xs" variant="light" color="yellow">
|
||||
{labels("migrationRequired")}
|
||||
</Badge>
|
||||
)}
|
||||
<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>
|
||||
{selectedDefinition?.migrationRequired && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
title={labels("migrationRequired")}
|
||||
>
|
||||
<Text size="xs">{labels("migrationDescription")}</Text>
|
||||
{isAdmin ? (
|
||||
<Anchor component={Link} href="/manage/custom-widgets" size="xs" fw={600}>
|
||||
{labels("manageMigration")}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text size="xs" fw={600}>
|
||||
{labels("contactAdmin")}
|
||||
</Text>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
|
||||
export const WidgetInternalInput = (_props: CommonWidgetInputProps<"internal">) => null;
|
||||
Reference in new issue
Block a user