diff --git a/apps/docs/docs/management/custom-widgets/authoring.mdx b/apps/docs/docs/management/custom-widgets/authoring.mdx index a2742a2e7..8faebf60c 100644 --- a/apps/docs/docs/management/custom-widgets/authoring.mdx +++ b/apps/docs/docs/management/custom-widgets/authoring.mdx @@ -41,6 +41,10 @@ needs attention. 4. Write responsive JSX using `data`, `status`, `options`, and temporary `inputs`. 5. Test and preview, inspect Data and the redacted request journal, then save. +While focus is inside a code editor, use `Ctrl+Z`/`Cmd+Z` to undo and `Ctrl+Shift+Z`/`Cmd+Shift+Z` to redo. The toolbar exposes the same actions. This history is local to that editor; builder-wide undo is not yet available. + +Changing the definition, instance options, or credentials invalidates the previous preview and its live-action session. Run **Test and preview** again before treating the data or result as current. Automatic load queries are queued with up to four running at once so larger previews stay within the request limit. + ; export function CodeEditor(props: CodeEditorProps) { const t = useI18n("customWidget.editor"); const tCommon = useI18n("common"); - return ( - t("componentReference.count", { count }), - insertStarter: t("action.insertStarter"), - format: t("action.format"), - copy: tCommon("action.copy"), - copied: t("action.copied"), - schema: t("action.schema"), - schemaTab: invariantTechnicalLabels.jsonSchema, - minimalTab: t("reference.minimal"), - fullTab: t("reference.full"), - errors: (count) => t("status.errors", { count }), - warnings: (count) => t("status.warnings", { count }), - ready: t("status.ready"), - position: (cursor) => t("status.position", cursor), - characters: (count, limit) => - limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }), - diagnosticsTitle: t("diagnostics.title"), - diagnostic: (diagnostic) => - `${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t( - `diagnostics.${diagnostic.code}` as never, - { value: diagnostic.value ?? "" } as never, - )}`, - }} - /> + const messages = useMemo( + () => ({ + languageJsx: invariantTechnicalLabels.jsx, + languageJson: invariantTechnicalLabels.json, + undo: tCommon("action.undo"), + redo: t("action.redo"), + components: t("action.components"), + componentSearch: t("componentReference.search"), + componentEmpty: t("componentReference.empty"), + componentCount: (count) => t("componentReference.count", { count }), + insertStarter: t("action.insertStarter"), + format: t("action.format"), + copy: tCommon("action.copy"), + copied: t("action.copied"), + schema: t("action.schema"), + schemaTab: invariantTechnicalLabels.jsonSchema, + minimalTab: t("reference.minimal"), + fullTab: t("reference.full"), + errors: (count) => t("status.errors", { count }), + warnings: (count) => t("status.warnings", { count }), + ready: t("status.ready"), + position: (cursor) => t("status.position", cursor), + characters: (count, limit) => + limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }), + diagnosticsTitle: t("diagnostics.title"), + diagnostic: (diagnostic) => + `${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t( + `diagnostics.${diagnostic.code}` as never, + { value: diagnostic.value ?? "" } as never, + )}`, + }), + [t, tCommon], ); + return ; } diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx index 2e05917c7..3db1ab746 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx @@ -3,37 +3,70 @@ import { ActionIcon, Group, Tooltip } from "@mantine/core"; import { IconCopy, IconRobot } from "@tabler/icons-react"; -import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt"; +import { buildCustomWidgetAiPrompt, buildCustomWidgetAssistantPrompt } from "@homarr/custom-widgets/authoring-prompt"; +import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context"; -interface CopyAiPromptButtonProps { +interface CopyAiPromptButtonBaseProps { rawResponse?: string | null; - currentConfig?: Record | null; request?: string | null; documentationUrl?: string | null; } +type CopyAiPromptButtonProps = CopyAiPromptButtonBaseProps & + ({ draft: CustomWidgetAiDraft; getDraft?: never } | { draft?: never; getDraft(): CustomWidgetAiDraft }) & + ( + | { diagnostics: readonly CustomWidgetAiDiagnostic[]; getDiagnostics?: never } + | { diagnostics?: never; getDiagnostics(): readonly CustomWidgetAiDiagnostic[] } + ); + export const CopyAiPromptButton = ({ rawResponse, - currentConfig, + draft, + getDraft, + diagnostics, + getDiagnostics, request, documentationUrl, }: CopyAiPromptButtonProps) => { const t = useI18n("customWidget"); const assistant = useOptionalHomarrAssistant(); - const prompt = () => buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl); + + const readDraft = () => { + if (getDraft) return getDraft(); + return draft; + }; + const readDiagnostics = () => { + if (getDiagnostics) return getDiagnostics(); + return diagnostics; + }; const handleAssistant = () => { - if (!assistant?.enabled || !assistant.sendPrompt(prompt())) return; + const prompt = buildCustomWidgetAssistantPrompt( + undefined, + rawResponse, + readDraft(), + request, + documentationUrl, + readDiagnostics(), + ); + if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return; showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") }); }; const handleCopy = async () => { try { - const value = prompt(); + const value = buildCustomWidgetAiPrompt( + undefined, + rawResponse, + readDraft(), + request, + documentationUrl, + readDiagnostics(), + ); await navigator.clipboard.writeText(value); showSuccessNotification({ title: t("action.copyAiPrompt"), diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-advanced-manifest.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-advanced-manifest.tsx index 26cf74e6e..bba1a4cf4 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-advanced-manifest.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-advanced-manifest.tsx @@ -1,27 +1,38 @@ "use client"; import { useEffect, useState } from "react"; -import { Accordion } from "@mantine/core"; import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core"; import { useI18n } from "@homarr/translation/client"; import { CodeEditor } from "./_code-editor"; +import { useDeferredCustomWidgetFormDocumentValues } from "./_custom-widget-form-state"; import { applyDefinition, parseJson } from "./_custom-widget-form-utils"; import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; +import { LazyOnceAccordion } from "./_lazy-once-accordion"; export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkbenchForm }) { const t = useI18n("customWidget.workbench.builder"); + return ( + + + + ); +} + +function CustomWidgetManifestEditor({ form }: { form: CustomWidgetWorkbenchForm }) { + const t = useI18n("customWidget.workbench.builder"); + const values = useDeferredCustomWidgetFormDocumentValues(); const serialized = JSON.stringify( { $schema: "homarr-custom-widget-v2", - name: form.values.name, - ...(form.values.description ? { description: form.values.description } : {}), - ...(form.values.iconUrl ? { iconUrl: form.values.iconUrl } : {}), - sources: parseJson(form.values.sources), - requests: parseJson(form.values.requests), - options: parseJson(form.values.options), - template: form.values.template, + name: values.name, + ...(values.description ? { description: values.description } : {}), + ...(values.iconUrl ? { iconUrl: values.iconUrl } : {}), + sources: parseJson(values.sources), + requests: parseJson(values.requests), + options: parseJson(values.options), + template: values.template, }, null, 2, @@ -30,34 +41,27 @@ export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkb const [error, setError] = useState(); useEffect(() => setDraft(serialized), [serialized]); return ( - - - {t("advancedManifest")} - - { - setDraft(next); - try { - const value: unknown = JSON.parse(next); - const parsed = customWidgetDefinitionSchema.safeParse(value); - if (!parsed.success) { - setError(parsed.error.issues[0]?.message ?? t("invalidWidget")); - return; - } - setError(undefined); - applyDefinition(form, parsed.data); - } catch (cause) { - setError(cause instanceof Error ? cause.message : t("invalidJson")); - } - }} - /> - - - + { + setDraft(next); + try { + const value: unknown = JSON.parse(next); + const parsed = customWidgetDefinitionSchema.safeParse(value); + if (!parsed.success) { + setError(parsed.error.issues[0]?.message ?? t("invalidWidget")); + return; + } + setError(undefined); + applyDefinition(form, parsed.data); + } catch (cause) { + setError(cause instanceof Error ? cause.message : t("invalidJson")); + } + }} + /> ); } diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-ai-card.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-ai-card.tsx index 0d41ce0ee..30847c581 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-ai-card.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-ai-card.tsx @@ -3,13 +3,14 @@ import { Accordion, Button, Group, Stack, Text, TextInput, Textarea } from "@mantine/core"; import { IconClipboard, IconSparkles } from "@tabler/icons-react"; -import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; +import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt"; import { useI18n } from "@homarr/translation/client"; import { CopyAiPromptButton } from "./_copy-ai-prompt-button"; interface AiCardProps { - candidate: HomarrCustomWidgetV2 | null; + getDraft(): CustomWidgetAiDraft; + getDiagnostics(): readonly CustomWidgetAiDiagnostic[]; request: string; onRequestChange(value: string): void; documentationUrl: string; @@ -27,14 +28,15 @@ export function CustomWidgetAiCard(props: AiCardProps) { {t("title")} - + {t("description")}