From d875fb9a75c5bf6f45f032acd4f3dd575a93748a Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 24 Aug 2026 00:45:53 +0200 Subject: [PATCH] fix(custom-widgets): isolate workbench typing updates --- .../custom-widgets/_copy-ai-prompt-button.tsx | 35 +- .../_custom-widget-advanced-manifest.tsx | 16 +- .../custom-widgets/_custom-widget-ai-card.tsx | 8 +- .../_custom-widget-form-layout.tsx | 104 ++++- .../_custom-widget-form-sections.tsx | 222 +++++++++ .../_custom-widget-form-state.tsx | 154 +++++++ .../_custom-widget-form-utils.spec.ts | 4 +- .../_custom-widget-form-utils.ts | 2 +- .../custom-widgets/_custom-widget-form.tsx | 426 +++++++++--------- .../_custom-widget-rename-handlers.ts | 4 +- .../_use-custom-widget-form-actions.ts | 79 +--- .../_use-custom-widget-form-analysis.ts | 169 +++++-- .../src/jsx/runtime-components.tsx | 8 +- .../src/workbench/code-editor.tsx | 3 +- packages/form/src/index.ts | 4 +- 15 files changed, 888 insertions(+), 350 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-sections.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-state.tsx 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 849e1b7f5..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 @@ -10,32 +10,48 @@ import { useI18n } from "@homarr/translation/client"; import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context"; -interface CopyAiPromptButtonProps { +interface CopyAiPromptButtonBaseProps { rawResponse?: string | null; - draft: CustomWidgetAiDraft; - diagnostics: readonly CustomWidgetAiDiagnostic[]; 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, draft, + getDraft, diagnostics, + getDiagnostics, request, documentationUrl, }: CopyAiPromptButtonProps) => { const t = useI18n("customWidget"); const assistant = useOptionalHomarrAssistant(); + const readDraft = () => { + if (getDraft) return getDraft(); + return draft; + }; + const readDiagnostics = () => { + if (getDiagnostics) return getDiagnostics(); + return diagnostics; + }; + const handleAssistant = () => { const prompt = buildCustomWidgetAssistantPrompt( undefined, rawResponse, - draft, + readDraft(), request, documentationUrl, - diagnostics, + readDiagnostics(), ); if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return; showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") }); @@ -43,7 +59,14 @@ export const CopyAiPromptButton = ({ const handleCopy = async () => { try { - const value = buildCustomWidgetAiPrompt(undefined, rawResponse, draft, request, documentationUrl, diagnostics); + 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 0e38f7cf8..200952d69 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 @@ -6,6 +6,7 @@ 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"; @@ -21,16 +22,17 @@ export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkb 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, 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 ce11e40f0..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 @@ -9,8 +9,8 @@ import { useI18n } from "@homarr/translation/client"; import { CopyAiPromptButton } from "./_copy-ai-prompt-button"; interface AiCardProps { - draft: CustomWidgetAiDraft; - diagnostics: readonly CustomWidgetAiDiagnostic[]; + getDraft(): CustomWidgetAiDraft; + getDiagnostics(): readonly CustomWidgetAiDiagnostic[]; request: string; onRequestChange(value: string): void; documentationUrl: string; @@ -28,8 +28,8 @@ export function CustomWidgetAiCard(props: AiCardProps) { {t("title")} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-layout.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-layout.tsx index 85ab7c6e5..972b6a624 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-layout.tsx @@ -1,14 +1,39 @@ "use client"; -import type { ReactNode } from "react"; +import { useEffect } from "react"; +import type { Dispatch, ReactNode, SetStateAction } from "react"; import type { Icon } from "@tabler/icons-react"; import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core"; -import { IconAlertTriangle, IconCheck, IconPlayerPlay } from "@tabler/icons-react"; +import { + IconAlertCircle, + IconAlertTriangle, + IconApi, + IconBraces, + IconCheck, + IconCode, + IconDatabase, + IconEye, + IconPlayerPlay, + IconSettings, +} from "@tabler/icons-react"; +import { getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core"; import { useI18n } from "@homarr/translation/client"; +import { useCustomWidgetFormDocumentDirty } from "./_custom-widget-form-state"; +import { useCustomWidgetFormAnalysisField } from "./_use-custom-widget-form-analysis"; +import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard"; import classes from "./_custom-widget-form.module.css"; +const sectionLinks = [ + ["general", "section.general", IconSettings], + ["sources", "section.sources", IconApi], + ["requests", "section.requests", IconDatabase], + ["options", "section.options", IconBraces], + ["jsx", "section.jsx", IconCode], + ["preview", "section.preview", IconEye], +] as const; + export function EditorSection({ id, title, @@ -84,3 +109,78 @@ export function SaveActions({ ); } + +export function CustomWidgetSectionNavigation({ + onSelect, +}: { + onSelect(section: (typeof sectionLinks)[number][0]): void; +}) { + const t = useI18n("customWidget.workbench"); + const invalidSections = useCustomWidgetFormAnalysisField("invalidSections"); + return ( + + ); +} + +export function CustomWidgetSaveActions({ + mode, + savePending, + previewPending, + onPreview, +}: { + mode: "create" | "edit"; + savePending: boolean; + previewPending: boolean; + onPreview(): void; +}) { + const dirty = useCustomWidgetFormDocumentDirty(); + const candidateValid = useCustomWidgetFormAnalysisField("candidateValid"); + const hasDiagnostics = useCustomWidgetFormAnalysisField("hasDiagnostics"); + return ( + + ); +} + +export function CustomWidgetUnsavedChangesGuard() { + const dirty = useCustomWidgetFormDocumentDirty(); + useUnsavedChangesGuard(dirty); + return null; +} + +export function CustomWidgetOptionsSnapshotSync({ + setOptionsSnapshot, +}: { + setOptionsSnapshot: Dispatch>>; +}) { + const parsedOptions = useCustomWidgetFormAnalysisField("parsedOptions"); + useEffect(() => { + if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data)); + }, [parsedOptions, setOptionsSnapshot]); + return null; +} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-sections.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-sections.tsx new file mode 100644 index 000000000..4556ca31b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-sections.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { TextInput, Textarea } from "@mantine/core"; +import { IconApi, IconBraces, IconCode, IconDatabase, IconSettings } from "@tabler/icons-react"; + +import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt"; +import { IconPicker } from "@homarr/forms-collection"; +import { useI18n } from "@homarr/translation/client"; + +import { CodeEditor } from "./_code-editor"; +import { CustomWidgetAiCard } from "./_custom-widget-ai-card"; +import { EditorSection } from "./_custom-widget-form-layout"; +import { customWidgetOptionsSchemaReference, customWidgetRequestReference } from "./_custom-widget-form-references"; +import { useCustomWidgetFormDocumentField } from "./_custom-widget-form-state"; +import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; +import { CustomWidgetOptionsEditor } from "./_custom-widget-options-editor"; +import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel"; +import type { PreviewState } from "./_custom-widget-preview-panel"; +import { CustomWidgetRequestsEditor } from "./_custom-widget-requests-editor"; +import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor"; +import { LazyOnceAccordion } from "./_lazy-once-accordion"; +import { useCustomWidgetFormAnalysisField } from "./_use-custom-widget-form-analysis"; + +export function CustomWidgetAiSection({ + getDraft, + getDiagnostics, + onPaste, +}: { + getDraft(): CustomWidgetAiDraft; + getDiagnostics(): readonly CustomWidgetAiDiagnostic[]; + onPaste(): void; +}) { + const [request, setRequest] = useState(""); + const [documentationUrl, setDocumentationUrl] = useState(""); + return ( + + ); +} + +export function CustomWidgetGeneralSection({ + form, + formRevision, + mode, +}: { + form: CustomWidgetWorkbenchForm; + formRevision: string; + mode: "create" | "edit"; +}) { + const t = useI18n("customWidget"); + const tCommon = useI18n("common"); + const w = useI18n("customWidget.workbench"); + const [suggestedIconSearch, setSuggestedIconSearch] = useState(() => form.getValues().name); + const iconUrl = useCustomWidgetFormDocumentField("iconUrl"); + const nameInputProps = form.getInputProps("name"); + useEffect(() => setSuggestedIconSearch(form.getValues().name), [form, formRevision]); + return ( + + { + nameInputProps.onBlur?.(event); + setSuggestedIconSearch(form.getValues().name); + }} + /> +