From 65c3aea7eabf9ccea1927bcc6eebc576107fee40 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 17 Apr 2026 17:47:48 -0400 Subject: [PATCH 1/2] feat: improve label resolution in JSON Schema to FormBuilderField conversion for consistent admin and public form display --- packages/stack/package.json | 2 +- .../__tests__/schema-utils.test.ts | 87 +++++++++++++++++++ .../form-builder/components/index.tsx | 15 +++- .../components/form-builder/schema-utils.ts | 7 +- 4 files changed, 106 insertions(+), 5 deletions(-) diff --git a/packages/stack/package.json b/packages/stack/package.json index b6012b1b..c0c83ab5 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "2.11.6", + "version": "2.11.7", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git", diff --git a/packages/stack/src/plugins/form-builder/__tests__/schema-utils.test.ts b/packages/stack/src/plugins/form-builder/__tests__/schema-utils.test.ts index 0387cadd..1c4558c8 100644 --- a/packages/stack/src/plugins/form-builder/__tests__/schema-utils.test.ts +++ b/packages/stack/src/plugins/form-builder/__tests__/schema-utils.test.ts @@ -107,6 +107,93 @@ describe("jsonSchemaToFieldsAndSteps", () => { }); }); + describe("label resolution", () => { + // Label resolution must mirror what `AutoForm` shows on the public form, + // so a Zod-seeded schema (whose properties only carry `description`, + // not `label`/`title`) reads identically in the admin canvas. + // See `auto-form/fields/object.tsx` → `beautifyObjectName(name)`. + + it("uses an explicit `label` when present", () => { + const schema: JSONSchema = { + type: "object", + properties: { + primaryGoal: { + type: "string", + label: "What's your main focus?", + }, + }, + }; + + const { fields } = jsonSchemaToFieldsAndSteps(schema, defaultComponents); + expect(fields[0]?.props.label).toBe("What's your main focus?"); + }); + + it("falls back to JSON Schema `title` when `label` is absent", () => { + const schema: JSONSchema = { + type: "object", + properties: { + primaryGoal: { + type: "string", + title: "Primary Goal (titled)", + }, + }, + }; + + const { fields } = jsonSchemaToFieldsAndSteps(schema, defaultComponents); + expect(fields[0]?.props.label).toBe("Primary Goal (titled)"); + }); + + it("humanises the field key when no `label` or `title` is present", () => { + // This is the regression: previously the admin showed the raw key + // (`primaryGoal`) when no label/title was set, while the public form + // showed `Primary Goal`. Now both paths agree. + const schema: JSONSchema = { + type: "object", + properties: { + primaryGoal: { type: "string" }, + riskTolerance: { + type: "string", + enum: ["low", "moderate", "high"], + }, + noInjections: { type: "boolean", fieldType: "switch" }, + email: { type: "string", format: "email" }, + age: { type: "number" }, + notes: { type: "string", fieldType: "textarea" }, + }, + }; + + const { fields } = jsonSchemaToFieldsAndSteps(schema, defaultComponents); + const labels = Object.fromEntries( + fields.map((f) => [f.id, f.props.label]), + ); + + expect(labels).toEqual({ + primaryGoal: "Primary Goal", + riskTolerance: "Risk Tolerance", + noInjections: "No Injections", + email: "Email", + age: "Age", + notes: "Notes", + }); + }); + + it("humanises the field key in the unknown-field-type fallback", () => { + // When no component matches, propertiesToFields creates a generic + // text field. That branch must humanise the key too. + const schema: JSONSchema = { + type: "object", + properties: { + mysteriousField: { + // no `type` — falls through to the generic text fallback + } as JSONSchema["properties"][string], + }, + }; + + const { fields } = jsonSchemaToFieldsAndSteps(schema, defaultComponents); + expect(fields[0]?.props.label).toBe("Mysterious Field"); + }); + }); + describe("multi-step: Path A — per-property stepGroup", () => { // Canonical format written by the visual FormBuilder when saving. const schema: JSONSchema = { diff --git a/packages/ui/src/components/form-builder/components/index.tsx b/packages/ui/src/components/form-builder/components/index.tsx index 2e0d72bf..455f00dd 100644 --- a/packages/ui/src/components/form-builder/components/index.tsx +++ b/packages/ui/src/components/form-builder/components/index.tsx @@ -39,6 +39,7 @@ import { arrayValidationSchema, DEFAULT_VALUE_SCHEMAS, } from "../validation-schemas"; +import { beautifyObjectName } from "../../auto-form/helpers"; /** * Helper to convert a value to a number, handling empty strings and undefined @@ -70,10 +71,20 @@ function getPlaceholder(prop: JSONSchemaProperty): string | undefined { } /** - * Helper to extract label from JSONSchemaProperty + * Helper to extract a display label from a JSONSchemaProperty. + * + * Resolution order: + * 1. Explicit `prop.label` (set via the visual builder's edit dialog). + * 2. Standard JSON Schema `prop.title`. + * 3. Humanised field key — `beautifyObjectName("primaryGoal")` → `"Primary Goal"`. + * + * The humanised key fallback matches what `AutoForm` renders on the public + * form (see `auto-form/fields/object.tsx`), so a Zod-seeded form whose + * properties only carry a `description` (no `label`/`title`) reads the same + * in the admin canvas as it does to end users. */ function getLabel(prop: JSONSchemaProperty, key: string): string { - return prop.label || prop.title || key; + return prop.label || prop.title || beautifyObjectName(key); } /** diff --git a/packages/ui/src/components/form-builder/schema-utils.ts b/packages/ui/src/components/form-builder/schema-utils.ts index 3da26ee7..6c2d22e7 100644 --- a/packages/ui/src/components/form-builder/schema-utils.ts +++ b/packages/ui/src/components/form-builder/schema-utils.ts @@ -5,6 +5,7 @@ import type { JSONSchema, JSONSchemaProperty, } from "./types"; +import { beautifyObjectName } from "../auto-form/helpers"; /** * Helper to convert fields to JSON Schema properties (recursive) @@ -152,13 +153,15 @@ function propertiesToFields( fields.push(field); } else { - // Fallback: create a generic text field for unknown types + // Fallback: create a generic text field for unknown types. + // Label resolution mirrors `getLabel` in `./components/index.tsx` so the + // admin canvas always shows a humanised label for unknown shapes too. console.warn(`Could not parse JSON Schema property: ${key}`, prop); fields.push({ id: key, type: "text", props: { - label: prop.title || key, + label: prop.label || prop.title || beautifyObjectName(key), description: prop.description, placeholder: prop.placeholder, required: isRequired, From b214838c2489d5692b83ead87d409d07af980eb3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 17 Apr 2026 21:59:51 +0000 Subject: [PATCH 2/2] chore: update shadcn registry [skip ci] --- packages/stack/registry/btst-cms.json | 6 +++--- packages/stack/registry/btst-form-builder.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/stack/registry/btst-cms.json b/packages/stack/registry/btst-cms.json index eae95ea4..abd0ea0d 100644 --- a/packages/stack/registry/btst-cms.json +++ b/packages/stack/registry/btst-cms.json @@ -49,7 +49,7 @@ { "path": "btst/cms/client/components/forms/content-form.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState, useMemo, useEffect, useRef } from \"react\";\nimport { z } from \"zod\";\nimport { SteppedAutoForm } from \"@/components/ui/auto-form/stepped-auto-form\";\nimport type {\n\tFieldConfig,\n\tAutoFormInputComponentProps,\n} from \"@/components/ui/auto-form/types\";\nimport { buildFieldConfigFromJsonSchema as buildFieldConfigBase } from \"@/components/ui/auto-form/helpers\";\nimport { formSchemaToZod } from \"@/lib/schema-converter\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport type { SerializedContentType, RelationConfig } from \"../../../types\";\nimport { slugify } from \"../../../utils\";\nimport { CMS_LOCALIZATION } from \"../../localization\";\nimport { CMSFileUpload } from \"./file-upload\";\nimport { RelationField } from \"./relation-field\";\n\ninterface ContentFormProps {\n\tcontentType: SerializedContentType;\n\tinitialData?: Record;\n\tinitialSlug?: string;\n\tisEditing?: boolean;\n\tonSubmit: (data: {\n\t\tslug: string;\n\t\tdata: Record;\n\t}) => Promise;\n\tonCancel?: () => void;\n}\n\n/**\n * Build field configuration for AutoForm with CMS-specific file upload handling.\n *\n * Uses the shared buildFieldConfigFromJsonSchema from auto-form/helpers as a base,\n * then adds special handling for \"file\" fieldType to inject CMSFileUpload component\n * ONLY if no custom component is provided via fieldComponents.\n *\n * @param jsonSchema - The JSON Schema from the content type (with fieldType embedded in properties)\n * @param uploadImage - The uploadImage function from overrides (for file fields)\n * @param fieldComponents - Custom field components from overrides\n */\ninterface JsonSchemaProperty {\n\tfieldType?: string;\n\trelation?: RelationConfig;\n\t[key: string]: unknown;\n}\n\nfunction buildFieldConfigFromJsonSchema(\n\tjsonSchema: Record,\n\tuploadImage?: (file: File) => Promise,\n\tfieldComponents?: Record<\n\t\tstring,\n\t\tReact.ComponentType\n\t>,\n\timagePicker?: React.ComponentType<{ onSelect: (url: string) => void }>,\n\timageInputField?: React.ComponentType<{\n\t\tvalue: string;\n\t\tonChange: (value: string) => void;\n\t\tisRequired?: boolean;\n\t}>,\n): FieldConfig> {\n\t// Get base config from shared utility (handles fieldType from JSON Schema)\n\tconst baseConfig = buildFieldConfigBase(jsonSchema, fieldComponents);\n\n\t// Apply CMS-specific handling for special fieldTypes ONLY if no custom component exists\n\t// Custom fieldComponents take priority - don't override if user provided one\n\tconst properties = jsonSchema.properties as Record<\n\t\tstring,\n\t\tJsonSchemaProperty\n\t>;\n\n\tif (!properties) return baseConfig;\n\n\tfor (const [key, prop] of Object.entries(properties)) {\n\t\t// Handle \"file\" fieldType when there's NO custom component for \"file\"\n\t\tif (prop.fieldType === \"file\" && !fieldComponents?.[\"file\"]) {\n\t\t\t// Use CMSFileUpload as the default file component\n\t\t\tif (!uploadImage && !imageInputField) {\n\t\t\t\t// Show a clear error message if neither uploadImage nor imageInputField is provided\n\t\t\t\tbaseConfig[key] = {\n\t\t\t\t\t...baseConfig[key],\n\t\t\t\t\tfieldType: () => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\tFile upload requires an uploadImage or{\" \"}\n\t\t\t\t\t\t\timageInputField function in CMS overrides.\n\t\t\t\t\t\t
\n\t\t\t\t\t),\n\t\t\t\t};\n\t\t\t} else {\n\t\t\t\tbaseConfig[key] = {\n\t\t\t\t\t...baseConfig[key],\n\t\t\t\t\tfieldType: (props: AutoFormInputComponentProps) => (\n\t\t\t\t\t\t Promise.resolve(\"\"))}\n\t\t\t\t\t\t\timageInputField={imageInputField}\n\t\t\t\t\t\t\timagePicker={imagePicker}\n\t\t\t\t\t\t/>\n\t\t\t\t\t),\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Handle \"relation\" fieldType when there's NO custom component for \"relation\"\n\t\tif (\n\t\t\tprop.fieldType === \"relation\" &&\n\t\t\tprop.relation &&\n\t\t\t!fieldComponents?.[\"relation\"]\n\t\t) {\n\t\t\tconst relationConfig = prop.relation;\n\t\t\tbaseConfig[key] = {\n\t\t\t\t...baseConfig[key],\n\t\t\t\tfieldType: (props: AutoFormInputComponentProps) => (\n\t\t\t\t\t\n\t\t\t\t),\n\t\t\t};\n\t\t}\n\t}\n\n\treturn baseConfig;\n}\n\n/**\n * Determine the first string field in the schema for slug auto-generation\n */\nfunction findSlugSourceField(\n\tjsonSchema: Record,\n): string | null {\n\tconst properties = jsonSchema.properties as Record;\n\tif (!properties) return null;\n\n\t// Look for common name fields first\n\tconst priorityFields = [\"name\", \"title\", \"heading\", \"label\"];\n\tfor (const field of priorityFields) {\n\t\tif (properties[field]?.type === \"string\") {\n\t\t\treturn field;\n\t\t}\n\t}\n\n\t// Fall back to first string field\n\tfor (const [key, value] of Object.entries(properties)) {\n\t\tif (value.type === \"string\") {\n\t\t\treturn key;\n\t\t}\n\t}\n\n\treturn null;\n}\n\nexport function ContentForm({\n\tcontentType,\n\tinitialData = {},\n\tinitialSlug = \"\",\n\tisEditing = false,\n\tonSubmit,\n\tonCancel,\n}: ContentFormProps) {\n\tconst {\n\t\tlocalization: customLocalization,\n\t\tuploadImage,\n\t\timagePicker,\n\t\timageInputField,\n\t\tfieldComponents,\n\t} = usePluginOverrides(\"cms\");\n\tconst localization = { ...CMS_LOCALIZATION, ...customLocalization };\n\n\tconst [slug, setSlug] = useState(initialSlug);\n\tconst [slugManuallyEdited, setSlugManuallyEdited] = useState(isEditing);\n\tconst [isSubmitting, setIsSubmitting] = useState(false);\n\tconst [formData, setFormData] =\n\t\tuseState>(initialData);\n\tconst [slugError, setSlugError] = useState(null);\n\tconst [submitError, setSubmitError] = useState(null);\n\n\t// Track if we've already synced prefill data to avoid overwriting user input\n\tconst hasSyncedPrefillRef = useRef(false);\n\n\t// Sync formData with initialData when it changes\n\t// This handles both:\n\t// 1. Editing mode: always sync when item data is loaded (isEditing=true)\n\t// 2. Create mode: only sync prefill data ONCE to avoid overwriting user input\n\t// useState only uses the initial value on mount, so we need this effect for updates\n\tuseEffect(() => {\n\t\tconst hasData = Object.keys(initialData).length > 0;\n\t\t// In edit mode, always sync (user is loading existing data)\n\t\t// In create mode, only sync prefill data once\n\t\tconst shouldSync = hasData && (isEditing || !hasSyncedPrefillRef.current);\n\n\t\tif (shouldSync) {\n\t\t\tsetFormData(initialData);\n\t\t\tif (!isEditing) {\n\t\t\t\thasSyncedPrefillRef.current = true;\n\t\t\t}\n\t\t}\n\t}, [initialData, isEditing]);\n\n\t// Also sync slug when initialSlug changes\n\tuseEffect(() => {\n\t\tif (isEditing && initialSlug) {\n\t\t\tsetSlug(initialSlug);\n\t\t}\n\t}, [initialSlug, isEditing]);\n\n\t// Parse JSON Schema (now includes fieldType embedded in properties)\n\tconst jsonSchema = useMemo(() => {\n\t\ttry {\n\t\t\treturn JSON.parse(contentType.jsonSchema) as Record;\n\t\t} catch {\n\t\t\treturn {};\n\t\t}\n\t}, [contentType.jsonSchema]);\n\n\t// Convert JSON Schema to Zod schema using formSchemaToZod utility\n\t// This properly handles date fields (format: \"date-time\") and min/max date constraints\n\tconst zodSchema = useMemo(() => {\n\t\ttry {\n\t\t\treturn formSchemaToZod(jsonSchema);\n\t\t} catch {\n\t\t\treturn z.object({});\n\t\t}\n\t}, [jsonSchema]);\n\n\t// Build field config for AutoForm (fieldType is now embedded in jsonSchema)\n\tconst fieldConfig = useMemo(\n\t\t() =>\n\t\t\tbuildFieldConfigFromJsonSchema(\n\t\t\t\tjsonSchema,\n\t\t\t\tuploadImage,\n\t\t\t\tfieldComponents,\n\t\t\t\timagePicker,\n\t\t\t\timageInputField,\n\t\t\t),\n\t\t[jsonSchema, uploadImage, fieldComponents, imagePicker, imageInputField],\n\t);\n\n\t// Find the field to use for slug auto-generation\n\tconst slugSourceField = useMemo(\n\t\t() => findSlugSourceField(jsonSchema),\n\t\t[jsonSchema],\n\t);\n\n\t// Handle form value changes for slug auto-generation\n\tconst handleValuesChange = (values: Record) => {\n\t\tsetFormData(values);\n\n\t\t// Auto-generate slug from source field if not manually edited\n\t\tif (!isEditing && !slugManuallyEdited && slugSourceField) {\n\t\t\tconst sourceValue = values[slugSourceField];\n\t\t\tif (typeof sourceValue === \"string\" && sourceValue.trim()) {\n\t\t\t\tsetSlug(slugify(sourceValue));\n\t\t\t}\n\t\t}\n\t};\n\n\t// Handle form submission\n\tconst handleSubmit = async (data: Record) => {\n\t\tsetSlugError(null);\n\t\tsetSubmitError(null);\n\n\t\tif (!slug.trim()) {\n\t\t\tsetSlugError(\"Slug is required\");\n\t\t\treturn;\n\t\t}\n\n\t\tsetIsSubmitting(true);\n\t\ttry {\n\t\t\tawait onSubmit({ slug, data });\n\t\t} catch (error) {\n\t\t\tconst message =\n\t\t\t\terror instanceof Error ? error.message : localization.CMS_TOAST_ERROR;\n\t\t\tsetSubmitError(message);\n\t\t} finally {\n\t\t\tsetIsSubmitting(false);\n\t\t}\n\t};\n\n\treturn (\n\t\t
\n\t\t\t{/* Slug field */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t{!isEditing && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{slugManuallyEdited\n\t\t\t\t\t\t\t\t? localization.CMS_EDITOR_SLUG_MANUAL\n\t\t\t\t\t\t\t\t: localization.CMS_EDITOR_SLUG_AUTO}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t\t {\n\t\t\t\t\t\tsetSlug(e.target.value);\n\t\t\t\t\t\tsetSlugError(null);\n\t\t\t\t\t\tif (!isEditing) {\n\t\t\t\t\t\t\tsetSlugManuallyEdited(true);\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t\tdisabled={isEditing}\n\t\t\t\t\tplaceholder={\n\t\t\t\t\t\tslugSourceField\n\t\t\t\t\t\t\t? `Auto-generated from ${slugSourceField}`\n\t\t\t\t\t\t\t: \"Enter slug...\"\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t\t{slugError &&

{slugError}

}\n\t\t\t\t

\n\t\t\t\t\t{localization.CMS_LABEL_SLUG_DESCRIPTION}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t{/* Submit error message */}\n\t\t\t{submitError && (\n\t\t\t\t
\n\t\t\t\t\t

{submitError}

\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{/* Dynamic form from Zod schema */}\n\t\t\t{/* Uses SteppedAutoForm which automatically handles both single-step and multi-step content types */}\n\t\t\t}\n\t\t\t\tvalues={formData as any}\n\t\t\t\tonValuesChange={handleValuesChange as any}\n\t\t\t\tonSubmit={handleSubmit as any}\n\t\t\t\tfieldConfig={fieldConfig as any}\n\t\t\t\tisSubmitting={isSubmitting}\n\t\t\t\tsubmitButtonText={\n\t\t\t\t\tisSubmitting\n\t\t\t\t\t\t? localization.CMS_STATUS_SAVING\n\t\t\t\t\t\t: localization.CMS_BUTTON_SAVE\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t{onCancel && (\n\t\t\t\t\t\n\t\t\t\t\t\t{localization.CMS_BUTTON_CANCEL}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState, useMemo, useEffect, useRef } from \"react\";\nimport { z } from \"zod\";\nimport { SteppedAutoForm } from \"@/components/ui/auto-form/stepped-auto-form\";\nimport type {\n\tFieldConfig,\n\tAutoFormInputComponentProps,\n} from \"@/components/ui/auto-form/types\";\nimport { buildFieldConfigFromJsonSchema as buildFieldConfigBase } from \"@/components/ui/auto-form/helpers\";\nimport { formSchemaToZod } from \"@/lib/schema-converter\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport type { SerializedContentType, RelationConfig } from \"../../../types\";\nimport { slugify } from \"../../../utils\";\nimport { CMS_LOCALIZATION } from \"../../localization\";\nimport { CMSFileUpload } from \"./file-upload\";\nimport { RelationField } from \"./relation-field\";\n\ninterface ContentFormProps {\n\tcontentType: SerializedContentType;\n\tinitialData?: Record;\n\tinitialSlug?: string;\n\tisEditing?: boolean;\n\tonSubmit: (data: {\n\t\tslug: string;\n\t\tdata: Record;\n\t}) => Promise;\n\tonCancel?: () => void;\n}\n\n/**\n * Build field configuration for AutoForm with CMS-specific file upload handling.\n *\n * Uses the shared buildFieldConfigFromJsonSchema from auto-form/helpers as a base,\n * then adds special handling for \"file\" fieldType to inject CMSFileUpload component\n * ONLY if no custom component is provided via fieldComponents.\n *\n * @param jsonSchema - The JSON Schema from the content type (with fieldType embedded in properties)\n * @param uploadImage - The uploadImage function from overrides (for file fields)\n * @param fieldComponents - Custom field components from overrides\n */\ninterface JsonSchemaProperty {\n\tfieldType?: string;\n\trelation?: RelationConfig;\n\t[key: string]: unknown;\n}\n\nfunction buildFieldConfigFromJsonSchema(\n\tjsonSchema: Record,\n\tuploadImage?: (file: File) => Promise,\n\tfieldComponents?: Record<\n\t\tstring,\n\t\tReact.ComponentType\n\t>,\n\timagePicker?: React.ComponentType<{ onSelect: (url: string) => void }>,\n\timageInputField?: React.ComponentType<{\n\t\tvalue: string;\n\t\tonChange: (value: string) => void;\n\t\tisRequired?: boolean;\n\t}>,\n): FieldConfig> {\n\t// Get base config from shared utility (handles fieldType from JSON Schema,\n\t// including per-item configs for arrays of objects).\n\tconst baseConfig = buildFieldConfigBase(jsonSchema, fieldComponents);\n\n\tconst properties = jsonSchema.properties as\n\t\t| Record\n\t\t| undefined;\n\n\tif (!properties) return baseConfig;\n\n\t// Recursively walk the JSON Schema properties and inject CMS-specific custom\n\t// components (file upload, relation picker) for any field with a matching\n\t// fieldType, regardless of nesting depth. Targets:\n\t// - top-level fields\n\t// - properties of nested object fields\n\t// - properties of array items (e.g. `components: z.array(z.object({...}))`)\n\t//\n\t// `targetConfig` is the FieldConfigObject slot to mutate for the property at\n\t// `key`. The recursion mirrors how AutoFormObject + AutoFormArray look up\n\t// per-property configs: nested object/array per-item configs live as keys\n\t// alongside their parent's meta on the same FieldConfigObject.\n\tconst injectCustomFieldTypes = (\n\t\tprops: Record,\n\t\ttargetConfig: Record,\n\t) => {\n\t\tfor (const [key, prop] of Object.entries(props)) {\n\t\t\t// Ensure a slot exists so we can mutate it whether or not the base\n\t\t\t// helper produced an entry for this key.\n\t\t\tconst existing =\n\t\t\t\t(targetConfig[key] as Record | undefined) ?? {};\n\n\t\t\tlet updated = existing;\n\n\t\t\t// Handle \"file\" fieldType when there's NO custom component for \"file\"\n\t\t\tif (prop.fieldType === \"file\" && !fieldComponents?.[\"file\"]) {\n\t\t\t\tif (!uploadImage && !imageInputField) {\n\t\t\t\t\tupdated = {\n\t\t\t\t\t\t...updated,\n\t\t\t\t\t\tfieldType: () => (\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\tFile upload requires an uploadImage or{\" \"}\n\t\t\t\t\t\t\t\timageInputField function in CMS overrides.\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t),\n\t\t\t\t\t};\n\t\t\t\t} else {\n\t\t\t\t\tupdated = {\n\t\t\t\t\t\t...updated,\n\t\t\t\t\t\tfieldType: (componentProps: AutoFormInputComponentProps) => (\n\t\t\t\t\t\t\t Promise.resolve(\"\"))}\n\t\t\t\t\t\t\t\timageInputField={imageInputField}\n\t\t\t\t\t\t\t\timagePicker={imagePicker}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t),\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Handle \"relation\" fieldType when there's NO custom component for \"relation\"\n\t\t\tif (\n\t\t\t\tprop.fieldType === \"relation\" &&\n\t\t\t\tprop.relation &&\n\t\t\t\t!fieldComponents?.[\"relation\"]\n\t\t\t) {\n\t\t\t\tconst relationConfig = prop.relation;\n\t\t\t\tupdated = {\n\t\t\t\t\t...updated,\n\t\t\t\t\tfieldType: (componentProps: AutoFormInputComponentProps) => (\n\t\t\t\t\t\t\n\t\t\t\t\t),\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Recurse into nested objects — their per-property configs live as\n\t\t\t// keys on the same parent FieldConfigObject.\n\t\t\tif (prop.properties) {\n\t\t\t\tinjectCustomFieldTypes(\n\t\t\t\t\tprop.properties as Record,\n\t\t\t\t\tupdated,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\t// Recurse into array items — same convention as nested objects.\n\t\t\tconst items = prop.items as JsonSchemaProperty | undefined;\n\t\t\tif (items?.properties) {\n\t\t\t\tinjectCustomFieldTypes(\n\t\t\t\t\titems.properties as Record,\n\t\t\t\t\tupdated,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tif (Object.keys(updated).length > 0) {\n\t\t\t\ttargetConfig[key] = updated;\n\t\t\t}\n\t\t}\n\t};\n\n\tinjectCustomFieldTypes(\n\t\tproperties,\n\t\tbaseConfig as unknown as Record,\n\t);\n\n\treturn baseConfig;\n}\n\n/**\n * Determine the first string field in the schema for slug auto-generation\n */\nfunction findSlugSourceField(\n\tjsonSchema: Record,\n): string | null {\n\tconst properties = jsonSchema.properties as Record;\n\tif (!properties) return null;\n\n\t// Look for common name fields first\n\tconst priorityFields = [\"name\", \"title\", \"heading\", \"label\"];\n\tfor (const field of priorityFields) {\n\t\tif (properties[field]?.type === \"string\") {\n\t\t\treturn field;\n\t\t}\n\t}\n\n\t// Fall back to first string field\n\tfor (const [key, value] of Object.entries(properties)) {\n\t\tif (value.type === \"string\") {\n\t\t\treturn key;\n\t\t}\n\t}\n\n\treturn null;\n}\n\nexport function ContentForm({\n\tcontentType,\n\tinitialData = {},\n\tinitialSlug = \"\",\n\tisEditing = false,\n\tonSubmit,\n\tonCancel,\n}: ContentFormProps) {\n\tconst {\n\t\tlocalization: customLocalization,\n\t\tuploadImage,\n\t\timagePicker,\n\t\timageInputField,\n\t\tfieldComponents,\n\t} = usePluginOverrides(\"cms\");\n\tconst localization = { ...CMS_LOCALIZATION, ...customLocalization };\n\n\tconst [slug, setSlug] = useState(initialSlug);\n\tconst [slugManuallyEdited, setSlugManuallyEdited] = useState(isEditing);\n\tconst [isSubmitting, setIsSubmitting] = useState(false);\n\tconst [formData, setFormData] =\n\t\tuseState>(initialData);\n\tconst [slugError, setSlugError] = useState(null);\n\tconst [submitError, setSubmitError] = useState(null);\n\n\t// Track if we've already synced prefill data to avoid overwriting user input\n\tconst hasSyncedPrefillRef = useRef(false);\n\n\t// Sync formData with initialData when it changes\n\t// This handles both:\n\t// 1. Editing mode: always sync when item data is loaded (isEditing=true)\n\t// 2. Create mode: only sync prefill data ONCE to avoid overwriting user input\n\t// useState only uses the initial value on mount, so we need this effect for updates\n\tuseEffect(() => {\n\t\tconst hasData = Object.keys(initialData).length > 0;\n\t\t// In edit mode, always sync (user is loading existing data)\n\t\t// In create mode, only sync prefill data once\n\t\tconst shouldSync = hasData && (isEditing || !hasSyncedPrefillRef.current);\n\n\t\tif (shouldSync) {\n\t\t\tsetFormData(initialData);\n\t\t\tif (!isEditing) {\n\t\t\t\thasSyncedPrefillRef.current = true;\n\t\t\t}\n\t\t}\n\t}, [initialData, isEditing]);\n\n\t// Also sync slug when initialSlug changes\n\tuseEffect(() => {\n\t\tif (isEditing && initialSlug) {\n\t\t\tsetSlug(initialSlug);\n\t\t}\n\t}, [initialSlug, isEditing]);\n\n\t// Parse JSON Schema (now includes fieldType embedded in properties)\n\tconst jsonSchema = useMemo(() => {\n\t\ttry {\n\t\t\treturn JSON.parse(contentType.jsonSchema) as Record;\n\t\t} catch {\n\t\t\treturn {};\n\t\t}\n\t}, [contentType.jsonSchema]);\n\n\t// Convert JSON Schema to Zod schema using formSchemaToZod utility\n\t// This properly handles date fields (format: \"date-time\") and min/max date constraints\n\tconst zodSchema = useMemo(() => {\n\t\ttry {\n\t\t\treturn formSchemaToZod(jsonSchema);\n\t\t} catch {\n\t\t\treturn z.object({});\n\t\t}\n\t}, [jsonSchema]);\n\n\t// Build field config for AutoForm (fieldType is now embedded in jsonSchema)\n\tconst fieldConfig = useMemo(\n\t\t() =>\n\t\t\tbuildFieldConfigFromJsonSchema(\n\t\t\t\tjsonSchema,\n\t\t\t\tuploadImage,\n\t\t\t\tfieldComponents,\n\t\t\t\timagePicker,\n\t\t\t\timageInputField,\n\t\t\t),\n\t\t[jsonSchema, uploadImage, fieldComponents, imagePicker, imageInputField],\n\t);\n\n\t// Find the field to use for slug auto-generation\n\tconst slugSourceField = useMemo(\n\t\t() => findSlugSourceField(jsonSchema),\n\t\t[jsonSchema],\n\t);\n\n\t// Handle form value changes for slug auto-generation\n\tconst handleValuesChange = (values: Record) => {\n\t\tsetFormData(values);\n\n\t\t// Auto-generate slug from source field if not manually edited\n\t\tif (!isEditing && !slugManuallyEdited && slugSourceField) {\n\t\t\tconst sourceValue = values[slugSourceField];\n\t\t\tif (typeof sourceValue === \"string\" && sourceValue.trim()) {\n\t\t\t\tsetSlug(slugify(sourceValue));\n\t\t\t}\n\t\t}\n\t};\n\n\t// Handle form submission\n\tconst handleSubmit = async (data: Record) => {\n\t\tsetSlugError(null);\n\t\tsetSubmitError(null);\n\n\t\tif (!slug.trim()) {\n\t\t\tsetSlugError(\"Slug is required\");\n\t\t\treturn;\n\t\t}\n\n\t\tsetIsSubmitting(true);\n\t\ttry {\n\t\t\tawait onSubmit({ slug, data });\n\t\t} catch (error) {\n\t\t\tconst message =\n\t\t\t\terror instanceof Error ? error.message : localization.CMS_TOAST_ERROR;\n\t\t\tsetSubmitError(message);\n\t\t} finally {\n\t\t\tsetIsSubmitting(false);\n\t\t}\n\t};\n\n\treturn (\n\t\t
\n\t\t\t{/* Slug field */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t{!isEditing && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{slugManuallyEdited\n\t\t\t\t\t\t\t\t? localization.CMS_EDITOR_SLUG_MANUAL\n\t\t\t\t\t\t\t\t: localization.CMS_EDITOR_SLUG_AUTO}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t\t {\n\t\t\t\t\t\tsetSlug(e.target.value);\n\t\t\t\t\t\tsetSlugError(null);\n\t\t\t\t\t\tif (!isEditing) {\n\t\t\t\t\t\t\tsetSlugManuallyEdited(true);\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t\tdisabled={isEditing}\n\t\t\t\t\tplaceholder={\n\t\t\t\t\t\tslugSourceField\n\t\t\t\t\t\t\t? `Auto-generated from ${slugSourceField}`\n\t\t\t\t\t\t\t: \"Enter slug...\"\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t\t{slugError &&

{slugError}

}\n\t\t\t\t

\n\t\t\t\t\t{localization.CMS_LABEL_SLUG_DESCRIPTION}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t{/* Submit error message */}\n\t\t\t{submitError && (\n\t\t\t\t
\n\t\t\t\t\t

{submitError}

\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{/* Dynamic form from Zod schema */}\n\t\t\t{/* Uses SteppedAutoForm which automatically handles both single-step and multi-step content types */}\n\t\t\t}\n\t\t\t\tvalues={formData as any}\n\t\t\t\tonValuesChange={handleValuesChange as any}\n\t\t\t\tonSubmit={handleSubmit as any}\n\t\t\t\tfieldConfig={fieldConfig as any}\n\t\t\t\tisSubmitting={isSubmitting}\n\t\t\t\tsubmitButtonText={\n\t\t\t\t\tisSubmitting\n\t\t\t\t\t\t? localization.CMS_STATUS_SAVING\n\t\t\t\t\t\t: localization.CMS_BUTTON_SAVE\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t{onCancel && (\n\t\t\t\t\t\n\t\t\t\t\t\t{localization.CMS_BUTTON_CANCEL}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/cms/client/components/forms/content-form.tsx" }, { @@ -67,7 +67,7 @@ { "path": "btst/cms/client/components/inverse-relations-panel.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport {\n\tChevronDown,\n\tChevronRight,\n\tExternalLink,\n\tPlus,\n\tTrash2,\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport { createApiClient } from \"@btst/stack/plugins/client\";\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { useDeleteContent } from \"@btst/stack/plugins/cms/client/hooks\";\nimport type { CMSPluginOverrides } from \"../overrides\";\nimport type { CMSApiRouter } from \"@btst/stack/plugins/cms/api\";\nimport type { SerializedContentItemWithType } from \"../../types\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\n\ninterface InverseRelation {\n\tsourceType: string;\n\tsourceTypeName: string;\n\tfieldName: string;\n\tcount: number;\n}\n\ninterface InverseRelationsPanelProps {\n\tcontentTypeSlug: string;\n\titemId: string;\n}\n\n/**\n * Panel that shows content items that reference this item via belongsTo relations.\n * For example, when editing a Resource, this shows all Comments that belong to it.\n */\nexport function InverseRelationsPanel({\n\tcontentTypeSlug,\n\titemId,\n}: InverseRelationsPanelProps) {\n\tconst { apiBaseURL, apiBasePath, headers, navigate, Link } =\n\t\tusePluginOverrides(\"cms\");\n\tconst basePath = useBasePath();\n\tconst client = createApiClient({\n\t\tbaseURL: apiBaseURL,\n\t\tbasePath: apiBasePath,\n\t});\n\n\t// Fetch inverse relations metadata\n\tconst { data: inverseRelationsData, isLoading } = useQuery({\n\t\tqueryKey: [\"cmsInverseRelations\", contentTypeSlug, itemId],\n\t\tqueryFn: async () => {\n\t\t\tconst response = await client(\"/content-types/:slug/inverse-relations\", {\n\t\t\t\tmethod: \"GET\",\n\t\t\t\tparams: { slug: contentTypeSlug },\n\t\t\t\tquery: { itemId },\n\t\t\t\theaders,\n\t\t\t});\n\t\t\treturn (\n\t\t\t\t(response as { data?: { inverseRelations: InverseRelation[] } }).data\n\t\t\t\t\t?.inverseRelations ?? []\n\t\t\t);\n\t\t},\n\t\tstaleTime: 1000 * 60 * 5,\n\t});\n\n\tif (isLoading) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\tconst inverseRelations = inverseRelationsData ?? [];\n\n\tif (inverseRelations.length === 0) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t
\n\t\t\t

Related Items

\n\t\t\t{inverseRelations.map((relation) => (\n\t\t\t\t\n\t\t\t))}\n\t\t
\n\t);\n}\n\ninterface InverseRelationSectionProps {\n\trelation: InverseRelation;\n\tcontentTypeSlug: string;\n\titemId: string;\n\tbasePath: string;\n\tnavigate: (path: string) => void;\n\tLink?: React.ComponentType<{\n\t\thref?: string;\n\t\tchildren?: React.ReactNode;\n\t\tclassName?: string;\n\t}>;\n\tclient: ReturnType>;\n\theaders?: HeadersInit;\n}\n\nfunction InverseRelationSection({\n\trelation,\n\tcontentTypeSlug,\n\titemId,\n\tbasePath,\n\tnavigate,\n\tLink,\n\tclient,\n\theaders,\n}: InverseRelationSectionProps) {\n\tconst [isExpanded, setIsExpanded] = useState(true);\n\tconst [deleteItemId, setDeleteItemId] = useState(null);\n\tconst [deleteError, setDeleteError] = useState(null);\n\tconst deleteContent = useDeleteContent(relation.sourceType);\n\n\t// Fetch items for this inverse relation\n\tconst { data: itemsData, refetch } = useQuery({\n\t\tqueryKey: [\n\t\t\t\"cmsInverseRelationItems\",\n\t\t\tcontentTypeSlug,\n\t\t\trelation.sourceType,\n\t\t\titemId,\n\t\t\trelation.fieldName,\n\t\t],\n\t\tqueryFn: async () => {\n\t\t\tconst response = await client(\n\t\t\t\t\"/content-types/:slug/inverse-relations/:sourceType\",\n\t\t\t\t{\n\t\t\t\t\tmethod: \"GET\",\n\t\t\t\t\tparams: { slug: contentTypeSlug, sourceType: relation.sourceType },\n\t\t\t\t\tquery: { itemId, fieldName: relation.fieldName },\n\t\t\t\t\theaders,\n\t\t\t\t},\n\t\t\t);\n\t\t\treturn (\n\t\t\t\t(\n\t\t\t\t\tresponse as {\n\t\t\t\t\t\tdata?: { items: SerializedContentItemWithType[]; total: number };\n\t\t\t\t\t}\n\t\t\t\t).data ?? { items: [], total: 0 }\n\t\t\t);\n\t\t},\n\t\tstaleTime: 1000 * 60 * 5,\n\t\tenabled: isExpanded,\n\t});\n\n\tconst items = itemsData?.items ?? [];\n\tconst total = itemsData?.total ?? relation.count;\n\n\tconst handleDelete = async () => {\n\t\tif (deleteItemId) {\n\t\t\tsetDeleteError(null);\n\t\t\ttry {\n\t\t\t\tawait deleteContent.mutateAsync(deleteItemId);\n\t\t\t\tsetDeleteItemId(null);\n\t\t\t\trefetch();\n\t\t\t} catch (error) {\n\t\t\t\tconst message =\n\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t: \"Failed to delete item. Please try again.\";\n\t\t\t\tsetDeleteError(message);\n\t\t\t}\n\t\t}\n\t};\n\n\t// Create new item with pre-filled belongsTo field\n\tconst handleAddNew = () => {\n\t\t// Navigate to create page with query param to pre-fill the relation.\n\t\t// ContentEditorPage reads prefill_* query params and passes them to ContentForm as initialData.\n\t\tconst createUrl = `${basePath}/cms/${relation.sourceType}/new?prefill_${relation.fieldName}=${itemId}`;\n\t\tnavigate(createUrl);\n\t};\n\n\tconst LinkComponent = Link ?? \"a\";\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t setIsExpanded(!isExpanded)}\n\t\t\t\t\tclassName=\"flex items-center justify-between w-full text-left\"\n\t\t\t\t>\n\t\t\t\t\t\n\t\t\t\t\t\t{isExpanded ? (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{relation.sourceTypeName} ({total})\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t{isExpanded && (\n\t\t\t\t\n\t\t\t\t\t{items.length === 0 ? (\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\tNo {relation.sourceTypeName.toLowerCase()} items yet.\n\t\t\t\t\t\t

\n\t\t\t\t\t) : (\n\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t{items.map((item) => {\n\t\t\t\t\t\t\t\tconst displayValue = getDisplayValue(item);\n\t\t\t\t\t\t\t\tconst editUrl = `${basePath}/cms/${relation.sourceType}/${item.id}`;\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{displayValue}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t setDeleteItemId(item.id)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd {relation.sourceTypeName}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{/* Delete confirmation dialog */}\n\t\t\t {\n\t\t\t\t\tif (!open) {\n\t\t\t\t\t\tsetDeleteItemId(null);\n\t\t\t\t\t\tsetDeleteError(null);\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tDelete {relation.sourceTypeName}?\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tThis action cannot be undone. This will permanently delete this{\" \"}\n\t\t\t\t\t\t\t{relation.sourceTypeName.toLowerCase()}.\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{deleteError && (\n\t\t\t\t\t\t

{deleteError}

\n\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tDelete\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n\n/**\n * Get a display value from an item's parsedData\n */\nfunction getDisplayValue(item: SerializedContentItemWithType): string {\n\tconst data = item.parsedData as Record;\n\t// Try common display fields\n\tconst displayFields = [\"name\", \"title\", \"label\", \"content\", \"author\", \"slug\"];\n\tfor (const field of displayFields) {\n\t\tif (typeof data[field] === \"string\" && data[field]) {\n\t\t\tconst value = data[field] as string;\n\t\t\treturn value.length > 50 ? `${value.slice(0, 50)}...` : value;\n\t\t}\n\t}\n\treturn item.slug;\n}\n", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport {\n\tChevronDown,\n\tChevronRight,\n\tExternalLink,\n\tPlus,\n\tTrash2,\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport { createApiClient } from \"@btst/stack/plugins/client\";\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { useDeleteContent } from \"@btst/stack/plugins/cms/client/hooks\";\nimport type { CMSPluginOverrides } from \"../overrides\";\nimport type { CMSApiRouter } from \"@btst/stack/plugins/cms/api\";\nimport type { SerializedContentItemWithType } from \"../../types\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\n\ninterface InverseRelation {\n\tsourceType: string;\n\tsourceTypeName: string;\n\tfieldName: string;\n\tcount: number;\n}\n\ninterface InverseRelationsPanelProps {\n\tcontentTypeSlug: string;\n\titemId: string;\n}\n\n/**\n * Panel that shows content items that reference this item via belongsTo relations.\n * For example, when editing a Resource, this shows all Comments that belong to it.\n */\nexport function InverseRelationsPanel({\n\tcontentTypeSlug,\n\titemId,\n}: InverseRelationsPanelProps) {\n\tconst { apiBaseURL, apiBasePath, headers, navigate, Link } =\n\t\tusePluginOverrides(\"cms\");\n\tconst basePath = useBasePath();\n\tconst client = createApiClient({\n\t\tbaseURL: apiBaseURL,\n\t\tbasePath: apiBasePath,\n\t});\n\n\t// Fetch inverse relations metadata\n\tconst { data: inverseRelationsData, isLoading } = useQuery({\n\t\tqueryKey: [\"cmsInverseRelations\", contentTypeSlug, itemId],\n\t\tqueryFn: async () => {\n\t\t\tconst response = await client(\"/content-types/:slug/inverse-relations\", {\n\t\t\t\tmethod: \"GET\",\n\t\t\t\tparams: { slug: contentTypeSlug },\n\t\t\t\tquery: { itemId },\n\t\t\t\theaders,\n\t\t\t});\n\t\t\treturn (\n\t\t\t\t(response as { data?: { inverseRelations: InverseRelation[] } }).data\n\t\t\t\t\t?.inverseRelations ?? []\n\t\t\t);\n\t\t},\n\t\tstaleTime: 1000 * 60 * 5,\n\t});\n\n\tif (isLoading) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\tconst inverseRelations = inverseRelationsData ?? [];\n\n\tif (inverseRelations.length === 0) {\n\t\treturn null;\n\t}\n\n\t// When a single source content type has multiple belongsTo fields pointing\n\t// at this target type (e.g. StackSynergy has both compoundAId and\n\t// compoundBId → compound), the section title alone (\"Stack Synergy\") is\n\t// ambiguous — two cards would render with identical headings. Mark those\n\t// relations so we can disambiguate them by field name.\n\tconst sourceTypeCounts = new Map();\n\tfor (const rel of inverseRelations) {\n\t\tsourceTypeCounts.set(\n\t\t\trel.sourceType,\n\t\t\t(sourceTypeCounts.get(rel.sourceType) ?? 0) + 1,\n\t\t);\n\t}\n\n\treturn (\n\t\t
\n\t\t\t

Related Items

\n\t\t\t{inverseRelations.map((relation) => (\n\t\t\t\t 1}\n\t\t\t\t/>\n\t\t\t))}\n\t\t
\n\t);\n}\n\n/**\n * Turn a relation field name like `compoundAId` / `categoryIds` into a\n * friendlier label like `Compound A` / `Category` for display in the\n * inverse-relations panel when two sections would otherwise share a title.\n *\n * Strips a trailing `Id` or `Ids`, splits camelCase boundaries, and\n * title-cases the result. Leaves unrecognised shapes as-is so we never\n * produce an empty string.\n */\nfunction humanizeFieldName(fieldName: string): string {\n\tconst stripped = fieldName.replace(/Ids?$/, \"\") || fieldName;\n\tconst words = stripped\n\t\t.replace(/([a-z0-9])([A-Z])/g, \"$1 $2\")\n\t\t.replace(/[_-]+/g, \" \")\n\t\t.trim()\n\t\t.split(/\\s+/);\n\treturn words.map((w) => (w ? w[0]!.toUpperCase() + w.slice(1) : w)).join(\" \");\n}\n\ninterface InverseRelationSectionProps {\n\trelation: InverseRelation;\n\tcontentTypeSlug: string;\n\titemId: string;\n\tbasePath: string;\n\tnavigate: (path: string) => void;\n\tLink?: React.ComponentType<{\n\t\thref?: string;\n\t\tchildren?: React.ReactNode;\n\t\tclassName?: string;\n\t}>;\n\tclient: ReturnType>;\n\theaders?: HeadersInit;\n\t/**\n\t * True when another inverse relation from the same `sourceType` is also\n\t * being rendered — in which case the field-name suffix is shown so the\n\t * user can tell the two cards apart.\n\t */\n\tambiguous: boolean;\n}\n\nfunction InverseRelationSection({\n\trelation,\n\tcontentTypeSlug,\n\titemId,\n\tbasePath,\n\tnavigate,\n\tLink,\n\tclient,\n\theaders,\n\tambiguous,\n}: InverseRelationSectionProps) {\n\tconst [isExpanded, setIsExpanded] = useState(true);\n\tconst [deleteItemId, setDeleteItemId] = useState(null);\n\tconst [deleteError, setDeleteError] = useState(null);\n\tconst deleteContent = useDeleteContent(relation.sourceType);\n\n\t// Fetch items for this inverse relation\n\tconst { data: itemsData, refetch } = useQuery({\n\t\tqueryKey: [\n\t\t\t\"cmsInverseRelationItems\",\n\t\t\tcontentTypeSlug,\n\t\t\trelation.sourceType,\n\t\t\titemId,\n\t\t\trelation.fieldName,\n\t\t],\n\t\tqueryFn: async () => {\n\t\t\tconst response = await client(\n\t\t\t\t\"/content-types/:slug/inverse-relations/:sourceType\",\n\t\t\t\t{\n\t\t\t\t\tmethod: \"GET\",\n\t\t\t\t\tparams: { slug: contentTypeSlug, sourceType: relation.sourceType },\n\t\t\t\t\tquery: { itemId, fieldName: relation.fieldName },\n\t\t\t\t\theaders,\n\t\t\t\t},\n\t\t\t);\n\t\t\treturn (\n\t\t\t\t(\n\t\t\t\t\tresponse as {\n\t\t\t\t\t\tdata?: { items: SerializedContentItemWithType[]; total: number };\n\t\t\t\t\t}\n\t\t\t\t).data ?? { items: [], total: 0 }\n\t\t\t);\n\t\t},\n\t\tstaleTime: 1000 * 60 * 5,\n\t\tenabled: isExpanded,\n\t});\n\n\tconst items = itemsData?.items ?? [];\n\tconst total = itemsData?.total ?? relation.count;\n\n\tconst handleDelete = async () => {\n\t\tif (deleteItemId) {\n\t\t\tsetDeleteError(null);\n\t\t\ttry {\n\t\t\t\tawait deleteContent.mutateAsync(deleteItemId);\n\t\t\t\tsetDeleteItemId(null);\n\t\t\t\trefetch();\n\t\t\t} catch (error) {\n\t\t\t\tconst message =\n\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t: \"Failed to delete item. Please try again.\";\n\t\t\t\tsetDeleteError(message);\n\t\t\t}\n\t\t}\n\t};\n\n\t// Create new item with pre-filled belongsTo field\n\tconst handleAddNew = () => {\n\t\t// Navigate to create page with query param to pre-fill the relation.\n\t\t// ContentEditorPage reads prefill_* query params and passes them to ContentForm as initialData.\n\t\tconst createUrl = `${basePath}/cms/${relation.sourceType}/new?prefill_${relation.fieldName}=${itemId}`;\n\t\tnavigate(createUrl);\n\t};\n\n\tconst LinkComponent = Link ?? \"a\";\n\tconst fieldLabel = ambiguous ? humanizeFieldName(relation.fieldName) : null;\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t setIsExpanded(!isExpanded)}\n\t\t\t\t\tclassName=\"flex items-center justify-between w-full text-left\"\n\t\t\t\t>\n\t\t\t\t\t\n\t\t\t\t\t\t{isExpanded ? (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{relation.sourceTypeName}\n\t\t\t\t\t\t{fieldLabel && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t· {fieldLabel}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t({total})\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t{isExpanded && (\n\t\t\t\t\n\t\t\t\t\t{items.length === 0 ? (\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\tNo {relation.sourceTypeName.toLowerCase()} items yet.\n\t\t\t\t\t\t

\n\t\t\t\t\t) : (\n\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t{items.map((item) => {\n\t\t\t\t\t\t\t\tconst displayValue = getDisplayValue(item);\n\t\t\t\t\t\t\t\tconst editUrl = `${basePath}/cms/${relation.sourceType}/${item.id}`;\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{displayValue}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t setDeleteItemId(item.id)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd {relation.sourceTypeName}\n\t\t\t\t\t\t\t{fieldLabel ? ` (${fieldLabel})` : \"\"}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{/* Delete confirmation dialog */}\n\t\t\t {\n\t\t\t\t\tif (!open) {\n\t\t\t\t\t\tsetDeleteItemId(null);\n\t\t\t\t\t\tsetDeleteError(null);\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tDelete {relation.sourceTypeName}?\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tThis action cannot be undone. This will permanently delete this{\" \"}\n\t\t\t\t\t\t\t{relation.sourceTypeName.toLowerCase()}.\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{deleteError && (\n\t\t\t\t\t\t

{deleteError}

\n\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tDelete\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n\n/**\n * Get a display value from an item's parsedData\n */\nfunction getDisplayValue(item: SerializedContentItemWithType): string {\n\tconst data = item.parsedData as Record;\n\t// Try common display fields\n\tconst displayFields = [\"name\", \"title\", \"label\", \"content\", \"author\", \"slug\"];\n\tfor (const field of displayFields) {\n\t\tif (typeof data[field] === \"string\" && data[field]) {\n\t\t\tconst value = data[field] as string;\n\t\t\treturn value.length > 50 ? `${value.slice(0, 50)}...` : value;\n\t\t}\n\t}\n\treturn item.slug;\n}\n", "target": "src/components/btst/cms/client/components/inverse-relations-panel.tsx" }, { @@ -223,7 +223,7 @@ { "path": "ui/components/auto-form/helpers.tsx", "type": "registry:component", - "content": "import React from \"react\";\nimport type { DefaultValues } from \"react-hook-form\";\nimport { z } from \"zod\";\nimport type { AutoFormInputComponentProps, FieldConfig } from \"./types\";\n\nexport const BUILTIN_FIELD_TYPES = [\n\t\"checkbox\",\n\t\"date\",\n\t\"select\",\n\t\"radio\",\n\t\"switch\",\n\t\"textarea\",\n\t\"number\",\n\t\"fallback\",\n] as const;\n\n/**\n * Get the type name from a Zod v4 schema's _zod.def.\n * In Zod v4: _zod.def.type (e.g., \"default\", \"optional\", \"object\", \"string\")\n */\nfunction getDefTypeName(schema: z.ZodType): string {\n // Access through _zod.def.type for Zod v4\n const def = (schema as any)._zod?.def;\n return def?.type || \"\";\n}\n\n/**\n * Type for wrapped object schemas in Zod v4.\n * In Zod v4, ZodEffects is replaced with ZodPipe for transforms.\n * For autoform purposes, we mainly deal with objects that might be wrapped in optional/default/nullable.\n */\nexport type ZodObjectOrWrapped = z.ZodObject | z.ZodType;\n\n/**\n * Beautify a camelCase string.\n * e.g. \"myString\" -> \"My String\"\n */\nexport function beautifyObjectName(string: string) {\n // if numbers only return the string\n let output = string.replace(/([A-Z])/g, \" $1\");\n output = output.charAt(0).toUpperCase() + output.slice(1);\n return output;\n}\n\n/**\n * Get the lowest level Zod type.\n * This will unpack optionals, defaults, nullables, pipes, etc.\n */\nexport function getBaseSchema(\n schema: ChildType\n): ChildType | null {\n if (!schema) return null;\n\n const def = (schema as any)._zod?.def;\n if (!def) return schema as ChildType;\n\n // Handle wrapped types by checking for innerType or wrapped property\n if (def.innerType) {\n return getBaseSchema(def.innerType as ChildType);\n }\n\n // Handle ZodPipe (transforms) - get the output schema\n if (def.out) {\n return getBaseSchema(def.out as ChildType);\n }\n\n // Handle schema property (for some wrapper types)\n if (def.schema) {\n return getBaseSchema(def.schema as ChildType);\n }\n\n return schema as ChildType;\n}\n\n/**\n * Get the type name of the lowest level Zod type.\n * This will unpack optionals, defaults, etc.\n * \n * Returns Zod v4 style type names (e.g., \"enum\", \"boolean\", \"object\")\n */\nexport function getBaseType(schema: z.ZodType): string {\n const baseSchema = getBaseSchema(schema);\n if (!baseSchema) return \"\";\n\n const typeName = getDefTypeName(baseSchema);\n \n // Map to consistent type names (capitalize first letter for component lookup)\n const typeMap: Record = {\n object: \"ZodObject\",\n array: \"ZodArray\",\n string: \"ZodString\",\n number: \"ZodNumber\",\n int: \"ZodNumber\",\n float: \"ZodNumber\",\n boolean: \"ZodBoolean\",\n date: \"ZodDate\",\n enum: \"ZodEnum\",\n nativeEnum: \"ZodNativeEnum\",\n literal: \"ZodLiteral\",\n union: \"ZodUnion\",\n };\n\n return typeMap[typeName] || typeName;\n}\n\n/**\n * Search for a \"default\" wrapper in the Zod stack and return its value.\n * In Zod v4: _zod.def.defaultValue is the default value (not a function)\n */\nexport function getDefaultValueInZodStack(schema: z.ZodType): any {\n const def = (schema as any)._zod?.def;\n if (!def) return undefined;\n\n if (def.type === \"default\") {\n // In Zod v4, defaultValue is the value directly (not a function)\n const defaultValue = def.defaultValue;\n // Handle both function (legacy) and value\n if (typeof defaultValue === \"function\") {\n return defaultValue();\n }\n return defaultValue;\n }\n\n // Check wrapped types\n if (def.innerType) {\n return getDefaultValueInZodStack(def.innerType);\n }\n if (def.schema) {\n return getDefaultValueInZodStack(def.schema);\n }\n\n return undefined;\n}\n\n/**\n * Get all default values from a Zod schema.\n */\nexport function getDefaultValues>(\n schema: Schema,\n fieldConfig?: FieldConfig>\n) {\n if (!schema) return null;\n const { shape } = schema;\n type DefaultValuesType = DefaultValues>>;\n const defaultValues = {} as DefaultValuesType;\n if (!shape) return defaultValues;\n\n for (const key of Object.keys(shape)) {\n const item = shape[key] as z.ZodType;\n\n if (getBaseType(item) === \"ZodObject\") {\n const defaultItems = getDefaultValues(\n getBaseSchema(item) as unknown as z.ZodObject,\n fieldConfig?.[key] as FieldConfig>\n );\n\n if (defaultItems !== null) {\n for (const defaultItemKey of Object.keys(defaultItems)) {\n const pathKey = `${key}.${defaultItemKey}` as keyof DefaultValuesType;\n (defaultValues as any)[pathKey] = defaultItems[defaultItemKey];\n }\n }\n } else {\n let defaultValue = getDefaultValueInZodStack(item);\n // Also check fieldConfig for default values (important for JSON schema derived forms)\n if (\n (defaultValue === undefined || defaultValue === null || defaultValue === \"\") &&\n fieldConfig?.[key]?.inputProps\n ) {\n defaultValue = (fieldConfig?.[key]?.inputProps as unknown as any)\n .defaultValue;\n }\n if (defaultValue !== undefined) {\n (defaultValues as any)[key as keyof DefaultValuesType] = defaultValue;\n }\n }\n }\n\n return defaultValues;\n}\n\n/**\n * Extract the object schema from a potentially wrapped schema.\n * Handles pipes, defaults, optionals, etc.\n */\nexport function getObjectFormSchema(\n schema: ZodObjectOrWrapped\n): z.ZodObject {\n if (!schema) return schema as z.ZodObject;\n\n const def = (schema as any)._zod?.def;\n if (!def) return schema as z.ZodObject;\n\n // If it's a pipe (transform), get the input schema\n if (def.type === \"pipe\") {\n return getObjectFormSchema(def.in);\n }\n\n // Handle wrapped types\n if (def.innerType) {\n return getObjectFormSchema(def.innerType);\n }\n if (def.schema) {\n return getObjectFormSchema(def.schema);\n }\n\n return schema as z.ZodObject;\n}\n\n/**\n * Get description from a Zod schema.\n * In Zod v4, descriptions are stored in the global registry.\n */\nexport function getSchemaDescription(schema: z.ZodType): string | undefined {\n // Try to get from registry first (Zod v4)\n const registered = z.globalRegistry.get(schema);\n if (registered?.description) {\n return registered.description;\n }\n\n // Fallback: check if description is on the schema itself\n const def = (schema as any)._zod?.def;\n return def?.description;\n}\n\n/**\n * Convert a Zod schema to HTML input props to give direct feedback to the user.\n * Once submitted, the schema will be validated completely.\n */\nexport function zodToHtmlInputProps(\n schema: z.ZodType\n): React.InputHTMLAttributes {\n const def = (schema as any)._zod?.def;\n const defType = def?.type || \"\";\n\n // Check for optional/nullable\n if ([\"optional\", \"nullable\"].includes(defType)) {\n return {\n ...zodToHtmlInputProps(def.innerType),\n required: false,\n };\n }\n\n // Check for default - fields with defaults should not be required\n // since the default value will be used if not provided\n if (defType === \"default\") {\n return {\n ...zodToHtmlInputProps(def.innerType),\n required: false,\n };\n }\n\n const inputProps: React.InputHTMLAttributes = {\n required: true,\n };\n\n // Get checks from the schema\n const checks = def?.checks;\n if (!checks || !Array.isArray(checks)) {\n return inputProps;\n }\n\n const type = getBaseType(schema);\n\n for (const check of checks) {\n // In Zod v4, checks have 'kind' property\n const checkKind = check.kind || check.type;\n \n if (checkKind === \"min\" || checkKind === \"min_length\") {\n if (type === \"ZodString\") {\n inputProps.minLength = check.value ?? check.minimum;\n } else {\n inputProps.min = check.value ?? check.minimum;\n }\n }\n if (checkKind === \"max\" || checkKind === \"max_length\") {\n if (type === \"ZodString\") {\n inputProps.maxLength = check.value ?? check.maximum;\n } else {\n inputProps.max = check.value ?? check.maximum;\n }\n }\n }\n\n return inputProps;\n}\n\n/**\n * Sort the fields by order.\n * If no order is set, the field will be sorted based on the order in the schema.\n */\nexport function sortFieldsByOrder>(\n fieldConfig: FieldConfig> | undefined,\n keys: string[]\n) {\n const sortedFields = keys.sort((a, b) => {\n const fieldA: number = (fieldConfig?.[a]?.order as number) ?? 0;\n const fieldB = (fieldConfig?.[b]?.order as number) ?? 0;\n return fieldA - fieldB;\n });\n\n return sortedFields;\n}\n\n// Import shared JSON Schema property type for consistency with form-builder\nimport type { JSONSchemaPropertyBase } from \"./shared-form-types\";\n\n/**\n * JSON schema property shape that includes FieldConfigItem-compatible metadata.\n * Uses the shared type for consistency between form-builder and auto-form.\n */\ntype JsonSchemaProperty = JSONSchemaPropertyBase;\n\nexport function buildFieldConfigFromJsonSchema(\n\tjsonSchema: Record,\n\tfieldComponents?: Record<\n\t\tstring,\n\t\tReact.ComponentType\n\t>,\n): FieldConfig> {\n\tconst fieldConfig: FieldConfig> = {};\n\tconst properties = jsonSchema.properties as Record;\n\n\tif (!properties) return fieldConfig;\n\n\tfor (const [key, value] of Object.entries(properties)) {\n\t\tconst config: Record = {};\n\n\t\t// Extract label from meta (support both 'label' and JSON Schema 'title')\n\t\tif (value.label) {\n\t\t\tconfig.label = value.label;\n\t\t} else if (value.title) {\n\t\t\tconfig.label = value.title;\n\t\t}\n\n\t\t// Extract description from meta\n\t\tif (value.description) {\n\t\t\tconfig.description = value.description;\n\t\t}\n\n\t\t// Extract inputProps from meta (includes placeholder, type, etc.)\n\t\t// Also merge in default value if present\n\t\tconst inputProps: Record = value.inputProps ? { ...value.inputProps } : {};\n\t\t\n\t\t// Extract placeholder from JSON Schema\n\t\tif (value.placeholder) {\n\t\t\tinputProps.placeholder = value.placeholder;\n\t\t}\n\t\t\n\t\t// Extract default value from JSON schema and pass it via inputProps\n\t\t// Also mark field as not required if it has a default value\n\t\tif (value.default !== undefined) {\n\t\t\tinputProps.defaultValue = value.default;\n\t\t\tinputProps.required = false;\n\t\t}\n\t\t\n\t\tif (Object.keys(inputProps).length > 0) {\n\t\t\tconfig.inputProps = inputProps;\n\t\t}\n\n\t\t// Extract order from meta\n\t\tif (value.order !== undefined) {\n\t\t\tconfig.order = value.order;\n\t\t}\n\n\t\t// Extract fieldType from JSON Schema meta\n\t\t// Also detect date-time format from JSON Schema (from z.date() -> toJSONSchema with override)\n\t\tlet fieldType = value.fieldType;\n\t\t\n\t\t// Auto-detect date fields from JSON Schema format: \"date-time\"\n\t\t// This handles the roundtrip: z.date() -> toJSONSchema (with override) -> { type: \"string\", format: \"date-time\" }\n\t\tif (!fieldType && value.type === \"string\" && value.format === \"date-time\") {\n\t\t\tfieldType = \"date\";\n\t\t}\n\n\t\tif (fieldType) {\n\t\t\t// 1. Check if there's a custom component in fieldComponents\n\t\t\tconst CustomComponent = fieldComponents?.[fieldType];\n\t\t\tif (CustomComponent) {\n\t\t\t\tconfig.fieldType = (props: AutoFormInputComponentProps) => (\n\t\t\t\t\t\n\t\t\t\t);\n\t\t\t}\n\t\t\t// 2. For built-in types, pass through to auto-form\n\t\t\telse if (\n\t\t\t\tBUILTIN_FIELD_TYPES.includes(\n\t\t\t\t\tfieldType as (typeof BUILTIN_FIELD_TYPES)[number],\n\t\t\t\t)\n\t\t\t) {\n\t\t\t\tconfig.fieldType = fieldType;\n\t\t\t}\n\t\t\t// 3. Unknown custom type without a component - log warning and skip\n\t\t\telse {\n\t\t\t\tconsole.warn(\n\t\t\t\t\t`CMS: Unknown fieldType \"${fieldType}\" for field \"${key}\". ` +\n\t\t\t\t\t\t`Provide a component via fieldComponents override or use a built-in type.`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// Handle nested object properties recursively\n\t\tif (value.properties) {\n\t\t\tconst nestedConfig = buildFieldConfigFromJsonSchema(\n\t\t\t\t{ properties: value.properties } as Record,\n\t\t\t\tfieldComponents,\n\t\t\t);\n\t\t\t// Reserved FieldConfigItem property names that should not be overwritten by nested field configs.\n\t\t\t// If a nested field has the same name as a reserved property (e.g., a field named \"description\"),\n\t\t\t// we skip it to prevent overwriting the parent's config (like its help text).\n\t\t\tconst reservedProps = new Set(['description', 'label', 'inputProps', 'fieldType', 'renderParent', 'order']);\n\t\t\t\n\t\t\t// Merge nested config, but skip keys that match reserved property names\n\t\t\tfor (const [nestedKey, nestedValue] of Object.entries(nestedConfig)) {\n\t\t\t\tif (!reservedProps.has(nestedKey)) {\n\t\t\t\t\tconfig[nestedKey] = nestedValue;\n\t\t\t\t} else {\n console.warn(\n `Field \"${key}\" has a nested field named \"${nestedKey}\" which conflicts with a reserved FieldConfigItem property. ` +\n `The nested field's config will not be accessible at the parent level.`\n );\n }\n\t\t\t}\n\t\t}\n\n\t\tif (Object.keys(config).length > 0) {\n\t\t\tfieldConfig[key] = config;\n\t\t}\n\t}\n\n\treturn fieldConfig;\n}\n", + "content": "import React from \"react\";\nimport type { DefaultValues } from \"react-hook-form\";\nimport { z } from \"zod\";\nimport type { AutoFormInputComponentProps, FieldConfig } from \"./types\";\n\nexport const BUILTIN_FIELD_TYPES = [\n\t\"checkbox\",\n\t\"date\",\n\t\"select\",\n\t\"radio\",\n\t\"switch\",\n\t\"textarea\",\n\t\"number\",\n\t\"fallback\",\n] as const;\n\n/**\n * Get the type name from a Zod v4 schema's _zod.def.\n * In Zod v4: _zod.def.type (e.g., \"default\", \"optional\", \"object\", \"string\")\n */\nfunction getDefTypeName(schema: z.ZodType): string {\n // Access through _zod.def.type for Zod v4\n const def = (schema as any)._zod?.def;\n return def?.type || \"\";\n}\n\n/**\n * Type for wrapped object schemas in Zod v4.\n * In Zod v4, ZodEffects is replaced with ZodPipe for transforms.\n * For autoform purposes, we mainly deal with objects that might be wrapped in optional/default/nullable.\n */\nexport type ZodObjectOrWrapped = z.ZodObject | z.ZodType;\n\n/**\n * Beautify a camelCase string.\n * e.g. \"myString\" -> \"My String\"\n */\nexport function beautifyObjectName(string: string) {\n // if numbers only return the string\n let output = string.replace(/([A-Z])/g, \" $1\");\n output = output.charAt(0).toUpperCase() + output.slice(1);\n return output;\n}\n\n/**\n * Get the lowest level Zod type.\n * This will unpack optionals, defaults, nullables, pipes, etc.\n */\nexport function getBaseSchema(\n schema: ChildType\n): ChildType | null {\n if (!schema) return null;\n\n const def = (schema as any)._zod?.def;\n if (!def) return schema as ChildType;\n\n // Handle wrapped types by checking for innerType or wrapped property\n if (def.innerType) {\n return getBaseSchema(def.innerType as ChildType);\n }\n\n // Handle ZodPipe (transforms) - get the output schema\n if (def.out) {\n return getBaseSchema(def.out as ChildType);\n }\n\n // Handle schema property (for some wrapper types)\n if (def.schema) {\n return getBaseSchema(def.schema as ChildType);\n }\n\n return schema as ChildType;\n}\n\n/**\n * Get the type name of the lowest level Zod type.\n * This will unpack optionals, defaults, etc.\n * \n * Returns Zod v4 style type names (e.g., \"enum\", \"boolean\", \"object\")\n */\nexport function getBaseType(schema: z.ZodType): string {\n const baseSchema = getBaseSchema(schema);\n if (!baseSchema) return \"\";\n\n const typeName = getDefTypeName(baseSchema);\n \n // Map to consistent type names (capitalize first letter for component lookup)\n const typeMap: Record = {\n object: \"ZodObject\",\n array: \"ZodArray\",\n string: \"ZodString\",\n number: \"ZodNumber\",\n int: \"ZodNumber\",\n float: \"ZodNumber\",\n boolean: \"ZodBoolean\",\n date: \"ZodDate\",\n enum: \"ZodEnum\",\n nativeEnum: \"ZodNativeEnum\",\n literal: \"ZodLiteral\",\n union: \"ZodUnion\",\n };\n\n return typeMap[typeName] || typeName;\n}\n\n/**\n * Search for a \"default\" wrapper in the Zod stack and return its value.\n * In Zod v4: _zod.def.defaultValue is the default value (not a function)\n */\nexport function getDefaultValueInZodStack(schema: z.ZodType): any {\n const def = (schema as any)._zod?.def;\n if (!def) return undefined;\n\n if (def.type === \"default\") {\n // In Zod v4, defaultValue is the value directly (not a function)\n const defaultValue = def.defaultValue;\n // Handle both function (legacy) and value\n if (typeof defaultValue === \"function\") {\n return defaultValue();\n }\n return defaultValue;\n }\n\n // Check wrapped types\n if (def.innerType) {\n return getDefaultValueInZodStack(def.innerType);\n }\n if (def.schema) {\n return getDefaultValueInZodStack(def.schema);\n }\n\n return undefined;\n}\n\n/**\n * Get all default values from a Zod schema.\n */\nexport function getDefaultValues>(\n schema: Schema,\n fieldConfig?: FieldConfig>\n) {\n if (!schema) return null;\n const { shape } = schema;\n type DefaultValuesType = DefaultValues>>;\n const defaultValues = {} as DefaultValuesType;\n if (!shape) return defaultValues;\n\n for (const key of Object.keys(shape)) {\n const item = shape[key] as z.ZodType;\n\n if (getBaseType(item) === \"ZodObject\") {\n const defaultItems = getDefaultValues(\n getBaseSchema(item) as unknown as z.ZodObject,\n fieldConfig?.[key] as FieldConfig>\n );\n\n if (defaultItems !== null) {\n for (const defaultItemKey of Object.keys(defaultItems)) {\n const pathKey = `${key}.${defaultItemKey}` as keyof DefaultValuesType;\n (defaultValues as any)[pathKey] = defaultItems[defaultItemKey];\n }\n }\n } else {\n let defaultValue = getDefaultValueInZodStack(item);\n // Also check fieldConfig for default values (important for JSON schema derived forms)\n if (\n (defaultValue === undefined || defaultValue === null || defaultValue === \"\") &&\n fieldConfig?.[key]?.inputProps\n ) {\n defaultValue = (fieldConfig?.[key]?.inputProps as unknown as any)\n .defaultValue;\n }\n if (defaultValue !== undefined) {\n (defaultValues as any)[key as keyof DefaultValuesType] = defaultValue;\n }\n }\n }\n\n return defaultValues;\n}\n\n/**\n * Extract the object schema from a potentially wrapped schema.\n * Handles pipes, defaults, optionals, etc.\n */\nexport function getObjectFormSchema(\n schema: ZodObjectOrWrapped\n): z.ZodObject {\n if (!schema) return schema as z.ZodObject;\n\n const def = (schema as any)._zod?.def;\n if (!def) return schema as z.ZodObject;\n\n // If it's a pipe (transform), get the input schema\n if (def.type === \"pipe\") {\n return getObjectFormSchema(def.in);\n }\n\n // Handle wrapped types\n if (def.innerType) {\n return getObjectFormSchema(def.innerType);\n }\n if (def.schema) {\n return getObjectFormSchema(def.schema);\n }\n\n return schema as z.ZodObject;\n}\n\n/**\n * Get description from a Zod schema.\n * In Zod v4, descriptions are stored in the global registry.\n */\nexport function getSchemaDescription(schema: z.ZodType): string | undefined {\n // Try to get from registry first (Zod v4)\n const registered = z.globalRegistry.get(schema);\n if (registered?.description) {\n return registered.description;\n }\n\n // Fallback: check if description is on the schema itself\n const def = (schema as any)._zod?.def;\n return def?.description;\n}\n\n/**\n * Convert a Zod schema to HTML input props to give direct feedback to the user.\n * Once submitted, the schema will be validated completely.\n */\nexport function zodToHtmlInputProps(\n schema: z.ZodType\n): React.InputHTMLAttributes {\n const def = (schema as any)._zod?.def;\n const defType = def?.type || \"\";\n\n // Check for optional/nullable\n if ([\"optional\", \"nullable\"].includes(defType)) {\n return {\n ...zodToHtmlInputProps(def.innerType),\n required: false,\n };\n }\n\n // Check for default - fields with defaults should not be required\n // since the default value will be used if not provided\n if (defType === \"default\") {\n return {\n ...zodToHtmlInputProps(def.innerType),\n required: false,\n };\n }\n\n const inputProps: React.InputHTMLAttributes = {\n required: true,\n };\n\n // Get checks from the schema\n const checks = def?.checks;\n if (!checks || !Array.isArray(checks)) {\n return inputProps;\n }\n\n const type = getBaseType(schema);\n\n for (const check of checks) {\n // In Zod v4, checks have 'kind' property\n const checkKind = check.kind || check.type;\n \n if (checkKind === \"min\" || checkKind === \"min_length\") {\n if (type === \"ZodString\") {\n inputProps.minLength = check.value ?? check.minimum;\n } else {\n inputProps.min = check.value ?? check.minimum;\n }\n }\n if (checkKind === \"max\" || checkKind === \"max_length\") {\n if (type === \"ZodString\") {\n inputProps.maxLength = check.value ?? check.maximum;\n } else {\n inputProps.max = check.value ?? check.maximum;\n }\n }\n }\n\n return inputProps;\n}\n\n/**\n * Sort the fields by order.\n * If no order is set, the field will be sorted based on the order in the schema.\n */\nexport function sortFieldsByOrder>(\n fieldConfig: FieldConfig> | undefined,\n keys: string[]\n) {\n const sortedFields = keys.sort((a, b) => {\n const fieldA: number = (fieldConfig?.[a]?.order as number) ?? 0;\n const fieldB = (fieldConfig?.[b]?.order as number) ?? 0;\n return fieldA - fieldB;\n });\n\n return sortedFields;\n}\n\n// Import shared JSON Schema property type for consistency with form-builder\nimport type { JSONSchemaPropertyBase } from \"./shared-form-types\";\n\n/**\n * JSON schema property shape that includes FieldConfigItem-compatible metadata.\n * Uses the shared type for consistency between form-builder and auto-form.\n */\ntype JsonSchemaProperty = JSONSchemaPropertyBase;\n\nexport function buildFieldConfigFromJsonSchema(\n\tjsonSchema: Record,\n\tfieldComponents?: Record<\n\t\tstring,\n\t\tReact.ComponentType\n\t>,\n): FieldConfig> {\n\tconst fieldConfig: FieldConfig> = {};\n\tconst properties = jsonSchema.properties as Record;\n\n\tif (!properties) return fieldConfig;\n\n\tfor (const [key, value] of Object.entries(properties)) {\n\t\tconst config: Record = {};\n\n\t\t// Extract label from meta (support both 'label' and JSON Schema 'title')\n\t\tif (value.label) {\n\t\t\tconfig.label = value.label;\n\t\t} else if (value.title) {\n\t\t\tconfig.label = value.title;\n\t\t}\n\n\t\t// Extract description from meta\n\t\tif (value.description) {\n\t\t\tconfig.description = value.description;\n\t\t}\n\n\t\t// Extract inputProps from meta (includes placeholder, type, etc.)\n\t\t// Also merge in default value if present\n\t\tconst inputProps: Record = value.inputProps ? { ...value.inputProps } : {};\n\t\t\n\t\t// Extract placeholder from JSON Schema\n\t\tif (value.placeholder) {\n\t\t\tinputProps.placeholder = value.placeholder;\n\t\t}\n\t\t\n\t\t// Extract default value from JSON schema and pass it via inputProps\n\t\t// Also mark field as not required if it has a default value\n\t\tif (value.default !== undefined) {\n\t\t\tinputProps.defaultValue = value.default;\n\t\t\tinputProps.required = false;\n\t\t}\n\t\t\n\t\tif (Object.keys(inputProps).length > 0) {\n\t\t\tconfig.inputProps = inputProps;\n\t\t}\n\n\t\t// Extract order from meta\n\t\tif (value.order !== undefined) {\n\t\t\tconfig.order = value.order;\n\t\t}\n\n\t\t// Extract fieldType from JSON Schema meta\n\t\t// Also detect date-time format from JSON Schema (from z.date() -> toJSONSchema with override)\n\t\tlet fieldType = value.fieldType;\n\t\t\n\t\t// Auto-detect date fields from JSON Schema format: \"date-time\"\n\t\t// This handles the roundtrip: z.date() -> toJSONSchema (with override) -> { type: \"string\", format: \"date-time\" }\n\t\tif (!fieldType && value.type === \"string\" && value.format === \"date-time\") {\n\t\t\tfieldType = \"date\";\n\t\t}\n\n\t\tif (fieldType) {\n\t\t\t// 1. Check if there's a custom component in fieldComponents\n\t\t\tconst CustomComponent = fieldComponents?.[fieldType];\n\t\t\tif (CustomComponent) {\n\t\t\t\tconfig.fieldType = (props: AutoFormInputComponentProps) => (\n\t\t\t\t\t\n\t\t\t\t);\n\t\t\t}\n\t\t\t// 2. For built-in types, pass through to auto-form\n\t\t\telse if (\n\t\t\t\tBUILTIN_FIELD_TYPES.includes(\n\t\t\t\t\tfieldType as (typeof BUILTIN_FIELD_TYPES)[number],\n\t\t\t\t)\n\t\t\t) {\n\t\t\t\tconfig.fieldType = fieldType;\n\t\t\t}\n\t\t\t// 3. Unknown custom type without a component - log warning and skip\n\t\t\telse {\n\t\t\t\tconsole.warn(\n\t\t\t\t\t`CMS: Unknown fieldType \"${fieldType}\" for field \"${key}\". ` +\n\t\t\t\t\t\t`Provide a component via fieldComponents override or use a built-in type.`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// Reserved FieldConfigItem property names that should not be overwritten by nested field configs.\n\t\t// If a nested field has the same name as a reserved property (e.g., a field named \"description\"),\n\t\t// we skip it to prevent overwriting the parent's config (like its help text).\n\t\tconst reservedProps = new Set([\n\t\t\t\"description\",\n\t\t\t\"label\",\n\t\t\t\"inputProps\",\n\t\t\t\"fieldType\",\n\t\t\t\"renderParent\",\n\t\t\t\"order\",\n\t\t]);\n\n\t\t// Handle nested object properties recursively\n\t\tif (value.properties) {\n\t\t\tconst nestedConfig = buildFieldConfigFromJsonSchema(\n\t\t\t\t{ properties: value.properties } as Record,\n\t\t\t\tfieldComponents,\n\t\t\t);\n\n\t\t\t// Merge nested config, but skip keys that match reserved property names\n\t\t\tfor (const [nestedKey, nestedValue] of Object.entries(nestedConfig)) {\n\t\t\t\tif (!reservedProps.has(nestedKey)) {\n\t\t\t\t\tconfig[nestedKey] = nestedValue;\n\t\t\t\t} else {\n\t\t\t\t\tconsole.warn(\n\t\t\t\t\t\t`Field \"${key}\" has a nested field named \"${nestedKey}\" which conflicts with a reserved FieldConfigItem property. ` +\n\t\t\t\t\t\t\t`The nested field's config will not be accessible at the parent level.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Handle array items recursively — for `z.array(z.object({...}))` shapes\n\t\t// the array's items.properties carry per-item field metadata (placeholder,\n\t\t// fieldType, relation config, etc.) that AutoFormArray needs to forward\n\t\t// to its inner AutoFormObject. Mirror the nested-object merge so per-item\n\t\t// configs end up at `fieldConfig[arrayKey][itemPropertyKey]`.\n\t\tconst items = value.items as JsonSchemaProperty | undefined;\n\t\tif (items?.properties) {\n\t\t\tconst itemConfig = buildFieldConfigFromJsonSchema(\n\t\t\t\t{ properties: items.properties } as Record,\n\t\t\t\tfieldComponents,\n\t\t\t);\n\n\t\t\tfor (const [itemKey, itemValue] of Object.entries(itemConfig)) {\n\t\t\t\tif (!reservedProps.has(itemKey)) {\n\t\t\t\t\tconfig[itemKey] = itemValue;\n\t\t\t\t} else {\n\t\t\t\t\tconsole.warn(\n\t\t\t\t\t\t`Array field \"${key}\" has an item property named \"${itemKey}\" which conflicts with a reserved FieldConfigItem property. ` +\n\t\t\t\t\t\t\t`The item field's config will not be accessible inside array items.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (Object.keys(config).length > 0) {\n\t\t\tfieldConfig[key] = config;\n\t\t}\n\t}\n\n\treturn fieldConfig;\n}\n", "target": "src/components/ui/auto-form/helpers.tsx" }, { diff --git a/packages/stack/registry/btst-form-builder.json b/packages/stack/registry/btst-form-builder.json index 1d1d2520..aed7c037 100644 --- a/packages/stack/registry/btst-form-builder.json +++ b/packages/stack/registry/btst-form-builder.json @@ -199,7 +199,7 @@ { "path": "ui/components/auto-form/helpers.tsx", "type": "registry:component", - "content": "import React from \"react\";\nimport type { DefaultValues } from \"react-hook-form\";\nimport { z } from \"zod\";\nimport type { AutoFormInputComponentProps, FieldConfig } from \"./types\";\n\nexport const BUILTIN_FIELD_TYPES = [\n\t\"checkbox\",\n\t\"date\",\n\t\"select\",\n\t\"radio\",\n\t\"switch\",\n\t\"textarea\",\n\t\"number\",\n\t\"fallback\",\n] as const;\n\n/**\n * Get the type name from a Zod v4 schema's _zod.def.\n * In Zod v4: _zod.def.type (e.g., \"default\", \"optional\", \"object\", \"string\")\n */\nfunction getDefTypeName(schema: z.ZodType): string {\n // Access through _zod.def.type for Zod v4\n const def = (schema as any)._zod?.def;\n return def?.type || \"\";\n}\n\n/**\n * Type for wrapped object schemas in Zod v4.\n * In Zod v4, ZodEffects is replaced with ZodPipe for transforms.\n * For autoform purposes, we mainly deal with objects that might be wrapped in optional/default/nullable.\n */\nexport type ZodObjectOrWrapped = z.ZodObject | z.ZodType;\n\n/**\n * Beautify a camelCase string.\n * e.g. \"myString\" -> \"My String\"\n */\nexport function beautifyObjectName(string: string) {\n // if numbers only return the string\n let output = string.replace(/([A-Z])/g, \" $1\");\n output = output.charAt(0).toUpperCase() + output.slice(1);\n return output;\n}\n\n/**\n * Get the lowest level Zod type.\n * This will unpack optionals, defaults, nullables, pipes, etc.\n */\nexport function getBaseSchema(\n schema: ChildType\n): ChildType | null {\n if (!schema) return null;\n\n const def = (schema as any)._zod?.def;\n if (!def) return schema as ChildType;\n\n // Handle wrapped types by checking for innerType or wrapped property\n if (def.innerType) {\n return getBaseSchema(def.innerType as ChildType);\n }\n\n // Handle ZodPipe (transforms) - get the output schema\n if (def.out) {\n return getBaseSchema(def.out as ChildType);\n }\n\n // Handle schema property (for some wrapper types)\n if (def.schema) {\n return getBaseSchema(def.schema as ChildType);\n }\n\n return schema as ChildType;\n}\n\n/**\n * Get the type name of the lowest level Zod type.\n * This will unpack optionals, defaults, etc.\n * \n * Returns Zod v4 style type names (e.g., \"enum\", \"boolean\", \"object\")\n */\nexport function getBaseType(schema: z.ZodType): string {\n const baseSchema = getBaseSchema(schema);\n if (!baseSchema) return \"\";\n\n const typeName = getDefTypeName(baseSchema);\n \n // Map to consistent type names (capitalize first letter for component lookup)\n const typeMap: Record = {\n object: \"ZodObject\",\n array: \"ZodArray\",\n string: \"ZodString\",\n number: \"ZodNumber\",\n int: \"ZodNumber\",\n float: \"ZodNumber\",\n boolean: \"ZodBoolean\",\n date: \"ZodDate\",\n enum: \"ZodEnum\",\n nativeEnum: \"ZodNativeEnum\",\n literal: \"ZodLiteral\",\n union: \"ZodUnion\",\n };\n\n return typeMap[typeName] || typeName;\n}\n\n/**\n * Search for a \"default\" wrapper in the Zod stack and return its value.\n * In Zod v4: _zod.def.defaultValue is the default value (not a function)\n */\nexport function getDefaultValueInZodStack(schema: z.ZodType): any {\n const def = (schema as any)._zod?.def;\n if (!def) return undefined;\n\n if (def.type === \"default\") {\n // In Zod v4, defaultValue is the value directly (not a function)\n const defaultValue = def.defaultValue;\n // Handle both function (legacy) and value\n if (typeof defaultValue === \"function\") {\n return defaultValue();\n }\n return defaultValue;\n }\n\n // Check wrapped types\n if (def.innerType) {\n return getDefaultValueInZodStack(def.innerType);\n }\n if (def.schema) {\n return getDefaultValueInZodStack(def.schema);\n }\n\n return undefined;\n}\n\n/**\n * Get all default values from a Zod schema.\n */\nexport function getDefaultValues>(\n schema: Schema,\n fieldConfig?: FieldConfig>\n) {\n if (!schema) return null;\n const { shape } = schema;\n type DefaultValuesType = DefaultValues>>;\n const defaultValues = {} as DefaultValuesType;\n if (!shape) return defaultValues;\n\n for (const key of Object.keys(shape)) {\n const item = shape[key] as z.ZodType;\n\n if (getBaseType(item) === \"ZodObject\") {\n const defaultItems = getDefaultValues(\n getBaseSchema(item) as unknown as z.ZodObject,\n fieldConfig?.[key] as FieldConfig>\n );\n\n if (defaultItems !== null) {\n for (const defaultItemKey of Object.keys(defaultItems)) {\n const pathKey = `${key}.${defaultItemKey}` as keyof DefaultValuesType;\n (defaultValues as any)[pathKey] = defaultItems[defaultItemKey];\n }\n }\n } else {\n let defaultValue = getDefaultValueInZodStack(item);\n // Also check fieldConfig for default values (important for JSON schema derived forms)\n if (\n (defaultValue === undefined || defaultValue === null || defaultValue === \"\") &&\n fieldConfig?.[key]?.inputProps\n ) {\n defaultValue = (fieldConfig?.[key]?.inputProps as unknown as any)\n .defaultValue;\n }\n if (defaultValue !== undefined) {\n (defaultValues as any)[key as keyof DefaultValuesType] = defaultValue;\n }\n }\n }\n\n return defaultValues;\n}\n\n/**\n * Extract the object schema from a potentially wrapped schema.\n * Handles pipes, defaults, optionals, etc.\n */\nexport function getObjectFormSchema(\n schema: ZodObjectOrWrapped\n): z.ZodObject {\n if (!schema) return schema as z.ZodObject;\n\n const def = (schema as any)._zod?.def;\n if (!def) return schema as z.ZodObject;\n\n // If it's a pipe (transform), get the input schema\n if (def.type === \"pipe\") {\n return getObjectFormSchema(def.in);\n }\n\n // Handle wrapped types\n if (def.innerType) {\n return getObjectFormSchema(def.innerType);\n }\n if (def.schema) {\n return getObjectFormSchema(def.schema);\n }\n\n return schema as z.ZodObject;\n}\n\n/**\n * Get description from a Zod schema.\n * In Zod v4, descriptions are stored in the global registry.\n */\nexport function getSchemaDescription(schema: z.ZodType): string | undefined {\n // Try to get from registry first (Zod v4)\n const registered = z.globalRegistry.get(schema);\n if (registered?.description) {\n return registered.description;\n }\n\n // Fallback: check if description is on the schema itself\n const def = (schema as any)._zod?.def;\n return def?.description;\n}\n\n/**\n * Convert a Zod schema to HTML input props to give direct feedback to the user.\n * Once submitted, the schema will be validated completely.\n */\nexport function zodToHtmlInputProps(\n schema: z.ZodType\n): React.InputHTMLAttributes {\n const def = (schema as any)._zod?.def;\n const defType = def?.type || \"\";\n\n // Check for optional/nullable\n if ([\"optional\", \"nullable\"].includes(defType)) {\n return {\n ...zodToHtmlInputProps(def.innerType),\n required: false,\n };\n }\n\n // Check for default - fields with defaults should not be required\n // since the default value will be used if not provided\n if (defType === \"default\") {\n return {\n ...zodToHtmlInputProps(def.innerType),\n required: false,\n };\n }\n\n const inputProps: React.InputHTMLAttributes = {\n required: true,\n };\n\n // Get checks from the schema\n const checks = def?.checks;\n if (!checks || !Array.isArray(checks)) {\n return inputProps;\n }\n\n const type = getBaseType(schema);\n\n for (const check of checks) {\n // In Zod v4, checks have 'kind' property\n const checkKind = check.kind || check.type;\n \n if (checkKind === \"min\" || checkKind === \"min_length\") {\n if (type === \"ZodString\") {\n inputProps.minLength = check.value ?? check.minimum;\n } else {\n inputProps.min = check.value ?? check.minimum;\n }\n }\n if (checkKind === \"max\" || checkKind === \"max_length\") {\n if (type === \"ZodString\") {\n inputProps.maxLength = check.value ?? check.maximum;\n } else {\n inputProps.max = check.value ?? check.maximum;\n }\n }\n }\n\n return inputProps;\n}\n\n/**\n * Sort the fields by order.\n * If no order is set, the field will be sorted based on the order in the schema.\n */\nexport function sortFieldsByOrder>(\n fieldConfig: FieldConfig> | undefined,\n keys: string[]\n) {\n const sortedFields = keys.sort((a, b) => {\n const fieldA: number = (fieldConfig?.[a]?.order as number) ?? 0;\n const fieldB = (fieldConfig?.[b]?.order as number) ?? 0;\n return fieldA - fieldB;\n });\n\n return sortedFields;\n}\n\n// Import shared JSON Schema property type for consistency with form-builder\nimport type { JSONSchemaPropertyBase } from \"./shared-form-types\";\n\n/**\n * JSON schema property shape that includes FieldConfigItem-compatible metadata.\n * Uses the shared type for consistency between form-builder and auto-form.\n */\ntype JsonSchemaProperty = JSONSchemaPropertyBase;\n\nexport function buildFieldConfigFromJsonSchema(\n\tjsonSchema: Record,\n\tfieldComponents?: Record<\n\t\tstring,\n\t\tReact.ComponentType\n\t>,\n): FieldConfig> {\n\tconst fieldConfig: FieldConfig> = {};\n\tconst properties = jsonSchema.properties as Record;\n\n\tif (!properties) return fieldConfig;\n\n\tfor (const [key, value] of Object.entries(properties)) {\n\t\tconst config: Record = {};\n\n\t\t// Extract label from meta (support both 'label' and JSON Schema 'title')\n\t\tif (value.label) {\n\t\t\tconfig.label = value.label;\n\t\t} else if (value.title) {\n\t\t\tconfig.label = value.title;\n\t\t}\n\n\t\t// Extract description from meta\n\t\tif (value.description) {\n\t\t\tconfig.description = value.description;\n\t\t}\n\n\t\t// Extract inputProps from meta (includes placeholder, type, etc.)\n\t\t// Also merge in default value if present\n\t\tconst inputProps: Record = value.inputProps ? { ...value.inputProps } : {};\n\t\t\n\t\t// Extract placeholder from JSON Schema\n\t\tif (value.placeholder) {\n\t\t\tinputProps.placeholder = value.placeholder;\n\t\t}\n\t\t\n\t\t// Extract default value from JSON schema and pass it via inputProps\n\t\t// Also mark field as not required if it has a default value\n\t\tif (value.default !== undefined) {\n\t\t\tinputProps.defaultValue = value.default;\n\t\t\tinputProps.required = false;\n\t\t}\n\t\t\n\t\tif (Object.keys(inputProps).length > 0) {\n\t\t\tconfig.inputProps = inputProps;\n\t\t}\n\n\t\t// Extract order from meta\n\t\tif (value.order !== undefined) {\n\t\t\tconfig.order = value.order;\n\t\t}\n\n\t\t// Extract fieldType from JSON Schema meta\n\t\t// Also detect date-time format from JSON Schema (from z.date() -> toJSONSchema with override)\n\t\tlet fieldType = value.fieldType;\n\t\t\n\t\t// Auto-detect date fields from JSON Schema format: \"date-time\"\n\t\t// This handles the roundtrip: z.date() -> toJSONSchema (with override) -> { type: \"string\", format: \"date-time\" }\n\t\tif (!fieldType && value.type === \"string\" && value.format === \"date-time\") {\n\t\t\tfieldType = \"date\";\n\t\t}\n\n\t\tif (fieldType) {\n\t\t\t// 1. Check if there's a custom component in fieldComponents\n\t\t\tconst CustomComponent = fieldComponents?.[fieldType];\n\t\t\tif (CustomComponent) {\n\t\t\t\tconfig.fieldType = (props: AutoFormInputComponentProps) => (\n\t\t\t\t\t\n\t\t\t\t);\n\t\t\t}\n\t\t\t// 2. For built-in types, pass through to auto-form\n\t\t\telse if (\n\t\t\t\tBUILTIN_FIELD_TYPES.includes(\n\t\t\t\t\tfieldType as (typeof BUILTIN_FIELD_TYPES)[number],\n\t\t\t\t)\n\t\t\t) {\n\t\t\t\tconfig.fieldType = fieldType;\n\t\t\t}\n\t\t\t// 3. Unknown custom type without a component - log warning and skip\n\t\t\telse {\n\t\t\t\tconsole.warn(\n\t\t\t\t\t`CMS: Unknown fieldType \"${fieldType}\" for field \"${key}\". ` +\n\t\t\t\t\t\t`Provide a component via fieldComponents override or use a built-in type.`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// Handle nested object properties recursively\n\t\tif (value.properties) {\n\t\t\tconst nestedConfig = buildFieldConfigFromJsonSchema(\n\t\t\t\t{ properties: value.properties } as Record,\n\t\t\t\tfieldComponents,\n\t\t\t);\n\t\t\t// Reserved FieldConfigItem property names that should not be overwritten by nested field configs.\n\t\t\t// If a nested field has the same name as a reserved property (e.g., a field named \"description\"),\n\t\t\t// we skip it to prevent overwriting the parent's config (like its help text).\n\t\t\tconst reservedProps = new Set(['description', 'label', 'inputProps', 'fieldType', 'renderParent', 'order']);\n\t\t\t\n\t\t\t// Merge nested config, but skip keys that match reserved property names\n\t\t\tfor (const [nestedKey, nestedValue] of Object.entries(nestedConfig)) {\n\t\t\t\tif (!reservedProps.has(nestedKey)) {\n\t\t\t\t\tconfig[nestedKey] = nestedValue;\n\t\t\t\t} else {\n console.warn(\n `Field \"${key}\" has a nested field named \"${nestedKey}\" which conflicts with a reserved FieldConfigItem property. ` +\n `The nested field's config will not be accessible at the parent level.`\n );\n }\n\t\t\t}\n\t\t}\n\n\t\tif (Object.keys(config).length > 0) {\n\t\t\tfieldConfig[key] = config;\n\t\t}\n\t}\n\n\treturn fieldConfig;\n}\n", + "content": "import React from \"react\";\nimport type { DefaultValues } from \"react-hook-form\";\nimport { z } from \"zod\";\nimport type { AutoFormInputComponentProps, FieldConfig } from \"./types\";\n\nexport const BUILTIN_FIELD_TYPES = [\n\t\"checkbox\",\n\t\"date\",\n\t\"select\",\n\t\"radio\",\n\t\"switch\",\n\t\"textarea\",\n\t\"number\",\n\t\"fallback\",\n] as const;\n\n/**\n * Get the type name from a Zod v4 schema's _zod.def.\n * In Zod v4: _zod.def.type (e.g., \"default\", \"optional\", \"object\", \"string\")\n */\nfunction getDefTypeName(schema: z.ZodType): string {\n // Access through _zod.def.type for Zod v4\n const def = (schema as any)._zod?.def;\n return def?.type || \"\";\n}\n\n/**\n * Type for wrapped object schemas in Zod v4.\n * In Zod v4, ZodEffects is replaced with ZodPipe for transforms.\n * For autoform purposes, we mainly deal with objects that might be wrapped in optional/default/nullable.\n */\nexport type ZodObjectOrWrapped = z.ZodObject | z.ZodType;\n\n/**\n * Beautify a camelCase string.\n * e.g. \"myString\" -> \"My String\"\n */\nexport function beautifyObjectName(string: string) {\n // if numbers only return the string\n let output = string.replace(/([A-Z])/g, \" $1\");\n output = output.charAt(0).toUpperCase() + output.slice(1);\n return output;\n}\n\n/**\n * Get the lowest level Zod type.\n * This will unpack optionals, defaults, nullables, pipes, etc.\n */\nexport function getBaseSchema(\n schema: ChildType\n): ChildType | null {\n if (!schema) return null;\n\n const def = (schema as any)._zod?.def;\n if (!def) return schema as ChildType;\n\n // Handle wrapped types by checking for innerType or wrapped property\n if (def.innerType) {\n return getBaseSchema(def.innerType as ChildType);\n }\n\n // Handle ZodPipe (transforms) - get the output schema\n if (def.out) {\n return getBaseSchema(def.out as ChildType);\n }\n\n // Handle schema property (for some wrapper types)\n if (def.schema) {\n return getBaseSchema(def.schema as ChildType);\n }\n\n return schema as ChildType;\n}\n\n/**\n * Get the type name of the lowest level Zod type.\n * This will unpack optionals, defaults, etc.\n * \n * Returns Zod v4 style type names (e.g., \"enum\", \"boolean\", \"object\")\n */\nexport function getBaseType(schema: z.ZodType): string {\n const baseSchema = getBaseSchema(schema);\n if (!baseSchema) return \"\";\n\n const typeName = getDefTypeName(baseSchema);\n \n // Map to consistent type names (capitalize first letter for component lookup)\n const typeMap: Record = {\n object: \"ZodObject\",\n array: \"ZodArray\",\n string: \"ZodString\",\n number: \"ZodNumber\",\n int: \"ZodNumber\",\n float: \"ZodNumber\",\n boolean: \"ZodBoolean\",\n date: \"ZodDate\",\n enum: \"ZodEnum\",\n nativeEnum: \"ZodNativeEnum\",\n literal: \"ZodLiteral\",\n union: \"ZodUnion\",\n };\n\n return typeMap[typeName] || typeName;\n}\n\n/**\n * Search for a \"default\" wrapper in the Zod stack and return its value.\n * In Zod v4: _zod.def.defaultValue is the default value (not a function)\n */\nexport function getDefaultValueInZodStack(schema: z.ZodType): any {\n const def = (schema as any)._zod?.def;\n if (!def) return undefined;\n\n if (def.type === \"default\") {\n // In Zod v4, defaultValue is the value directly (not a function)\n const defaultValue = def.defaultValue;\n // Handle both function (legacy) and value\n if (typeof defaultValue === \"function\") {\n return defaultValue();\n }\n return defaultValue;\n }\n\n // Check wrapped types\n if (def.innerType) {\n return getDefaultValueInZodStack(def.innerType);\n }\n if (def.schema) {\n return getDefaultValueInZodStack(def.schema);\n }\n\n return undefined;\n}\n\n/**\n * Get all default values from a Zod schema.\n */\nexport function getDefaultValues>(\n schema: Schema,\n fieldConfig?: FieldConfig>\n) {\n if (!schema) return null;\n const { shape } = schema;\n type DefaultValuesType = DefaultValues>>;\n const defaultValues = {} as DefaultValuesType;\n if (!shape) return defaultValues;\n\n for (const key of Object.keys(shape)) {\n const item = shape[key] as z.ZodType;\n\n if (getBaseType(item) === \"ZodObject\") {\n const defaultItems = getDefaultValues(\n getBaseSchema(item) as unknown as z.ZodObject,\n fieldConfig?.[key] as FieldConfig>\n );\n\n if (defaultItems !== null) {\n for (const defaultItemKey of Object.keys(defaultItems)) {\n const pathKey = `${key}.${defaultItemKey}` as keyof DefaultValuesType;\n (defaultValues as any)[pathKey] = defaultItems[defaultItemKey];\n }\n }\n } else {\n let defaultValue = getDefaultValueInZodStack(item);\n // Also check fieldConfig for default values (important for JSON schema derived forms)\n if (\n (defaultValue === undefined || defaultValue === null || defaultValue === \"\") &&\n fieldConfig?.[key]?.inputProps\n ) {\n defaultValue = (fieldConfig?.[key]?.inputProps as unknown as any)\n .defaultValue;\n }\n if (defaultValue !== undefined) {\n (defaultValues as any)[key as keyof DefaultValuesType] = defaultValue;\n }\n }\n }\n\n return defaultValues;\n}\n\n/**\n * Extract the object schema from a potentially wrapped schema.\n * Handles pipes, defaults, optionals, etc.\n */\nexport function getObjectFormSchema(\n schema: ZodObjectOrWrapped\n): z.ZodObject {\n if (!schema) return schema as z.ZodObject;\n\n const def = (schema as any)._zod?.def;\n if (!def) return schema as z.ZodObject;\n\n // If it's a pipe (transform), get the input schema\n if (def.type === \"pipe\") {\n return getObjectFormSchema(def.in);\n }\n\n // Handle wrapped types\n if (def.innerType) {\n return getObjectFormSchema(def.innerType);\n }\n if (def.schema) {\n return getObjectFormSchema(def.schema);\n }\n\n return schema as z.ZodObject;\n}\n\n/**\n * Get description from a Zod schema.\n * In Zod v4, descriptions are stored in the global registry.\n */\nexport function getSchemaDescription(schema: z.ZodType): string | undefined {\n // Try to get from registry first (Zod v4)\n const registered = z.globalRegistry.get(schema);\n if (registered?.description) {\n return registered.description;\n }\n\n // Fallback: check if description is on the schema itself\n const def = (schema as any)._zod?.def;\n return def?.description;\n}\n\n/**\n * Convert a Zod schema to HTML input props to give direct feedback to the user.\n * Once submitted, the schema will be validated completely.\n */\nexport function zodToHtmlInputProps(\n schema: z.ZodType\n): React.InputHTMLAttributes {\n const def = (schema as any)._zod?.def;\n const defType = def?.type || \"\";\n\n // Check for optional/nullable\n if ([\"optional\", \"nullable\"].includes(defType)) {\n return {\n ...zodToHtmlInputProps(def.innerType),\n required: false,\n };\n }\n\n // Check for default - fields with defaults should not be required\n // since the default value will be used if not provided\n if (defType === \"default\") {\n return {\n ...zodToHtmlInputProps(def.innerType),\n required: false,\n };\n }\n\n const inputProps: React.InputHTMLAttributes = {\n required: true,\n };\n\n // Get checks from the schema\n const checks = def?.checks;\n if (!checks || !Array.isArray(checks)) {\n return inputProps;\n }\n\n const type = getBaseType(schema);\n\n for (const check of checks) {\n // In Zod v4, checks have 'kind' property\n const checkKind = check.kind || check.type;\n \n if (checkKind === \"min\" || checkKind === \"min_length\") {\n if (type === \"ZodString\") {\n inputProps.minLength = check.value ?? check.minimum;\n } else {\n inputProps.min = check.value ?? check.minimum;\n }\n }\n if (checkKind === \"max\" || checkKind === \"max_length\") {\n if (type === \"ZodString\") {\n inputProps.maxLength = check.value ?? check.maximum;\n } else {\n inputProps.max = check.value ?? check.maximum;\n }\n }\n }\n\n return inputProps;\n}\n\n/**\n * Sort the fields by order.\n * If no order is set, the field will be sorted based on the order in the schema.\n */\nexport function sortFieldsByOrder>(\n fieldConfig: FieldConfig> | undefined,\n keys: string[]\n) {\n const sortedFields = keys.sort((a, b) => {\n const fieldA: number = (fieldConfig?.[a]?.order as number) ?? 0;\n const fieldB = (fieldConfig?.[b]?.order as number) ?? 0;\n return fieldA - fieldB;\n });\n\n return sortedFields;\n}\n\n// Import shared JSON Schema property type for consistency with form-builder\nimport type { JSONSchemaPropertyBase } from \"./shared-form-types\";\n\n/**\n * JSON schema property shape that includes FieldConfigItem-compatible metadata.\n * Uses the shared type for consistency between form-builder and auto-form.\n */\ntype JsonSchemaProperty = JSONSchemaPropertyBase;\n\nexport function buildFieldConfigFromJsonSchema(\n\tjsonSchema: Record,\n\tfieldComponents?: Record<\n\t\tstring,\n\t\tReact.ComponentType\n\t>,\n): FieldConfig> {\n\tconst fieldConfig: FieldConfig> = {};\n\tconst properties = jsonSchema.properties as Record;\n\n\tif (!properties) return fieldConfig;\n\n\tfor (const [key, value] of Object.entries(properties)) {\n\t\tconst config: Record = {};\n\n\t\t// Extract label from meta (support both 'label' and JSON Schema 'title')\n\t\tif (value.label) {\n\t\t\tconfig.label = value.label;\n\t\t} else if (value.title) {\n\t\t\tconfig.label = value.title;\n\t\t}\n\n\t\t// Extract description from meta\n\t\tif (value.description) {\n\t\t\tconfig.description = value.description;\n\t\t}\n\n\t\t// Extract inputProps from meta (includes placeholder, type, etc.)\n\t\t// Also merge in default value if present\n\t\tconst inputProps: Record = value.inputProps ? { ...value.inputProps } : {};\n\t\t\n\t\t// Extract placeholder from JSON Schema\n\t\tif (value.placeholder) {\n\t\t\tinputProps.placeholder = value.placeholder;\n\t\t}\n\t\t\n\t\t// Extract default value from JSON schema and pass it via inputProps\n\t\t// Also mark field as not required if it has a default value\n\t\tif (value.default !== undefined) {\n\t\t\tinputProps.defaultValue = value.default;\n\t\t\tinputProps.required = false;\n\t\t}\n\t\t\n\t\tif (Object.keys(inputProps).length > 0) {\n\t\t\tconfig.inputProps = inputProps;\n\t\t}\n\n\t\t// Extract order from meta\n\t\tif (value.order !== undefined) {\n\t\t\tconfig.order = value.order;\n\t\t}\n\n\t\t// Extract fieldType from JSON Schema meta\n\t\t// Also detect date-time format from JSON Schema (from z.date() -> toJSONSchema with override)\n\t\tlet fieldType = value.fieldType;\n\t\t\n\t\t// Auto-detect date fields from JSON Schema format: \"date-time\"\n\t\t// This handles the roundtrip: z.date() -> toJSONSchema (with override) -> { type: \"string\", format: \"date-time\" }\n\t\tif (!fieldType && value.type === \"string\" && value.format === \"date-time\") {\n\t\t\tfieldType = \"date\";\n\t\t}\n\n\t\tif (fieldType) {\n\t\t\t// 1. Check if there's a custom component in fieldComponents\n\t\t\tconst CustomComponent = fieldComponents?.[fieldType];\n\t\t\tif (CustomComponent) {\n\t\t\t\tconfig.fieldType = (props: AutoFormInputComponentProps) => (\n\t\t\t\t\t\n\t\t\t\t);\n\t\t\t}\n\t\t\t// 2. For built-in types, pass through to auto-form\n\t\t\telse if (\n\t\t\t\tBUILTIN_FIELD_TYPES.includes(\n\t\t\t\t\tfieldType as (typeof BUILTIN_FIELD_TYPES)[number],\n\t\t\t\t)\n\t\t\t) {\n\t\t\t\tconfig.fieldType = fieldType;\n\t\t\t}\n\t\t\t// 3. Unknown custom type without a component - log warning and skip\n\t\t\telse {\n\t\t\t\tconsole.warn(\n\t\t\t\t\t`CMS: Unknown fieldType \"${fieldType}\" for field \"${key}\". ` +\n\t\t\t\t\t\t`Provide a component via fieldComponents override or use a built-in type.`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// Reserved FieldConfigItem property names that should not be overwritten by nested field configs.\n\t\t// If a nested field has the same name as a reserved property (e.g., a field named \"description\"),\n\t\t// we skip it to prevent overwriting the parent's config (like its help text).\n\t\tconst reservedProps = new Set([\n\t\t\t\"description\",\n\t\t\t\"label\",\n\t\t\t\"inputProps\",\n\t\t\t\"fieldType\",\n\t\t\t\"renderParent\",\n\t\t\t\"order\",\n\t\t]);\n\n\t\t// Handle nested object properties recursively\n\t\tif (value.properties) {\n\t\t\tconst nestedConfig = buildFieldConfigFromJsonSchema(\n\t\t\t\t{ properties: value.properties } as Record,\n\t\t\t\tfieldComponents,\n\t\t\t);\n\n\t\t\t// Merge nested config, but skip keys that match reserved property names\n\t\t\tfor (const [nestedKey, nestedValue] of Object.entries(nestedConfig)) {\n\t\t\t\tif (!reservedProps.has(nestedKey)) {\n\t\t\t\t\tconfig[nestedKey] = nestedValue;\n\t\t\t\t} else {\n\t\t\t\t\tconsole.warn(\n\t\t\t\t\t\t`Field \"${key}\" has a nested field named \"${nestedKey}\" which conflicts with a reserved FieldConfigItem property. ` +\n\t\t\t\t\t\t\t`The nested field's config will not be accessible at the parent level.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Handle array items recursively — for `z.array(z.object({...}))` shapes\n\t\t// the array's items.properties carry per-item field metadata (placeholder,\n\t\t// fieldType, relation config, etc.) that AutoFormArray needs to forward\n\t\t// to its inner AutoFormObject. Mirror the nested-object merge so per-item\n\t\t// configs end up at `fieldConfig[arrayKey][itemPropertyKey]`.\n\t\tconst items = value.items as JsonSchemaProperty | undefined;\n\t\tif (items?.properties) {\n\t\t\tconst itemConfig = buildFieldConfigFromJsonSchema(\n\t\t\t\t{ properties: items.properties } as Record,\n\t\t\t\tfieldComponents,\n\t\t\t);\n\n\t\t\tfor (const [itemKey, itemValue] of Object.entries(itemConfig)) {\n\t\t\t\tif (!reservedProps.has(itemKey)) {\n\t\t\t\t\tconfig[itemKey] = itemValue;\n\t\t\t\t} else {\n\t\t\t\t\tconsole.warn(\n\t\t\t\t\t\t`Array field \"${key}\" has an item property named \"${itemKey}\" which conflicts with a reserved FieldConfigItem property. ` +\n\t\t\t\t\t\t\t`The item field's config will not be accessible inside array items.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (Object.keys(config).length > 0) {\n\t\t\tfieldConfig[key] = config;\n\t\t}\n\t}\n\n\treturn fieldConfig;\n}\n", "target": "src/components/ui/auto-form/helpers.tsx" }, { @@ -211,7 +211,7 @@ { "path": "ui/components/form-builder/types.ts", "type": "registry:component", - "content": "import type { z } from \"zod\";\nimport type { \n JSONSchemaPropertyBase, \n SerializableInputProps,\n FieldType,\n StringInputProps,\n NumberInputProps,\n BooleanInputProps,\n DateInputProps,\n EnumInputProps,\n InputPropsByBackingType,\n} from \"../auto-form/shared-form-types\";\n\n// Re-export discriminated input prop types from shared-form-types\nexport type {\n SerializableInputProps,\n StringInputProps,\n NumberInputProps,\n BooleanInputProps,\n DateInputProps,\n EnumInputProps,\n InputPropsByBackingType,\n};\n\n// ============================================================================\n// STEP TYPES\n// ============================================================================\n\n/**\n * Represents a step in a multi-step form.\n */\nexport interface FormStep {\n /** Unique identifier for the step */\n id: string;\n /** Display title for the step */\n title: string;\n}\n\n/**\n * JSON Schema types for form builder I/O.\n * Extends the shared base types with form-builder specific needs.\n */\nexport interface JSONSchema {\n type: \"object\";\n properties: Record;\n required?: string[];\n $schema?: string;\n /** Step definitions for multi-step forms (stored in schema meta) */\n steps?: FormStep[];\n additionalProperties?: boolean;\n}\n\n/**\n * JSON Schema property with form-builder metadata.\n * Extends the shared JSONSchemaPropertyBase for compatibility with auto-form.\n */\nexport interface JSONSchemaProperty extends Omit {\n /** JSON Schema type - required for form-builder fields */\n type: string;\n /** Enum values for select/radio fields (string-only for form-builder) */\n enum?: string[];\n /** Nested properties for object types (self-referential) */\n properties?: Record;\n /** Item schema for array types (self-referential) */\n items?: JSONSchemaProperty;\n /** Minimum items for array types */\n minItems?: number;\n /** Maximum items for array types */\n maxItems?: number;\n /** Step group index for multi-step forms (0-indexed) */\n stepGroup?: number;\n}\n\n/**\n * Internal field representation used by form builder.\n */\nexport interface FormBuilderField {\n id: string;\n type: string;\n props: FormBuilderFieldProps;\n /** Nested fields for object type containers */\n children?: FormBuilderField[];\n /** Template fields defining the shape of each array item */\n itemTemplate?: FormBuilderField[];\n /** Step group index (0-indexed) for multi-step forms */\n stepGroup?: number;\n}\n\n/**\n * Backing types supported by form-builder fields.\n */\nexport type BackingType = keyof InputPropsByBackingType;\n\n// ============================================================================\n// COMPONENT TYPE REGISTRY\n// ============================================================================\n\n/**\n * Registry mapping component type names to their backing Zod types.\n * This enables compile-time type checking for component definitions.\n */\nexport const COMPONENT_BACKING_TYPES = {\n text: \"string\",\n email: \"string\",\n password: \"string\",\n url: \"string\",\n phone: \"string\",\n textarea: \"string\",\n number: \"number\",\n checkbox: \"boolean\",\n switch: \"boolean\",\n select: \"enum\",\n radio: \"enum\",\n date: \"date\",\n color: \"string\", // color picker stores hex string\n} as const;\n\n/**\n * All registered component type names.\n */\nexport type ComponentType = keyof typeof COMPONENT_BACKING_TYPES;\n\n/**\n * Get the backing type for a component type.\n */\nexport type BackingTypeFor = typeof COMPONENT_BACKING_TYPES[C];\n\n// ============================================================================\n// DISCRIMINATED FIELD PROPS BY BACKING TYPE\n// ============================================================================\n\n/**\n * Base properties shared by all form builder fields.\n */\nexport interface BaseFieldProps {\n /** Display label for the field */\n label: string;\n /** Description text */\n description?: string;\n /** Whether the field is required */\n required?: boolean;\n /** Field type override (checkbox, date, select, radio, switch, etc.) */\n fieldType?: FieldType;\n /** Additional input props passed through to the field */\n inputProps?: SerializableInputProps;\n}\n\n/**\n * Props for string-backed fields (text, email, password, url, phone, textarea).\n */\nexport interface StringFieldProps extends BaseFieldProps {\n /** Placeholder text */\n placeholder?: string;\n /** Minimum length */\n minLength?: number;\n /** Maximum length */\n maxLength?: number;\n /** Regex pattern for validation */\n pattern?: string;\n /** HTML input type (text, email, password, tel, url) */\n inputType?: string;\n /** Default value */\n defaultValue?: string;\n}\n\n/**\n * Props for number-backed fields.\n */\nexport interface NumberFieldProps extends BaseFieldProps {\n /** Placeholder text */\n placeholder?: string;\n /** Minimum value */\n min?: number;\n /** Maximum value */\n max?: number;\n /** Default value */\n defaultValue?: number;\n}\n\n/**\n * Props for boolean-backed fields (checkbox, switch).\n */\nexport interface BooleanFieldProps extends BaseFieldProps {\n /** Default value */\n defaultValue?: boolean;\n}\n\n/**\n * Props for date-backed fields.\n */\nexport interface DateFieldProps extends BaseFieldProps {\n /** Minimum date */\n minDate?: Date | string;\n /** Maximum date */\n maxDate?: Date | string;\n /** Default value */\n defaultValue?: Date | string;\n}\n\n/**\n * Props for enum-backed fields (select, radio).\n */\nexport interface EnumFieldProps extends BaseFieldProps {\n /** Placeholder text (for select) */\n placeholder?: string;\n /** Options for the select/radio */\n options?: string[];\n /** Default value (must match one of the options) */\n defaultValue?: string;\n}\n\n/**\n * Maps backing types to their corresponding field props.\n */\nexport interface FieldPropsByBackingType {\n string: StringFieldProps;\n number: NumberFieldProps;\n boolean: BooleanFieldProps;\n date: DateFieldProps;\n enum: EnumFieldProps;\n}\n\n/**\n * Get the correct field props type for a backing type.\n */\nexport type FieldPropsFor = FieldPropsByBackingType[T];\n\n/**\n * Union of all discriminated field props.\n */\nexport type TypedFieldProps = \n | StringFieldProps\n | NumberFieldProps\n | BooleanFieldProps\n | DateFieldProps\n | EnumFieldProps;\n\n/**\n * Properties for a form builder field.\n * This is the runtime union type used when the backing type is not known.\n * \n * For type-safe field props, use the discriminated variants:\n * - StringFieldProps for text, email, password, url, phone, textarea\n * - NumberFieldProps for number fields\n * - BooleanFieldProps for checkbox, switch\n * - DateFieldProps for date fields\n * - EnumFieldProps for select, radio\n */\nexport interface FormBuilderFieldProps extends BaseFieldProps {\n // String field props\n placeholder?: string;\n minLength?: number;\n maxLength?: number;\n pattern?: string;\n inputType?: string;\n \n // Number field props\n min?: number;\n max?: number;\n \n // Date field props\n minDate?: Date | string;\n maxDate?: Date | string;\n \n // Enum field props\n options?: string[];\n \n // Array field props\n minItems?: number;\n maxItems?: number;\n \n // Default value (type depends on backing type)\n defaultValue?: unknown;\n}\n\n/**\n * Typed component definition for a specific backing type.\n * \n * Use this when defining components to get type-safe field props:\n * \n * @example\n * ```typescript\n * const textField: TypedComponentDefinition<\"string\"> = {\n * type: \"text\",\n * backingType: \"string\",\n * defaultProps: { label: \"Text\", placeholder: \"Enter text\" }, // ✓ type-safe\n * // ...\n * };\n * ```\n */\nexport interface TypedComponentDefinition {\n /** Unique identifier for this component type */\n type: ComponentType | string;\n /** The backing Zod type for this component */\n backingType: T;\n /** Display name shown in the palette */\n label: string;\n /** Icon shown in the palette */\n icon?: React.ComponentType<{ className?: string }>;\n /** Default props when a new field is created - type-safe based on backing type */\n defaultProps: Partial>;\n /** Zod schema for the property panel */\n propertiesSchema: z.ZodObject;\n /** Convert field props to JSON Schema property */\n toJSONSchema: (props: FieldPropsFor, isRequired: boolean) => JSONSchemaProperty;\n /** Try to parse a JSON Schema property into this component type */\n fromJSONSchema: (prop: JSONSchemaProperty, key: string, isRequired: boolean) => \n { id: string; type: string; props: FieldPropsFor } | null;\n /** Optional custom preview component */\n PreviewComponent?: React.ComponentType<{ field: FormBuilderField }>;\n}\n\n/**\n * Component definition provided by developers.\n * \n * This is the runtime type used when the backing type is not known at compile time.\n * For type-safe definitions, use `TypedComponentDefinition`.\n */\nexport interface FormBuilderComponentDefinition {\n /** Unique identifier for this component type */\n type: string;\n /** The backing Zod type for this component (optional for backwards compatibility) */\n backingType?: BackingType;\n /** Display name shown in the palette */\n label: string;\n /** Icon shown in the palette */\n icon?: React.ComponentType<{ className?: string }>;\n /** Default props when a new field is created */\n defaultProps: Partial;\n /** Zod schema for the property panel */\n propertiesSchema: z.ZodObject;\n /** Convert field props to JSON Schema property */\n toJSONSchema: (props: FormBuilderFieldProps, isRequired: boolean) => JSONSchemaProperty;\n /** Try to parse a JSON Schema property into this component type */\n fromJSONSchema: (prop: JSONSchemaProperty, key: string, isRequired: boolean) => FormBuilderField | null;\n /** Optional custom preview component */\n PreviewComponent?: React.ComponentType<{ field: FormBuilderField }>;\n}\n\n/**\n * Helper to create a type-safe component definition.\n * The returned definition is assignable to FormBuilderComponentDefinition.\n */\nexport function defineComponent(\n def: TypedComponentDefinition\n): FormBuilderComponentDefinition {\n return def as unknown as FormBuilderComponentDefinition;\n}\n\n/**\n * Form builder context for sharing state\n */\nexport interface FormBuilderContextValue {\n fields: FormBuilderField[];\n components: FormBuilderComponentDefinition[];\n mode: \"build\" | \"preview\";\n setFields: (fields: FormBuilderField[]) => void;\n setMode: (mode: \"build\" | \"preview\") => void;\n addField: (componentType: string, index?: number) => void;\n updateField: (id: string, props: Partial) => void;\n removeField: (id: string) => void;\n moveField: (fromIndex: number, toIndex: number) => void;\n}\n\n/**\n * Drag data types for dnd-kit\n */\nexport interface PaletteDragData {\n type: \"palette\";\n componentType: string;\n}\n\nexport interface FieldDragData {\n type: \"field\";\n fieldId: string;\n index: number;\n}\n\nexport type DragData = PaletteDragData | FieldDragData;\n", + "content": "import type { z } from \"zod\";\nimport type { \n JSONSchemaPropertyBase, \n SerializableInputProps,\n FieldType,\n StringInputProps,\n NumberInputProps,\n BooleanInputProps,\n DateInputProps,\n EnumInputProps,\n InputPropsByBackingType,\n} from \"../auto-form/shared-form-types\";\n\n// Re-export discriminated input prop types from shared-form-types\nexport type {\n SerializableInputProps,\n StringInputProps,\n NumberInputProps,\n BooleanInputProps,\n DateInputProps,\n EnumInputProps,\n InputPropsByBackingType,\n};\n\n// ============================================================================\n// STEP TYPES\n// ============================================================================\n\n/**\n * Represents a step in a multi-step form.\n */\nexport interface FormStep {\n /** Unique identifier for the step */\n id: string;\n /** Display title for the step */\n title: string;\n}\n\n/**\n * JSON Schema types for form builder I/O.\n * Extends the shared base types with form-builder specific needs.\n */\nexport interface JSONSchema {\n type: \"object\";\n properties: Record;\n required?: string[];\n $schema?: string;\n /** Step definitions for multi-step forms (stored in schema meta) */\n steps?: FormStep[];\n /**\n * Optional root-level field-name → step-index map. This is the format produced\n * by `zodToFormSchema(schema, { steps, stepGroupMap })` and consumed by\n * `SteppedAutoForm`. The visual FormBuilder writes per-property `stepGroup`\n * instead, but we accept both on read so a Zod-seeded form opens with its\n * fields correctly assigned to their steps.\n */\n stepGroupMap?: Record;\n additionalProperties?: boolean;\n}\n\n/**\n * JSON Schema property with form-builder metadata.\n * Extends the shared JSONSchemaPropertyBase for compatibility with auto-form.\n */\nexport interface JSONSchemaProperty extends Omit {\n /** JSON Schema type - required for form-builder fields */\n type: string;\n /** Enum values for select/radio fields (string-only for form-builder) */\n enum?: string[];\n /** Nested properties for object types (self-referential) */\n properties?: Record;\n /** Item schema for array types (self-referential) */\n items?: JSONSchemaProperty;\n /** Minimum items for array types */\n minItems?: number;\n /** Maximum items for array types */\n maxItems?: number;\n /** Step group index for multi-step forms (0-indexed) */\n stepGroup?: number;\n}\n\n/**\n * Internal field representation used by form builder.\n */\nexport interface FormBuilderField {\n id: string;\n type: string;\n props: FormBuilderFieldProps;\n /** Nested fields for object type containers */\n children?: FormBuilderField[];\n /** Template fields defining the shape of each array item */\n itemTemplate?: FormBuilderField[];\n /** Step group index (0-indexed) for multi-step forms */\n stepGroup?: number;\n}\n\n/**\n * Backing types supported by form-builder fields.\n */\nexport type BackingType = keyof InputPropsByBackingType;\n\n// ============================================================================\n// COMPONENT TYPE REGISTRY\n// ============================================================================\n\n/**\n * Registry mapping component type names to their backing Zod types.\n * This enables compile-time type checking for component definitions.\n */\nexport const COMPONENT_BACKING_TYPES = {\n text: \"string\",\n email: \"string\",\n password: \"string\",\n url: \"string\",\n phone: \"string\",\n textarea: \"string\",\n number: \"number\",\n checkbox: \"boolean\",\n switch: \"boolean\",\n select: \"enum\",\n radio: \"enum\",\n date: \"date\",\n color: \"string\", // color picker stores hex string\n} as const;\n\n/**\n * All registered component type names.\n */\nexport type ComponentType = keyof typeof COMPONENT_BACKING_TYPES;\n\n/**\n * Get the backing type for a component type.\n */\nexport type BackingTypeFor = typeof COMPONENT_BACKING_TYPES[C];\n\n// ============================================================================\n// DISCRIMINATED FIELD PROPS BY BACKING TYPE\n// ============================================================================\n\n/**\n * Base properties shared by all form builder fields.\n */\nexport interface BaseFieldProps {\n /** Display label for the field */\n label: string;\n /** Description text */\n description?: string;\n /** Whether the field is required */\n required?: boolean;\n /** Field type override (checkbox, date, select, radio, switch, etc.) */\n fieldType?: FieldType;\n /** Additional input props passed through to the field */\n inputProps?: SerializableInputProps;\n}\n\n/**\n * Props for string-backed fields (text, email, password, url, phone, textarea).\n */\nexport interface StringFieldProps extends BaseFieldProps {\n /** Placeholder text */\n placeholder?: string;\n /** Minimum length */\n minLength?: number;\n /** Maximum length */\n maxLength?: number;\n /** Regex pattern for validation */\n pattern?: string;\n /** HTML input type (text, email, password, tel, url) */\n inputType?: string;\n /** Default value */\n defaultValue?: string;\n}\n\n/**\n * Props for number-backed fields.\n */\nexport interface NumberFieldProps extends BaseFieldProps {\n /** Placeholder text */\n placeholder?: string;\n /** Minimum value */\n min?: number;\n /** Maximum value */\n max?: number;\n /** Default value */\n defaultValue?: number;\n}\n\n/**\n * Props for boolean-backed fields (checkbox, switch).\n */\nexport interface BooleanFieldProps extends BaseFieldProps {\n /** Default value */\n defaultValue?: boolean;\n}\n\n/**\n * Props for date-backed fields.\n */\nexport interface DateFieldProps extends BaseFieldProps {\n /** Minimum date */\n minDate?: Date | string;\n /** Maximum date */\n maxDate?: Date | string;\n /** Default value */\n defaultValue?: Date | string;\n}\n\n/**\n * Props for enum-backed fields (select, radio).\n */\nexport interface EnumFieldProps extends BaseFieldProps {\n /** Placeholder text (for select) */\n placeholder?: string;\n /** Options for the select/radio */\n options?: string[];\n /** Default value (must match one of the options) */\n defaultValue?: string;\n}\n\n/**\n * Maps backing types to their corresponding field props.\n */\nexport interface FieldPropsByBackingType {\n string: StringFieldProps;\n number: NumberFieldProps;\n boolean: BooleanFieldProps;\n date: DateFieldProps;\n enum: EnumFieldProps;\n}\n\n/**\n * Get the correct field props type for a backing type.\n */\nexport type FieldPropsFor = FieldPropsByBackingType[T];\n\n/**\n * Union of all discriminated field props.\n */\nexport type TypedFieldProps = \n | StringFieldProps\n | NumberFieldProps\n | BooleanFieldProps\n | DateFieldProps\n | EnumFieldProps;\n\n/**\n * Properties for a form builder field.\n * This is the runtime union type used when the backing type is not known.\n * \n * For type-safe field props, use the discriminated variants:\n * - StringFieldProps for text, email, password, url, phone, textarea\n * - NumberFieldProps for number fields\n * - BooleanFieldProps for checkbox, switch\n * - DateFieldProps for date fields\n * - EnumFieldProps for select, radio\n */\nexport interface FormBuilderFieldProps extends BaseFieldProps {\n // String field props\n placeholder?: string;\n minLength?: number;\n maxLength?: number;\n pattern?: string;\n inputType?: string;\n \n // Number field props\n min?: number;\n max?: number;\n \n // Date field props\n minDate?: Date | string;\n maxDate?: Date | string;\n \n // Enum field props\n options?: string[];\n \n // Array field props\n minItems?: number;\n maxItems?: number;\n \n // Default value (type depends on backing type)\n defaultValue?: unknown;\n}\n\n/**\n * Typed component definition for a specific backing type.\n * \n * Use this when defining components to get type-safe field props:\n * \n * @example\n * ```typescript\n * const textField: TypedComponentDefinition<\"string\"> = {\n * type: \"text\",\n * backingType: \"string\",\n * defaultProps: { label: \"Text\", placeholder: \"Enter text\" }, // ✓ type-safe\n * // ...\n * };\n * ```\n */\nexport interface TypedComponentDefinition {\n /** Unique identifier for this component type */\n type: ComponentType | string;\n /** The backing Zod type for this component */\n backingType: T;\n /** Display name shown in the palette */\n label: string;\n /** Icon shown in the palette */\n icon?: React.ComponentType<{ className?: string }>;\n /** Default props when a new field is created - type-safe based on backing type */\n defaultProps: Partial>;\n /** Zod schema for the property panel */\n propertiesSchema: z.ZodObject;\n /** Convert field props to JSON Schema property */\n toJSONSchema: (props: FieldPropsFor, isRequired: boolean) => JSONSchemaProperty;\n /** Try to parse a JSON Schema property into this component type */\n fromJSONSchema: (prop: JSONSchemaProperty, key: string, isRequired: boolean) => \n { id: string; type: string; props: FieldPropsFor } | null;\n /** Optional custom preview component */\n PreviewComponent?: React.ComponentType<{ field: FormBuilderField }>;\n}\n\n/**\n * Component definition provided by developers.\n * \n * This is the runtime type used when the backing type is not known at compile time.\n * For type-safe definitions, use `TypedComponentDefinition`.\n */\nexport interface FormBuilderComponentDefinition {\n /** Unique identifier for this component type */\n type: string;\n /** The backing Zod type for this component (optional for backwards compatibility) */\n backingType?: BackingType;\n /** Display name shown in the palette */\n label: string;\n /** Icon shown in the palette */\n icon?: React.ComponentType<{ className?: string }>;\n /** Default props when a new field is created */\n defaultProps: Partial;\n /** Zod schema for the property panel */\n propertiesSchema: z.ZodObject;\n /** Convert field props to JSON Schema property */\n toJSONSchema: (props: FormBuilderFieldProps, isRequired: boolean) => JSONSchemaProperty;\n /** Try to parse a JSON Schema property into this component type */\n fromJSONSchema: (prop: JSONSchemaProperty, key: string, isRequired: boolean) => FormBuilderField | null;\n /** Optional custom preview component */\n PreviewComponent?: React.ComponentType<{ field: FormBuilderField }>;\n}\n\n/**\n * Helper to create a type-safe component definition.\n * The returned definition is assignable to FormBuilderComponentDefinition.\n */\nexport function defineComponent(\n def: TypedComponentDefinition\n): FormBuilderComponentDefinition {\n return def as unknown as FormBuilderComponentDefinition;\n}\n\n/**\n * Form builder context for sharing state\n */\nexport interface FormBuilderContextValue {\n fields: FormBuilderField[];\n components: FormBuilderComponentDefinition[];\n mode: \"build\" | \"preview\";\n setFields: (fields: FormBuilderField[]) => void;\n setMode: (mode: \"build\" | \"preview\") => void;\n addField: (componentType: string, index?: number) => void;\n updateField: (id: string, props: Partial) => void;\n removeField: (id: string) => void;\n moveField: (fromIndex: number, toIndex: number) => void;\n}\n\n/**\n * Drag data types for dnd-kit\n */\nexport interface PaletteDragData {\n type: \"palette\";\n componentType: string;\n}\n\nexport interface FieldDragData {\n type: \"field\";\n fieldId: string;\n index: number;\n}\n\nexport type DragData = PaletteDragData | FieldDragData;\n", "target": "src/components/ui/form-builder/types.ts" }, {