fix(custom-widgets): address v2 merge review
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user