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

@@ -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) => (