fix(custom-widgets): surface save validation errors

This commit is contained in:
Thomas Camlong committed 2026-07-20 18:06:34 +02:00
1 parent 0d5ca3db78
commit b509679274
5 files changed
+182 -8

No files matched your search

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
import { Alert, Box, Button, List, Paper, SegmentedControl, Stack, Text, TextInput, Textarea } from "@mantine/core";
import {
IconAlertCircle,
IconApi,
@@ -119,7 +119,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
[candidate, requestDiagnostics, templateDiagnostics],
);
const { save, runPreview, pasteAiResponse, copyDiagnostics, savePending, previewPending } =
const { save, runPreview, pasteAiResponse, copyDiagnostics, saveIssues, savePending, previewPending } =
useCustomWidgetFormActions({
mode,
definitionId,
@@ -164,6 +164,21 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
{ value: "preview", label: w("section.preview") },
]}
/>
{saveIssues.length > 0 && (
<Alert color="red" title={w("saveError.title")} icon={<IconAlertCircle size={18} />}>
<Text size="sm" mb="xs">
{w("saveError.description")}
</Text>
<List size="sm" spacing={4}>
{saveIssues.map((issue, index) => (
<List.Item key={`${issue.path ?? "widget"}-${index}`}>
{issue.path ? `${issue.path}: ` : ""}
{issue.message}
</List.Item>
))}
</List>
</Alert>
)}
<nav className={classes.sectionNav} aria-label={w("sectionNavigation")}>
{sectionLinks.map(([id, key, SectionIcon]) => (
<Button
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { extractCustomWidgetSaveIssues } from "./_custom-widget-save-errors";
describe("extractCustomWidgetSaveIssues", () => {
it("extracts every flattened server validation message", () => {
expect(
extractCustomWidgetSaveIssues({
data: {
zodError: {
fieldErrors: {
template: [
"UNKNOWN_COMPONENT: 'Radio.Group' is not available. Did you mean 'RadioGroup'?",
"UNKNOWN_COMPONENT: 'Radio.Card' is not available. Did you mean 'RadioCard'?",
],
},
},
},
}),
).toEqual([
{ path: "template", message: "UNKNOWN_COMPONENT: 'Radio.Group' is not available. Did you mean 'RadioGroup'?" },
{ path: "template", message: "UNKNOWN_COMPONENT: 'Radio.Card' is not available. Did you mean 'RadioCard'?" },
]);
});
it("extracts paths and messages from a serialized Zod issue list", () => {
const message = JSON.stringify([
{
code: "custom",
message: "UNKNOWN_COMPONENT: 'Radio.Indicator' is not available. Did you mean 'RadioIndicator'?",
path: ["template"],
},
]);
expect(extractCustomWidgetSaveIssues({ message })).toEqual([
{
path: "template",
message: "UNKNOWN_COMPONENT: 'Radio.Indicator' is not available. Did you mean 'RadioIndicator'?",
},
]);
});
it("extracts root validation errors from the tRPC error shape", () => {
expect(
extractCustomWidgetSaveIssues({
shape: {
data: {
zodError: {
formErrors: ["The widget definition is invalid."],
fieldErrors: {},
},
},
},
}),
).toEqual([{ message: "The widget definition is invalid." }]);
});
});
@@ -0,0 +1,54 @@
export interface CustomWidgetSaveIssue {
path?: string;
message: string;
}
const MAX_SAVE_ISSUES = 20;
export function extractCustomWidgetSaveIssues(error: unknown): CustomWidgetSaveIssue[] {
const record = asRecord(error);
const data = asRecord(record?.data) ?? asRecord(asRecord(record?.shape)?.data);
const zodError = asRecord(data?.zodError);
const fieldErrors = asRecord(zodError?.fieldErrors);
const flattened = fieldErrors
? Object.entries(fieldErrors).flatMap(([path, value]) =>
Array.isArray(value)
? value.flatMap((message) => (typeof message === "string" ? [{ path, message }] : []))
: [],
)
: [];
const formErrors = Array.isArray(zodError?.formErrors)
? zodError.formErrors.flatMap((message) => (typeof message === "string" ? [{ message }] : []))
: [];
if (flattened.length > 0 || formErrors.length > 0) return uniqueIssues([...formErrors, ...flattened]);
const message = typeof record?.message === "string" ? record.message : null;
if (!message) return [];
const parsed = parseZodIssueMessage(message);
return parsed.length > 0 ? uniqueIssues(parsed) : [{ message }];
}
function parseZodIssueMessage(message: string): CustomWidgetSaveIssue[] {
try {
const parsed = JSON.parse(message) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.flatMap((candidate) => {
const issue = asRecord(candidate);
if (typeof issue?.message !== "string") return [];
const path = Array.isArray(issue.path) ? issue.path.filter((part) => typeof part === "string").join(".") : undefined;
return [{ path: path || undefined, message: issue.message }];
});
} catch {
return [];
}
}
function uniqueIssues(issues: CustomWidgetSaveIssue[]) {
return issues
.filter((issue, index) => issues.findIndex((candidate) => candidate.path === issue.path && candidate.message === issue.message) === index)
.slice(0, MAX_SAVE_ISSUES);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
}
@@ -18,6 +18,8 @@ import { useScopedI18n } from "@homarr/translation/client";
import { applyDefinition, buildDefinition, isRecord, loadPreviewQueries } from "./_custom-widget-form-utils";
import type { PreviewState } from "./_custom-widget-preview-panel";
import { extractCustomWidgetSaveIssues } from "./_custom-widget-save-errors";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
interface FormActionsInput {
mode: "create" | "edit";
@@ -43,15 +45,21 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const updateMutation = clientApi.customWidget.update.useMutation();
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
const [importIssues, setImportIssues] = useState<string[]>([]);
const [saveIssues, setSaveIssues] = useState<CustomWidgetSaveIssue[]>([]);
const [previewPending, setPreviewPending] = useState(false);
const save = input.form.onSubmit(async (values) => {
clearSaveIssues(input.form, saveIssues);
setSaveIssues([]);
const definition = buildDefinition(values);
if (!definition.success) {
showErrorNotification({
title: t("action.save"),
message: definition.error.issues[0]?.message ?? w("invalidWidget"),
});
reportSaveIssues(
input.form,
definition.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })),
setSaveIssues,
t("action.save"),
(count) => w("saveError.more", { count }),
);
return;
}
const changedSecrets = values.secrets
@@ -88,8 +96,15 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
message: t("notification.updated", { name: values.name }),
});
}
} catch {
showErrorNotification({ title: t("action.save"), message: t("notification.updateError") });
} catch (error) {
const issues = extractCustomWidgetSaveIssues(error);
if (issues.length > 0) {
reportSaveIssues(input.form, issues, setSaveIssues, t("action.save"), (count) =>
w("saveError.more", { count }),
);
} else {
showErrorNotification({ title: t("action.save"), message: t("notification.updateError") });
}
}
});
@@ -205,7 +220,35 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
runPreview,
pasteAiResponse,
copyDiagnostics,
saveIssues,
savePending: createMutation.isPending || updateMutation.isPending,
previewPending,
};
}
function reportSaveIssues(
form: UseFormReturnType<CustomWidgetFormValues>,
issues: CustomWidgetSaveIssue[],
setIssues: Dispatch<SetStateAction<CustomWidgetSaveIssue[]>>,
title: string,
getRemainingLabel: (count: number) => string,
) {
setIssues(issues);
const messagesByPath = new Map<string, string[]>();
for (const issue of issues) {
if (!issue.path) continue;
messagesByPath.set(issue.path, [...(messagesByPath.get(issue.path) ?? []), issue.message]);
}
for (const [path, messages] of messagesByPath) {
form.setFieldError(path, messages.join(" "));
}
const remaining = issues.length - 1;
showErrorNotification({
title,
message: `${issues[0]?.message ?? ""}${remaining > 0 ? ` ${getRemainingLabel(remaining)}` : ""}`,
});
}
function clearSaveIssues(form: UseFormReturnType<CustomWidgetFormValues>, issues: CustomWidgetSaveIssue[]) {
for (const path of new Set(issues.flatMap((issue) => (issue.path ? [issue.path] : [])))) form.clearFieldError(path);
}
+5
View File
@@ -828,6 +828,11 @@
"invalid": "Needs attention",
"ready": "Ready"
},
"saveError": {
"title": "Widget was not saved",
"description": "Fix these validation errors and save again.",
"more": "+{count} more errors are shown in the workbench."
},
"action": {
"preview": "Test and preview",
"previewLoading": "Testing requests",