diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_form-error-boundary.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_form-error-boundary.tsx new file mode 100644 index 000000000..b77b7cec1 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_form-error-boundary.tsx @@ -0,0 +1,54 @@ +"use client"; + +import type { ErrorInfo, ReactNode } from "react"; +import { Component } from "react"; +import { Alert, Button, Stack, Text } from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +interface FormErrorBoundaryState { + error: Error | null; +} + +interface FormErrorBoundaryProps { + children: ReactNode; +} + +export class FormErrorBoundary extends Component { + public state: FormErrorBoundaryState = { error: null }; + + public static getDerivedStateFromError(error: Error): FormErrorBoundaryState { + return { error }; + } + + public componentDidCatch(error: Error, _info: ErrorInfo) { + console.error("Custom widget form error:", error); + } + + public render() { + if (this.state.error) { + return this.setState({ error: null })} />; + } + return this.props.children; + } +} + +function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }) { + const t = useScopedI18n("customWidget"); + return ( + } p="md"> + + + {t("editor.errorBoundary.title")} + + + {error.message} + + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/edit/[id]/page.tsx index 77439ac72..c98152f7a 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/edit/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/edit/[id]/page.tsx @@ -9,6 +9,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { catchTrpcNotFound } from "~/errors/trpc-catch-error"; import { CustomWidgetBetaBanner } from "../../_beta-banner"; import { CustomWidgetForm } from "../../_custom-widget-form"; +import { FormErrorBoundary } from "../../_form-error-boundary"; const authTypeExpectedSecrets: Record = { bearer: ["apiKey"], @@ -52,22 +53,24 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr {definition.name} - + + + diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx index 12a147997..a195f3a94 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx @@ -7,6 +7,7 @@ import { getScopedI18n } from "@homarr/translation/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { CustomWidgetBetaBanner } from "../_beta-banner"; import { CustomWidgetForm } from "../_custom-widget-form"; +import { FormErrorBoundary } from "../_form-error-boundary"; export default async function NewCustomWidgetPage() { const session = await auth(); @@ -28,7 +29,9 @@ export default async function NewCustomWidgetPage() { - + + + diff --git a/packages/custom-widgets/src/core/display-form.ts b/packages/custom-widgets/src/core/display-form.ts index c2c516721..3b1ae0deb 100644 --- a/packages/custom-widgets/src/core/display-form.ts +++ b/packages/custom-widgets/src/core/display-form.ts @@ -253,6 +253,12 @@ export function buildDisplayConfigFromFormValues(values: CustomWidgetDisplayForm } function parseRequestManifest(value: string) { - const parsed: unknown = JSON.parse(value); - return customJsxRequestSchema.array().parse(parsed); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return []; + } + const result = customJsxRequestSchema.array().safeParse(parsed); + return result.success ? result.data : []; } diff --git a/packages/custom-widgets/src/test/display-form.spec.ts b/packages/custom-widgets/src/test/display-form.spec.ts index d08b86681..4fb35bbbf 100644 --- a/packages/custom-widgets/src/test/display-form.spec.ts +++ b/packages/custom-widgets/src/test/display-form.spec.ts @@ -62,4 +62,16 @@ describe("custom-widget display form contracts", () => { requests: [{ id: "status", kind: "query", method: "GET" }], }); }); + + test("does not throw on invalid request manifest JSON while serializing v2 form state", () => { + const values = requiredFormValues("customJsx"); + values.jsxApiVersion = "2"; + + for (const invalidManifest of ["[invalid", "{not an array}", "", '{"id":1}', "null"]) { + values.requestManifest = invalidManifest; + expect(() => buildDisplayConfigFromFormValues(values)).not.toThrow(); + const config = buildDisplayConfigFromFormValues(values) as { requests?: unknown }; + expect(config.requests).toEqual([]); + } + }); }); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 31eb269fa..2132d6e9e 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -803,6 +803,10 @@ "editor": { "saveBlocked": "Resolve the editor errors before saving.", "saveReady": "The configuration is ready to save.", + "errorBoundary": { + "title": "Something went wrong", + "retry": "Try again" + }, "language": { "jsx": "JSX", "json": "JSON" @@ -4084,6 +4088,7 @@ }, "noDefinition": "No definition selected", "definitionNotFound": "Widget definition not found. It may have been deleted.", + "editModePending": "Save the board to load this widget.", "noDisplayOutput": "No display output", "openJson": "Open in browser JSON viewer", "execute": "Execute",