fix(custom-widgets): stabilize authoring flows

This commit is contained in:
Thomas Camlong committed 2026-07-20 17:55:57 +02:00
1 parent e4388b0712
commit 091eca3192
22 files changed
+454 -203

No files matched your search

@@ -15,7 +15,7 @@ The **Schema** action beside Undo, Redo, and Format expands the generated JSON S
3. Declare named load/manual queries and actions.
4. Define persisted item options.
5. Write JSX using `data`, `status`, `options`, and temporary `inputs`.
6. Test requests, preview compact/standard/wide layouts, and inspect runtime and accessibility diagnostics.
6. Select **Test and preview**. Its loading state shows while load requests run; the Data tab dot and result summary turn green or red when they finish. Open Data for responses and request errors, then preview compact/standard/wide layouts and inspect runtime and accessibility diagnostics. A preview that initially lacks request data retries rendering when that data arrives.
7. Save, then select the definition and its options in the board widget picker.
## ChatGPT copy/paste
@@ -7,6 +7,7 @@ import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ModalProvider } from "@homarr/modals";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
@@ -68,17 +69,19 @@ export const createBoardLayout = <TParams extends Params>({
<BoardReadyProvider>
<EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss />
<BoardTourWrapper hasSession={withTour && !!session}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardTourWrapper>
<ModalProvider>
<CustomCss />
<BoardTourWrapper hasSession={withTour && !!session}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardTourWrapper>
</ModalProvider>
</BoardMantineProvider>
</EditModeProvider>
</BoardReadyProvider>
@@ -39,13 +39,15 @@ export function EditorSection({
export function SaveActions({
dirty,
pending,
savePending,
previewPending,
invalid,
mode,
onPreview,
}: {
dirty: boolean;
pending: boolean;
savePending: boolean;
previewPending: boolean;
invalid: boolean;
mode: "create" | "edit";
onPreview(): void;
@@ -64,10 +66,17 @@ export function SaveActions({
</Text>
</Group>
<Group gap="xs" wrap="nowrap">
<Button type="button" variant="light" leftSection={<IconPlayerPlay size={16} />} onClick={onPreview}>
{t("action.preview")}
<Button
type="button"
variant="light"
leftSection={<IconPlayerPlay size={16} />}
onClick={onPreview}
loading={previewPending}
disabled={invalid || savePending}
>
{previewPending ? t("action.previewLoading") : t("action.preview")}
</Button>
<Button type="submit" loading={pending} disabled={invalid}>
<Button type="submit" loading={savePending} disabled={invalid || previewPending}>
{mode === "create" ? t("action.create") : t("action.save")}
</Button>
</Group>
@@ -83,7 +83,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
const [request, setRequest] = useState("");
const [documentationUrl, setDocumentationUrl] = useState("");
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null });
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null, outcome: "idle" });
const [previewSize, setPreviewSize] = useState("standard");
const [previewTheme, setPreviewTheme] = useState<"light" | "dark">("dark");
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>({});
@@ -119,20 +119,21 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
[candidate, requestDiagnostics, templateDiagnostics],
);
const { save, runPreview, pasteAiResponse, copyDiagnostics, pending } = useCustomWidgetFormActions({
mode,
definitionId,
form,
candidate,
templateDiagnostics,
requestDiagnostics,
preview,
setPreview,
setMobilePane,
setOptionsSnapshot,
request,
documentationUrl,
});
const { save, runPreview, pasteAiResponse, copyDiagnostics, savePending, previewPending } =
useCustomWidgetFormActions({
mode,
definitionId,
form,
candidate,
templateDiagnostics,
requestDiagnostics,
preview,
setPreview,
setMobilePane,
setOptionsSnapshot,
request,
documentationUrl,
});
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
@@ -254,7 +255,8 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
<SaveActions
dirty={form.isDirty()}
pending={pending}
savePending={savePending}
previewPending={previewPending}
invalid={invalid}
mode={mode}
onPreview={() => void runPreview()}
@@ -265,7 +267,8 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
<Paper p="md" className={classes.actionBar} shadow="sm">
<SaveActions
dirty={form.isDirty()}
pending={pending}
savePending={savePending}
previewPending={previewPending}
invalid={invalid}
mode={mode}
onPreview={() => void runPreview()}
@@ -25,6 +25,8 @@ import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
import { CodeEditor } from "./_code-editor";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
import { getPreviewSummary, PreviewResult, PreviewStatusDot } from "./_custom-widget-preview-status";
import type { PreviewOutcome } from "./_custom-widget-preview-status";
import { PreviewActionControl } from "./_custom-widget-preview-action";
import classes from "./_custom-widget-form.module.css";
@@ -32,6 +34,7 @@ export interface PreviewState {
data: Record<string, unknown>;
status: Record<string, unknown>;
session: { id: string; expiresAt: number; liveActions: boolean } | null;
outcome: PreviewOutcome;
}
interface PreviewPanelProps {
@@ -89,6 +92,21 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
queriesDisabled: fixture !== "live",
isEditMode: fixture !== "live",
};
const previewSummary = getPreviewSummary(props.preview.status);
const previewResult =
props.preview.outcome === "success"
? {
title: t("result.success.title"),
description: t("result.success.description", previewSummary),
}
: props.preview.outcome === "error"
? {
title: t("result.error.title"),
description: t("result.error.description", previewSummary),
}
: props.preview.outcome === "loading"
? { title: t("result.loading.title"), description: t("result.loading.description") }
: { title: "", description: "" };
return (
<Card withBorder p="md">
@@ -125,11 +143,21 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
{ value: "error", label: t("fixture.error") },
]}
/>
<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) => (
<Tabs.Tab key={tab} value={tab}>
{t(`tab.${tab}`)}
<Group gap={6} wrap="nowrap" justify="center">
{t(`tab.${tab}`)}
{tab === "data" && (
<PreviewStatusDot outcome={props.preview.outcome} label={t(`status.${props.preview.outcome}`)} />
)}
</Group>
</Tabs.Tab>
))}
</Tabs.List>
@@ -0,0 +1,54 @@
"use client";
import { Alert, Box, Text, Tooltip } from "@mantine/core";
import { isRecord } from "./_custom-widget-form-utils";
export type PreviewOutcome = "idle" | "loading" | "success" | "error";
export function PreviewResult({
outcome,
title,
description,
}: {
outcome: PreviewOutcome;
title: string;
description: string;
}) {
if (outcome === "idle") return null;
return (
<Alert color={outcome === "error" ? "red" : outcome === "success" ? "green" : "yellow"}>
<Text size="sm" fw={600}>
{title}
</Text>
<Text size="sm">{description}</Text>
</Alert>
);
}
export function PreviewStatusDot({ outcome, label }: { outcome: PreviewOutcome; label: string }) {
const colors = { idle: "gray", loading: "yellow", success: "green", error: "red" } as const;
return (
<Tooltip label={label}>
<Box
component="output"
aria-label={label}
w={8}
h={8}
bg={`var(--mantine-color-${colors[outcome]}-6)`}
style={{ borderRadius: "50%", flexShrink: 0 }}
/>
</Tooltip>
);
}
export function getPreviewSummary(status: Record<string, unknown>) {
return Object.values(status).reduce<{ succeeded: number; failed: number }>(
(summary, value) => {
if (isRecord(value) && value.ok === false) summary.failed += 1;
else summary.succeeded += 1;
return summary;
},
{ succeeded: 0, failed: 0 },
);
}
@@ -13,10 +13,10 @@ import {
parseCustomWidgetAiResponse,
} from "@homarr/custom-widgets/core";
import type { EditorDiagnostic, CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { applyDefinition, buildDefinition, loadPreviewQueries } from "./_custom-widget-form-utils";
import { applyDefinition, buildDefinition, isRecord, loadPreviewQueries } from "./_custom-widget-form-utils";
import type { PreviewState } from "./_custom-widget-preview-panel";
interface FormActionsInput {
@@ -43,6 +43,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const updateMutation = clientApi.customWidget.update.useMutation();
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
const [importIssues, setImportIssues] = useState<string[]>([]);
const [previewPending, setPreviewPending] = useState(false);
const save = input.form.onSubmit(async (values) => {
const definition = buildDefinition(values);
@@ -93,6 +94,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
});
const runPreview = async () => {
if (previewPending) return;
if (!input.candidate.success) {
showErrorNotification({
title: w("section.preview"),
@@ -101,6 +103,8 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
return;
}
input.setMobilePane("preview");
input.setPreview({ data: {}, status: {}, session: null, outcome: "loading" });
setPreviewPending(true);
try {
const created = await previewMutation.mutateAsync({
definition: input.candidate.data,
@@ -115,9 +119,34 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
});
input.setOptionsSnapshot(input.candidate.data.defaultOptions);
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id);
input.setPreview({ ...snapshot, session: created.previewSession });
const failed = Object.values(snapshot.status).filter((status) => isRecord(status) && status.ok === false).length;
input.setPreview({
...snapshot,
session: created.previewSession,
outcome: failed > 0 ? "error" : "success",
});
if (failed > 0) {
showWarningNotification({
title: w("preview.result.error.title"),
message: w("preview.result.error.description", {
succeeded: Object.keys(snapshot.status).length - failed,
failed,
}),
});
} else {
showSuccessNotification({
title: w("preview.result.success.title"),
message: w("preview.result.success.description", {
succeeded: Object.keys(snapshot.status).length,
failed: 0,
}),
});
}
} catch {
input.setPreview((current) => ({ ...current, outcome: "error" }));
showErrorNotification({ title: w("section.preview"), message: t("notification.previewError") });
} finally {
setPreviewPending(false);
}
};
@@ -130,7 +159,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
}
setImportIssues([]);
applyDefinition(input.form, customWidgetDefinitionSchema.parse(result.widget));
input.setPreview({ data: {}, status: {}, session: null });
input.setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
showSuccessNotification({
title: w("ai.response"),
message:
@@ -176,6 +205,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
runPreview,
pasteAiResponse,
copyDiagnostics,
pending: createMutation.isPending || updateMutation.isPending,
savePending: createMutation.isPending || updateMutation.isPending,
previewPending,
};
}
+3 -1
View File
@@ -35,6 +35,8 @@
"@codemirror/lang-javascript": "catalog:",
"@codemirror/lang-json": "catalog:",
"@codemirror/lint": "catalog:",
"@codemirror/state": "catalog:",
"@codemirror/theme-one-dark": "catalog:",
"@codemirror/view": "catalog:",
"@mantine/charts": "catalog:",
"@mantine/core": "catalog:",
@@ -42,9 +44,9 @@
"@mantine/hooks": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/react-query": "catalog:",
"@uiw/react-codemirror": "catalog:",
"acorn": "catalog:",
"acorn-jsx": "catalog:",
"codemirror": "catalog:",
"jsonpath-plus": "catalog:",
"react": "catalog:",
"undici": "catalog:",
@@ -86,8 +86,8 @@ const editorAdapter = await readFile(
join(repositoryRoot, "packages/custom-widgets/src/workbench/code-editor.tsx"),
"utf8",
);
if (!/useEffect\(\(\)\s*=>[\s\S]*import\(["']@uiw\/react-codemirror["']\)/u.test(editorAdapter)) {
failures.push("CodeMirror must remain dynamically loaded");
if (!/lazy\(\(\)\s*=>\s*import\(["'].\/direct-code-mirror["']\)\)/u.test(editorAdapter)) {
failures.push("CodeMirror must remain dynamically loaded after the client mounts");
}
if (failures.length > 0) {
@@ -60,7 +60,8 @@ export function buildCustomWidgetAiPrompt(
if (currentConfig) {
sections.push(`Current widget:\n\n\`\`\`json\n${JSON.stringify(redactValue(currentConfig), null, 2)}\n\`\`\``);
}
return sections.join("\n\n").slice(0, 8_000);
const prompt = sections.join("\n\n");
return embeddedSkill ? prompt : prompt.slice(0, 8_000);
}
export function buildCustomWidgetFixPrompt(input: {
@@ -1,7 +1,7 @@
import type { ComponentType, ErrorInfo, ReactNode } from "react";
import { Component, useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Badge, Box, Group, Popover, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconNetwork } from "@tabler/icons-react";
import { Alert, Box, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { renderSafeJsx } from "../jsx/interpreter";
import { CustomJsxInputsProvider } from "../jsx/runtime-components";
@@ -23,8 +23,6 @@ const permissions = new Set(["view", "modify", "full"]);
export interface CustomJsxRendererMessages {
noTemplate: string;
interactive: string;
networkCapabilities: string;
templateWarnings(count: number): string;
}
@@ -33,7 +31,6 @@ export interface CustomJsxRendererProps {
data: unknown;
status?: Record<string, unknown>;
options?: Record<string, unknown>;
requestCapabilities: unknown;
components: Readonly<Record<string, ComponentType<never>>>;
createBindings(data: unknown): Readonly<Record<string, unknown>>;
messages: CustomJsxRendererMessages;
@@ -107,7 +104,6 @@ export function CustomJsxRenderer({
data,
status = EMPTY_RECORD,
options = EMPTY_RECORD,
requestCapabilities: rawCapabilities,
components,
createBindings,
messages,
@@ -150,7 +146,7 @@ export function CustomJsxRenderer({
() => ({ ...createBindings(data), status, options, inputs }),
[createBindings, data, inputs, options, status],
);
const capabilities = useMemo(() => parseRequestCapabilities(rawCapabilities), [rawCapabilities]);
const boundaryKey = useMemo(() => createBoundaryKey(template, bindings), [bindings, template]);
const rendered = useMemo(() => {
try {
return { ...renderSafeJsx({ template, components, bindings }), error: null };
@@ -182,47 +178,6 @@ export function CustomJsxRenderer({
);
return (
<Stack gap={0} h="100%">
{capabilities.length > 0 && (
<Group justify="flex-end" mb={4}>
<Popover width={320} position="bottom-end" withinPortal={false} shadow="md">
<Popover.Target>
<Badge
component="button"
type="button"
size="sm"
color="gray"
variant="light"
leftSection={<IconNetwork size={12} />}
style={{ cursor: "pointer" }}
>
{messages.interactive}
</Badge>
</Popover.Target>
<Popover.Dropdown>
<Stack gap={6}>
<Text size="sm" fw={600}>
{messages.networkCapabilities}
</Text>
{capabilities.map((capability) => (
<Group key={capability.id} justify="space-between" gap="xs" wrap="nowrap">
<Text size="xs" ff="monospace" truncate>
{capability.id}
</Text>
<Group gap={4} wrap="nowrap">
<Badge size="xs" color={methodColors[capability.method] ?? "gray"} variant="light">
{capability.method}
</Badge>
<Badge size="xs" color="gray" variant="outline">
{capability.minimumBoardPermission}
</Badge>
</Group>
</Group>
))}
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
)}
<Box h="100%" style={{ contain: "layout paint style", isolation: "isolate", overflow: "auto", minHeight: 0 }}>
{rendered.error ? (
<ErrorAlert error={rendered.error} />
@@ -233,7 +188,7 @@ export function CustomJsxRenderer({
registerInput={registerInput}
setInputValue={setInputValue}
>
<RendererErrorBoundary key={template} onError={handleError}>
<RendererErrorBoundary key={boundaryKey} onError={handleError}>
{rendered.node}
</RendererErrorBoundary>
</CustomJsxInputsProvider>
@@ -254,3 +209,10 @@ export function CustomJsxRenderer({
</Stack>
);
}
function createBoundaryKey(template: string, bindings: Readonly<Record<string, unknown>>) {
let hash = 0;
const value = `${template}\0${JSON.stringify(bindings)}`;
for (let index = 0; index < value.length; index += 1) hash = (hash * 31 + value.charCodeAt(index)) | 0;
return `${template.length}:${hash}`;
}
@@ -18,7 +18,7 @@ describe("buildCustomWidgetAiPrompt", () => {
expect(prompt).toContain("Use this embedded Homarr Custom Widget skill while authoring:");
expect(prompt).toContain("# Homarr Custom Widget");
expect(prompt).toContain("installed Mantine components can differ by Homarr release.");
expect(prompt.length).toBeLessThanOrEqual(8_000);
expect(prompt.endsWith(CUSTOM_WIDGET_SKILL_MD)).toBe(true);
});
test("is concise and describes the one-widget v2 contract", () => {
@@ -110,8 +110,6 @@ describe("Custom Widget runtime ports", () => {
const setItem = vi.spyOn(Storage.prototype, "setItem");
const rendererMessages = {
noTemplate: "No template",
interactive: "Interactive",
networkCapabilities: "Network capabilities",
templateWarnings: (count: number) => `${count} warnings`,
};
const components = createCustomJsxComponents({
@@ -122,7 +120,6 @@ describe("Custom Widget runtime ports", () => {
<CustomJsxRenderer
template={'<Stack><TextInput bind="search" defaultValue="containers"/><Text>{inputs.search}</Text></Stack>'}
data={{}}
requestCapabilities={[]}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
@@ -1,4 +1,3 @@
import type { EditorView } from "@codemirror/view";
import { Alert, Stack, Text, UnstyledButton } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
@@ -8,12 +7,12 @@ import classes from "./code-editor.module.css";
export function EditorDiagnostics({
diagnostics,
editorView,
messages,
onSelect,
}: {
diagnostics: EditorDiagnostic[];
editorView: EditorView | null;
messages: CustomWidgetEditorMessages;
onSelect(diagnostic: EditorDiagnostic): void;
}) {
if (diagnostics.length === 0) return null;
return (
@@ -24,12 +23,7 @@ export function EditorDiagnostics({
type="button"
w="100%"
className={classes.diagnosticButton}
onClick={() => {
if (!editorView || diagnostic.index === undefined) return;
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
editorView.focus();
}}
onClick={() => onSelect(diagnostic)}
>
<Alert
color={diagnostic.severity === "error" ? "red" : "yellow"}
@@ -1,5 +1,3 @@
import { redo, undo } from "@codemirror/commands";
import type { EditorView } from "@codemirror/view";
import { Button, Group, Text } from "@mantine/core";
import {
IconArrowBackUp,
@@ -17,24 +15,28 @@ import classes from "./code-editor.module.css";
interface CodeEditorToolbarProps {
props: CustomWidgetCodeEditorProps;
editorView: EditorView | null;
editorCreated: boolean;
historyDepth: { undo: number; redo: number };
formattedValue: string;
copied: boolean;
referenceOpened: boolean;
onReferenceToggle(): void;
onCopy(): void;
onUndo(): void;
onRedo(): void;
}
export function CodeEditorToolbar({
props,
editorView,
editorCreated,
historyDepth,
formattedValue,
copied,
referenceOpened,
onReferenceToggle,
onCopy,
onUndo,
onRedo,
}: CodeEditorToolbarProps) {
const starter = props.starter;
return (
@@ -53,8 +55,8 @@ export function CodeEditorToolbar({
size="compact-xs"
variant="subtle"
leftSection={<IconArrowBackUp size={14} />}
onClick={() => editorView && undo(editorView)}
disabled={!editorView || historyDepth.undo === 0}
onClick={onUndo}
disabled={!editorCreated || historyDepth.undo === 0}
>
{props.messages.undo}
</Button>
@@ -63,8 +65,8 @@ export function CodeEditorToolbar({
size="compact-xs"
variant="subtle"
leftSection={<IconArrowForwardUp size={14} />}
onClick={() => editorView && redo(editorView)}
disabled={!editorView || historyDepth.redo === 0}
onClick={onRedo}
disabled={!editorCreated || historyDepth.redo === 0}
>
{props.messages.redo}
</Button>
@@ -1,21 +1,15 @@
"use client";
import { autocompletion } from "@codemirror/autocomplete";
import { redoDepth, undoDepth } from "@codemirror/commands";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { linter } from "@codemirror/lint";
import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { EditorView } from "@codemirror/view";
import type { ComponentType } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { lazy, Suspense, useCallback, useEffect, useId, useMemo, useState } from "react";
import { Badge, Collapse, Group, Input, Loader, Tabs, Text, useComputedColorScheme } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { createCustomJsxCompletionSource, customJsxComponentHover } from "./code-language";
import { EditorDiagnostics } from "./code-editor-diagnostics";
import { CodeEditorToolbar } from "./code-editor-toolbar";
import type { EditorDiagnostic } from "./analyzer";
import type {
CustomWidgetCodeEditorProps,
CustomWidgetEditorMessages,
@@ -30,53 +24,24 @@ export type {
} from "./code-editor-types";
const EMPTY_DIAGNOSTICS: NonNullable<CustomWidgetCodeEditorProps["diagnostics"]> = [];
const LazyCodeMirror = lazy(() => import("./direct-code-mirror"));
const editorViews = new Map<string, EditorViewType>();
export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
const editorInstanceId = useId();
const diagnostics = props.diagnostics ?? EMPTY_DIAGNOSTICS;
const [CodeMirror, setCodeMirror] = useState<ComponentType<ReactCodeMirrorProps> | null>(null);
const [editorReady, setEditorReady] = useState(false);
const [copied, setCopied] = useState(false);
const [referenceOpened, setReferenceOpened] = useState(false);
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [editorView, setEditorView] = useState<EditorViewType | null>(null);
const [editorCreated, setEditorCreated] = useState(false);
const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 });
const colorScheme = useComputedColorScheme("light");
useEffect(() => {
let cancelled = false;
void import("@uiw/react-codemirror").then((module) => {
if (!cancelled) setCodeMirror(() => module.default);
});
return () => {
cancelled = true;
};
setEditorReady(true);
}, []);
const extensions = useMemo(() => {
const diagnosticsExtension = linter((view) =>
diagnostics.map((diagnostic) => {
const from = Math.min(diagnostic.index ?? 0, view.state.doc.length);
return {
from,
to: Math.min(from + 1, view.state.doc.length),
severity: diagnostic.severity,
message: props.messages.diagnostic(diagnostic),
};
}),
);
const accessibilityExtension = EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label });
const editable = props.readOnly
? [EditorView.editable.of(false), EditorView.contentAttributes.of({ tabindex: "0" })]
: [];
return props.language === "jsx"
? [
javascript({ jsx: true }),
autocompletion({ override: [createCustomJsxCompletionSource(props.completions ?? [])] }),
customJsxComponentHover,
diagnosticsExtension,
accessibilityExtension,
...editable,
]
: [json(), diagnosticsExtension, accessibilityExtension, ...editable];
}, [diagnostics, props.completions, props.id, props.label, props.language, props.messages, props.readOnly]);
useEffect(() => {
const editorView = editorViews.get(editorInstanceId);
if (!editorView || !props.revealText) return;
const from = editorView.state.doc.toString().indexOf(props.revealText);
if (from < 0) return;
@@ -85,12 +50,27 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
effects: EditorView.scrollIntoView(from, { y: "center" }),
});
editorView.focus();
}, [editorView, props.revealKey, props.revealText]);
const handleCreateEditor = useCallback((view: EditorViewType) => {
// @uiw invokes this while constructing CodeMirror. Deferring the parent update avoids
// re-entering React's renderer during initialisation in Next.js and Docusaurus.
window.setTimeout(() => setEditorView(view), 0);
}, []);
}, [editorCreated, editorInstanceId, props.revealKey, props.revealText]);
const handleCreateEditor = useCallback(
(view: EditorViewType) => {
editorViews.set(editorInstanceId, view);
window.setTimeout(() => setEditorCreated(true), 0);
},
[editorInstanceId],
);
const handleDestroyEditor = useCallback(() => {
editorViews.delete(editorInstanceId);
}, [editorInstanceId]);
const selectDiagnostic = useCallback(
(diagnostic: EditorDiagnostic) => {
const editorView = editorViews.get(editorInstanceId);
if (!editorView || diagnostic.index === undefined) return;
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
editorView.focus();
},
[editorInstanceId],
);
const formattedValue = useMemo(() => formatCode(props.value, props.language), [props.language, props.value]);
const errorCount = diagnostics.filter(({ severity }) => severity === "error").length;
const warningCount = diagnostics.length - errorCount;
@@ -115,13 +95,21 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
<div className={classes.root}>
<CodeEditorToolbar
props={props}
editorView={editorView}
editorCreated={editorCreated}
historyDepth={historyDepth}
formattedValue={formattedValue}
copied={copied}
referenceOpened={referenceOpened}
onReferenceToggle={() => setReferenceOpened((value) => !value)}
onCopy={() => void copy()}
onUndo={() => {
const editorView = editorViews.get(editorInstanceId);
if (editorView) undo(editorView);
}}
onRedo={() => {
const editorView = editorViews.get(editorInstanceId);
if (editorView) redo(editorView);
}}
/>
{props.reference && (
<Collapse expanded={referenceOpened}>
@@ -129,36 +117,32 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
</Collapse>
)}
<div className={classes.viewport}>
{CodeMirror ? (
<CodeMirror
id={`${props.id}-root`}
value={props.value}
onChange={props.readOnly ? () => undefined : props.onChange}
placeholder={props.placeholder}
extensions={extensions}
theme={colorScheme}
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
basicSetup={{
autocompletion: props.language !== "jsx" && !props.readOnly,
bracketMatching: true,
closeBrackets: !props.readOnly,
foldGutter: true,
history: !props.readOnly,
highlightActiveLine: !props.readOnly,
highlightActiveLineGutter: !props.readOnly,
indentOnInput: !props.readOnly,
lineNumbers: true,
}}
onCreateEditor={handleCreateEditor}
onUpdate={(update) => {
if (!editorView) return;
if (!update.selectionSet && !update.docChanged) return;
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
setCursor({ line: line.number, column: head - line.from + 1 });
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
}}
/>
{editorReady ? (
<Suspense fallback={<EditorLoader />}>
<LazyCodeMirror
id={`${props.id}-root`}
value={props.value}
language={props.language}
diagnostics={diagnostics}
completions={props.completions ?? []}
label={props.label}
placeholder={props.placeholder}
theme={colorScheme}
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
readOnly={props.readOnly ?? false}
diagnosticMessage={props.messages.diagnostic}
onChange={props.readOnly ? () => undefined : props.onChange}
onCreateEditor={handleCreateEditor}
onDestroyEditor={handleDestroyEditor}
onUpdate={(update) => {
if (!update.selectionSet && !update.docChanged) return;
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
setCursor({ line: line.number, column: head - line.from + 1 });
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
}}
/>
</Suspense>
) : (
<EditorLoader />
)}
@@ -190,7 +174,7 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
</Text>
</Group>
</div>
<EditorDiagnostics diagnostics={diagnostics} editorView={editorView} messages={props.messages} />
<EditorDiagnostics diagnostics={diagnostics} messages={props.messages} onSelect={selectDiagnostic} />
</Input.Wrapper>
);
}
@@ -0,0 +1,135 @@
"use client";
import { autocompletion } from "@codemirror/autocomplete";
import { indentWithTab } from "@codemirror/commands";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { linter } from "@codemirror/lint";
import { EditorState, StateEffect } from "@codemirror/state";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView, keymap, placeholder as codeMirrorPlaceholder } from "@codemirror/view";
import type { ViewUpdate } from "@codemirror/view";
import { basicSetup } from "codemirror";
import { useCallback, useEffect, useId, useState } from "react";
import type { Completion } from "@codemirror/autocomplete";
import { createCustomJsxCompletionSource, customJsxComponentHover } from "./code-language";
import type { EditorDiagnostic } from "./analyzer";
interface DirectCodeMirrorProps {
value: string;
language: "jsx" | "json";
diagnostics: EditorDiagnostic[];
completions: Completion[];
id: string;
label: string;
placeholder?: string;
height: string;
theme: "light" | "dark";
readOnly: boolean;
diagnosticMessage(diagnostic: EditorDiagnostic): string;
onChange(value: string): void;
onCreateEditor(view: EditorView): void;
onDestroyEditor(): void;
onUpdate(update: ViewUpdate): void;
}
interface EditorCallbacks {
onChange(value: string): void;
onUpdate(update: ViewUpdate): void;
}
const editorViews = new Map<string, EditorView>();
const editorCallbacks = new Map<string, EditorCallbacks>();
const editorContainers = new Map<string, HTMLDivElement>();
export default function DirectCodeMirror(props: DirectCodeMirrorProps) {
const instanceId = useId();
const [initialProps] = useState(props);
const [containerReady, setContainerReady] = useState(false);
const setContainer = useCallback(
(container: HTMLDivElement | null) => {
if (!container) {
editorContainers.delete(instanceId);
return;
}
editorContainers.set(instanceId, container);
setContainerReady(true);
},
[instanceId],
);
useEffect(() => {
editorCallbacks.set(instanceId, { onChange: props.onChange, onUpdate: props.onUpdate });
}, [instanceId, props.onChange, props.onUpdate]);
useEffect(() => {
const parent = editorContainers.get(instanceId);
if (!parent) return;
const view = new EditorView({
parent,
state: EditorState.create({ doc: initialProps.value, extensions: createExtensions(instanceId, initialProps) }),
});
editorViews.set(instanceId, view);
initialProps.onCreateEditor(view);
return () => {
editorViews.delete(instanceId);
editorCallbacks.delete(instanceId);
initialProps.onDestroyEditor();
view.destroy();
};
}, [containerReady, initialProps, instanceId]);
useEffect(() => {
const view = editorViews.get(instanceId);
if (!view || view.state.doc.toString() === props.value) return;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: props.value } });
}, [instanceId, props.value]);
useEffect(() => {
const view = editorViews.get(instanceId);
if (!view) return;
view.dispatch({ effects: StateEffect.reconfigure.of(createExtensions(instanceId, props)) });
}, [instanceId, props]);
return <div ref={setContainer} />;
}
function createExtensions(instanceId: string, props: DirectCodeMirrorProps) {
const language =
props.language === "jsx"
? [
javascript({ jsx: true }),
autocompletion({ override: [createCustomJsxCompletionSource(props.completions)] }),
customJsxComponentHover,
]
: [json()];
return [
basicSetup,
...language,
linter((view) =>
props.diagnostics.map((diagnostic) => {
const from = Math.min(diagnostic.index ?? 0, view.state.doc.length);
return {
from,
to: Math.min(from + 1, view.state.doc.length),
severity: diagnostic.severity,
message: props.diagnosticMessage(diagnostic),
};
}),
),
EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label }),
EditorView.editable.of(!props.readOnly),
EditorState.readOnly.of(props.readOnly),
keymap.of(props.readOnly ? [] : [indentWithTab]),
props.placeholder ? codeMirrorPlaceholder(props.placeholder) : [],
EditorView.theme({ "&": { height: props.height }, ".cm-scroller": { overflow: "auto" } }),
props.theme === "dark" ? oneDark : [],
EditorView.updateListener.of((update) => {
const callbacks = editorCallbacks.get(instanceId);
if (!callbacks) return;
if (update.docChanged) callbacks.onChange(update.state.doc.toString());
callbacks.onUpdate(update);
}),
];
}
+21
View File
@@ -830,6 +830,7 @@
},
"action": {
"preview": "Test and preview",
"previewLoading": "Testing requests",
"create": "Create",
"save": "Save"
},
@@ -893,6 +894,26 @@
"invalid": "Fix validation errors to preview this widget.",
"fixtureError": "Preview fixture error",
"requestData": "Request data",
"status": {
"idle": "Requests have not been tested",
"loading": "Testing requests",
"success": "All load requests succeeded",
"error": "One or more load requests failed"
},
"result": {
"loading": {
"title": "Testing API requests",
"description": "Homarr is creating a preview session and loading the widget data."
},
"success": {
"title": "Preview ready",
"description": "{succeeded, plural, =0 {This widget has no load requests.} one {# load request succeeded.} other {# load requests succeeded.}} Open Data to inspect the response."
},
"error": {
"title": "Preview completed with request errors",
"description": "{succeeded, number} succeeded and {failed, number} failed. Open Data for the response and error details."
}
},
"instanceOptions": "Instance options",
"liveActions": "Run actions against the configured API",
"liveActionsDescription": "Off is safe simulation. Turning this on affects the real service.",
@@ -84,6 +84,26 @@ describe("CustomJsxDisplay", () => {
expect(container.textContent).toContain("3");
});
it("recovers when preview data arrives after an initial render error", async () => {
const template = "<Stack>{data.items.map((item) => <Text key={item}>{item}</Text>)}</Stack>";
await renderDisplay({ template, data: {} });
expect(container.textContent).toContain("RUNTIME_RENDER_ERROR");
await act(async () => {
root.render(
createElement(
MantineProvider,
null,
createElement(CustomJsxDisplay, { data: { template, data: { items: ["Bulbasaur", "Pikachu"] } } }),
),
);
});
expect(container.textContent).toContain("Bulbasaur");
expect(container.textContent).toContain("Pikachu");
expect(container.textContent).not.toContain("RUNTIME_RENDER_ERROR");
});
it("blocks script tags and event handlers", async () => {
await renderDisplay({
template: '<script>document.title = "hacked"</script><button onClick={() => {}}>Click</button><Text>Safe</Text>',
@@ -44,13 +44,10 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.status])),
}}
options={isRecord(data.options) ? data.options : {}}
requestCapabilities={capabilities}
components={components}
createBindings={SAFE_BINDINGS}
messages={{
noTemplate: t("noTemplate"),
interactive: t("interactive"),
networkCapabilities: t("networkCapabilities"),
templateWarnings: (count) => t("templateWarnings", { count: String(count) }),
}}
/>
+9 -3
View File
@@ -1719,6 +1719,12 @@ importers:
'@codemirror/lint':
specifier: 'catalog:'
version: 6.9.6
'@codemirror/state':
specifier: 'catalog:'
version: 6.6.0
'@codemirror/theme-one-dark':
specifier: 'catalog:'
version: 6.1.3
'@codemirror/view':
specifier: 'catalog:'
version: 6.43.0
@@ -1740,15 +1746,15 @@ importers:
'@tanstack/react-query':
specifier: 'catalog:'
version: 5.101.2(react@19.2.7)
'@uiw/react-codemirror':
specifier: 'catalog:'
version: 4.25.11(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.1)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.0)(codemirror@6.0.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
acorn:
specifier: 'catalog:'
version: 8.16.0
acorn-jsx:
specifier: 'catalog:'
version: 5.3.2(acorn@8.16.0)
codemirror:
specifier: 'catalog:'
version: 6.0.2
jsonpath-plus:
specifier: 'catalog:'
version: 10.4.0
+3
View File
@@ -8,6 +8,8 @@ catalog:
"@codemirror/lang-javascript": ^6.2.5
"@codemirror/lang-json": ^6.0.2
"@codemirror/lint": ^6.9.6
"@codemirror/state": ^6.6.0
"@codemirror/theme-one-dark": ^6.1.3
"@codemirror/view": ^6.43.0
"@auth/core": ^0.41.2
"@auth/drizzle-adapter": ^1.11.2
@@ -119,6 +121,7 @@ catalog:
better-sqlite3: ^12.11.1
chroma-js: ^3.2.0
clsx: ^2.1.1
codemirror: ^6.0.2
concurrently: ^10.0.3
conventional-changelog-conventionalcommits: ^10.2.0
cookie: 2.0.1