fix(custom-widgets): simplify error sharing

This commit is contained in:
Thomas Camlong committed 2026-07-20 18:11:10 +02:00
1 parent b509679274
commit 211e059521
10 files changed
+65 -115

No files matched your search

@@ -1,7 +1,7 @@
"use client";
import { Button, Card, Group, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { IconCopy, IconSparkles } from "@tabler/icons-react";
import { IconSparkles } from "@tabler/icons-react";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
@@ -15,7 +15,6 @@ interface AiCardProps {
documentationUrl: string;
onDocumentationUrlChange(value: string): void;
onPaste(): void;
onCopyDiagnostics(): void;
}
export function CustomWidgetAiCard(props: AiCardProps) {
@@ -48,9 +47,6 @@ export function CustomWidgetAiCard(props: AiCardProps) {
<Button type="button" variant="light" leftSection={<IconSparkles size={16} />} onClick={props.onPaste}>
{t("paste")}
</Button>
<Button type="button" variant="subtle" leftSection={<IconCopy size={16} />} onClick={props.onCopyDiagnostics}>
{t("copyDiagnostics")}
</Button>
</Group>
</Stack>
</Card>
@@ -1,12 +1,25 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Alert, Box, Button, List, Paper, SegmentedControl, Stack, Text, TextInput, Textarea } from "@mantine/core";
import {
Alert,
Box,
Button,
CopyButton,
Paper,
SegmentedControl,
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import {
IconAlertCircle,
IconApi,
IconBraces,
IconCheck,
IconCode,
IconCopy,
IconDatabase,
IconEye,
IconSettings,
@@ -119,21 +132,17 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
[candidate, requestDiagnostics, templateDiagnostics],
);
const { save, runPreview, pasteAiResponse, copyDiagnostics, saveIssues, savePending, previewPending } =
useCustomWidgetFormActions({
mode,
definitionId,
form,
candidate,
templateDiagnostics,
requestDiagnostics,
preview,
setPreview,
setMobilePane,
setOptionsSnapshot,
request,
documentationUrl,
});
const { save, runPreview, pasteAiResponse, saveIssues, savePending, previewPending } = useCustomWidgetFormActions({
mode,
definitionId,
form,
candidate,
preview,
setPreview,
setMobilePane,
setOptionsSnapshot,
});
const saveIssueText = saveIssues.map((issue) => `${issue.path ? `${issue.path}: ` : ""}${issue.message}`).join("\n");
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
@@ -169,14 +178,29 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
<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>
<Textarea
value={saveIssueText}
readOnly
autosize
minRows={2}
maxRows={10}
aria-label={w("saveError.errors")}
mb="xs"
/>
<CopyButton value={saveIssueText} timeout={2_000}>
{({ copied, copy }) => (
<Button
type="button"
size="compact-sm"
variant="light"
color={copied ? "green" : "red"}
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
onClick={copy}
>
{copied ? w("saveError.copied") : w("saveError.copy")}
</Button>
)}
</CopyButton>
</Alert>
)}
<nav className={classes.sectionNav} aria-label={w("sectionNavigation")}>
@@ -265,7 +289,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
documentationUrl={documentationUrl}
onDocumentationUrlChange={setDocumentationUrl}
onPaste={() => void pasteAiResponse()}
onCopyDiagnostics={() => void copyDiagnostics()}
/>
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
<SaveActions
@@ -5,14 +5,13 @@ import type { Dispatch, SetStateAction } from "react";
import { useRouter } from "next/navigation";
import type { UseFormReturnType } from "@mantine/form";
import { clientApi, fetchApi } from "@homarr/api/client";
import { clientApi } from "@homarr/api/client";
import {
buildCustomWidgetFixPrompt,
customWidgetDefinitionSchema,
formatCustomWidgetImportIssues,
parseCustomWidgetAiResponse,
} from "@homarr/custom-widgets/core";
import type { EditorDiagnostic, CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
@@ -26,14 +25,10 @@ interface FormActionsInput {
definitionId?: string;
form: UseFormReturnType<CustomWidgetFormValues>;
candidate: ReturnType<typeof buildDefinition>;
templateDiagnostics: EditorDiagnostic[];
requestDiagnostics: EditorDiagnostic[];
preview: PreviewState;
setPreview: Dispatch<SetStateAction<PreviewState>>;
setMobilePane: Dispatch<SetStateAction<"configure" | "preview">>;
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
request: string;
documentationUrl: string;
}
export function useCustomWidgetFormActions(input: FormActionsInput) {
@@ -44,7 +39,6 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const createMutation = clientApi.customWidget.create.useMutation();
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);
@@ -169,10 +163,8 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
try {
const result = parseCustomWidgetAiResponse(await navigator.clipboard.readText());
if (!result.success) {
setImportIssues(result.issues.map((issue) => `${issue.path?.join(".") ?? "widget"}: ${issue.message}`));
throw new Error(formatCustomWidgetImportIssues(result.issues));
}
setImportIssues([]);
applyDefinition(input.form, customWidgetDefinitionSchema.parse(result.widget));
input.setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
showSuccessNotification({
@@ -191,35 +183,10 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
}
};
const copyDiagnostics = async () => {
try {
const diagnostics = [...input.templateDiagnostics, ...input.requestDiagnostics]
.map((entry) => `${entry.severity.toUpperCase()} ${entry.code}: ${entry.value ?? ""}`)
.concat(importIssues.map((issue) => `IMPORT: ${issue}`))
.join("\n");
const journal = input.preview.session
? await fetchApi.customWidget.previewJournal.query({ sessionId: input.preview.session.id }).catch(() => [])
: [];
await navigator.clipboard.writeText(
buildCustomWidgetFixPrompt({
currentConfig: input.candidate.success ? input.candidate.data : input.form.values,
request: input.request,
documentationUrl: input.documentationUrl,
diagnostics,
journal,
}),
);
showSuccessNotification({ title: w("ai.diagnostics"), message: w("ai.diagnosticsCopied") });
} catch {
showErrorNotification({ title: w("ai.diagnostics"), message: t("notification.aiPromptCopyError") });
}
};
return {
save,
runPreview,
pasteAiResponse,
copyDiagnostics,
saveIssues,
savePending: createMutation.isPending || updateMutation.isPending,
previewPending,