From 091eca31925573a9fcaf24f3e1ea707845734437 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 20 Jul 2026 17:55:57 +0200 Subject: [PATCH] fix(custom-widgets): stabilize authoring flows --- .../management/custom-widgets/authoring.mdx | 2 +- .../app/[locale]/boards/_layout-creator.tsx | 25 +-- .../_custom-widget-form-layout.tsx | 19 ++- .../custom-widgets/_custom-widget-form.tsx | 37 +++-- .../_custom-widget-preview-panel.tsx | 30 +++- .../_custom-widget-preview-status.tsx | 54 +++++++ .../_use-custom-widget-form-actions.ts | 40 ++++- packages/custom-widgets/package.json | 4 +- .../scripts/check-architecture.mjs | 4 +- packages/custom-widgets/src/core/ai-prompt.ts | 3 +- .../src/runtime/custom-jsx-renderer.tsx | 60 ++----- .../custom-widgets/src/test/ai-prompt.spec.ts | 2 +- .../custom-widgets/src/test/runtime.spec.tsx | 3 - .../src/workbench/code-editor-diagnostics.tsx | 12 +- .../src/workbench/code-editor-toolbar.tsx | 18 ++- .../src/workbench/code-editor.tsx | 150 ++++++++---------- .../src/workbench/direct-code-mirror.tsx | 135 ++++++++++++++++ packages/translation/src/lang/en.json | 21 +++ .../src/custom-api/custom-jsx-display.spec.ts | 20 +++ .../src/custom-api/custom-jsx-display.tsx | 3 - pnpm-lock.yaml | 12 +- pnpm-workspace.yaml | 3 + 22 files changed, 454 insertions(+), 203 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-status.tsx create mode 100644 packages/custom-widgets/src/workbench/direct-code-mirror.tsx diff --git a/apps/docs/docs/management/custom-widgets/authoring.mdx b/apps/docs/docs/management/custom-widgets/authoring.mdx index 8cc9f395b..c5794b9c0 100644 --- a/apps/docs/docs/management/custom-widgets/authoring.mdx +++ b/apps/docs/docs/management/custom-widgets/authoring.mdx @@ -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 diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index 3cddc6685..711596e65 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -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 = ({ - - - - } - actions={headerActions} - hasNavigation={false} - /> - {children} - - + + + + + } + actions={headerActions} + hasNavigation={false} + /> + {children} + + + diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-layout.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-layout.tsx index ab4eb4257..a124b7cb0 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-layout.tsx @@ -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({ - - 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 145747c17..7c9db4577 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 @@ -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({ data: {}, status: {}, session: null }); + const [preview, setPreview] = useState({ data: {}, status: {}, session: null, outcome: "idle" }); const [previewSize, setPreviewSize] = useState("standard"); const [previewTheme, setPreviewTheme] = useState<"light" | "dark">("dark"); const [optionsSnapshot, setOptionsSnapshot] = useState>({}); @@ -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 void runPreview()} @@ -265,7 +267,8 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi void runPreview()} 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 4367ef867..707f1ef4d 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 @@ -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; status: Record; 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 ( @@ -125,11 +143,21 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) { { value: "error", label: t("fixture.error") }, ]} /> + {(["widget", "data", "options", "actions", "diagnostics"] as const).map((tab) => ( - {t(`tab.${tab}`)} + + {t(`tab.${tab}`)} + {tab === "data" && ( + + )} + ))} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-status.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-status.tsx new file mode 100644 index 000000000..28d729f00 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-status.tsx @@ -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 ( + + + {title} + + {description} + + ); +} + +export function PreviewStatusDot({ outcome, label }: { outcome: PreviewOutcome; label: string }) { + const colors = { idle: "gray", loading: "yellow", success: "green", error: "red" } as const; + return ( + + + + ); +} + +export function getPreviewSummary(status: Record) { + 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 }, + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts index fc8e6eb34..9d527949f 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts @@ -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([]); + 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, }; } diff --git a/packages/custom-widgets/package.json b/packages/custom-widgets/package.json index 204d06cd9..7ca1a86ee 100644 --- a/packages/custom-widgets/package.json +++ b/packages/custom-widgets/package.json @@ -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:", diff --git a/packages/custom-widgets/scripts/check-architecture.mjs b/packages/custom-widgets/scripts/check-architecture.mjs index ec9563d9d..16e4901c7 100644 --- a/packages/custom-widgets/scripts/check-architecture.mjs +++ b/packages/custom-widgets/scripts/check-architecture.mjs @@ -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) { diff --git a/packages/custom-widgets/src/core/ai-prompt.ts b/packages/custom-widgets/src/core/ai-prompt.ts index 8ea3d7ae8..bace0bbe5 100644 --- a/packages/custom-widgets/src/core/ai-prompt.ts +++ b/packages/custom-widgets/src/core/ai-prompt.ts @@ -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: { diff --git a/packages/custom-widgets/src/runtime/custom-jsx-renderer.tsx b/packages/custom-widgets/src/runtime/custom-jsx-renderer.tsx index 3a655ed1a..ac94e8124 100644 --- a/packages/custom-widgets/src/runtime/custom-jsx-renderer.tsx +++ b/packages/custom-widgets/src/runtime/custom-jsx-renderer.tsx @@ -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; options?: Record; - requestCapabilities: unknown; components: Readonly>>; createBindings(data: unknown): Readonly>; 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 ( - {capabilities.length > 0 && ( - - - - } - style={{ cursor: "pointer" }} - > - {messages.interactive} - - - - - - {messages.networkCapabilities} - - {capabilities.map((capability) => ( - - - {capability.id} - - - - {capability.method} - - - {capability.minimumBoardPermission} - - - - ))} - - - - - )} {rendered.error ? ( @@ -233,7 +188,7 @@ export function CustomJsxRenderer({ registerInput={registerInput} setInputValue={setInputValue} > - + {rendered.node} @@ -254,3 +209,10 @@ export function CustomJsxRenderer({ ); } + +function createBoundaryKey(template: string, bindings: Readonly>) { + 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}`; +} diff --git a/packages/custom-widgets/src/test/ai-prompt.spec.ts b/packages/custom-widgets/src/test/ai-prompt.spec.ts index 9a4e5b525..ee0e7dd50 100644 --- a/packages/custom-widgets/src/test/ai-prompt.spec.ts +++ b/packages/custom-widgets/src/test/ai-prompt.spec.ts @@ -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", () => { diff --git a/packages/custom-widgets/src/test/runtime.spec.tsx b/packages/custom-widgets/src/test/runtime.spec.tsx index 27260b617..81fa688d5 100644 --- a/packages/custom-widgets/src/test/runtime.spec.tsx +++ b/packages/custom-widgets/src/test/runtime.spec.tsx @@ -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", () => { {inputs.search}'} data={{}} - requestCapabilities={[]} components={components} createBindings={() => ({})} messages={rendererMessages} diff --git a/packages/custom-widgets/src/workbench/code-editor-diagnostics.tsx b/packages/custom-widgets/src/workbench/code-editor-diagnostics.tsx index 26c750e68..735804854 100644 --- a/packages/custom-widgets/src/workbench/code-editor-diagnostics.tsx +++ b/packages/custom-widgets/src/workbench/code-editor-diagnostics.tsx @@ -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)} > } - onClick={() => editorView && undo(editorView)} - disabled={!editorView || historyDepth.undo === 0} + onClick={onUndo} + disabled={!editorCreated || historyDepth.undo === 0} > {props.messages.undo} @@ -63,8 +65,8 @@ export function CodeEditorToolbar({ size="compact-xs" variant="subtle" leftSection={} - onClick={() => editorView && redo(editorView)} - disabled={!editorView || historyDepth.redo === 0} + onClick={onRedo} + disabled={!editorCreated || historyDepth.redo === 0} > {props.messages.redo} diff --git a/packages/custom-widgets/src/workbench/code-editor.tsx b/packages/custom-widgets/src/workbench/code-editor.tsx index 2485953cd..2f01c97be 100644 --- a/packages/custom-widgets/src/workbench/code-editor.tsx +++ b/packages/custom-widgets/src/workbench/code-editor.tsx @@ -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 = []; +const LazyCodeMirror = lazy(() => import("./direct-code-mirror")); +const editorViews = new Map(); export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) { + const editorInstanceId = useId(); const diagnostics = props.diagnostics ?? EMPTY_DIAGNOSTICS; - const [CodeMirror, setCodeMirror] = useState | 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(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) {
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 && ( @@ -129,36 +117,32 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) { )}
- {CodeMirror ? ( - 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 ? ( + }> + 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) }); + }} + /> + ) : ( )} @@ -190,7 +174,7 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
- + ); } diff --git a/packages/custom-widgets/src/workbench/direct-code-mirror.tsx b/packages/custom-widgets/src/workbench/direct-code-mirror.tsx new file mode 100644 index 000000000..657f9367c --- /dev/null +++ b/packages/custom-widgets/src/workbench/direct-code-mirror.tsx @@ -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(); +const editorCallbacks = new Map(); +const editorContainers = new Map(); + +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
; +} + +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); + }), + ]; +} diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 09c73f928..26130994c 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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.", diff --git a/packages/widgets/src/custom-api/custom-jsx-display.spec.ts b/packages/widgets/src/custom-api/custom-jsx-display.spec.ts index d13a4ac1f..2dd965f5f 100644 --- a/packages/widgets/src/custom-api/custom-jsx-display.spec.ts +++ b/packages/widgets/src/custom-api/custom-jsx-display.spec.ts @@ -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 = "{data.items.map((item) => {item})}"; + 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: 'Safe', diff --git a/packages/widgets/src/custom-api/custom-jsx-display.tsx b/packages/widgets/src/custom-api/custom-jsx-display.tsx index ae376459c..4880097bf 100644 --- a/packages/widgets/src/custom-api/custom-jsx-display.tsx +++ b/packages/widgets/src/custom-api/custom-jsx-display.tsx @@ -44,13 +44,10 @@ export default function CustomJsxDisplay({ data }: { data: Record [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) }), }} /> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6bfed0842..1f273185c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c2b3ced88..1a2106464 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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