diff --git a/biome.json b/biome.json index d53dd07a..dfa490ef 100644 --- a/biome.json +++ b/biome.json @@ -64,7 +64,8 @@ "!**/.btst-stack-src/**", "!**/.btst-stack-ui/**", "!**/packages/stack/registry/**", - "!**/codegen-projects/**" + "!**/codegen-projects/**", + "!**/playwright-report-codegen/trace/**" ] } } diff --git a/docs/content/docs/plugins/cms.mdx b/docs/content/docs/plugins/cms.mdx index 25a99682..6b6eae00 100644 --- a/docs/content/docs/plugins/cms.mdx +++ b/docs/content/docs/plugins/cms.mdx @@ -276,6 +276,48 @@ cms: { The built-in file component will use your `uploadImage` function to upload files and store the returned URL. +### Repeating Groups (Arrays of Objects) + +You can model repeating sub-records — variants, line items, blend components, FAQ entries, etc. — with `z.array(z.object({...}))`. The admin renders each item as a sub-form inside an accordion with **Add** and **Remove** buttons, and `useFieldArray` from react-hook-form drives the row state. + +`.meta()` placeholders, `fieldType` overrides, and custom `fieldComponents` (including `"file"` and `"relation"`) are propagated **into the array items**, so you can build rich nested forms without writing a custom field component. + +```ts +const ProductSchema = z.object({ + name: z.string().min(1), + variants: z + .array( + z.object({ + sku: z.string().meta({ placeholder: "SKU-001" }), + price: z.coerce.number().min(0).meta({ placeholder: "0.00" }), + notes: z.string().optional().meta({ fieldType: "textarea" }), + // Nested file uploads work when `uploadImage` is provided in overrides. + image: z.string().optional().meta({ fieldType: "file" }), + // Nested belongsTo relations render the searchable picker inside the row. + categoryId: z + .object({ id: z.string() }) + .optional() + .meta({ + fieldType: "relation", + relation: { + type: "belongsTo", + targetType: "category", + displayField: "name", + }, + }), + }), + ) + .default([]) + .meta({ description: "Product variants" }), +}); +``` + +A few rules worth remembering: + +- New rows are created with `append({})` — fields with no default render empty until the user fills them in. +- Inside item objects, do **not** name properties using reserved `FieldConfigItem` keys (`label`, `description`, `inputProps`, `fieldType`, `renderParent`, `order`). The admin will warn and skip those properties to avoid clobbering the array's own metadata. +- Validation, defaults, and required-vs-optional behavior follow the inner Zod schema as usual. + ## Admin Routes The CMS plugin provides these admin routes: diff --git a/packages/stack/package.json b/packages/stack/package.json index 82f7b182..d0e0a843 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "2.11.4", + "version": "2.11.5", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git", @@ -127,6 +127,16 @@ "default": "./dist/plugins/blog/client/index.cjs" } }, + "./plugins/blog/client/components": { + "import": { + "types": "./dist/plugins/blog/client/components/index.d.ts", + "default": "./dist/plugins/blog/client/components/index.mjs" + }, + "require": { + "types": "./dist/plugins/blog/client/components/index.d.cts", + "default": "./dist/plugins/blog/client/components/index.cjs" + } + }, "./plugins/blog/client/hooks": { "import": { "types": "./dist/plugins/blog/client/hooks/index.d.ts", @@ -607,6 +617,9 @@ "plugins/blog/client": [ "./dist/plugins/blog/client/index.d.ts" ], + "plugins/blog/client/components": [ + "./dist/plugins/blog/client/components/index.d.ts" + ], "plugins/blog/client/hooks": [ "./dist/plugins/blog/client/hooks/index.d.ts" ], 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..b324147d 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" }, { diff --git a/packages/stack/src/plugins/blog/client/components/index.tsx b/packages/stack/src/plugins/blog/client/components/index.tsx index cc2a4c02..abb5a134 100644 --- a/packages/stack/src/plugins/blog/client/components/index.tsx +++ b/packages/stack/src/plugins/blog/client/components/index.tsx @@ -3,9 +3,61 @@ import { HomePageComponent as PostListPageImpl } from "./pages/home-page"; import { NewPostPageComponent as NewPostPageImpl } from "./pages/new-post-page"; import { PostPageComponent as PostPageImpl } from "./pages/post-page"; import { EditPostPageComponent as EditPostPageImpl } from "./pages/edit-post-page"; +import { PostCard as PostCardImpl } from "./shared/post-card"; +import { PostsList as PostsListImpl } from "./shared/posts-list"; +import { EmptyList as EmptyListImpl } from "./shared/empty-list"; +import { RecentPostsCarousel as RecentPostsCarouselImpl } from "./shared/recent-posts-carousel"; +import { PostNavigation as PostNavigationImpl } from "./shared/post-navigation"; +import { TagsList as TagsListImpl } from "./shared/tags-list"; +import { PostCardSkeleton as PostCardSkeletonImpl } from "./loading/post-card-skeleton"; // Re-export to ensure the client boundary is preserved export const PostListPage = PostListPageImpl; export const NewPostPage = NewPostPageImpl; export const PostPage = PostPageImpl; export const EditPostPage = EditPostPageImpl; + +/** + * Card component that renders a single blog post summary + * (cover image, title, date, tags). Used by the built-in posts list and + * available for composing custom blog landing pages. + * + * Requires a `StackProvider` with the blog plugin registered so that + * `Link` and `Image` overrides are resolved correctly. + */ +export const PostCard = PostCardImpl; + +/** + * Skeleton placeholder that mirrors the layout of {@link PostCard}. + * Use inside a `` fallback or while data is loading. + */ +export const PostCardSkeleton = PostCardSkeletonImpl; + +/** + * Grid of {@link PostCard}s with optional load-more pagination and a + * built-in search input. Pass an array of `SerializedPost`s. + */ +export const PostsList = PostsListImpl; + +/** + * Empty-state placeholder used by the built-in blog list pages. + * Renders a centered icon and a customisable message. + */ +export const EmptyList = EmptyListImpl; + +/** + * Horizontal carousel of recent posts. Drop-in component for "you might + * also like" sections on a blog post page. + */ +export const RecentPostsCarousel = RecentPostsCarouselImpl; + +/** + * Previous/next post navigation strip, typically rendered at the bottom + * of a post page. + */ +export const PostNavigation = PostNavigationImpl; + +/** + * Renders a list of tags for a post or for the whole blog. + */ +export const TagsList = TagsListImpl; diff --git a/packages/stack/src/plugins/cms/__tests__/auto-form-nested-config.test.ts b/packages/stack/src/plugins/cms/__tests__/auto-form-nested-config.test.ts new file mode 100644 index 00000000..b39671e3 --- /dev/null +++ b/packages/stack/src/plugins/cms/__tests__/auto-form-nested-config.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi } from "vitest"; +import { z } from "zod"; +import { buildFieldConfigFromJsonSchema } from "@workspace/ui/components/auto-form/helpers"; + +/** + * Verifies that buildFieldConfigFromJsonSchema recurses into nested objects AND + * array items.properties so that per-property metadata (label, placeholder, + * fieldType, etc.) reaches the AutoForm renderer for fields nested inside + * arrays-of-objects. + * + * The CMS auto-form pipeline relies on this for blend-style schemas like: + * components: z.array(z.object({ + * name: z.string(), + * compoundId: z.object({ id: z.string() }).meta({ fieldType: "relation" }), + * })) + * + * Without this recursion, AutoFormArray would render the inner items with no + * field config — placeholders and custom field components on nested array + * properties would silently disappear. + */ + +type ConfigMap = Record | undefined>; + +function getConfig(map: ConfigMap, key: string): Record { + const value = map[key]; + if (!value) { + throw new Error(`Expected config entry for "${key}" but got undefined`); + } + return value; +} + +describe("buildFieldConfigFromJsonSchema — nested + array recursion", () => { + it("propagates per-item placeholders into the array's field config", () => { + const schema = z.object({ + components: z + .array( + z.object({ + name: z.string().meta({ placeholder: "e.g. GHK-Cu" }), + doseLow: z.coerce.number().meta({ placeholder: "1" }), + }), + ) + .default([]) + .meta({ description: "Blend components" }), + }); + + const jsonSchema = z.toJSONSchema(schema) as Record; + const config = buildFieldConfigFromJsonSchema(jsonSchema) as ConfigMap; + const components = getConfig(config, "components") as ConfigMap & { + description?: string; + }; + + expect(components.description).toBe("Blend components"); + + // Per-item configs must live as keys on the array's FieldConfigObject so + // that AutoFormObject (rendered per array item) can look them up by name. + const nameConfig = getConfig(components, "name") as { + inputProps?: { placeholder?: string }; + }; + expect(nameConfig.inputProps?.placeholder).toBe("e.g. GHK-Cu"); + + const doseLowConfig = getConfig(components, "doseLow") as { + inputProps?: { placeholder?: string }; + }; + expect(doseLowConfig.inputProps?.placeholder).toBe("1"); + }); + + it("propagates fieldType (textarea) into array items", () => { + const schema = z.object({ + components: z + .array( + z.object({ + notes: z.string().meta({ fieldType: "textarea" }), + }), + ) + .default([]), + }); + + const jsonSchema = z.toJSONSchema(schema) as Record; + const config = buildFieldConfigFromJsonSchema(jsonSchema) as ConfigMap; + const components = getConfig(config, "components") as ConfigMap; + const notesConfig = getConfig(components, "notes") as { + fieldType?: string; + }; + + expect(notesConfig.fieldType).toBe("textarea"); + }); + + it("invokes a custom fieldComponent for nested array fields", () => { + const schema = z.object({ + components: z + .array( + z.object({ + compoundId: z + .object({ id: z.string() }) + .meta({ fieldType: "relation" }), + }), + ) + .default([]), + }); + + const jsonSchema = z.toJSONSchema(schema) as Record; + + const RelationStub = () => null; + const config = buildFieldConfigFromJsonSchema(jsonSchema, { + relation: RelationStub, + }) as ConfigMap; + + const components = getConfig(config, "components") as ConfigMap; + const compoundIdConfig = getConfig(components, "compoundId") as { + fieldType?: unknown; + }; + + // fieldComponents["relation"] should be wired up for the nested array + // element field, not just top-level fields. + expect(typeof compoundIdConfig.fieldType).toBe("function"); + }); + + it("still propagates per-property configs into nested objects (regression)", () => { + const schema = z.object({ + seo: z.object({ + title: z.string().meta({ placeholder: "Page title" }), + }), + }); + + const jsonSchema = z.toJSONSchema(schema) as Record; + const config = buildFieldConfigFromJsonSchema(jsonSchema) as ConfigMap; + const seo = getConfig(config, "seo") as ConfigMap; + const titleConfig = getConfig(seo, "title") as { + inputProps?: { placeholder?: string }; + }; + + expect(titleConfig.inputProps?.placeholder).toBe("Page title"); + }); + + it("warns and skips item properties whose names collide with reserved FieldConfigItem keys", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const schema = z.object({ + items: z + .array( + z.object({ + // "label" is a reserved key on FieldConfigItem and would + // silently overwrite the array's own label if not guarded. + label: z.string().meta({ placeholder: "Item label" }), + value: z.string().meta({ placeholder: "Item value" }), + }), + ) + .default([]) + .meta({ description: "An array" }), + }); + + const jsonSchema = z.toJSONSchema(schema) as Record; + const config = buildFieldConfigFromJsonSchema(jsonSchema) as ConfigMap; + const items = getConfig(config, "items") as ConfigMap & { + description?: string; + }; + + // The array's own description should remain intact (not overwritten by + // a nested item field also named "description"). + expect(items.description).toBe("An array"); + + // Non-reserved item properties propagate normally. + const valueConfig = getConfig(items, "value") as { + inputProps?: { placeholder?: string }; + }; + expect(valueConfig.inputProps?.placeholder).toBe("Item value"); + + // The "label" item property collides with a reserved FieldConfigItem + // key and must be skipped + warned about. + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Array field "items" has an item property named "label"', + ), + ); + warn.mockRestore(); + }); +}); diff --git a/packages/stack/src/plugins/cms/client/components/forms/content-form.tsx b/packages/stack/src/plugins/cms/client/components/forms/content-form.tsx index de59c6a5..6eddcaad 100644 --- a/packages/stack/src/plugins/cms/client/components/forms/content-form.tsx +++ b/packages/stack/src/plugins/cms/client/components/forms/content-form.tsx @@ -63,63 +63,109 @@ function buildFieldConfigFromJsonSchema( isRequired?: boolean; }>, ): FieldConfig> { - // Get base config from shared utility (handles fieldType from JSON Schema) + // Get base config from shared utility (handles fieldType from JSON Schema, + // including per-item configs for arrays of objects). const baseConfig = buildFieldConfigBase(jsonSchema, fieldComponents); - // Apply CMS-specific handling for special fieldTypes ONLY if no custom component exists - // Custom fieldComponents take priority - don't override if user provided one - const properties = jsonSchema.properties as Record< - string, - JsonSchemaProperty - >; + const properties = jsonSchema.properties as + | Record + | undefined; if (!properties) return baseConfig; - for (const [key, prop] of Object.entries(properties)) { - // Handle "file" fieldType when there's NO custom component for "file" - if (prop.fieldType === "file" && !fieldComponents?.["file"]) { - // Use CMSFileUpload as the default file component - if (!uploadImage && !imageInputField) { - // Show a clear error message if neither uploadImage nor imageInputField is provided - baseConfig[key] = { - ...baseConfig[key], - fieldType: () => ( -
- File upload requires an uploadImage or{" "} - imageInputField function in CMS overrides. -
- ), - }; - } else { - baseConfig[key] = { - ...baseConfig[key], - fieldType: (props: AutoFormInputComponentProps) => ( - Promise.resolve(""))} - imageInputField={imageInputField} - imagePicker={imagePicker} - /> + // Recursively walk the JSON Schema properties and inject CMS-specific custom + // components (file upload, relation picker) for any field with a matching + // fieldType, regardless of nesting depth. Targets: + // - top-level fields + // - properties of nested object fields + // - properties of array items (e.g. `components: z.array(z.object({...}))`) + // + // `targetConfig` is the FieldConfigObject slot to mutate for the property at + // `key`. The recursion mirrors how AutoFormObject + AutoFormArray look up + // per-property configs: nested object/array per-item configs live as keys + // alongside their parent's meta on the same FieldConfigObject. + const injectCustomFieldTypes = ( + props: Record, + targetConfig: Record, + ) => { + for (const [key, prop] of Object.entries(props)) { + // Ensure a slot exists so we can mutate it whether or not the base + // helper produced an entry for this key. + const existing = + (targetConfig[key] as Record | undefined) ?? {}; + + let updated = existing; + + // Handle "file" fieldType when there's NO custom component for "file" + if (prop.fieldType === "file" && !fieldComponents?.["file"]) { + if (!uploadImage && !imageInputField) { + updated = { + ...updated, + fieldType: () => ( +
+ File upload requires an uploadImage or{" "} + imageInputField function in CMS overrides. +
+ ), + }; + } else { + updated = { + ...updated, + fieldType: (componentProps: AutoFormInputComponentProps) => ( + Promise.resolve(""))} + imageInputField={imageInputField} + imagePicker={imagePicker} + /> + ), + }; + } + } + + // Handle "relation" fieldType when there's NO custom component for "relation" + if ( + prop.fieldType === "relation" && + prop.relation && + !fieldComponents?.["relation"] + ) { + const relationConfig = prop.relation; + updated = { + ...updated, + fieldType: (componentProps: AutoFormInputComponentProps) => ( + ), }; } - } - // Handle "relation" fieldType when there's NO custom component for "relation" - if ( - prop.fieldType === "relation" && - prop.relation && - !fieldComponents?.["relation"] - ) { - const relationConfig = prop.relation; - baseConfig[key] = { - ...baseConfig[key], - fieldType: (props: AutoFormInputComponentProps) => ( - - ), - }; + // Recurse into nested objects — their per-property configs live as + // keys on the same parent FieldConfigObject. + if (prop.properties) { + injectCustomFieldTypes( + prop.properties as Record, + updated, + ); + } + + // Recurse into array items — same convention as nested objects. + const items = prop.items as JsonSchemaProperty | undefined; + if (items?.properties) { + injectCustomFieldTypes( + items.properties as Record, + updated, + ); + } + + if (Object.keys(updated).length > 0) { + targetConfig[key] = updated; + } } - } + }; + + injectCustomFieldTypes( + properties, + baseConfig as unknown as Record, + ); return baseConfig; } diff --git a/packages/stack/src/plugins/cms/client/components/inverse-relations-panel.tsx b/packages/stack/src/plugins/cms/client/components/inverse-relations-panel.tsx index b7c72984..d2df3fe9 100644 --- a/packages/stack/src/plugins/cms/client/components/inverse-relations-panel.tsx +++ b/packages/stack/src/plugins/cms/client/components/inverse-relations-panel.tsx @@ -95,6 +95,19 @@ export function InverseRelationsPanel({ return null; } + // When a single source content type has multiple belongsTo fields pointing + // at this target type (e.g. StackSynergy has both compoundAId and + // compoundBId → compound), the section title alone ("Stack Synergy") is + // ambiguous — two cards would render with identical headings. Mark those + // relations so we can disambiguate them by field name. + const sourceTypeCounts = new Map(); + for (const rel of inverseRelations) { + sourceTypeCounts.set( + rel.sourceType, + (sourceTypeCounts.get(rel.sourceType) ?? 0) + 1, + ); + } + return (

