From afa54755fd1b216319bb8fb80e8a38e2b2c6c89f Mon Sep 17 00:00:00 2001 From: ajnart Date: Fri, 14 Aug 2026 10:15:57 +0200 Subject: [PATCH] fix custom widget review findings --- .../custom-widgets/_custom-widget-form.tsx | 1 + .../_custom-widget-preview-panel.spec.tsx | 9 +++- .../_custom-widget-preview-panel.tsx | 2 + .../_custom-widget-sources-editor.tsx | 4 +- .../_json-preview-editor.spec.tsx | 46 +++++++++++++++++++ .../custom-widgets/_json-preview-editor.tsx | 13 +++++- .../_unsaved-changes-guard.spec.ts | 13 ++++++ .../custom-widgets/_unsaved-changes-guard.ts | 12 +++++ .../components/workshop/workshop-browser.tsx | 31 ++++++------- .../workshop/workshop-publish-modal.tsx | 4 ++ .../custom-widget/preview-query-procedures.ts | 10 +++- .../router/test/widgets/custom-api-v2.spec.ts | 19 +++++++- packages/api/src/router/widgets/custom-api.ts | 7 +++ .../custom-widgets/src/runtime/sub-fetch.tsx | 4 +- .../src/test/workshop-translations.spec.ts | 20 ++++++++ packages/translation/src/lang/en.json | 18 ++++++++ .../custom-api/widget-definition-context.tsx | 20 +++++++- 17 files changed, 207 insertions(+), 26 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/_json-preview-editor.spec.tsx 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 70cf130e7..0e724a8df 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 @@ -145,6 +145,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi key={id} component="a" href={`#${id}`} + onClick={() => setMobilePane(id === "preview" ? "preview" : "configure")} size="compact-sm" variant="subtle" color={invalidSections.has(id) ? "red" : undefined} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.spec.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.spec.tsx index 0968e0281..d1fd13241 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.spec.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.spec.tsx @@ -22,6 +22,7 @@ vi.mock("@homarr/api/client", () => ({ vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key })); vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() })); const rendererAttempt = vi.hoisted(() => vi.fn()); +const codeEditorProps = vi.hoisted(() => vi.fn()); vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({ default: ({ data }: { data: Record }) => { @@ -32,7 +33,12 @@ vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({ return
widget-renderer
; }, })); -vi.mock("./_code-editor", () => ({ CodeEditor: ({ label }: { label: string }) =>
{label}
})); +vi.mock("./_code-editor", () => ({ + CodeEditor: (props: { label: string; readOnly?: boolean }) => { + codeEditorProps(props); + return
{props.label}
; + }, +})); vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () =>
action-control
})); let root: Root; @@ -109,6 +115,7 @@ describe("Custom Widget preview panel", () => { ); await act(async () => dataTab?.click()); expect(host.textContent).toContain("requestData"); + expect(codeEditorProps).toHaveBeenCalledWith(expect.objectContaining({ label: "requestData", readOnly: true })); expect(host.textContent).not.toContain("invalid"); const optionsTab = [...host.querySelectorAll('[role="tab"]')].find((element) => 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 b038f2d54..21b46b90c 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 @@ -172,6 +172,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) { label={t("requestData")} language="json" value={JSON.stringify({ data: props.preview.data, status: props.preview.status }, null, 2)} + readOnly onChange={() => undefined} /> @@ -251,6 +252,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) { label={t("journal")} language="json" value={JSON.stringify(journalQuery.data ?? [], null, 2)} + readOnly onChange={() => undefined} /> )} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx index c3a2c44cc..b95274baf 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx @@ -87,7 +87,9 @@ export function CustomWidgetSourcesEditor({ form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]); }; const addSource = () => { - const id = sources.length === 0 ? "default" : `source-${sources.length + 1}`; + let suffix = sources.length + 1; + while (sources.some(({ id }) => id === `source-${suffix}`)) suffix += 1; + const id = sources.length === 0 ? "default" : `source-${suffix}`; form.setFieldValue( "sources", JSON.stringify( diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_json-preview-editor.spec.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_json-preview-editor.spec.tsx new file mode 100644 index 000000000..d795e8276 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_json-preview-editor.spec.tsx @@ -0,0 +1,46 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { JsonPreviewEditor } from "./_json-preview-editor"; + +const editorProps = vi.hoisted(() => vi.fn()); + +vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key })); +vi.mock("./_code-editor", () => ({ + CodeEditor: (props: { error?: string; onChange(value: string): void }) => { + editorProps(props); + return ; + }, +})); + +let root: Root; +let host: HTMLDivElement; + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root.unmount()); + host.remove(); +}); + +describe("preview JSON editor", () => { + it("surfaces invalid JSON and clears the previously rendered options", async () => { + const onChange = vi.fn(); + await act(async () => { + root.render(); + }); + await act(async () => (host.querySelector("button") as HTMLButtonElement).click()); + + expect(onChange).toHaveBeenCalledWith({}); + expect(editorProps).toHaveBeenLastCalledWith(expect.objectContaining({ error: "invalidJson" })); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_json-preview-editor.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_json-preview-editor.tsx index 36edc12e7..9c48fa926 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_json-preview-editor.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_json-preview-editor.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from "react"; +import { useScopedI18n } from "@homarr/translation/client"; + import { CodeEditor } from "./_code-editor"; import { isRecord, parseJson } from "./_custom-widget-form-utils"; @@ -16,19 +18,26 @@ export function JsonPreviewEditor({ value: Record; onChange(value: Record): void; }) { + const t = useScopedI18n("customWidget.workbench.builder"); const serialized = JSON.stringify(value, null, 2); const [draft, setDraft] = useState(serialized); - useEffect(() => setDraft(serialized), [serialized]); + const [invalid, setInvalid] = useState(false); + useEffect(() => { + if (!invalid) setDraft(serialized); + }, [invalid, serialized]); return ( { setDraft(next); const parsed = parseJson(next); - if (isRecord(parsed)) onChange(parsed); + const valid = isRecord(parsed); + setInvalid(!valid); + onChange(valid ? parsed : {}); }} /> ); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_unsaved-changes-guard.spec.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_unsaved-changes-guard.spec.ts index 353806380..4fa0d0788 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_unsaved-changes-guard.spec.ts +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_unsaved-changes-guard.spec.ts @@ -48,4 +48,17 @@ describe("custom widget unsaved changes guard", () => { expect(listLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(true); expect(confirmNavigation).not.toHaveBeenCalled(); }); + + it("restores the current page and confirms browser history navigation", () => { + window.history.replaceState(null, "", "/en/manage/custom-widgets"); + window.history.pushState(null, "", "/en/manage/custom-widgets/edit/widget-1"); + const confirmNavigation = vi.fn(); + cleanups.push(registerUnsavedChangesGuard({ isDirty: () => true, confirmNavigation })); + + window.history.replaceState(null, "", "/en/manage/custom-widgets"); + window.dispatchEvent(new PopStateEvent("popstate")); + + expect(window.location.pathname).toBe("/en/manage/custom-widgets/edit/widget-1"); + expect(confirmNavigation).toHaveBeenCalledWith("/en/manage/custom-widgets"); + }); }); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_unsaved-changes-guard.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_unsaved-changes-guard.ts index 280138de8..73ab6d5d5 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_unsaved-changes-guard.ts +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_unsaved-changes-guard.ts @@ -4,6 +4,7 @@ interface UnsavedChangesGuardOptions { } export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) { + let currentHref = `${window.location.pathname}${window.location.search}${window.location.hash}`; const handleClick = (event: MouseEvent) => { if (!isDirty() || event.defaultPrevented || event.button !== 0) return; if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; @@ -32,11 +33,22 @@ export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: Unsa event.preventDefault(); event.returnValue = true; }; + const handlePopState = () => { + const destination = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (!isDirty()) { + currentHref = destination; + return; + } + window.history.pushState(window.history.state, "", currentHref); + confirmNavigation(destination); + }; document.addEventListener("click", handleClick, true); window.addEventListener("beforeunload", handleBeforeUnload); + window.addEventListener("popstate", handlePopState); return () => { document.removeEventListener("click", handleClick, true); window.removeEventListener("beforeunload", handleBeforeUnload); + window.removeEventListener("popstate", handlePopState); }; } diff --git a/apps/nextjs/src/components/workshop/workshop-browser.tsx b/apps/nextjs/src/components/workshop/workshop-browser.tsx index 22692c8cf..89d069b30 100644 --- a/apps/nextjs/src/components/workshop/workshop-browser.tsx +++ b/apps/nextjs/src/components/workshop/workshop-browser.tsx @@ -295,19 +295,15 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo - {t("author", { name: item.authorName || "Community member" })} + {t("author", { name: item.authorName || t("communityMember") })} {item.upvotes} ยท {item.downvotes} - {item.outdated && Outdated} - {item.reportCount > 0 && ( - - {item.reportCount} report{item.reportCount === 1 ? "" : "s"} - - )} + {item.outdated && {t("outdated")}} + {item.reportCount > 0 && {t("reportCount", { count: item.reportCount })}} {item.title} @@ -349,7 +345,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo {detail.data.title} - {t("author", { name: detail.data.authorName || "Community member" })} + {t("author", { name: detail.data.authorName || t("communityMember") })}