fix(custom-widgets): isolate workbench typing updates

This commit is contained in:
ajnart committed 2026-08-24 00:45:53 +02:00
1 parent b742840e96
commit d875fb9a75
15 files changed
+888 -350

No files matched your search

@@ -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"),
@@ -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,
@@ -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")}
</Accordion.Control>
<CopyAiPromptButton
draft={props.draft}
diagnostics={props.diagnostics}
getDraft={props.getDraft}
getDiagnostics={props.getDiagnostics}
request={props.request}
documentationUrl={props.documentationUrl}
/>
@@ -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({
</Group>
);
}
export function CustomWidgetSectionNavigation({
onSelect,
}: {
onSelect(section: (typeof sectionLinks)[number][0]): void;
}) {
const t = useI18n("customWidget.workbench");
const invalidSections = useCustomWidgetFormAnalysisField("invalidSections");
return (
<nav className={classes.sectionNav} aria-label={t("sectionNavigation")}>
{sectionLinks.map(([id, key, SectionIcon]) => {
const invalid = invalidSections.has(id);
return (
<Button
key={id}
component="a"
href={`#${id}`}
onClick={() => onSelect(id)}
size="compact-sm"
variant="subtle"
color={invalid ? "red" : undefined}
leftSection={<SectionIcon size={14} />}
rightSection={invalid ? <IconAlertCircle size={13} aria-label={t("status.invalid")} /> : undefined}
>
{t(key)}
</Button>
);
})}
</nav>
);
}
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 (
<SaveActions
dirty={dirty}
savePending={savePending}
previewPending={previewPending}
invalid={hasDiagnostics || !candidateValid}
mode={mode}
onPreview={onPreview}
/>
);
}
export function CustomWidgetUnsavedChangesGuard() {
const dirty = useCustomWidgetFormDocumentDirty();
useUnsavedChangesGuard(dirty);
return null;
}
export function CustomWidgetOptionsSnapshotSync({
setOptionsSnapshot,
}: {
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
}) {
const parsedOptions = useCustomWidgetFormAnalysisField("parsedOptions");
useEffect(() => {
if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data));
}, [parsedOptions, setOptionsSnapshot]);
return null;
}
@@ -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 (
<CustomWidgetAiCard
getDraft={getDraft}
getDiagnostics={getDiagnostics}
request={request}
onRequestChange={setRequest}
documentationUrl={documentationUrl}
onDocumentationUrlChange={setDocumentationUrl}
onPaste={onPaste}
/>
);
}
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 (
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
<TextInput
key={form.key("name")}
label={tCommon("field.name")}
required
{...nameInputProps}
onBlur={(event) => {
nameInputProps.onBlur?.(event);
setSuggestedIconSearch(form.getValues().name);
}}
/>
<Textarea
key={form.key("description")}
label={t("field.description")}
autosize
minRows={2}
{...form.getInputProps("description")}
/>
<IconPicker
key={form.key("iconUrl")}
withAsterisk={false}
suggestedSearch={mode === "create" ? suggestedIconSearch : undefined}
{...form.getInputProps("iconUrl")}
value={iconUrl}
/>
</EditorSection>
);
}
export function CustomWidgetSourcesSection({
form,
definitionId,
}: {
form: CustomWidgetWorkbenchForm;
definitionId?: string;
}) {
const w = useI18n("customWidget.workbench");
useCustomWidgetFormDocumentField("sources");
useCustomWidgetFormDocumentField("requests");
useCustomWidgetFormDocumentField("secrets");
return (
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
</EditorSection>
);
}
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 (
<EditorSection id="requests" title={w("requests.title")} icon={IconDatabase}>
<CustomWidgetRequestsEditor form={form} onRename={onRename} />
<LazyOnceAccordion label={w("builder.advancedJson")}>
<CodeEditor
id="requests-editor"
label={w("requests.label")}
description={w("requests.description")}
language="json"
value={requests}
onChange={(value) => form.setFieldValue("requests", value, { forceUpdate: false })}
diagnostics={requestDiagnostics}
error={form.errors.requests}
reference={customWidgetRequestReference}
required
/>
</LazyOnceAccordion>
</EditorSection>
);
}
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 (
<EditorSection id="options" title={w("options.title")} icon={IconBraces}>
<CustomWidgetOptionsEditor form={form} onRename={onRename} />
<LazyOnceAccordion label={w("builder.advancedJson")}>
<CodeEditor
id="options-editor"
label={w("options.title")}
language="json"
value={options}
onChange={(value) => form.setFieldValue("options", value, { forceUpdate: false })}
error={form.errors.options}
reference={customWidgetOptionsSchemaReference}
/>
</LazyOnceAccordion>
</EditorSection>
);
}
export function CustomWidgetTemplateSection({ form }: { form: CustomWidgetWorkbenchForm }) {
const w = useI18n("customWidget.workbench");
const template = useCustomWidgetFormDocumentField("template");
const jsxCompletions = useCustomWidgetFormAnalysisField("jsxCompletions");
const templateDiagnostics = useCustomWidgetFormAnalysisField("templateDiagnostics");
return (
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
<CodeEditor
id="jsx-editor"
label={w("jsx.label")}
language="jsx"
value={template}
onChange={(value) => form.setFieldValue("template", value, { forceUpdate: false })}
diagnostics={templateDiagnostics}
completions={jsxCompletions}
error={form.errors.template}
required
maxLength={50_000}
/>
</EditorSection>
);
}
interface CustomWidgetPreviewSectionProps {
preview: PreviewState;
size: string;
onSizeChange(size: string): void;
optionsSnapshot: Record<string, unknown>;
onOptionsChange: Dispatch<SetStateAction<Record<string, unknown>>>;
onLiveActionsChange(enabled: boolean): void;
}
export function CustomWidgetPreviewSection(props: CustomWidgetPreviewSectionProps) {
const previewCandidate = useCustomWidgetFormAnalysisField("previewCandidate");
const previewValidationIssues = useCustomWidgetFormAnalysisField("previewValidationIssues");
return (
<CustomWidgetPreviewPanel
candidate={previewCandidate}
validationIssues={previewValidationIssues}
preview={props.preview}
size={props.size}
onSizeChange={props.onSizeChange}
optionsSnapshot={props.optionsSnapshot}
onOptionsChange={props.onOptionsChange}
onLiveActionsChange={props.onLiveActionsChange}
/>
);
}
@@ -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<CustomWidgetFormDocumentStore | null>(null);
export function CustomWidgetFormDocumentProvider({
store,
children,
}: {
store: CustomWidgetFormDocumentStore;
children: ReactNode;
}) {
return <CustomWidgetFormDocumentContext.Provider value={store}>{children}</CustomWidgetFormDocumentContext.Provider>;
}
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<typeof setTimeout> | 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<Key extends keyof CustomWidgetFormValues>(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<CustomWidgetFormValues>,
store: CustomWidgetFormDocumentStore,
) {
const formRef = useRef(form);
formRef.current = form;
return useMemo(
() =>
new Proxy({} as UseFormReturnType<CustomWidgetFormValues>, {
get: (_target, property) => {
if (property === "values") return store.getValues();
return Reflect.get(formRef.current, property);
},
}),
[store],
);
}
@@ -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\`\`\``,
@@ -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 ?? "",
@@ -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<CustomWidgetFormValues>;
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<SetStateAction<MobilePane>>;
preview: PreviewState;
setPreview: Dispatch<SetStateAction<PreviewState>>;
previewSize: string;
setPreviewSize: Dispatch<SetStateAction<string>>;
optionsSnapshot: Record<string, unknown>;
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
saveIssues: CustomWidgetSaveIssue[];
savePending: boolean;
previewPending: boolean;
onSubmit: FormEventHandler<HTMLFormElement>;
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<PreviewState>({ data: {}, status: {}, session: null, outcome: "idle" });
const [previewSize, setPreviewSize] = useState("standard");
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => {
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 (
<form onSubmit={save} className={classes.form}>
<SegmentedControl
className={classes.paneSwitcher}
fullWidth
value={mobilePane}
onChange={(value) => setMobilePane(value as typeof mobilePane)}
data={[
{ value: "configure", label: w("configure") },
{ value: "preview", label: w("section.preview") },
]}
/>
<CustomWidgetSaveIssuesAlert issues={saveIssues} />
<nav className={classes.sectionNav} aria-label={w("sectionNavigation")}>
{sectionLinks.map(([id, key, SectionIcon]) => (
<Button
key={id}
component="a"
href={`#${id}`}
onClick={() => setMobilePane(id === "preview" ? "preview" : "configure")}
size="compact-sm"
variant="subtle"
color={invalidSections.has(id) ? "red" : undefined}
leftSection={<SectionIcon size={14} />}
rightSection={
invalidSections.has(id) ? <IconAlertCircle size={13} aria-label={w("status.invalid")} /> : undefined
}
>
{w(key)}
</Button>
))}
</nav>
<div className={classes.workbench} data-mobile-pane={mobilePane}>
<Stack gap="lg" className={classes.configuration}>
<CustomWidgetAiCard
draft={aiDraft}
diagnostics={aiDiagnostics}
request={request}
onRequestChange={setRequest}
documentationUrl={documentationUrl}
onDocumentationUrlChange={setDocumentationUrl}
onPaste={() => void pasteAiResponse()}
<CustomWidgetFormDocumentProvider store={documentStore}>
<CustomWidgetFormAnalysisProvider>
<form onSubmit={props.onSubmit} className={classes.form}>
<CustomWidgetUnsavedChangesGuard />
<CustomWidgetOptionsSnapshotSync setOptionsSnapshot={props.setOptionsSnapshot} />
<SegmentedControl
className={classes.paneSwitcher}
fullWidth
value={props.mobilePane}
onChange={(value) => props.setMobilePane(value as MobilePane)}
data={[
{ value: "configure", label: w("configure") },
{ value: "preview", label: w("section.preview") },
]}
/>
<CustomWidgetAdvancedManifest form={form} />
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
<TextInput label={tCommon("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
<IconPicker
withAsterisk={false}
suggestedSearch={mode === "create" ? form.values.name : undefined}
{...form.getInputProps("iconUrl")}
/>
</EditorSection>
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
</EditorSection>
<EditorSection id="requests" title={w("requests.title")} icon={IconDatabase}>
<CustomWidgetRequestsEditor form={form} onRename={renameRequest} />
<LazyOnceAccordion label={w("builder.advancedJson")}>
<CodeEditor
id="requests-editor"
label={w("requests.label")}
description={w("requests.description")}
language="json"
value={form.values.requests}
onChange={(value) => form.setFieldValue("requests", value)}
diagnostics={requestDiagnostics}
error={form.errors.requests}
reference={customWidgetRequestReference}
required
<CustomWidgetSaveIssuesAlert issues={props.saveIssues} />
<CustomWidgetSectionNavigation onSelect={handleSectionSelect} />
<div className={classes.workbench} data-mobile-pane={props.mobilePane}>
<Stack gap="lg" className={classes.configuration}>
<CustomWidgetAiSection
getDraft={readAiDraft}
getDiagnostics={readAiDiagnostics}
onPaste={props.onPasteAiResponse}
/>
</LazyOnceAccordion>
</EditorSection>
<EditorSection id="options" title={w("options.title")} icon={IconBraces}>
<CustomWidgetOptionsEditor form={form} onRename={renameOption} />
<LazyOnceAccordion label={w("builder.advancedJson")}>
<CodeEditor
id="options-editor"
label={w("options.title")}
language="json"
value={form.values.options}
onChange={(value) => form.setFieldValue("options", value)}
error={form.errors.options}
reference={customWidgetOptionsSchemaReference}
<CustomWidgetAdvancedManifest form={form} />
<CustomWidgetGeneralSection form={form} formRevision={props.formRevision} mode={props.mode} />
<CustomWidgetSourcesSection form={form} definitionId={props.definitionId} />
<CustomWidgetRequestsSection form={form} onRename={renameRequest} />
<CustomWidgetOptionsSection form={form} onRename={renameOption} />
<CustomWidgetTemplateSection form={form} />
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
<CustomWidgetSaveActions
mode={props.mode}
savePending={props.savePending}
previewPending={props.previewPending}
onPreview={props.onPreview}
/>
</Paper>
</Stack>
<Box id="preview" component="aside" className={classes.previewPane} aria-label={w("widgetPreview")}>
<Paper p="md" className={classes.actionBar} shadow="sm">
<CustomWidgetSaveActions
mode={props.mode}
savePending={props.savePending}
previewPending={props.previewPending}
onPreview={props.onPreview}
/>
</Paper>
<CustomWidgetPreviewSection
preview={props.preview}
size={props.previewSize}
onSizeChange={props.setPreviewSize}
optionsSnapshot={props.optionsSnapshot}
onOptionsChange={props.setOptionsSnapshot}
onLiveActionsChange={handleLiveActionsChange}
/>
</LazyOnceAccordion>
</EditorSection>
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
<CodeEditor
id="jsx-editor"
label={w("jsx.label")}
language="jsx"
value={form.values.template}
onChange={(value) => form.setFieldValue("template", value)}
diagnostics={templateDiagnostics}
completions={jsxCompletions}
error={form.errors.template}
required
maxLength={50_000}
/>
</EditorSection>
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
<SaveActions
dirty={form.isDirty()}
savePending={savePending}
previewPending={previewPending}
invalid={invalid}
mode={mode}
onPreview={() => void runPreview()}
/>
</Paper>
</Stack>
<Box id="preview" component="aside" className={classes.previewPane} aria-label={w("widgetPreview")}>
<Paper p="md" className={classes.actionBar} shadow="sm">
<SaveActions
dirty={form.isDirty()}
savePending={savePending}
previewPending={previewPending}
invalid={invalid}
mode={mode}
onPreview={() => void runPreview()}
/>
</Paper>
<CustomWidgetPreviewPanel
candidate={candidate.success ? candidate.data : null}
validationIssues={previewValidationIssues}
preview={preview}
size={previewSize}
onSizeChange={setPreviewSize}
optionsSnapshot={optionsSnapshot}
onOptionsChange={setOptionsSnapshot}
onLiveActionsChange={handleLiveActionsChange}
/>
</Box>
</div>
</form>
</Box>
</div>
</form>
</CustomWidgetFormAnalysisProvider>
</CustomWidgetFormDocumentProvider>
);
});
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
const [formInitialValues] = useState<CustomWidgetFormValues>(() => ({
...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<MobilePane>("configure");
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null, outcome: "idle" });
const [previewSize, setPreviewSize] = useState("standard");
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => {
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<FormEventHandler<HTMLFormElement>>((event) => latestActions.current.save(event), []);
const handlePreview = useCallback(() => void latestActions.current.runPreview(), []);
const handlePasteAiResponse = useCallback(() => void latestActions.current.pasteAiResponse(), []);
return (
<CustomWidgetFormView
mode={mode}
definitionId={definitionId}
form={form}
formErrors={mantineForm.errors}
formRevision={mantineForm.key("name")}
documentStore={documentStore}
mobilePane={mobilePane}
setMobilePane={setMobilePane}
preview={preview}
setPreview={setPreview}
previewSize={previewSize}
setPreviewSize={setPreviewSize}
optionsSnapshot={optionsSnapshot}
setOptionsSnapshot={setOptionsSnapshot}
saveIssues={actions.saveIssues}
savePending={actions.savePending}
previewPending={actions.previewPending}
onSubmit={handleSubmit}
onPreview={handlePreview}
onPasteAiResponse={handlePasteAiResponse}
/>
);
}
@@ -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;
@@ -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<CustomWidgetFormValues>;
documentStore: CustomWidgetFormDocumentStore;
setPreview: Dispatch<SetStateAction<PreviewState>>;
setMobilePane: Dispatch<SetStateAction<"configure" | "preview">>;
optionsSnapshot: Record<string, unknown>;
@@ -44,52 +46,9 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
const [saveIssues, setSaveIssues] = useState<CustomWidgetSaveIssue[]>([]);
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<void>((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);
@@ -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<typeof buildDefinition>,
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<CustomWidgetAiDraft>(
() => ({
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<CustomWidgetAiDiagnostic[]>(() => {
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<typeof useCustomWidgetFormAnalysis>;
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<CustomWidgetFormAnalysisStore | null>(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<Key extends keyof CustomWidgetFormAnalysis>(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);
}
@@ -144,11 +144,15 @@ function getInitialInputValue(type: WidgetInputType | null, props: Record<string
if (!type) return "";
const candidate = type === "boolean" ? props.defaultChecked : props.defaultValue;
if (type === "boolean" && typeof candidate === "boolean") return candidate;
if (type === "number" && typeof candidate === "number") return candidate;
if (type === "number" && typeof candidate === "number" && Number.isFinite(candidate)) return candidate;
if (type === "string" && typeof candidate === "string") return candidate;
if (type === "string[]" && Array.isArray(candidate) && candidate.every((item) => 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);
}
@@ -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}
+2 -2
View File
@@ -21,7 +21,7 @@ export const useZodForm = <
schema: TSchema,
options: Omit<
Exclude<Parameters<typeof useForm<inferPossibleSchema<TSchema>>>[0], undefined>,
"validate" | "validateInputOnBlur" | "validateInputOnChange"
"validate" | "validateInputOnBlur"
>,
) => {
const t = useI18n();
@@ -32,7 +32,7 @@ export const useZodForm = <
return useForm<inferPossibleSchema<TSchema>>({
...options,
validateInputOnBlur: true,
validateInputOnChange: true,
validateInputOnChange: options.validateInputOnChange ?? true,
validate: schemaResolver(schema, { sync: true }),
});
};