fix(custom-widgets): isolate invalid preview
This commit is contained in:
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}
|
||||
|
||||
+94
@@ -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.");
|
||||
});
|
||||
});
|
||||
+57
-35
@@ -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) => (
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in new issue
Block a user