Related Items

@@ -109,12 +122,32 @@ export function InverseRelationsPanel({ Link={Link} client={client} headers={headers} + ambiguous={(sourceTypeCounts.get(relation.sourceType) ?? 0) > 1} /> ))}
); } +/** + * Turn a relation field name like `compoundAId` / `categoryIds` into a + * friendlier label like `Compound A` / `Category` for display in the + * inverse-relations panel when two sections would otherwise share a title. + * + * Strips a trailing `Id` or `Ids`, splits camelCase boundaries, and + * title-cases the result. Leaves unrecognised shapes as-is so we never + * produce an empty string. + */ +function humanizeFieldName(fieldName: string): string { + const stripped = fieldName.replace(/Ids?$/, "") || fieldName; + const words = stripped + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .trim() + .split(/\s+/); + return words.map((w) => (w ? w[0]!.toUpperCase() + w.slice(1) : w)).join(" "); +} + interface InverseRelationSectionProps { relation: InverseRelation; contentTypeSlug: string; @@ -128,6 +161,12 @@ interface InverseRelationSectionProps { }>; client: ReturnType>; headers?: HeadersInit; + /** + * True when another inverse relation from the same `sourceType` is also + * being rendered — in which case the field-name suffix is shown so the + * user can tell the two cards apart. + */ + ambiguous: boolean; } function InverseRelationSection({ @@ -139,6 +178,7 @@ function InverseRelationSection({ Link, client, headers, + ambiguous, }: InverseRelationSectionProps) { const [isExpanded, setIsExpanded] = useState(true); const [deleteItemId, setDeleteItemId] = useState(null); @@ -205,6 +245,7 @@ function InverseRelationSection({ }; const LinkComponent = Link ?? "a"; + const fieldLabel = ambiguous ? humanizeFieldName(relation.fieldName) : null; return ( @@ -220,7 +261,13 @@ function InverseRelationSection({ ) : ( )} - {relation.sourceTypeName} ({total}) + {relation.sourceTypeName} + {fieldLabel && ( + + · {fieldLabel} + + )} + ({total}) @@ -269,6 +316,7 @@ function InverseRelationSection({ > Add {relation.sourceTypeName} + {fieldLabel ? ` (${fieldLabel})` : ""}
diff --git a/packages/ui/src/components/auto-form/helpers.tsx b/packages/ui/src/components/auto-form/helpers.tsx index 8a92a2a6..aec58f5a 100644 --- a/packages/ui/src/components/auto-form/helpers.tsx +++ b/packages/ui/src/components/auto-form/helpers.tsx @@ -398,27 +398,59 @@ export function buildFieldConfigFromJsonSchema( } } + // Reserved FieldConfigItem property names that should not be overwritten by nested field configs. + // If a nested field has the same name as a reserved property (e.g., a field named "description"), + // we skip it to prevent overwriting the parent's config (like its help text). + const reservedProps = new Set([ + "description", + "label", + "inputProps", + "fieldType", + "renderParent", + "order", + ]); + // Handle nested object properties recursively if (value.properties) { const nestedConfig = buildFieldConfigFromJsonSchema( { properties: value.properties } as Record, fieldComponents, ); - // Reserved FieldConfigItem property names that should not be overwritten by nested field configs. - // If a nested field has the same name as a reserved property (e.g., a field named "description"), - // we skip it to prevent overwriting the parent's config (like its help text). - const reservedProps = new Set(['description', 'label', 'inputProps', 'fieldType', 'renderParent', 'order']); - + // Merge nested config, but skip keys that match reserved property names for (const [nestedKey, nestedValue] of Object.entries(nestedConfig)) { if (!reservedProps.has(nestedKey)) { config[nestedKey] = nestedValue; } else { - console.warn( - `Field "${key}" has a nested field named "${nestedKey}" which conflicts with a reserved FieldConfigItem property. ` + - `The nested field's config will not be accessible at the parent level.` - ); - } + console.warn( + `Field "${key}" has a nested field named "${nestedKey}" which conflicts with a reserved FieldConfigItem property. ` + + `The nested field's config will not be accessible at the parent level.`, + ); + } + } + } + + // Handle array items recursively — for `z.array(z.object({...}))` shapes + // the array's items.properties carry per-item field metadata (placeholder, + // fieldType, relation config, etc.) that AutoFormArray needs to forward + // to its inner AutoFormObject. Mirror the nested-object merge so per-item + // configs end up at `fieldConfig[arrayKey][itemPropertyKey]`. + const items = value.items as JsonSchemaProperty | undefined; + if (items?.properties) { + const itemConfig = buildFieldConfigFromJsonSchema( + { properties: items.properties } as Record, + fieldComponents, + ); + + for (const [itemKey, itemValue] of Object.entries(itemConfig)) { + if (!reservedProps.has(itemKey)) { + config[itemKey] = itemValue; + } else { + console.warn( + `Array field "${key}" has an item property named "${itemKey}" which conflicts with a reserved FieldConfigItem property. ` + + `The item field's config will not be accessible inside array items.`, + ); + } } }