diff --git a/apps/docs/docs/management/custom-widgets/troubleshooting.mdx b/apps/docs/docs/management/custom-widgets/troubleshooting.mdx index c7fe1efdd..c103d8989 100644 --- a/apps/docs/docs/management/custom-widgets/troubleshooting.mdx +++ b/apps/docs/docs/management/custom-widgets/troubleshooting.mdx @@ -14,6 +14,8 @@ Use the first schema/JSX diagnostic and its path. Ensure source/request IDs are Incomplete API source and icon URLs are normal form validation errors. The workbench keeps the current draft mounted and highlights the invalid field while you type; malformed URLs must never replace the form with the error boundary. +An invalid definition disables only the rendered Widget canvas. The preview shell and its Data, Options, Actions, and Diagnostics tabs remain available, and Diagnostics lists the current schema issues. + ## Request fails Check the source network scope, base URL, credentials, request method/path/body, runtime parameters, and board permission. For “invalid placeholder” or an unresolved parameter on a load query, first verify that every declared parameter has an explicit `optionsBinding` option reference or literal. Matching parameter and option names are not a binding. The preview journal shows redacted method, request ID, path template, status, and duration without credentials. diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx index aafb33492..8a9951796 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx @@ -314,6 +314,14 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi ({ + path: issue.path.map(String).join(".") || undefined, + message: issue.message, + })) + } preview={preview} size={previewSize} onSizeChange={setPreviewSize} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.spec.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.spec.tsx new file mode 100644 index 000000000..b6aae3c3f --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.spec.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import { MantineProvider } from "@mantine/core"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel"; + +vi.mock("@homarr/api/client", () => ({ + clientApi: { + customWidget: { + setPreviewLiveActions: { useMutation: () => ({ isPending: false, mutate: vi.fn() }) }, + previewJournal: { useQuery: () => ({ data: [] }) }, + }, + }, +})); +vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key })); +vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() })); +vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({ default: () =>
widget-renderer
})); +vi.mock("./_code-editor", () => ({ CodeEditor: ({ label }: { label: string }) =>
{label}
})); +vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () =>
action-control
})); + +let root: Root; +let host: HTMLDivElement; + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + window.matchMedia = vi.fn().mockImplementation(() => ({ + matches: false, + media: "", + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); +}); + +describe("Custom Widget preview panel", () => { + test("contains invalid definitions inside the widget canvas while keeping every inspection tab", async () => { + await act(async () => { + root.render( + + + , + ); + }); + + expect(host.textContent).toContain("invalid"); + for (const tab of ["widget", "data", "options", "actions", "diagnostics"]) { + expect(host.textContent).toContain(`tab.${tab}`); + } + + const diagnosticsTab = [...host.querySelectorAll('[role="tab"]')].find((element) => + element.textContent?.includes("tab.diagnostics"), + ); + expect(diagnosticsTab).toBeDefined(); + await act(async () => diagnosticsTab?.click()); + + expect(host.textContent).toContain("sources.0.baseUrl"); + expect(host.textContent).toContain("Enter a complete URL."); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx index 707f1ef4d..3c0f82bd0 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx @@ -39,6 +39,7 @@ export interface PreviewState { interface PreviewPanelProps { candidate: HomarrCustomWidgetV2 | null; + validationIssues: Array<{ path?: string; message: string }>; preview: PreviewState; size: string; onSizeChange(value: string): void; @@ -58,40 +59,43 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) { { enabled: Boolean(props.preview.session), refetchInterval: props.preview.session ? 2_000 : false }, ); const widths: Record = { compact: 320, standard: 480, wide: 720 }; - if (!props.candidate) return {t("invalid")}; const candidate = props.candidate; - const accessibilityIssues = analyzeCustomWidgetAccessibility(candidate.template); + const accessibilityIssues = candidate ? analyzeCustomWidgetAccessibility(candidate.template) : []; const fixtureData = - fixture === "empty" ? Object.fromEntries(candidate.requests.map((entry) => [entry.id, []])) : props.preview.data; + fixture === "empty" && candidate + ? Object.fromEntries(candidate.requests.map((entry) => [entry.id, []])) + : props.preview.data; const fixtureStatus = - fixture === "loading" + fixture === "loading" && candidate ? Object.fromEntries(candidate.requests.map((entry) => [entry.id, { loading: true }])) - : fixture === "error" + : fixture === "error" && candidate ? Object.fromEntries( candidate.requests.map((entry) => [entry.id, { loading: false, ok: false, error: t("fixtureError") }]), ) : props.preview.status; - const displayData = { - template: candidate.template, - data: fixtureData, - status: fixtureStatus, - options: props.optionsSnapshot, - requestCapabilities: candidate.requests.map( - ({ id, kind, method, trigger, minimumBoardPermission, confirmation, invalidates }) => ({ - id, - kind, - method, - trigger, - minimumBoardPermission, - confirmation, - invalidates, - }), - ), - previewSessionId: props.preview.session?.id, - previewLiveActions: props.preview.session?.liveActions ?? false, - queriesDisabled: fixture !== "live", - isEditMode: fixture !== "live", - }; + const displayData = candidate + ? { + template: candidate.template, + data: fixtureData, + status: fixtureStatus, + options: props.optionsSnapshot, + requestCapabilities: candidate.requests.map( + ({ id, kind, method, trigger, minimumBoardPermission, confirmation, invalidates }) => ({ + id, + kind, + method, + trigger, + minimumBoardPermission, + confirmation, + invalidates, + }), + ), + previewSessionId: props.preview.session?.id, + previewLiveActions: props.preview.session?.liveActions ?? false, + queriesDisabled: fixture !== "live", + isEditMode: fixture !== "live", + } + : null; const previewSummary = getPreviewSummary(props.preview.status); const previewResult = props.preview.outcome === "success" @@ -135,6 +139,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) { setFixture(value as typeof fixture)} data={[ { value: "live", label: t("fixture.live") }, @@ -143,11 +148,13 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) { { value: "error", label: t("fixture.error") }, ]} /> - + {candidate && ( + + )} {(["widget", "data", "options", "actions", "diagnostics"] as const).map((tab) => ( @@ -165,7 +172,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) { - + {displayData ? : {t("invalid")}} @@ -206,7 +213,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) { ); }} /> - {candidate.requests + {candidate?.requests .filter((request) => request.kind === "action") .map((request) => ( ))} - {candidate.requests.every((request) => request.kind !== "action") && ( + {!candidate && {t("invalid")}} + {candidate?.requests.every((request) => request.kind !== "action") && ( {t("noActions")} @@ -233,10 +241,24 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) { {t("containedFailures")} + + {t("validation.title")} + + {props.validationIssues.length === 0 ? ( + {t("validation.ready")} + ) : ( + props.validationIssues.map((issue, index) => ( + + {issue.message} + + )) + )} {t("accessibility.title")} - {accessibilityIssues.length === 0 ? ( + {!candidate ? ( + {t("accessibility.pending")} + ) : accessibilityIssues.length === 0 ? ( {t("accessibility.ready")} ) : ( accessibilityIssues.map((issue) => ( diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 1ea4533a1..61dc0c5b3 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -934,9 +934,14 @@ "sessionActive": "Preview session active", "simulationPending": "Actions are simulated until you run preview", "containedFailures": "Runtime failures are contained inside this widget. Request journals never include secret values.", + "validation": { + "title": "Validation", + "ready": "The widget definition is valid." + }, "accessibility": { "title": "Accessibility checks", "ready": "No common accessibility omissions detected.", + "pending": "Accessibility checks will run when the widget definition is valid.", "imageAlt": "Add alt text to every Image, using an empty alt only for decorative images.", "actionIconLabel": "Add an aria-label or title to every ActionIcon.", "inputLabel": "Add a visible label or accessible name to every input."