fix(custom-widgets): isolate invalid preview

This commit is contained in:
Thomas Camlong committed 2026-07-20 19:19:02 +02:00
1 parent c1fca4c5a2
commit ee832ad3c6
5 files changed
+166 -35

No files matched your search

@@ -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.
@@ -314,6 +314,14 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
</Paper>
<CustomWidgetPreviewPanel
candidate={candidate.success ? candidate.data : null}
validationIssues={
candidate.success
? []
: candidate.error.issues.map((issue) => ({
path: issue.path.map(String).join(".") || undefined,
message: issue.message,
}))
}
preview={preview}
size={previewSize}
onSizeChange={setPreviewSize}
@@ -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: () => <div>widget-renderer</div> }));
vi.mock("./_code-editor", () => ({ CodeEditor: ({ label }: { label: string }) => <div>{label}</div> }));
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
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(
<MantineProvider>
<CustomWidgetPreviewPanel
candidate={null}
validationIssues={[{ path: "sources.0.baseUrl", message: "Enter a complete URL." }]}
preview={{ data: { previous: true }, status: {}, session: null, outcome: "idle" }}
size="standard"
onSizeChange={vi.fn()}
theme="dark"
onThemeChange={vi.fn()}
optionsSnapshot={{}}
onOptionsChange={vi.fn()}
onLiveActionsChange={vi.fn()}
/>
</MantineProvider>,
);
});
expect(host.textContent).toContain("invalid");
for (const tab of ["widget", "data", "options", "actions", "diagnostics"]) {
expect(host.textContent).toContain(`tab.${tab}`);
}
const diagnosticsTab = [...host.querySelectorAll<HTMLElement>('[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.");
});
});
@@ -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<string, number> = { compact: 320, standard: 480, wide: 720 };
if (!props.candidate) return <Alert color="yellow">{t("invalid")}</Alert>;
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) {
<SegmentedControl
size="xs"
value={fixture}
disabled={!candidate}
onChange={(value) => 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") },
]}
/>
<PreviewResult
outcome={props.preview.outcome}
title={previewResult.title}
description={previewResult.description}
/>
{candidate && (
<PreviewResult
outcome={props.preview.outcome}
title={previewResult.title}
description={previewResult.description}
/>
)}
<Tabs defaultValue="widget" keepMounted={false}>
<Tabs.List grow>
{(["widget", "data", "options", "actions", "diagnostics"] as const).map((tab) => (
@@ -165,7 +172,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
<Box className={classes.previewCanvas}>
<MantineProvider forceColorScheme={props.theme}>
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
<CustomJsxDisplay data={displayData} />
{displayData ? <CustomJsxDisplay data={displayData} /> : <Alert color="yellow">{t("invalid")}</Alert>}
</Paper>
</MantineProvider>
</Box>
@@ -206,7 +213,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
);
}}
/>
{candidate.requests
{candidate?.requests
.filter((request) => request.kind === "action")
.map((request) => (
<PreviewActionControl
@@ -216,7 +223,8 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
options={props.optionsSnapshot}
/>
))}
{candidate.requests.every((request) => request.kind !== "action") && (
{!candidate && <Alert color="yellow">{t("invalid")}</Alert>}
{candidate?.requests.every((request) => request.kind !== "action") && (
<Text size="sm" c="dimmed">
{t("noActions")}
</Text>
@@ -233,10 +241,24 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
<Text size="sm" c="dimmed">
{t("containedFailures")}
</Text>
<Text size="sm" fw={600}>
{t("validation.title")}
</Text>
{props.validationIssues.length === 0 ? (
<Alert color="green">{t("validation.ready")}</Alert>
) : (
props.validationIssues.map((issue, index) => (
<Alert key={`${issue.path ?? "widget"}-${index}`} color="red" title={issue.path}>
{issue.message}
</Alert>
))
)}
<Text size="sm" fw={600}>
{t("accessibility.title")}
</Text>
{accessibilityIssues.length === 0 ? (
{!candidate ? (
<Alert color="gray">{t("accessibility.pending")}</Alert>
) : accessibilityIssues.length === 0 ? (
<Alert color="green">{t("accessibility.ready")}</Alert>
) : (
accessibilityIssues.map((issue) => (
+5
View File
@@ -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."