fix(custom-widgets): address v2 merge review

This commit is contained in:
ajnart committed 2026-07-22 23:36:25 +02:00
1 parent 6aceb2fd1f
commit 48ce83806d
122 files changed
+8534 -1447

No files matched your search

@@ -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');
});
});
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Accordion,
Alert,
@@ -69,7 +69,7 @@ export const WidgetCustomWidgetConfigurationInput = ({
)}
{regular.map(([name, option]) => (
<OptionField
key={name}
key={`${definitionId}:${name}`}
option={option}
path={`options.${property}.${name}`}
configuration={configuration}
@@ -84,7 +84,7 @@ export const WidgetCustomWidgetConfigurationInput = ({
<Stack gap="sm">
{advanced.map(([name, option]) => (
<OptionField
key={name}
key={`${definitionId}:${name}`}
option={option}
path={`options.${property}.${name}`}
configuration={configuration}
@@ -167,6 +167,8 @@ function OptionField({
if (option.control === "json")
return (
<JsonOption
identity={`${definitionId}:${path}`}
editorId={`${definitionId}-${path}-json-option`}
label={option.label}
description={option.description}
value={input.value}
@@ -265,22 +267,36 @@ const editorMessages: CustomWidgetEditorMessages = {
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
};
function JsonOption({
export function JsonOption({
identity,
editorId,
label,
description,
value,
onChange,
}: {
identity: string;
editorId: string;
label: string;
description?: string;
value: unknown;
onChange(value: unknown): void;
}) {
const [draft, setDraft] = useState(() => JSON.stringify(value ?? null, null, 2));
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={`${label}-json-option`}
id={editorId}
label={label}
description={description}
language="json"
@@ -290,7 +306,9 @@ function JsonOption({
onChange={(next) => {
setDraft(next);
try {
onChange(JSON.parse(next) as unknown);
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");
@@ -0,0 +1,140 @@
// @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>>,
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/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 and import the returned v2 JSON.",
manageMigration: "Open Manage custom widgets",
})[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.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");
});
});
@@ -1,12 +1,25 @@
"use client";
import { useMemo, useState } from "react";
import { Avatar, Badge, Combobox, Group, InputBase, Loader, Stack, Text, useCombobox } from "@mantine/core";
import { IconApi } from "@tabler/icons-react";
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 { 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";
@@ -43,7 +56,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();
@@ -57,6 +71,7 @@ export const WidgetCustomWidgetSelectInput = ({
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(
@@ -89,7 +104,12 @@ export const WidgetCustomWidgetSelectInput = ({
<Combobox.Options>
<Combobox.Group label={labels("group")}>
{filteredOptions.map((def) => (
<Combobox.Option key={def.value} value={def.value} active={def.value === currentValue}>
<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} />
@@ -104,6 +124,11 @@ export const WidgetCustomWidgetSelectInput = ({
</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>
@@ -120,6 +145,19 @@ export const WidgetCustomWidgetSelectInput = ({
</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>
<Anchor component={Link} href="/manage/custom-widgets" size="xs" fw={600}>
{labels("manageMigration")}
</Anchor>
</Alert>
)}
</Stack>
);
};
+25 -15
View File
@@ -6,8 +6,10 @@ import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { isLegacyCustomWidgetMigrationError, isTerminalCustomWidgetDefinitionError } from "./migration-state";
const CustomJsxDisplay = dynamic(() => import("./custom-jsx-display"), { ssr: false });
@@ -26,8 +28,9 @@ export default function CustomApiWidget({
{ itemId: itemId ?? "" },
{
enabled: Boolean(itemId) && Boolean(definitionId),
refetchInterval: (currentQuery) => (isTerminalDefinitionError(currentQuery.state.error) ? false : intervalMs),
retry: (failureCount, error) => !isTerminalDefinitionError(error) && failureCount < 3,
refetchInterval: (currentQuery) =>
isTerminalCustomWidgetDefinitionError(currentQuery.state.error) ? false : intervalMs,
retry: (failureCount, error) => !isTerminalCustomWidgetDefinitionError(error) && failureCount < 3,
},
);
@@ -43,6 +46,7 @@ export default function CustomApiWidget({
);
if (query.error) {
const errorCode = query.error.data?.code;
const migrationRequired = isLegacyCustomWidgetMigrationError(query.error);
if (isEditMode && errorCode === "NOT_FOUND") {
return <Unavailable message={t("editModePending")} />;
}
@@ -50,14 +54,18 @@ export default function CustomApiWidget({
return (
<Unavailable
message={
isUnavailable
? t("definitionNotFound")
: errorCode === "PRECONDITION_FAILED"
? t("configurationNeedsRepair")
: tCustomJsx("requestFailed")
migrationRequired
? t("migrationRequired")
: isUnavailable
? t("definitionNotFound")
: errorCode === "PRECONDITION_FAILED"
? t("configurationNeedsRepair")
: tCustomJsx("requestFailed")
}
danger={!isUnavailable}
danger={!isUnavailable && !migrationRequired}
removeLabel={isUnavailable ? t("removeFromBoard") : undefined}
actionLabel={migrationRequired ? t("manageMigration") : undefined}
actionHref={migrationRequired ? "/manage/custom-widgets" : undefined}
onRemove={removeItem}
/>
);
@@ -76,22 +84,19 @@ export default function CustomApiWidget({
);
}
function isTerminalDefinitionError(error: unknown): boolean {
if (!error || typeof error !== "object" || !("data" in error)) return false;
const data = error.data;
if (!data || typeof data !== "object" || !("code" in data)) return false;
return data.code === "NOT_FOUND" || data.code === "FORBIDDEN" || data.code === "PRECONDITION_FAILED";
}
function Unavailable({
message,
danger = false,
removeLabel,
actionLabel,
actionHref,
onRemove,
}: {
message: string;
danger?: boolean;
removeLabel?: string;
actionLabel?: string;
actionHref?: string;
onRemove?: () => void;
}) {
return (
@@ -101,6 +106,11 @@ function Unavailable({
<Text c="dimmed" size="sm" ta="center">
{message}
</Text>
{actionLabel && actionHref && (
<Button component={Link} href={actionHref} size="compact-sm" color="yellow" variant="light">
{actionLabel}
</Button>
)}
{removeLabel && onRemove && (
<Button size="compact-sm" color="red" variant="light" onClick={onRemove}>
{removeLabel}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { isLegacyCustomWidgetMigrationError, isTerminalCustomWidgetDefinitionError } from "./migration-state";
describe("custom widget migration state", () => {
it("recognizes the preserved legacy definition error and stops retries", () => {
const error = {
message: "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED",
data: { code: "PRECONDITION_FAILED" },
};
expect(isLegacyCustomWidgetMigrationError(error)).toBe(true);
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(true);
});
it("does not label an invalid v2 configuration as a legacy migration", () => {
const error = {
message: "Custom widget configuration needs repair",
data: { code: "PRECONDITION_FAILED" },
};
expect(isLegacyCustomWidgetMigrationError(error)).toBe(false);
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(true);
});
it("allows transient request failures to retry", () => {
const error = { message: "Request failed", data: { code: "INTERNAL_SERVER_ERROR" } };
expect(isLegacyCustomWidgetMigrationError(error)).toBe(false);
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(false);
});
});
@@ -0,0 +1,13 @@
const LEGACY_MIGRATION_ERROR = "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED";
const TERMINAL_DEFINITION_CODES = new Set(["NOT_FOUND", "FORBIDDEN", "PRECONDITION_FAILED"]);
export function isLegacyCustomWidgetMigrationError(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "message" in error && error.message === LEGACY_MIGRATION_ERROR);
}
export function isTerminalCustomWidgetDefinitionError(error: unknown): boolean {
if (!error || typeof error !== "object" || !("data" in error)) return false;
const data = error.data;
if (!data || typeof data !== "object" || !("code" in data) || typeof data.code !== "string") return false;
return TERMINAL_DEFINITION_CODES.has(data.code);
}