fix(custom-widgets): surface save validation errors
This commit is contained in:
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;
|
||||
}
|
||||
+49
-6
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user