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);
+ }}
+ />
+
+
+
+ );
+}
+
+export function CustomWidgetSourcesSection({
+ form,
+ definitionId,
+}: {
+ form: CustomWidgetWorkbenchForm;
+ definitionId?: string;
+}) {
+ const w = useI18n("customWidget.workbench");
+ useCustomWidgetFormDocumentField("sources");
+ useCustomWidgetFormDocumentField("requests");
+ useCustomWidgetFormDocumentField("secrets");
+ return (
+
+
+
+ );
+}
+
+export function CustomWidgetRequestsSection({
+ form,
+ onRename,
+}: {
+ form: CustomWidgetWorkbenchForm;
+ onRename(currentId: string, nextId: string): void;
+}) {
+ const w = useI18n("customWidget.workbench");
+ const requests = useCustomWidgetFormDocumentField("requests");
+ useCustomWidgetFormDocumentField("sources");
+ useCustomWidgetFormDocumentField("options");
+ const requestDiagnostics = useCustomWidgetFormAnalysisField("requestDiagnostics");
+ return (
+
+
+
+ form.setFieldValue("requests", value, { forceUpdate: false })}
+ diagnostics={requestDiagnostics}
+ error={form.errors.requests}
+ reference={customWidgetRequestReference}
+ required
+ />
+
+
+ );
+}
+
+export function CustomWidgetOptionsSection({
+ form,
+ onRename,
+}: {
+ form: CustomWidgetWorkbenchForm;
+ onRename(currentName: string, nextName: string): void;
+}) {
+ const w = useI18n("customWidget.workbench");
+ const options = useCustomWidgetFormDocumentField("options");
+ useCustomWidgetFormDocumentField("requests");
+ return (
+
+
+
+ form.setFieldValue("options", value, { forceUpdate: false })}
+ error={form.errors.options}
+ reference={customWidgetOptionsSchemaReference}
+ />
+
+
+ );
+}
+
+export function CustomWidgetTemplateSection({ form }: { form: CustomWidgetWorkbenchForm }) {
+ const w = useI18n("customWidget.workbench");
+ const template = useCustomWidgetFormDocumentField("template");
+ const jsxCompletions = useCustomWidgetFormAnalysisField("jsxCompletions");
+ const templateDiagnostics = useCustomWidgetFormAnalysisField("templateDiagnostics");
+ return (
+
+ form.setFieldValue("template", value, { forceUpdate: false })}
+ diagnostics={templateDiagnostics}
+ completions={jsxCompletions}
+ error={form.errors.template}
+ required
+ maxLength={50_000}
+ />
+
+ );
+}
+
+interface CustomWidgetPreviewSectionProps {
+ preview: PreviewState;
+ size: string;
+ onSizeChange(size: string): void;
+ optionsSnapshot: Record;
+ onOptionsChange: Dispatch>>;
+ onLiveActionsChange(enabled: boolean): void;
+}
+
+export function CustomWidgetPreviewSection(props: CustomWidgetPreviewSectionProps) {
+ const previewCandidate = useCustomWidgetFormAnalysisField("previewCandidate");
+ const previewValidationIssues = useCustomWidgetFormAnalysisField("previewValidationIssues");
+ return (
+
+ );
+}
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-state.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-state.tsx
new file mode 100644
index 000000000..ccfe01c3f
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-state.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import {
+ createContext,
+ startTransition,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ useSyncExternalStore,
+} from "react";
+import type { ReactNode } from "react";
+import type { UseFormReturnType } from "@mantine/form";
+
+import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
+
+export interface CustomWidgetFormDocumentStore {
+ getValues(): CustomWidgetFormValues;
+ setValues(values: CustomWidgetFormValues): void;
+ getDirty(): boolean;
+ subscribe(listener: () => void): () => void;
+ markSaved(values: CustomWidgetFormValues): void;
+}
+
+function areDocumentValuesEqual(left: CustomWidgetFormValues, right: CustomWidgetFormValues) {
+ const secretsEqual =
+ left.secrets.length === right.secrets.length &&
+ left.secrets.every((secret, index) => {
+ const other = right.secrets[index];
+ return (
+ other !== undefined &&
+ secret.sourceId === other.sourceId &&
+ secret.kind === other.kind &&
+ secret.value === other.value &&
+ secret.hasValue === other.hasValue
+ );
+ });
+ return (
+ left.name === right.name &&
+ left.description === right.description &&
+ left.iconUrl === right.iconUrl &&
+ left.sources === right.sources &&
+ left.requests === right.requests &&
+ left.options === right.options &&
+ left.template === right.template &&
+ secretsEqual
+ );
+}
+
+export function createCustomWidgetFormDocumentStore(
+ initialValues: CustomWidgetFormValues,
+): CustomWidgetFormDocumentStore {
+ let values = initialValues;
+ let persistedValues = initialValues;
+ let dirty = false;
+ const listeners = new Set<() => void>();
+
+ const notify = () => listeners.forEach((listener) => listener());
+
+ return {
+ getValues: () => values,
+ setValues: (nextValues) => {
+ if (Object.is(values, nextValues)) return;
+ values = nextValues;
+ dirty = !areDocumentValuesEqual(nextValues, persistedValues);
+ notify();
+ },
+ getDirty: () => dirty,
+ subscribe: (listener) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ markSaved: (savedValues) => {
+ const wasDirty = dirty;
+ persistedValues = savedValues;
+ dirty = !areDocumentValuesEqual(values, persistedValues);
+ if (wasDirty !== dirty) notify();
+ },
+ };
+}
+
+const CustomWidgetFormDocumentContext = createContext(null);
+
+export function CustomWidgetFormDocumentProvider({
+ store,
+ children,
+}: {
+ store: CustomWidgetFormDocumentStore;
+ children: ReactNode;
+}) {
+ return {children};
+}
+
+export function useCustomWidgetFormDocumentStore() {
+ const store = useContext(CustomWidgetFormDocumentContext);
+ if (!store) throw new Error("Custom widget form document provider is missing");
+ return store;
+}
+
+export function useDeferredCustomWidgetFormDocumentValues() {
+ const store = useCustomWidgetFormDocumentStore();
+ const [values, setValues] = useState(() => store.getValues());
+ useEffect(() => {
+ let timeout: ReturnType | undefined;
+ const scheduleUpdate = () => {
+ const nextValues = store.getValues();
+ clearTimeout(timeout);
+ timeout = setTimeout(() => {
+ startTransition(() => {
+ setValues((currentValues) => {
+ if (!Object.is(store.getValues(), nextValues)) return currentValues;
+ return nextValues;
+ });
+ });
+ }, 150);
+ };
+ const unsubscribe = store.subscribe(scheduleUpdate);
+ return () => {
+ clearTimeout(timeout);
+ unsubscribe();
+ };
+ }, [store]);
+ return values;
+}
+
+export function useCustomWidgetFormDocumentField(field: Key) {
+ const store = useCustomWidgetFormDocumentStore();
+ const getSnapshot = () => store.getValues()[field];
+ return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
+}
+
+export function useCustomWidgetFormDocumentDirty() {
+ const store = useCustomWidgetFormDocumentStore();
+ return useSyncExternalStore(store.subscribe, store.getDirty, store.getDirty);
+}
+
+export function useCustomWidgetFormDocumentBridge(
+ form: UseFormReturnType,
+ store: CustomWidgetFormDocumentStore,
+) {
+ const formRef = useRef(form);
+ formRef.current = form;
+ return useMemo(
+ () =>
+ new Proxy({} as UseFormReturnType, {
+ get: (_target, property) => {
+ if (property === "values") return store.getValues();
+ return Reflect.get(formRef.current, property);
+ },
+ }),
+ [store],
+ );
+}
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.spec.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.spec.ts
index 6d0e652e6..5674014a0 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.spec.ts
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.spec.ts
@@ -28,7 +28,7 @@ describe("Custom Widget workbench preview options", () => {
const setValues = vi.fn();
const result = applyCustomWidgetAiResponse(
{
- values: {
+ getValues: () => ({
name: "",
description: "",
iconUrl: "",
@@ -37,7 +37,7 @@ describe("Custom Widget workbench preview options", () => {
options: "{}",
template: "",
secrets: [],
- },
+ }),
setValues,
} as never,
`\`\`\`json\n${JSON.stringify(definition)}\n\`\`\``,
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts
index d5caf3877..c0303010e 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts
@@ -48,7 +48,7 @@ export function filterSecretsForSourceAuthentication(
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
form.setValues({
- ...form.values,
+ ...form.getValues(),
name: widget.name,
description: widget.description ?? "",
iconUrl: widget.iconUrl ?? "",
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx
index 190c8a5e2..d6f08c22d 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx
@@ -1,41 +1,45 @@
"use client";
-import { useCallback, useEffect, useState } from "react";
-import { Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
-import {
- IconAlertCircle,
- IconApi,
- IconBraces,
- IconCode,
- IconDatabase,
- IconEye,
- IconSettings,
-} from "@tabler/icons-react";
+import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
+import type { Dispatch, FormEventHandler, SetStateAction } from "react";
+import { Box, Paper, SegmentedControl, Stack } from "@mantine/core";
-import { getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
+import type { CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
import { customWidgetFormSchema, DEFAULT_CUSTOM_WIDGET_FORM_VALUES } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { useZodForm } from "@homarr/form";
-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 { CustomWidgetAdvancedManifest } from "./_custom-widget-advanced-manifest";
-import { EditorSection, SaveActions } from "./_custom-widget-form-layout";
-import { customWidgetOptionsSchemaReference, customWidgetRequestReference } from "./_custom-widget-form-references";
+import {
+ CustomWidgetOptionsSnapshotSync,
+ CustomWidgetSaveActions,
+ CustomWidgetSectionNavigation,
+ CustomWidgetUnsavedChangesGuard,
+} from "./_custom-widget-form-layout";
+import {
+ CustomWidgetAiSection,
+ CustomWidgetGeneralSection,
+ CustomWidgetOptionsSection,
+ CustomWidgetPreviewSection,
+ CustomWidgetRequestsSection,
+ CustomWidgetSourcesSection,
+ CustomWidgetTemplateSection,
+} from "./_custom-widget-form-sections";
+import {
+ createCustomWidgetFormDocumentStore,
+ CustomWidgetFormDocumentProvider,
+ useCustomWidgetFormDocumentBridge,
+} from "./_custom-widget-form-state";
+import type { CustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
import { buildDefinition, getDefinitionDefaults } from "./_custom-widget-form-utils";
-import { CustomWidgetOptionsEditor } from "./_custom-widget-options-editor";
-import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
+import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import type { PreviewState } from "./_custom-widget-preview-panel";
-import { CustomWidgetRequestsEditor } from "./_custom-widget-requests-editor";
import { createCustomWidgetRenameHandlers } from "./_custom-widget-rename-handlers";
+import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
-import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
-import { LazyOnceAccordion } from "./_lazy-once-accordion";
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
-import { useCustomWidgetFormAnalysis } from "./_use-custom-widget-form-analysis";
-import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard";
+import { analyzeCustomWidgetAiDiagnostics, CustomWidgetFormAnalysisProvider } from "./_use-custom-widget-form-analysis";
import classes from "./_custom-widget-form.module.css";
interface CustomWidgetFormProps {
@@ -43,206 +47,204 @@ interface CustomWidgetFormProps {
initialValues?: Partial;
definitionId?: string;
}
-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 CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
- const t = useI18n("customWidget");
- const tCommon = useI18n("common");
+type MobilePane = "configure" | "preview";
+
+interface CustomWidgetFormViewProps {
+ mode: "create" | "edit";
+ definitionId?: string;
+ form: CustomWidgetWorkbenchForm;
+ formErrors: CustomWidgetWorkbenchForm["errors"];
+ formRevision: string;
+ documentStore: CustomWidgetFormDocumentStore;
+ mobilePane: MobilePane;
+ setMobilePane: Dispatch>;
+ preview: PreviewState;
+ setPreview: Dispatch>;
+ previewSize: string;
+ setPreviewSize: Dispatch>;
+ optionsSnapshot: Record;
+ setOptionsSnapshot: Dispatch>>;
+ saveIssues: CustomWidgetSaveIssue[];
+ savePending: boolean;
+ previewPending: boolean;
+ onSubmit: FormEventHandler;
+ onPreview(): void;
+ onPasteAiResponse(): void;
+}
+
+function getAiDraft(values: CustomWidgetFormValues): CustomWidgetAiDraft {
+ return {
+ name: values.name,
+ description: values.description,
+ iconUrl: values.iconUrl,
+ sources: values.sources,
+ requests: values.requests,
+ options: values.options,
+ template: values.template,
+ };
+}
+
+const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWidgetFormViewProps) {
const w = useI18n("customWidget.workbench");
- const formInitialValues: CustomWidgetFormValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues };
- const form = useZodForm(customWidgetFormSchema, { initialValues: formInitialValues });
- const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
- const [request, setRequest] = useState("");
- const [documentationUrl, setDocumentationUrl] = useState("");
- const [preview, setPreview] = useState({ data: {}, status: {}, session: null, outcome: "idle" });
- const [previewSize, setPreviewSize] = useState("standard");
- const [optionsSnapshot, setOptionsSnapshot] = useState>(() => {
- const initialDefinition = buildDefinition(formInitialValues);
- return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
- });
- useUnsavedChangesGuard(form.isDirty());
-
- const {
- candidate,
- parsedOptions,
- jsxCompletions,
- templateDiagnostics,
- requestDiagnostics,
- hasDiagnostics,
- invalidSections,
- aiDraft,
- aiDiagnostics,
- previewValidationIssues,
- } = useCustomWidgetFormAnalysis(form.values);
-
- const { save, runPreview, pasteAiResponse, saveIssues, savePending, previewPending } = useCustomWidgetFormActions({
- mode,
- definitionId,
- form,
- setPreview,
- setMobilePane,
- optionsSnapshot,
- setOptionsSnapshot,
- });
- useEffect(() => {
- if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data));
- }, [parsedOptions]);
-
- const invalid = hasDiagnostics || !candidate.success;
- const { renameRequest, renameOption } = createCustomWidgetRenameHandlers({
- form,
- invalidWidgetMessage: w("invalidWidget"),
- });
+ const documentStore = props.documentStore;
+ const form = props.form;
+ const setMobilePane = props.setMobilePane;
+ const setPreview = props.setPreview;
+ const { renameRequest, renameOption } = useMemo(
+ () =>
+ createCustomWidgetRenameHandlers({
+ form,
+ invalidWidgetMessage: w("invalidWidget"),
+ }),
+ [form, w],
+ );
+ const readAiDraft = useCallback(() => getAiDraft(documentStore.getValues()), [documentStore]);
+ const readAiDiagnostics = useCallback(
+ () => analyzeCustomWidgetAiDiagnostics(documentStore.getValues()),
+ [documentStore],
+ );
+ const handleSectionSelect = useCallback(
+ (section: string) => {
+ setMobilePane(section === "preview" ? "preview" : "configure");
+ },
+ [setMobilePane],
+ );
const handleLiveActionsChange = useCallback(
(enabled: boolean) =>
setPreview((current) => ({
...current,
session: current.session ? { ...current.session, liveActions: enabled } : null,
})),
- [],
+ [setPreview],
);
+
return (
-
+
+
+ );
+});
+
+export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
+ const [formInitialValues] = useState(() => ({
+ ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
+ ...initialValues,
+ }));
+ const [documentStore] = useState(() => createCustomWidgetFormDocumentStore(formInitialValues));
+ const mantineForm = useZodForm(customWidgetFormSchema, {
+ initialValues: formInitialValues,
+ mode: "uncontrolled",
+ validateDebounce: 300,
+ validateInputOnChange: false,
+ onValuesChange: documentStore.setValues,
+ });
+ const form = useCustomWidgetFormDocumentBridge(mantineForm, documentStore);
+ const [mobilePane, setMobilePane] = useState("configure");
+ const [preview, setPreview] = useState({ data: {}, status: {}, session: null, outcome: "idle" });
+ const [previewSize, setPreviewSize] = useState("standard");
+ const [optionsSnapshot, setOptionsSnapshot] = useState>(() => {
+ const initialDefinition = buildDefinition(formInitialValues);
+ return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
+ });
+ const actions = useCustomWidgetFormActions({
+ mode,
+ definitionId,
+ form,
+ documentStore,
+ setPreview,
+ setMobilePane,
+ optionsSnapshot,
+ setOptionsSnapshot,
+ });
+ const latestActions = useRef(actions);
+ useLayoutEffect(() => {
+ latestActions.current = actions;
+ }, [actions]);
+ const handleSubmit = useCallback>((event) => latestActions.current.save(event), []);
+ const handlePreview = useCallback(() => void latestActions.current.runPreview(), []);
+ const handlePasteAiResponse = useCallback(() => void latestActions.current.pasteAiResponse(), []);
+
+ return (
+
);
}
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-rename-handlers.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-rename-handlers.ts
index ec070c2a8..c98dec9ee 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-rename-handlers.ts
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-rename-handlers.ts
@@ -10,7 +10,7 @@ interface RenameHandlersInput {
export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) {
const renameRequest = (currentId: string, nextId: string) => {
- const candidate = buildDefinition(input.form.values);
+ const candidate = buildDefinition(input.form.getValues());
if (!candidate.success) {
input.form.setFieldError("requests", input.invalidWidgetMessage);
return;
@@ -23,7 +23,7 @@ export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) {
}
};
const renameOption = (currentName: string, nextName: string) => {
- const candidate = buildDefinition(input.form.values);
+ const candidate = buildDefinition(input.form.getValues());
if (!candidate.success) {
input.form.setFieldError("options", input.invalidWidgetMessage);
return;
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts
index c29c9f012..6aa6e024a 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { UseFormReturnType } from "@mantine/form";
@@ -23,11 +23,13 @@ import type { PreviewState } from "./_custom-widget-preview-panel";
import { extractCustomWidgetSaveIssues } from "./_custom-widget-save-errors";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
import { clearSaveIssues, reportSaveIssues } from "./_custom-widget-save-issue-utils";
+import type { CustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
interface FormActionsInput {
mode: "create" | "edit";
definitionId?: string;
form: UseFormReturnType;
+ documentStore: CustomWidgetFormDocumentStore;
setPreview: Dispatch>;
setMobilePane: Dispatch>;
optionsSnapshot: Record;
@@ -44,52 +46,9 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
const [saveIssues, setSaveIssues] = useState([]);
const [previewPending, setPreviewPending] = useState(false);
- const previewOptions = input.optionsSnapshot;
- const previewOptionsFingerprint = useMemo(() => JSON.stringify(previewOptions), [previewOptions]);
- const previewSecretValues = input.form.values.secrets;
- const previewSecrets = useMemo(() => getChangedSecrets({ secrets: previewSecretValues }), [previewSecretValues]);
- const previewSecretsFingerprint = useMemo(() => JSON.stringify(previewSecrets), [previewSecrets]);
- const previewDefinitionValues = useMemo(
- () => ({
- name: input.form.values.name,
- description: input.form.values.description,
- iconUrl: input.form.values.iconUrl,
- sources: input.form.values.sources,
- requests: input.form.values.requests,
- options: input.form.values.options,
- template: input.form.values.template,
- }),
- [
- input.form.values.description,
- input.form.values.iconUrl,
- input.form.values.name,
- input.form.values.options,
- input.form.values.requests,
- input.form.values.sources,
- input.form.values.template,
- ],
- );
const setPreview = input.setPreview;
const previewGeneration = useRef(0);
- const latestPreviewInput = useRef({
- definitionValues: previewDefinitionValues,
- optionsFingerprint: previewOptionsFingerprint,
- secretsFingerprint: previewSecretsFingerprint,
- });
- useLayoutEffect(() => {
- const previous = latestPreviewInput.current;
- const current = {
- definitionValues: previewDefinitionValues,
- optionsFingerprint: previewOptionsFingerprint,
- secretsFingerprint: previewSecretsFingerprint,
- };
- latestPreviewInput.current = current;
- if (
- previous.definitionValues === current.definitionValues &&
- previous.optionsFingerprint === current.optionsFingerprint &&
- previous.secretsFingerprint === current.secretsFingerprint
- )
- return;
+ const invalidatePreview = useCallback(() => {
previewGeneration.current += 1;
setPreview((preview) => {
if (
@@ -102,7 +61,11 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
return { data: {}, status: {}, session: null, outcome: "idle" };
});
setPreviewPending(false);
- }, [previewDefinitionValues, previewOptionsFingerprint, previewSecretsFingerprint, setPreview]);
+ }, [setPreview]);
+ useLayoutEffect(() => {
+ invalidatePreview();
+ }, [input.optionsSnapshot, invalidatePreview]);
+ useEffect(() => input.documentStore.subscribe(invalidatePreview), [input.documentStore, invalidatePreview]);
useEffect(
() => () => {
previewGeneration.current += 1;
@@ -137,6 +100,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
});
input.form.setInitialValues(values);
input.form.resetDirty();
+ input.documentStore.markSaved(values);
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
window.location.assign(result.managementPath);
} else if (input.definitionId) {
@@ -152,6 +116,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
]);
input.form.setInitialValues(values);
input.form.resetDirty();
+ input.documentStore.markSaved(values);
showSuccessNotification({
title: tCommon("action.save"),
message: t("notification.updated", { name: values.name }),
@@ -169,7 +134,8 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const runPreview = async () => {
if (previewPending) return;
- const candidateAtStart = buildDefinition(input.form.values);
+ const valuesAtStart = input.documentStore.getValues();
+ const candidateAtStart = buildDefinition(valuesAtStart);
if (!candidateAtStart.success) {
showErrorNotification({
title: w("section.preview"),
@@ -177,7 +143,8 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
});
return;
}
- const optionIssues = getCustomWidgetPreviewOptionIssues(candidateAtStart.data, input.optionsSnapshot);
+ const optionsAtStart = input.optionsSnapshot;
+ const optionIssues = getCustomWidgetPreviewOptionIssues(candidateAtStart.data, optionsAtStart);
if (optionIssues.length > 0) {
const issue = optionIssues[0];
showErrorNotification({
@@ -186,22 +153,10 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
});
return;
}
- const definitionValuesAtStart = previewDefinitionValues;
- const optionsAtStart = input.optionsSnapshot;
- const optionsFingerprintAtStart = previewOptionsFingerprint;
- const secretsAtStart = previewSecrets;
- const secretsFingerprintAtStart = previewSecretsFingerprint;
+ const secretsAtStart = getChangedSecrets(valuesAtStart);
const generation = previewGeneration.current + 1;
previewGeneration.current = generation;
- const isCurrent = () => {
- const latest = latestPreviewInput.current;
- return (
- previewGeneration.current === generation &&
- latest.definitionValues === definitionValuesAtStart &&
- latest.optionsFingerprint === optionsFingerprintAtStart &&
- latest.secretsFingerprint === secretsFingerprintAtStart
- );
- };
+ const isCurrent = () => previewGeneration.current === generation;
input.setMobilePane("preview");
input.setPreview({ data: {}, status: {}, session: null, outcome: "loading" });
setPreviewPending(true);
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-analysis.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-analysis.ts
index a0f24cfca..0dc12fbad 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-analysis.ts
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-analysis.ts
@@ -1,14 +1,29 @@
"use client";
-import { useDeferredValue, useMemo } from "react";
+import {
+ createContext,
+ createElement,
+ useCallback,
+ useContext,
+ useDeferredValue,
+ useLayoutEffect,
+ useMemo,
+ useState,
+ useSyncExternalStore,
+} from "react";
+import type { ReactNode } from "react";
-import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
+import type { CustomWidgetAiDiagnostic } from "@homarr/custom-widgets/authoring-prompt";
import { customWidgetOptionsSchema } from "@homarr/custom-widgets/core";
import { analyzeJsxTemplate, analyzeRequestManifest } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormValues, EditorDiagnostic } from "@homarr/custom-widgets/workbench";
import { createCustomWidgetCompletions, getInvalidCustomWidgetSections } from "./_custom-widget-form-analysis";
import { buildDefinition, isRecord, parseJson } from "./_custom-widget-form-utils";
+import { useDeferredCustomWidgetFormDocumentValues } from "./_custom-widget-form-state";
+
+const emptyPreviewValidationIssues: Array<{ path?: string; message: string }> = [];
+const emptyCandidateIssues: Array<{ path: PropertyKey[] }> = [];
function normalizeEditorDiagnostics(
section: "requests" | "template",
@@ -24,6 +39,37 @@ function normalizeEditorDiagnostics(
}));
}
+function createAiDiagnostics(
+ candidate: ReturnType,
+ requestDiagnostics: readonly EditorDiagnostic[],
+ templateDiagnostics: readonly EditorDiagnostic[],
+) {
+ const editorDiagnostics = [
+ ...normalizeEditorDiagnostics("requests", requestDiagnostics),
+ ...normalizeEditorDiagnostics("template", templateDiagnostics),
+ ];
+ if (candidate.success) return editorDiagnostics;
+ return [
+ ...candidate.error.issues.map((issue) => ({
+ section: String(issue.path[0] ?? "definition"),
+ severity: "error" as const,
+ path: issue.path.map(String).join(".") || undefined,
+ message: issue.message,
+ })),
+ ...editorDiagnostics,
+ ];
+}
+
+export function analyzeCustomWidgetAiDiagnostics(values: CustomWidgetFormValues): CustomWidgetAiDiagnostic[] {
+ const parsedRequests = parseJson(values.requests);
+ const requestIds = isRecord(parsedRequests) ? Object.keys(parsedRequests) : [];
+ return createAiDiagnostics(
+ buildDefinition(values),
+ analyzeRequestManifest(values.requests),
+ analyzeJsxTemplate(values.template, { requestIds }),
+ );
+}
+
export function useCustomWidgetFormAnalysis(values: CustomWidgetFormValues) {
const analysisValues = useDeferredValue(values);
const candidate = useMemo(() => buildDefinition(analysisValues), [analysisValues]);
@@ -50,47 +96,21 @@ export function useCustomWidgetFormAnalysis(values: CustomWidgetFormValues) {
);
const requestDiagnostics = useMemo(() => analyzeRequestManifest(analysisValues.requests), [analysisValues.requests]);
const hasDiagnostics = [...templateDiagnostics, ...requestDiagnostics].some((entry) => entry.severity === "error");
+ const candidateIssues = candidate.success ? emptyCandidateIssues : candidate.error.issues;
const invalidSections = useMemo(
- () =>
- getInvalidCustomWidgetSections(
- candidate.success ? [] : candidate.error.issues,
- requestDiagnostics,
- templateDiagnostics,
- ),
- [candidate, requestDiagnostics, templateDiagnostics],
+ () => getInvalidCustomWidgetSections(candidateIssues, requestDiagnostics, templateDiagnostics),
+ [candidateIssues, requestDiagnostics, templateDiagnostics],
);
- const aiDraft = useMemo(
- () => ({
- name: values.name,
- description: values.description,
- iconUrl: values.iconUrl,
- sources: values.sources,
- requests: values.requests,
- options: values.options,
- template: values.template,
- }),
- [values.description, values.iconUrl, values.name, values.options, values.requests, values.sources, values.template],
+ const previewCandidate = useMemo(
+ () => (candidate.success ? candidate.data : null),
+ // The preview renderer consumes only these definition fields; other fields affect the validity boolean below.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [analysisValues.requests, analysisValues.template, candidate.success],
);
- const aiDiagnostics = useMemo(() => {
- const diagnostics = [
- ...normalizeEditorDiagnostics("requests", requestDiagnostics),
- ...normalizeEditorDiagnostics("template", templateDiagnostics),
- ];
- if (candidate.success) return diagnostics;
- return [
- ...candidate.error.issues.map((issue) => ({
- section: String(issue.path[0] ?? "definition"),
- severity: "error" as const,
- path: issue.path.map(String).join(".") || undefined,
- message: issue.message,
- })),
- ...diagnostics,
- ];
- }, [candidate, requestDiagnostics, templateDiagnostics]);
const previewValidationIssues = useMemo(
() =>
candidate.success
- ? []
+ ? emptyPreviewValidationIssues
: candidate.error.issues.map((issue) => ({
path: issue.path.map(String).join(".") || undefined,
message: issue.message,
@@ -98,16 +118,71 @@ export function useCustomWidgetFormAnalysis(values: CustomWidgetFormValues) {
[candidate],
);
+ return useMemo(
+ () => ({
+ candidate,
+ candidateValid: candidate.success,
+ parsedOptions,
+ jsxCompletions,
+ templateDiagnostics,
+ requestDiagnostics,
+ hasDiagnostics,
+ invalidSections,
+ previewCandidate,
+ previewValidationIssues,
+ }),
+ [
+ candidate,
+ hasDiagnostics,
+ invalidSections,
+ jsxCompletions,
+ parsedOptions,
+ previewCandidate,
+ previewValidationIssues,
+ requestDiagnostics,
+ templateDiagnostics,
+ ],
+ );
+}
+
+type CustomWidgetFormAnalysis = ReturnType;
+
+interface CustomWidgetFormAnalysisStore {
+ getAnalysis(): CustomWidgetFormAnalysis;
+ setAnalysis(analysis: CustomWidgetFormAnalysis): void;
+ subscribe(listener: () => void): () => void;
+}
+
+function createCustomWidgetFormAnalysisStore(initialAnalysis: CustomWidgetFormAnalysis): CustomWidgetFormAnalysisStore {
+ let analysis = initialAnalysis;
+ const listeners = new Set<() => void>();
return {
- candidate,
- parsedOptions,
- jsxCompletions,
- templateDiagnostics,
- requestDiagnostics,
- hasDiagnostics,
- invalidSections,
- aiDraft,
- aiDiagnostics,
- previewValidationIssues,
+ getAnalysis: () => analysis,
+ setAnalysis: (nextAnalysis) => {
+ if (Object.is(analysis, nextAnalysis)) return;
+ analysis = nextAnalysis;
+ listeners.forEach((listener) => listener());
+ },
+ subscribe: (listener) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
};
}
+
+const CustomWidgetFormAnalysisContext = createContext(null);
+
+export function CustomWidgetFormAnalysisProvider({ children }: { children: ReactNode }) {
+ const values = useDeferredCustomWidgetFormDocumentValues();
+ const analysis = useCustomWidgetFormAnalysis(values);
+ const [store] = useState(() => createCustomWidgetFormAnalysisStore(analysis));
+ useLayoutEffect(() => store.setAnalysis(analysis), [analysis, store]);
+ return createElement(CustomWidgetFormAnalysisContext.Provider, { value: store }, children);
+}
+
+export function useCustomWidgetFormAnalysisField(field: Key) {
+ const store = useContext(CustomWidgetFormAnalysisContext);
+ if (!store) throw new Error("Custom widget form analysis provider is missing");
+ const getSnapshot = useCallback(() => store.getAnalysis()[field], [field, store]);
+ return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
+}
diff --git a/packages/custom-widgets/src/jsx/runtime-components.tsx b/packages/custom-widgets/src/jsx/runtime-components.tsx
index db7a5a101..66ed0a843 100644
--- a/packages/custom-widgets/src/jsx/runtime-components.tsx
+++ b/packages/custom-widgets/src/jsx/runtime-components.tsx
@@ -144,11 +144,15 @@ function getInitialInputValue(type: WidgetInputType | null, props: Record typeof item === "string"))
return candidate;
- if (type === "number[]" && Array.isArray(candidate) && candidate.every((item) => typeof item === "number"))
+ if (
+ type === "number[]" &&
+ Array.isArray(candidate) &&
+ candidate.every((item) => typeof item === "number" && Number.isFinite(item))
+ )
return candidate;
return emptyInputValue(type);
}
diff --git a/packages/custom-widgets/src/workbench/code-editor.tsx b/packages/custom-widgets/src/workbench/code-editor.tsx
index 71ae99a90..16f34839a 100644
--- a/packages/custom-widgets/src/workbench/code-editor.tsx
+++ b/packages/custom-widgets/src/workbench/code-editor.tsx
@@ -105,6 +105,7 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
const errorCount = diagnostics.filter(({ severity }) => severity === "error").length;
const warningCount = diagnostics.length - errorCount;
const isCharacterLimitExceeded = props.maxLength !== undefined && props.value.length > props.maxLength;
+ const isEditorInvalid = Boolean(props.error) || errorCount > 0 || isCharacterLimitExceeded;
const copy = async () => {
try {
await navigator.clipboard.writeText(props.value);
@@ -176,7 +177,7 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
label={props.label}
placeholder={props.placeholder}
theme={colorScheme}
- invalid={isCharacterLimitExceeded}
+ invalid={isEditorInvalid}
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
readOnly={props.readOnly ?? false}
diagnosticMessage={props.messages.diagnostic}
diff --git a/packages/form/src/index.ts b/packages/form/src/index.ts
index af8e4198e..018b4edb2 100644
--- a/packages/form/src/index.ts
+++ b/packages/form/src/index.ts
@@ -21,7 +21,7 @@ export const useZodForm = <
schema: TSchema,
options: Omit<
Exclude>>[0], undefined>,
- "validate" | "validateInputOnBlur" | "validateInputOnChange"
+ "validate" | "validateInputOnBlur"
>,
) => {
const t = useI18n();
@@ -32,7 +32,7 @@ export const useZodForm = <
return useForm>({
...options,
validateInputOnBlur: true,
- validateInputOnChange: true,
+ validateInputOnChange: options.validateInputOnChange ?? true,
validate: schemaResolver(schema, { sync: true }),
});
};