diff --git a/e2e/tests/smoke.cms.spec.ts b/e2e/tests/smoke.cms.spec.ts index f37c0e3b..6c9353cb 100644 --- a/e2e/tests/smoke.cms.spec.ts +++ b/e2e/tests/smoke.cms.spec.ts @@ -409,6 +409,63 @@ test.describe("CMS Plugin", () => { ); }); + test("search filters the content list and syncs the URL", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + // Create one item that matches the search and one that doesn't + const targetSlug = `search-target-${testRunId}`; + const otherSlug = `search-other-${testRunId}`; + for (const [slug, name] of [ + [targetSlug, "Searchable Widget"], + [otherSlug, "Unrelated Gadget"], + ]) { + await request.post("/api/data/content/product", { + headers: { "content-type": "application/json" }, + data: { + slug, + data: { + name, + description: "Product for search test", + price: 10, + featured: false, + category: "Electronics", + }, + }, + }); + } + + await page.goto("/pages/cms/product", { waitUntil: "networkidle" }); + await expect(page.locator('[data-testid="cms-list-page"]')).toBeVisible(); + + // Type into the search box; the query is debounced into the URL + await page.locator('[data-testid="cms-list-search"]').fill(targetSlug); + await expect(page).toHaveURL(new RegExp(`q=${targetSlug}`), { + timeout: 10000, + }); + + // Only the matching item remains in the table + await expect(page.locator(`tr:has-text("${targetSlug}")`)).toBeVisible({ + timeout: 30000, + }); + await expect(page.locator(`tr:has-text("${otherSlug}")`)).not.toBeVisible(); + + // Clearing the search restores the full list + await page.locator('[data-testid="cms-list-search"]').fill(""); + await expect(page.locator(`tr:has-text("${otherSlug}")`)).toBeVisible({ + timeout: 30000, + }); + + expect(errors, `Console errors detected: \n${errors.join("\n")}`).toEqual( + [], + ); + }); + test("slug auto-generation from name field", async ({ page }) => { const errors: string[] = []; page.on("console", (msg) => { diff --git a/packages/stack/registry/btst-cms.json b/packages/stack/registry/btst-cms.json index 30b05821..d7be191f 100644 --- a/packages/stack/registry/btst-cms.json +++ b/packages/stack/registry/btst-cms.json @@ -37,7 +37,7 @@ { "path": "btst/cms/schemas.ts", "type": "registry:lib", - "content": "import { z } from \"zod\";\n\n/** Default upper bound for a single page when no maxPageSize is configured. */\nexport const DEFAULT_MAX_PAGE_SIZE = 1000;\n\n/**\n * Factory that creates the list-content query schema with a configurable\n * upper bound on the `limit` parameter.\n *\n * Use this inside the backend plugin factory (where `config.maxPageSize` is\n * available) so the cap is set at registration time rather than hardcoded.\n */\nexport function createListContentQuerySchema(\n\tmaxPageSize = DEFAULT_MAX_PAGE_SIZE,\n) {\n\treturn z.object({\n\t\tslug: z.string().optional(),\n\t\tlimit: z.coerce.number().min(1).max(maxPageSize).optional().default(20),\n\t\toffset: z.coerce.number().min(0).optional().default(0),\n\t});\n}\n\n/**\n * Schema for listing content items with pagination.\n * Uses the default maxPageSize (1000).\n *\n * @deprecated Prefer {@link createListContentQuerySchema} inside plugin\n * factories so consumers can configure the upper bound via `maxPageSize`.\n */\nexport const listContentQuerySchema = createListContentQuerySchema();\n\n/**\n * Schema for creating a content item\n * Note: The actual data validation is done dynamically based on the content type's schema\n */\nexport const createContentSchema = z.object({\n\tslug: z.string().min(1, \"Slug is required\"),\n\t// Use passthrough object instead of z.record(z.unknown()) due to Zod v4 bug\n\tdata: z.object({}).passthrough(),\n});\n\n/**\n * Schema for updating a content item\n * Note: The actual data validation is done dynamically based on the content type's schema\n */\nexport const updateContentSchema = z.object({\n\tslug: z.string().min(1, \"Slug is required\").optional(),\n\t// Use passthrough object instead of z.record(z.unknown()) due to Zod v4 bug\n\tdata: z.object({}).passthrough().optional(),\n});\n\n/**\n * Schema for content type response\n * Note: fieldConfig is no longer included - it's merged into jsonSchema during read\n */\nexport const contentTypeResponseSchema = z.object({\n\tid: z.string(),\n\tname: z.string(),\n\tslug: z.string(),\n\tdescription: z.string().nullable().optional(),\n\tjsonSchema: z.string(),\n\tcreatedAt: z.string(),\n\tupdatedAt: z.string(),\n});\n\n/**\n * Schema for content item response\n */\nexport const contentItemResponseSchema = z.object({\n\tid: z.string(),\n\tcontentTypeId: z.string(),\n\tslug: z.string(),\n\tdata: z.string(),\n\tauthorId: z.string().nullable().optional(),\n\tcreatedAt: z.string(),\n\tupdatedAt: z.string(),\n});\n\n/**\n * Schema for content item with parsed data response\n */\nexport const contentItemWithDataResponseSchema =\n\tcontentItemResponseSchema.extend({\n\t\t// Use passthrough object instead of z.record(z.unknown()) due to Zod v4 bug\n\t\tparsedData: z.object({}).passthrough(),\n\t\tcontentType: contentTypeResponseSchema.optional(),\n\t});\n\n/**\n * Schema for paginated content items response\n */\nexport const paginatedContentResponseSchema = z.object({\n\titems: z.array(contentItemWithDataResponseSchema),\n\ttotal: z.number(),\n\tlimit: z.number(),\n\toffset: z.number(),\n});\n\nexport type ListContentQuery = z.infer;\nexport type CreateContentInput = z.infer;\nexport type UpdateContentInput = z.infer;\n", + "content": "import { z } from \"zod\";\n\n/** Default upper bound for a single page when no maxPageSize is configured. */\nexport const DEFAULT_MAX_PAGE_SIZE = 1000;\n\n/**\n * Factory that creates the list-content query schema with a configurable\n * upper bound on the `limit` parameter.\n *\n * Use this inside the backend plugin factory (where `config.maxPageSize` is\n * available) so the cap is set at registration time rather than hardcoded.\n */\nexport function createListContentQuerySchema(\n\tmaxPageSize = DEFAULT_MAX_PAGE_SIZE,\n) {\n\treturn z.object({\n\t\tslug: z.string().optional(),\n\t\tsearch: z.string().max(200).optional(),\n\t\tlimit: z.coerce.number().min(1).max(maxPageSize).optional().default(20),\n\t\toffset: z.coerce.number().min(0).optional().default(0),\n\t});\n}\n\n/**\n * Schema for listing content items with pagination.\n * Uses the default maxPageSize (1000).\n *\n * @deprecated Prefer {@link createListContentQuerySchema} inside plugin\n * factories so consumers can configure the upper bound via `maxPageSize`.\n */\nexport const listContentQuerySchema = createListContentQuerySchema();\n\n/**\n * Schema for creating a content item\n * Note: The actual data validation is done dynamically based on the content type's schema\n */\nexport const createContentSchema = z.object({\n\tslug: z.string().min(1, \"Slug is required\"),\n\t// Use passthrough object instead of z.record(z.unknown()) due to Zod v4 bug\n\tdata: z.object({}).passthrough(),\n});\n\n/**\n * Schema for updating a content item\n * Note: The actual data validation is done dynamically based on the content type's schema\n */\nexport const updateContentSchema = z.object({\n\tslug: z.string().min(1, \"Slug is required\").optional(),\n\t// Use passthrough object instead of z.record(z.unknown()) due to Zod v4 bug\n\tdata: z.object({}).passthrough().optional(),\n});\n\n/**\n * Schema for content type response\n * Note: fieldConfig is no longer included - it's merged into jsonSchema during read\n */\nexport const contentTypeResponseSchema = z.object({\n\tid: z.string(),\n\tname: z.string(),\n\tslug: z.string(),\n\tdescription: z.string().nullable().optional(),\n\tjsonSchema: z.string(),\n\tcreatedAt: z.string(),\n\tupdatedAt: z.string(),\n});\n\n/**\n * Schema for content item response\n */\nexport const contentItemResponseSchema = z.object({\n\tid: z.string(),\n\tcontentTypeId: z.string(),\n\tslug: z.string(),\n\tdata: z.string(),\n\tauthorId: z.string().nullable().optional(),\n\tcreatedAt: z.string(),\n\tupdatedAt: z.string(),\n});\n\n/**\n * Schema for content item with parsed data response\n */\nexport const contentItemWithDataResponseSchema =\n\tcontentItemResponseSchema.extend({\n\t\t// Use passthrough object instead of z.record(z.unknown()) due to Zod v4 bug\n\t\tparsedData: z.object({}).passthrough(),\n\t\tcontentType: contentTypeResponseSchema.optional(),\n\t});\n\n/**\n * Schema for paginated content items response\n */\nexport const paginatedContentResponseSchema = z.object({\n\titems: z.array(contentItemWithDataResponseSchema),\n\ttotal: z.number(),\n\tlimit: z.number(),\n\toffset: z.number(),\n});\n\nexport type ListContentQuery = z.infer;\nexport type CreateContentInput = z.infer;\nexport type UpdateContentInput = z.infer;\n", "target": "src/components/btst/cms/schemas.ts" }, { @@ -49,25 +49,25 @@ { "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\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", + "content": "\"use client\";\n\nimport { useState, useMemo, useEffect, useRef } from \"react\";\nimport { z } from \"zod\";\nimport type { FieldPath, FieldValues, UseFormReturn } from \"react-hook-form\";\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, useTranslate } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport type { SerializedContentType, RelationConfig } from \"../../../types\";\nimport { slugify } from \"../../../utils\";\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\t/**\n\t * Server-side field validation errors (`StackError.errors`), applied to\n\t * the matching form fields for inline display.\n\t */\n\tfieldErrors?: Record;\n\t/** Non-field submit error to display above the form */\n\terrorMessage?: string;\n\t/** External submit-in-flight state (e.g. from a resource `useForm`) */\n\tisSubmitting?: boolean;\n}\n\n/**\n * Applies server-side field validation errors onto react-hook-form field\n * state, and clears previously applied server errors that are no longer\n * present. The form instance arrives asynchronously (captured from\n * SteppedAutoForm's `onValuesChange`) and is `null` for multi-step forms.\n */\nfunction useServerFieldErrors(\n\tform: UseFormReturn | null,\n\tfieldErrors: Record,\n) {\n\tconst appliedFieldsRef = useRef([]);\n\n\tuseEffect(() => {\n\t\tif (!form) return;\n\n\t\t// Clear stale server errors from fields that are no longer failing\n\t\tfor (const field of appliedFieldsRef.current) {\n\t\t\tif (field in fieldErrors) continue;\n\t\t\tconst { error } = form.getFieldState(field as FieldPath);\n\t\t\tif (error?.type === \"server\") {\n\t\t\t\tform.clearErrors(field as FieldPath);\n\t\t\t}\n\t\t}\n\t\tappliedFieldsRef.current = Object.keys(fieldErrors);\n\n\t\tfor (const [field, message] of Object.entries(fieldErrors)) {\n\t\t\tform.setError(field as FieldPath, {\n\t\t\t\ttype: \"server\",\n\t\t\t\tmessage: Array.isArray(message) ? message.join(\", \") : message,\n\t\t\t});\n\t\t}\n\t}, [fieldErrors, form]);\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\tfieldErrors,\n\terrorMessage,\n\tisSubmitting: isSubmittingProp,\n}: ContentFormProps) {\n\tconst t = useTranslate();\n\tconst {\n\t\tlocalization,\n\t\tuploadImage,\n\t\timagePicker,\n\t\timageInputField,\n\t\tfieldComponents,\n\t} = usePluginOverrides(\"cms\");\n\n\tconst [slug, setSlug] = useState(initialSlug);\n\tconst [slugManuallyEdited, setSlugManuallyEdited] = useState(isEditing);\n\tconst [isSubmittingLocal, setIsSubmittingLocal] = useState(false);\n\tconst [formData, setFormData] =\n\t\tuseState>(initialData);\n\tconst [slugError, setSlugError] = useState(null);\n\tconst [submitError, setSubmitError] = useState(null);\n\n\tconst isSubmitting = isSubmittingProp || isSubmittingLocal;\n\n\t// Single-step forms pass their react-hook-form instance through\n\t// onValuesChange; multi-step forms pass undefined (no single instance).\n\tconst [formInstance, setFormInstance] = useState\n\t> | null>(null);\n\n\tconst serverFieldErrors = useMemo(() => fieldErrors ?? {}, [fieldErrors]);\n\tuseServerFieldErrors(formInstance, serverFieldErrors);\n\tconst hasFieldErrors = Object.keys(serverFieldErrors).length > 0;\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 = (\n\t\tvalues: Record,\n\t\tform?: UseFormReturn>,\n\t) => {\n\t\tif (form) {\n\t\t\tsetFormInstance((current) => (current === form ? current : form));\n\t\t}\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(\n\t\t\t\tlocalization?.CMS_EDITOR_SLUG_REQUIRED ??\n\t\t\t\t\tt(\"cms.editor.slugRequired\", \"Slug is required\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tsetIsSubmittingLocal(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\n\t\t\t\t\t? error.message\n\t\t\t\t\t: (localization?.CMS_TOAST_ERROR ??\n\t\t\t\t\t\tt(\"cms.toasts.error\", \"An error occurred. Please try again.\"));\n\t\t\tsetSubmitError(message);\n\t\t} finally {\n\t\t\tsetIsSubmittingLocal(false);\n\t\t}\n\t};\n\n\t// Non-field error from the parent (resource form), or a local submit\n\t// failure. Field errors display inline instead — unless the form\n\t// instance is unavailable (multi-step), where they land in the banner.\n\tconst bannerMessage =\n\t\terrorMessage ??\n\t\tsubmitError ??\n\t\t(hasFieldErrors && !formInstance\n\t\t\t? Object.entries(serverFieldErrors)\n\t\t\t\t\t.map(\n\t\t\t\t\t\t([field, message]) =>\n\t\t\t\t\t\t\t`${field}: ${Array.isArray(message) ? message.join(\", \") : message}`,\n\t\t\t\t\t)\n\t\t\t\t\t.join(\" · \")\n\t\t\t: undefined);\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\tt(\"cms.editor.slugManual\", \"Manually set\"))\n\t\t\t\t\t\t\t\t: (localization?.CMS_EDITOR_SLUG_AUTO ??\n\t\t\t\t\t\t\t\t\tt(\"cms.editor.slugAuto\", \"Auto-generated from first field\"))}\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? (\n\t\t\t\t\t\t\t\t\tlocalization?.CMS_EDITOR_SLUG_PLACEHOLDER_AUTO ??\n\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\"cms.editor.slugPlaceholderAuto\",\n\t\t\t\t\t\t\t\t\t\t\"Auto-generated from {field}\",\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t).replace(\"{field}\", slugSourceField)\n\t\t\t\t\t\t\t: (localization?.CMS_EDITOR_SLUG_PLACEHOLDER ??\n\t\t\t\t\t\t\t\tt(\"cms.editor.slugPlaceholder\", \"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\t\tt(\n\t\t\t\t\t\t\t\"cms.common.slugDescription\",\n\t\t\t\t\t\t\t\"URL-friendly identifier for this item\",\n\t\t\t\t\t\t)}\n\t\t\t\t

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

{bannerMessage}

\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\tt(\"cms.common.saving\", \"Saving...\"))\n\t\t\t\t\t\t: (localization?.CMS_BUTTON_SAVE ?? t(\"cms.common.save\", \"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\t\tt(\"cms.common.cancel\", \"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" }, { "path": "btst/cms/client/components/forms/file-upload.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport {\n\tuseState,\n\tuseCallback,\n\tuseEffect,\n\ttype ChangeEvent,\n\ttype ComponentType,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tFormControl,\n\tFormItem,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Trash2, Loader2 } from \"lucide-react\";\nimport AutoFormLabel from \"@/components/ui/auto-form/common/label\";\nimport AutoFormTooltip from \"@/components/ui/auto-form/common/tooltip\";\n\n/**\n * Props for the CMSFileUpload component\n */\nexport interface CMSFileUploadProps extends AutoFormInputComponentProps {\n\t/**\n\t * Function to upload an image file and return the URL.\n\t * This is required - consumers must provide an upload implementation.\n\t */\n\tuploadImage: (file: File) => Promise;\n\t/**\n\t * Optional custom component for the image field.\n\t * When provided, it replaces the default file-upload input entirely.\n\t */\n\timageInputField?: ComponentType<{\n\t\tvalue: string;\n\t\tonChange: (value: string) => void;\n\t\tisRequired?: boolean;\n\t}>;\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered as a \"Browse media\" option.\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n}\n\n/**\n * Default file upload component for CMS image fields.\n *\n * This component:\n * - Accepts image files via file input\n * - Uses the required uploadImage prop to upload and get a URL\n * - Shows a preview of the uploaded image\n * - Allows removing the uploaded image\n *\n * You can use this component directly in your fieldComponents override,\n * or create your own custom component using this as a reference.\n *\n * @example\n * ```tsx\n * // Use the default component with your upload function\n * fieldComponents: {\n * file: (props) => (\n * \n * ),\n * }\n * ```\n */\nexport function CMSFileUpload({\n\tlabel,\n\tisRequired,\n\tfieldConfigItem,\n\tfieldProps,\n\tfield,\n\tuploadImage,\n\timageInputField: ImageInputField,\n\timagePicker: ImagePickerTrigger,\n}: CMSFileUploadProps) {\n\t// Exclude showLabel and value from props spread\n\t// File inputs cannot have their value set programmatically (browser security)\n\tconst {\n\t\tshowLabel: _showLabel,\n\t\tvalue: _value,\n\t\t...safeFieldProps\n\t} = fieldProps;\n\tconst showLabel = _showLabel === undefined ? true : _showLabel;\n\n\t// All hooks must be called unconditionally before any early return.\n\tconst [isUploading, setIsUploading] = useState(false);\n\tconst [previewUrl, setPreviewUrl] = useState(\n\t\tfield.value || null,\n\t);\n\n\tuseEffect(() => {\n\t\tconst normalizedValue = field.value || null;\n\t\tif (normalizedValue !== previewUrl) {\n\t\t\tsetPreviewUrl(normalizedValue);\n\t\t}\n\t}, [field.value, previewUrl]);\n\n\tconst handleFileChange = useCallback(\n\t\tasync (e: ChangeEvent) => {\n\t\t\tconst file = e.target.files?.[0];\n\t\t\tif (!file) return;\n\n\t\t\tif (!file.type.startsWith(\"image/\")) {\n\t\t\t\ttoast.error(\"Please select an image file\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tsetIsUploading(true);\n\t\t\ttry {\n\t\t\t\tconst url = await uploadImage(file);\n\t\t\t\tsetPreviewUrl(url);\n\t\t\t\tfield.onChange(url);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"Image upload failed:\", error);\n\t\t\t\ttoast.error(\"Failed to upload image\");\n\t\t\t} finally {\n\t\t\t\tsetIsUploading(false);\n\t\t\t}\n\t\t},\n\t\t[field, uploadImage],\n\t);\n\n\tconst handleRemove = useCallback(() => {\n\t\tsetPreviewUrl(null);\n\t\tfield.onChange(\"\");\n\t}, [field]);\n\n\t// When a custom imageInputField component is provided via overrides, delegate to it.\n\tif (ImageInputField) {\n\t\treturn (\n\t\t\t\n\t\t\t\t{showLabel && (\n\t\t\t\t\t\n\t\t\t\t)}\n\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\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t{showLabel && (\n\t\t\t\t\n\t\t\t)}\n\t\t\t{!previewUrl && (\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\t\n\t\t\t\t\t\t\t{isUploading && (\n\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\t{ImagePickerTrigger && (\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\tsetPreviewUrl(url);\n\t\t\t\t\t\t\t\t\t\tfield.onChange(url);\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
\n\t\t\t)}\n\t\t\t{previewUrl && (\n\t\t\t\t
\n\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\tRemove\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport {\n\tuseState,\n\tuseCallback,\n\tuseEffect,\n\ttype ChangeEvent,\n\ttype ComponentType,\n} from \"react\";\nimport {\n\tuseNotify,\n\tusePluginOverrides,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tFormControl,\n\tFormItem,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Trash2, Loader2 } from \"lucide-react\";\nimport AutoFormLabel from \"@/components/ui/auto-form/common/label\";\nimport AutoFormTooltip from \"@/components/ui/auto-form/common/tooltip\";\n\n/**\n * Props for the CMSFileUpload component\n */\nexport interface CMSFileUploadProps extends AutoFormInputComponentProps {\n\t/**\n\t * Function to upload an image file and return the URL.\n\t * This is required - consumers must provide an upload implementation.\n\t */\n\tuploadImage: (file: File) => Promise;\n\t/**\n\t * Optional custom component for the image field.\n\t * When provided, it replaces the default file-upload input entirely.\n\t */\n\timageInputField?: ComponentType<{\n\t\tvalue: string;\n\t\tonChange: (value: string) => void;\n\t\tisRequired?: boolean;\n\t}>;\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered as a \"Browse media\" option.\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n}\n\n/**\n * Default file upload component for CMS image fields.\n *\n * This component:\n * - Accepts image files via file input\n * - Uses the required uploadImage prop to upload and get a URL\n * - Shows a preview of the uploaded image\n * - Allows removing the uploaded image\n *\n * You can use this component directly in your fieldComponents override,\n * or create your own custom component using this as a reference.\n *\n * @example\n * ```tsx\n * // Use the default component with your upload function\n * fieldComponents: {\n * file: (props) => (\n * \n * ),\n * }\n * ```\n */\nexport function CMSFileUpload({\n\tlabel,\n\tisRequired,\n\tfieldConfigItem,\n\tfieldProps,\n\tfield,\n\tuploadImage,\n\timageInputField: ImageInputField,\n\timagePicker: ImagePickerTrigger,\n}: CMSFileUploadProps) {\n\t// Exclude showLabel and value from props spread\n\t// File inputs cannot have their value set programmatically (browser security)\n\tconst {\n\t\tshowLabel: _showLabel,\n\t\tvalue: _value,\n\t\t...safeFieldProps\n\t} = fieldProps;\n\tconst showLabel = _showLabel === undefined ? true : _showLabel;\n\n\t// All hooks must be called unconditionally before any early return.\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst { localization } = usePluginOverrides(\"cms\");\n\tconst [isUploading, setIsUploading] = useState(false);\n\tconst [previewUrl, setPreviewUrl] = useState(\n\t\tfield.value || null,\n\t);\n\n\tuseEffect(() => {\n\t\tconst normalizedValue = field.value || null;\n\t\tif (normalizedValue !== previewUrl) {\n\t\t\tsetPreviewUrl(normalizedValue);\n\t\t}\n\t}, [field.value, previewUrl]);\n\n\tconst handleFileChange = useCallback(\n\t\tasync (e: ChangeEvent) => {\n\t\t\tconst file = e.target.files?.[0];\n\t\t\tif (!file) return;\n\n\t\t\tif (!file.type.startsWith(\"image/\")) {\n\t\t\t\tnotify.error(\n\t\t\t\t\tlocalization?.CMS_EDITOR_FILE_INVALID_TYPE ??\n\t\t\t\t\t\tt(\"cms.editor.fileInvalidType\", \"Please select an image file\"),\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tsetIsUploading(true);\n\t\t\ttry {\n\t\t\t\tconst url = await uploadImage(file);\n\t\t\t\tsetPreviewUrl(url);\n\t\t\t\tfield.onChange(url);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"Image upload failed:\", error);\n\t\t\t\tnotify.error(\n\t\t\t\t\tlocalization?.CMS_EDITOR_FILE_UPLOAD_FAILED ??\n\t\t\t\t\t\tt(\"cms.editor.fileUploadFailed\", \"Failed to upload image\"),\n\t\t\t\t);\n\t\t\t} finally {\n\t\t\t\tsetIsUploading(false);\n\t\t\t}\n\t\t},\n\t\t[field, uploadImage, notify, localization, t],\n\t);\n\n\tconst handleRemove = useCallback(() => {\n\t\tsetPreviewUrl(null);\n\t\tfield.onChange(\"\");\n\t}, [field]);\n\n\t// When a custom imageInputField component is provided via overrides, delegate to it.\n\tif (ImageInputField) {\n\t\treturn (\n\t\t\t\n\t\t\t\t{showLabel && (\n\t\t\t\t\t\n\t\t\t\t)}\n\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\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t{showLabel && (\n\t\t\t\t\n\t\t\t)}\n\t\t\t{!previewUrl && (\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\t\n\t\t\t\t\t\t\t{isUploading && (\n\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\t{ImagePickerTrigger && (\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\tsetPreviewUrl(url);\n\t\t\t\t\t\t\t\t\t\tfield.onChange(url);\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
\n\t\t\t)}\n\t\t\t{previewUrl && (\n\t\t\t\t
\n\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\tRemove\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/cms/client/components/forms/file-upload.tsx" }, { "path": "btst/cms/client/components/forms/relation-field.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState, useCallback, useMemo } from \"react\";\nimport { useQueries } from \"@tanstack/react-query\";\nimport { createApiClient } from \"@btst/stack/plugins/client\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { useContent, useCreateContent } from \"@btst/stack/plugins/cms/client/hooks\";\nimport type { CMSApiRouter } from \"@btst/stack/plugins/cms/api\";\nimport type { SerializedContentItemWithType } from \"../../../types\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport { createCMSQueryKeys } from \"@btst/stack/plugins/cms/api\";\nimport MultipleSelector from \"@/components/ui/multi-select\";\nimport type { Option } from \"@/components/ui/multi-select\";\nimport { Button } from \"@/components/ui/button\";\nimport { Plus, X } from \"lucide-react\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogHeader,\n\tDialogTitle,\n\tDialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\nimport type { RelationConfig } from \"../../../types\";\n\n/** Match cms-hooks SHARED_QUERY_CONFIG for detail fetches (deduped labels). */\nconst RELATION_DETAIL_QUERY_OPTS = {\n\tretry: false,\n\trefetchOnWindowFocus: false,\n\trefetchOnMount: false,\n\trefetchOnReconnect: false,\n\tstaleTime: 1000 * 60 * 5,\n\tgcTime: 1000 * 60 * 10,\n} as const;\n\ninterface RelationFieldProps extends AutoFormInputComponentProps {\n\trelation: RelationConfig;\n}\n\n/**\n * A form field component for handling CMS content relationships.\n * Supports selecting existing items and optionally creating new items inline.\n *\n * Handles two value formats:\n * - belongsTo: single object { id: string } or undefined\n * - hasMany/manyToMany: array of { id: string }\n */\nexport function RelationField({\n\tfield,\n\tfieldConfigItem,\n\tlabel,\n\tisRequired,\n\trelation,\n}: RelationFieldProps) {\n\tconst [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);\n\tconst [newItemName, setNewItemName] = useState(\"\");\n\tconst [newItemDescription, setNewItemDescription] = useState(\"\");\n\tconst [createError, setCreateError] = useState(null);\n\n\tconst { apiBaseURL, apiBasePath, headers } =\n\t\tusePluginOverrides(\"cms\");\n\n\tconst listClient = useMemo(\n\t\t() =>\n\t\t\tcreateApiClient({\n\t\t\t\tbaseURL: apiBaseURL,\n\t\t\t\tbasePath: apiBasePath,\n\t\t\t}),\n\t\t[apiBaseURL, apiBasePath],\n\t);\n\n\tconst cmsQueries = useMemo(\n\t\t() => createCMSQueryKeys(listClient, headers),\n\t\t[listClient, headers],\n\t);\n\n\t// For belongsTo (single relation), we only allow one selection\n\tconst isSingleSelect = relation.type === \"belongsTo\";\n\n\t// Normalize the field value to an array for internal use\n\t// belongsTo stores as single object { id }, hasMany/manyToMany store as array\n\tconst normalizedValue = useMemo((): Array<{ id: string }> => {\n\t\tif (!field.value) return [];\n\n\t\tif (isSingleSelect) {\n\t\t\t// belongsTo: value is { id: string } or undefined\n\t\t\tconst singleValue = field.value as { id?: string } | undefined;\n\t\t\tif (singleValue && singleValue.id) {\n\t\t\t\treturn [{ id: singleValue.id }];\n\t\t\t}\n\t\t\treturn [];\n\t\t}\n\n\t\t// hasMany/manyToMany: value is array\n\t\treturn (field.value as Array<{ id: string }>) || [];\n\t}, [field.value, isSingleSelect]);\n\n\t// Fetch available items from the target content type (first page only)\n\tconst { items: availableItems, isLoading } = useContent(relation.targetType, {\n\t\tlimit: 500,\n\t});\n\n\tconst missingDetailIds = useMemo(() => {\n\t\tconst loadedIds = new Set(availableItems.map((i) => i.id));\n\t\treturn normalizedValue\n\t\t\t.map((v) => v.id)\n\t\t\t.filter((id) => id.length > 0 && !loadedIds.has(id));\n\t}, [availableItems, normalizedValue]);\n\n\tconst hydrationResult = useQueries({\n\t\tqueries: missingDetailIds.map((id) => ({\n\t\t\t...cmsQueries.cmsContent.detail(relation.targetType, id),\n\t\t\t...RELATION_DETAIL_QUERY_OPTS,\n\t\t\tenabled: Boolean(relation.targetType && id),\n\t\t})),\n\t\tcombine: (results) => ({\n\t\t\tdata: results.map(\n\t\t\t\t(r) => r.data as SerializedContentItemWithType | null | undefined,\n\t\t\t),\n\t\t\tisHydrating: results.some((r) => r.isFetching),\n\t\t}),\n\t});\n\n\tconst isHydratingLabels = hydrationResult.isHydrating;\n\n\tconst itemById = useMemo(() => {\n\t\tconst m = new Map();\n\t\tfor (const it of availableItems) {\n\t\t\tm.set(it.id, it as SerializedContentItemWithType);\n\t\t}\n\t\tfor (let i = 0; i < missingDetailIds.length; i++) {\n\t\t\tconst row = hydrationResult.data[i];\n\t\t\tif (row?.id) {\n\t\t\t\tm.set(row.id, row);\n\t\t\t}\n\t\t}\n\t\treturn m;\n\t}, [availableItems, missingDetailIds, hydrationResult.data]);\n\n\t// Convert normalized value to Option[] for MultipleSelector\n\tconst selectedOptions: Option[] = normalizedValue.map((v) => {\n\t\tconst item = itemById.get(v.id);\n\t\tif (item) {\n\t\t\tconst displayValue =\n\t\t\t\t(item.parsedData as Record)?.[relation.displayField] ||\n\t\t\t\titem.slug;\n\t\t\treturn {\n\t\t\t\tvalue: item.id,\n\t\t\t\tlabel: String(displayValue),\n\t\t\t};\n\t\t}\n\t\treturn { value: v.id, label: `ID: ${v.id.slice(0, 8)}...` };\n\t});\n\n\t// Listed options + any selected partners loaded by id (not on first list page)\n\tconst options: Option[] = useMemo(() => {\n\t\tconst merged: SerializedContentItemWithType[] = [\n\t\t\t...(availableItems as SerializedContentItemWithType[]),\n\t\t];\n\t\tconst seen = new Set(merged.map((x) => x.id));\n\t\tfor (let i = 0; i < missingDetailIds.length; i++) {\n\t\t\tconst row = hydrationResult.data[i];\n\t\t\tif (row?.id && !seen.has(row.id)) {\n\t\t\t\tmerged.push(row);\n\t\t\t\tseen.add(row.id);\n\t\t\t}\n\t\t}\n\t\treturn merged.map((item) => {\n\t\t\tconst displayValue =\n\t\t\t\t(item.parsedData as Record)?.[relation.displayField] ||\n\t\t\t\titem.slug;\n\t\t\treturn {\n\t\t\t\tvalue: item.id,\n\t\t\t\tlabel: String(displayValue),\n\t\t\t};\n\t\t});\n\t}, [\n\t\tavailableItems,\n\t\thydrationResult.data,\n\t\tmissingDetailIds,\n\t\trelation.displayField,\n\t]);\n\n\t// Mutation for creating new items\n\tconst createMutation = useCreateContent(relation.targetType);\n\n\t// Handle selection change - convert back to appropriate format\n\tconst handleChange = useCallback(\n\t\t(newOptions: Option[]) => {\n\t\t\tif (isSingleSelect) {\n\t\t\t\t// belongsTo: store as single object or undefined\n\t\t\t\tif (newOptions.length > 0) {\n\t\t\t\t\tfield.onChange({ id: newOptions[0]!.value });\n\t\t\t\t} else {\n\t\t\t\t\tfield.onChange(undefined);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// hasMany/manyToMany: store as array\n\t\t\t\tconst newValue = newOptions.map((opt) => ({ id: opt.value }));\n\t\t\t\tfield.onChange(newValue);\n\t\t\t}\n\t\t},\n\t\t[field, isSingleSelect],\n\t);\n\n\t// Handle creating a new item\n\tconst handleCreateItem = async () => {\n\t\tif (!newItemName.trim()) return;\n\n\t\tsetCreateError(null);\n\t\ttry {\n\t\t\tconst result = await createMutation.mutateAsync({\n\t\t\t\tslug: newItemName.toLowerCase().replace(/\\s+/g, \"-\"),\n\t\t\t\tdata: {\n\t\t\t\t\t[relation.displayField]: newItemName,\n\t\t\t\t\tdescription: newItemDescription || undefined,\n\t\t\t\t} as Record,\n\t\t\t});\n\n\t\t\t// Add the new item to the selection\n\t\t\tif (isSingleSelect) {\n\t\t\t\t// belongsTo: replace with new item\n\t\t\t\tfield.onChange({ id: result.id });\n\t\t\t} else {\n\t\t\t\t// hasMany/manyToMany: append to array\n\t\t\t\tconst newValue = [...normalizedValue, { id: result.id }];\n\t\t\t\tfield.onChange(newValue);\n\t\t\t}\n\n\t\t\t// Reset and close dialog\n\t\t\tsetNewItemName(\"\");\n\t\t\tsetNewItemDescription(\"\");\n\t\t\tsetIsCreateDialogOpen(false);\n\t\t} catch (error) {\n\t\t\tconst message =\n\t\t\t\terror instanceof Error\n\t\t\t\t\t? error.message\n\t\t\t\t\t: \"Failed to create item. Please try again.\";\n\t\t\tsetCreateError(message);\n\t\t}\n\t};\n\n\t// Handle removing an item\n\tconst handleRemove = useCallback(\n\t\t(idToRemove: string) => {\n\t\t\tif (isSingleSelect) {\n\t\t\t\t// belongsTo: clear the value\n\t\t\t\tfield.onChange(undefined);\n\t\t\t} else {\n\t\t\t\t// hasMany/manyToMany: filter out the item\n\t\t\t\tconst newValue = normalizedValue.filter((v) => v.id !== idToRemove);\n\t\t\t\tfield.onChange(newValue);\n\t\t\t}\n\t\t},\n\t\t[normalizedValue, field, isSingleSelect],\n\t);\n\n\treturn (\n\t\t
\n\t\t\t\n\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\t\tNo {relation.targetType} items found\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t}\n\t\t\t\t\t\tmaxSelected={isSingleSelect ? 1 : undefined}\n\t\t\t\t\t\tclassName=\"min-h-10\"\n\t\t\t\t\t/>\n\t\t\t\t
\n\n\t\t\t\t{/* Create new item button/dialog */}\n\t\t\t\t{relation.creatable && (\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tCreate New {relation.targetType}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{createError && (\n\t\t\t\t\t\t\t\t\t

{createError}

\n\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\t\t\n\t\t\t\t\t\t\t\t\t setNewItemName(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tplaceholder={`Enter ${relation.displayField}...`}\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\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t setNewItemDescription(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"Enter description...\"\n\t\t\t\t\t\t\t\t\t\trows={3}\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\t
\n\t\t\t\t\t\t\t\t\t setIsCreateDialogOpen(false)}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tCancel\n\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\t\t{createMutation.isPending ? \"Creating...\" : \"Create\"}\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)}\n\t\t\t
\n\n\t\t\t{/* Show selected items as removable badges */}\n\t\t\t{selectedOptions.length > 0 && (\n\t\t\t\t
\n\t\t\t\t\t{selectedOptions.map((opt) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{opt.label}\n\t\t\t\t\t\t\t handleRemove(opt.value)}\n\t\t\t\t\t\t\t\tclassName=\"hover:text-destructive\"\n\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
\n\t\t\t)}\n\n\t\t\t{fieldConfigItem?.description && (\n\t\t\t\t

\n\t\t\t\t\t{fieldConfigItem.description}\n\t\t\t\t

\n\t\t\t)}\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState, useCallback, useMemo } from \"react\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport { useCreateContent, useContentOptions } from \"@btst/stack/plugins/cms/client/hooks\";\nimport type { SerializedContentItemWithType } from \"../../../types\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport MultipleSelector from \"@/components/ui/multi-select\";\nimport type { Option } from \"@/components/ui/multi-select\";\nimport { Button } from \"@/components/ui/button\";\nimport { Plus, X } from \"lucide-react\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogHeader,\n\tDialogTitle,\n\tDialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\nimport type { RelationConfig } from \"../../../types\";\n\ninterface RelationFieldProps extends AutoFormInputComponentProps {\n\trelation: RelationConfig;\n}\n\n/**\n * A form field component for handling CMS content relationships.\n * Supports selecting existing items and optionally creating new items inline.\n *\n * Options come from the resource `useSelect` hook: debounced server-side\n * search over the target type, with selected values not present in the\n * current results preloaded by id (for labels).\n *\n * Handles two value formats:\n * - belongsTo: single object { id: string } or undefined\n * - hasMany/manyToMany: array of { id: string }\n */\nexport function RelationField({\n\tfield,\n\tfieldConfigItem,\n\tlabel,\n\tisRequired,\n\trelation,\n}: RelationFieldProps) {\n\tconst t = useTranslate();\n\tconst [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);\n\tconst [newItemName, setNewItemName] = useState(\"\");\n\tconst [newItemDescription, setNewItemDescription] = useState(\"\");\n\tconst [createError, setCreateError] = useState(null);\n\n\tconst { localization } = usePluginOverrides(\"cms\");\n\n\t// For belongsTo (single relation), we only allow one selection\n\tconst isSingleSelect = relation.type === \"belongsTo\";\n\n\t// Normalize the field value to an array for internal use\n\t// belongsTo stores as single object { id }, hasMany/manyToMany store as array\n\tconst normalizedValue = useMemo((): Array<{ id: string }> => {\n\t\tif (!field.value) return [];\n\n\t\tif (isSingleSelect) {\n\t\t\t// belongsTo: value is { id: string } or undefined\n\t\t\tconst singleValue = field.value as { id?: string } | undefined;\n\t\t\tif (singleValue && singleValue.id) {\n\t\t\t\treturn [{ id: singleValue.id }];\n\t\t\t}\n\t\t\treturn [];\n\t\t}\n\n\t\t// hasMany/manyToMany: value is array\n\t\treturn (field.value as Array<{ id: string }>) || [];\n\t}, [field.value, isSingleSelect]);\n\n\tconst { displayField } = relation;\n\tconst getOptionLabel = useCallback(\n\t\t(item: SerializedContentItemWithType) =>\n\t\t\tString(\n\t\t\t\t(item.parsedData as Record)?.[displayField] ||\n\t\t\t\t\titem.slug,\n\t\t\t),\n\t\t[displayField],\n\t);\n\n\tconst select = useContentOptions({\n\t\ttargetType: relation.targetType,\n\t\tvalue: normalizedValue.map((v) => v.id),\n\t\tgetOptionLabel,\n\t});\n\n\tconst options: Option[] = select.options.map((option) => ({\n\t\tvalue: option.value,\n\t\tlabel: option.label,\n\t}));\n\tconst selectedOptions: Option[] = select.selectedOptions.map((option) => ({\n\t\tvalue: option.value,\n\t\tlabel: option.label,\n\t}));\n\n\t// Mutation for creating new items\n\tconst createMutation = useCreateContent(relation.targetType);\n\n\t// Handle selection change - convert back to appropriate format\n\tconst handleChange = useCallback(\n\t\t(newOptions: Option[]) => {\n\t\t\tif (isSingleSelect) {\n\t\t\t\t// belongsTo: store as single object or undefined\n\t\t\t\tif (newOptions.length > 0) {\n\t\t\t\t\tfield.onChange({ id: newOptions[0]!.value });\n\t\t\t\t} else {\n\t\t\t\t\tfield.onChange(undefined);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// hasMany/manyToMany: store as array\n\t\t\t\tconst newValue = newOptions.map((opt) => ({ id: opt.value }));\n\t\t\t\tfield.onChange(newValue);\n\t\t\t}\n\t\t},\n\t\t[field, isSingleSelect],\n\t);\n\n\t// Handle creating a new item\n\tconst handleCreateItem = async () => {\n\t\tif (!newItemName.trim()) return;\n\n\t\tsetCreateError(null);\n\t\ttry {\n\t\t\tconst result = await createMutation.mutateAsync({\n\t\t\t\tslug: newItemName.toLowerCase().replace(/\\s+/g, \"-\"),\n\t\t\t\tdata: {\n\t\t\t\t\t[relation.displayField]: newItemName,\n\t\t\t\t\tdescription: newItemDescription || undefined,\n\t\t\t\t} as Record,\n\t\t\t});\n\n\t\t\t// Add the new item to the selection\n\t\t\tif (isSingleSelect) {\n\t\t\t\t// belongsTo: replace with new item\n\t\t\t\tfield.onChange({ id: result.id });\n\t\t\t} else {\n\t\t\t\t// hasMany/manyToMany: append to array\n\t\t\t\tconst newValue = [...normalizedValue, { id: result.id }];\n\t\t\t\tfield.onChange(newValue);\n\t\t\t}\n\n\t\t\t// Reset and close dialog\n\t\t\tsetNewItemName(\"\");\n\t\t\tsetNewItemDescription(\"\");\n\t\t\tsetIsCreateDialogOpen(false);\n\t\t} catch (error) {\n\t\t\tconst message =\n\t\t\t\terror instanceof Error\n\t\t\t\t\t? error.message\n\t\t\t\t\t: (localization?.CMS_RELATION_CREATE_ERROR ??\n\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\"cms.relations.createError\",\n\t\t\t\t\t\t\t\"Failed to create item. Please try again.\",\n\t\t\t\t\t\t));\n\t\t\tsetCreateError(message);\n\t\t}\n\t};\n\n\t// Handle removing an item\n\tconst handleRemove = useCallback(\n\t\t(idToRemove: string) => {\n\t\t\tif (isSingleSelect) {\n\t\t\t\t// belongsTo: clear the value\n\t\t\t\tfield.onChange(undefined);\n\t\t\t} else {\n\t\t\t\t// hasMany/manyToMany: filter out the item\n\t\t\t\tconst newValue = normalizedValue.filter((v) => v.id !== idToRemove);\n\t\t\t\tfield.onChange(newValue);\n\t\t\t}\n\t\t},\n\t\t[normalizedValue, field, isSingleSelect],\n\t);\n\n\tconst displayFieldLabel =\n\t\trelation.displayField.charAt(0).toUpperCase() +\n\t\trelation.displayField.slice(1);\n\n\treturn (\n\t\t
\n\t\t\t\n\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\t\t{(\n\t\t\t\t\t\t\t\t\tlocalization?.CMS_RELATION_EMPTY ??\n\t\t\t\t\t\t\t\t\tt(\"cms.relations.empty\", \"No {targetType} items found\")\n\t\t\t\t\t\t\t\t).replace(\"{targetType}\", relation.targetType)}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t}\n\t\t\t\t\t\tmaxSelected={isSingleSelect ? 1 : undefined}\n\t\t\t\t\t\tclassName=\"min-h-10\"\n\t\t\t\t\t/>\n\t\t\t\t
\n\n\t\t\t\t{/* Create new item button/dialog */}\n\t\t\t\t{relation.creatable && (\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\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\t\tlocalization?.CMS_RELATION_CREATE_TITLE ??\n\t\t\t\t\t\t\t\t\t\tt(\"cms.relations.createTitle\", \"Create New {targetType}\")\n\t\t\t\t\t\t\t\t\t).replace(\"{targetType}\", relation.targetType)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{createError && (\n\t\t\t\t\t\t\t\t\t

{createError}

\n\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\t\t\n\t\t\t\t\t\t\t\t\t setNewItemName(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tplaceholder={(\n\t\t\t\t\t\t\t\t\t\t\tlocalization?.CMS_RELATION_NAME_PLACEHOLDER ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"cms.relations.namePlaceholder\", \"Enter {field}...\")\n\t\t\t\t\t\t\t\t\t\t).replace(\"{field}\", relation.displayField)}\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\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t setNewItemDescription(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\t\t\t\t\tlocalization?.CMS_RELATION_DESCRIPTION_PLACEHOLDER ??\n\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\"cms.relations.descriptionPlaceholder\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"Enter description...\",\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\trows={3}\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\t
\n\t\t\t\t\t\t\t\t\t setIsCreateDialogOpen(false)}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{localization?.CMS_BUTTON_CANCEL ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"cms.common.cancel\", \"Cancel\")}\n\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\t\t{createMutation.isPending\n\t\t\t\t\t\t\t\t\t\t\t? (localization?.CMS_RELATION_CREATING ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"cms.relations.creating\", \"Creating...\"))\n\t\t\t\t\t\t\t\t\t\t\t: (localization?.CMS_RELATION_CREATE_BUTTON ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"cms.relations.createButton\", \"Create\"))}\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)}\n\t\t\t
\n\n\t\t\t{/* Show selected items as removable badges */}\n\t\t\t{selectedOptions.length > 0 && (\n\t\t\t\t
\n\t\t\t\t\t{selectedOptions.map((opt) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{opt.label}\n\t\t\t\t\t\t\t handleRemove(opt.value)}\n\t\t\t\t\t\t\t\tclassName=\"hover:text-destructive\"\n\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
\n\t\t\t)}\n\n\t\t\t{fieldConfigItem?.description && (\n\t\t\t\t

\n\t\t\t\t\t{fieldConfigItem.description}\n\t\t\t\t

\n\t\t\t)}\n\t\t\n\t);\n}\n", "target": "src/components/btst/cms/client/components/forms/relation-field.tsx" }, { "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\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", + "content": "\"use client\";\n\nimport { useState } from \"react\";\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 {\n\tCanAccess,\n\tusePluginOverrides,\n\tuseBasePath,\n\tuseTranslate,\n\ttype TranslateFn,\n} from \"@btst/stack/context\";\nimport {\n\tuseDeleteContent,\n\tuseInverseRelations,\n\tuseInverseRelationItems,\n} from \"@btst/stack/plugins/cms/client/hooks\";\nimport type { CMSPluginOverrides } from \"../overrides\";\nimport type {\n\tInverseRelation,\n\tSerializedContentItemWithType,\n} 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 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 t = useTranslate();\n\tconst { navigate, Link, localization } =\n\t\tusePluginOverrides(\"cms\");\n\tconst basePath = useBasePath();\n\n\t// Fetch inverse relations metadata\n\tconst { inverseRelations, isLoading } = useInverseRelations(\n\t\tcontentTypeSlug,\n\t\titemId,\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\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

\n\t\t\t\t{localization?.CMS_RELATED_ITEMS_TITLE ??\n\t\t\t\t\tt(\"cms.relations.relatedItemsTitle\", \"Related Items\")}\n\t\t\t

\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 | Promise;\n\tLink?: React.ComponentType<{\n\t\thref?: string;\n\t\tchildren?: React.ReactNode;\n\t\tclassName?: string;\n\t}>;\n\tlocalization: CMSPluginOverrides[\"localization\"];\n\tt: TranslateFn;\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\tlocalization,\n\tt,\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 {\n\t\titems,\n\t\ttotal: fetchedTotal,\n\t\trefetch,\n\t} = useInverseRelationItems(\n\t\t{\n\t\t\tcontentTypeSlug,\n\t\t\tsourceType: relation.sourceType,\n\t\t\titemId,\n\t\t\tfieldName: relation.fieldName,\n\t\t},\n\t\t{ enabled: isExpanded },\n\t);\n\n\tconst total = fetchedTotal || 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\tvoid refetch();\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: (localization?.CMS_RELATED_DELETE_ERROR ??\n\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\"cms.relations.relatedDeleteError\",\n\t\t\t\t\t\t\t\t\"Failed to delete item. Please try again.\",\n\t\t\t\t\t\t\t));\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\tvoid navigate(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\t{(\n\t\t\t\t\t\t\t\tlocalization?.CMS_RELATED_EMPTY ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"cms.relations.relatedEmpty\",\n\t\t\t\t\t\t\t\t\t\"No {sourceTypeName} items yet.\",\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t).replace(\n\t\t\t\t\t\t\t\t\"{sourceTypeName}\",\n\t\t\t\t\t\t\t\trelation.sourceTypeName.toLowerCase(),\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\t
    \n\t\t\t\t\t\t\t{items.map((item: SerializedContentItemWithType) => {\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\n\t\t\t\t\t\t\t\t\t\t\t setDeleteItemId(item.id)}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\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\t\t\n\t\t\t\t\t\t\t\t{(\n\t\t\t\t\t\t\t\t\tlocalization?.CMS_RELATED_ADD ??\n\t\t\t\t\t\t\t\t\tt(\"cms.relations.relatedAdd\", \"Add {sourceTypeName}\")\n\t\t\t\t\t\t\t\t).replace(\"{sourceTypeName}\", relation.sourceTypeName)}\n\t\t\t\t\t\t\t\t{fieldLabel ? ` (${fieldLabel})` : \"\"}\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{/* 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\t{(\n\t\t\t\t\t\t\t\tlocalization?.CMS_RELATED_DELETE_TITLE ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"cms.relations.relatedDeleteTitle\",\n\t\t\t\t\t\t\t\t\t\"Delete {sourceTypeName}?\",\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t).replace(\"{sourceTypeName}\", relation.sourceTypeName)}\n\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\t\tlocalization?.CMS_RELATED_DELETE_DESCRIPTION ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"cms.relations.relatedDeleteDescription\",\n\t\t\t\t\t\t\t\t\t\"This action cannot be undone. This will permanently delete this {sourceTypeName}.\",\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t).replace(\n\t\t\t\t\t\t\t\t\"{sourceTypeName}\",\n\t\t\t\t\t\t\t\trelation.sourceTypeName.toLowerCase(),\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{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\t\n\t\t\t\t\t\t\t{localization?.CMS_BUTTON_CANCEL ??\n\t\t\t\t\t\t\t\tt(\"cms.common.cancel\", \"Cancel\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_BUTTON_DELETE ??\n\t\t\t\t\t\t\t\tt(\"cms.common.delete\", \"Delete\")}\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" }, { @@ -97,25 +97,25 @@ { "path": "btst/cms/client/components/pages/404-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\n\nexport function NotFoundPage() {\n\tconst { navigate, Link } = usePluginOverrides(\"cms\");\n\tconst basePath = useBasePath();\n\n\tconst LinkComponent = Link || \"a\";\n\n\treturn (\n\t\t
\n\t\t\t

404

\n\t\t\t

\n\t\t\t\tPage not found\n\t\t\t

\n\t\t\t

\n\t\t\t\tThe page you're looking for doesn't exist or has been moved.\n\t\t\t

\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tusePluginOverrides,\n\tuseBasePath,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\n\nexport function NotFoundPage() {\n\tconst t = useTranslate();\n\tconst { Link, localization } = usePluginOverrides(\"cms\");\n\tconst basePath = useBasePath();\n\n\tconst LinkComponent = Link || \"a\";\n\n\treturn (\n\t\t
\n\t\t\t

404

\n\t\t\t

\n\t\t\t\t{localization?.CMS_404_TITLE ??\n\t\t\t\t\tt(\"cms.common.404Title\", \"Page not found\")}\n\t\t\t

\n\t\t\t

\n\t\t\t\t{localization?.CMS_404_DESCRIPTION ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"cms.common.404Description\",\n\t\t\t\t\t\t\"The page you're looking for doesn't exist or has been moved.\",\n\t\t\t\t\t)}\n\t\t\t

\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/cms/client/components/pages/404-page.tsx" }, { "path": "btst/cms/client/components/pages/content-editor-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState, useEffect } from \"react\";\nimport { ArrowLeft } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport {\n\tuseSuspenseContentTypes,\n\tuseContentItem,\n\tuseCreateContent,\n\tuseUpdateContent,\n} from \"@btst/stack/plugins/cms/client/hooks\";\nimport { ContentForm } from \"../forms/content-form\";\nimport { InverseRelationsPanel } from \"../inverse-relations-panel\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EditorSkeleton } from \"../loading/editor-skeleton\";\nimport { CMS_LOCALIZATION } from \"../../localization\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\n/**\n * Parse prefill query parameters from the URL.\n * Looks for query params with the format `prefill_=`\n * and returns a record of field names to values.\n *\n * Uses useState + useEffect pattern to work correctly with SSR/hydration.\n * During SSR, returns empty object. After hydration, parses URL params.\n * Also listens for popstate events to handle browser back/forward navigation.\n *\n * @example\n * URL: /cms/comment/new?prefill_resourceId=123&prefill_author=John\n * Returns: { resourceId: \"123\", author: \"John\" }\n */\nfunction usePrefillParams(): Record {\n\tconst [prefillData, setPrefillData] = useState>({});\n\n\tuseEffect(() => {\n\t\tif (typeof window === \"undefined\") {\n\t\t\treturn;\n\t\t}\n\n\t\tconst parseAndSetPrefillData = () => {\n\t\t\tconst params = new URLSearchParams(window.location.search);\n\t\t\tconst data: Record = {};\n\n\t\t\tfor (const [key, value] of params.entries()) {\n\t\t\t\tif (key.startsWith(\"prefill_\")) {\n\t\t\t\t\tconst fieldName = key.slice(\"prefill_\".length);\n\t\t\t\t\tif (fieldName) {\n\t\t\t\t\t\tdata[fieldName] = value;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Always update state to ensure stale data is cleared when navigating\n\t\t\t// to a URL without prefill params (e.g., via browser back/forward)\n\t\t\tsetPrefillData(data);\n\t\t};\n\n\t\t// Parse on mount\n\t\tparseAndSetPrefillData();\n\n\t\t// Listen for popstate events (browser back/forward navigation)\n\t\twindow.addEventListener(\"popstate\", parseAndSetPrefillData);\n\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\"popstate\", parseAndSetPrefillData);\n\t\t};\n\t}, []);\n\n\treturn prefillData;\n}\n\ninterface JsonSchemaProperty {\n\tfieldType?: string;\n\trelation?: {\n\t\ttype: \"belongsTo\" | \"hasMany\" | \"manyToMany\";\n\t\ttargetType: string;\n\t};\n}\n\n/**\n * Convert prefill params to the correct format for the form.\n * Relation fields need special handling:\n * - belongsTo: value should be { id: \"uuid\" }\n * - hasMany/manyToMany: value should be [{ id: \"uuid\" }]\n *\n * @param prefillParams - Raw prefill params from URL\n * @param jsonSchema - The content type's JSON schema\n * @returns Converted data suitable for initialData\n */\nfunction convertPrefillToFormData(\n\tprefillParams: Record,\n\tjsonSchema: Record,\n): Record {\n\tconst properties = jsonSchema.properties as\n\t\t| Record\n\t\t| undefined;\n\n\tif (!properties) {\n\t\treturn prefillParams;\n\t}\n\n\tconst result: Record = {};\n\n\tfor (const [fieldName, value] of Object.entries(prefillParams)) {\n\t\tconst fieldSchema = properties[fieldName];\n\n\t\tif (fieldSchema?.fieldType === \"relation\" && fieldSchema.relation) {\n\t\t\t// Convert relation field value to the correct format\n\t\t\tif (fieldSchema.relation.type === \"belongsTo\") {\n\t\t\t\t// belongsTo expects { id: \"uuid\" }\n\t\t\t\tresult[fieldName] = { id: value };\n\t\t\t} else {\n\t\t\t\t// hasMany/manyToMany expect [{ id: \"uuid\" }]\n\t\t\t\tresult[fieldName] = [{ id: value }];\n\t\t\t}\n\t\t} else {\n\t\t\t// Non-relation fields: pass through as-is\n\t\t\tresult[fieldName] = value;\n\t\t}\n\t}\n\n\treturn result;\n}\n\ninterface ContentEditorPageProps {\n\ttypeSlug: string;\n\tid?: string;\n}\n\nexport function ContentEditorPage({ typeSlug, id }: ContentEditorPageProps) {\n\tconst overrides = usePluginOverrides(\"cms\");\n\tconst { navigate } = overrides;\n\tconst localization = { ...CMS_LOCALIZATION, ...overrides.localization };\n\tconst basePath = useBasePath();\n\n\t// Parse prefill query parameters for pre-populating fields when creating new items\n\t// This is used by the inverse relations panel to pre-fill the parent relation\n\tconst prefillParams = usePrefillParams();\n\n\t// Call lifecycle hooks for authorization\n\tuseRouteLifecycle({\n\t\trouteName: \"contentEditor\",\n\t\tcontext: {\n\t\t\tpath: id ? `/cms/${typeSlug}/${id}` : `/cms/${typeSlug}/new`,\n\t\t\tparams: id ? { typeSlug, id } : { typeSlug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforeEditorRendered) {\n\t\t\t\treturn overrides.onBeforeEditorRendered(typeSlug, id ?? null, context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\tconst { contentTypes } = useSuspenseContentTypes();\n\tconst contentType = contentTypes.find((ct) => ct.slug === typeSlug);\n\n\tconst isEditing = !!id;\n\n\t// useContentItem has enabled: !!id built-in, so it won't fetch when creating new items\n\t// This avoids conditional hook calls which violate React's Rules of Hooks\n\tconst { item, isLoading: isLoadingItem } = useContentItem(typeSlug, id ?? \"\");\n\n\tconst createContent = useCreateContent(typeSlug);\n\tconst updateContent = useUpdateContent(typeSlug);\n\n\tif (!contentType) {\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\t// Show loading skeleton while fetching item in edit mode\n\tif (isEditing && isLoadingItem) {\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\tif (isEditing && !item) {\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 handleSubmit = async (data: {\n\t\tslug: string;\n\t\tdata: Record;\n\t}) => {\n\t\tif (isEditing && id) {\n\t\t\tawait updateContent.mutateAsync({ id, data });\n\t\t} else {\n\t\t\tawait createContent.mutateAsync(data);\n\t\t}\n\t\tnavigate(`${basePath}/cms/${typeSlug}`);\n\t};\n\n\tconst title = isEditing\n\t\t? localization.CMS_EDITOR_TITLE_EDIT.replace(\"{typeName}\", contentType.name)\n\t\t: localization.CMS_EDITOR_TITLE_NEW.replace(\"{typeName}\", contentType.name);\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t navigate(`${basePath}/cms/${typeSlug}`)}\n\t\t\t\t\t>\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t

{title}

\n\t\t\t\t
\n\n\t\t\t\t 0\n\t\t\t\t\t\t\t\t? convertPrefillToFormData(\n\t\t\t\t\t\t\t\t\t\tprefillParams,\n\t\t\t\t\t\t\t\t\t\tJSON.parse(contentType.jsonSchema),\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t}\n\t\t\t\t\tinitialSlug={item?.slug}\n\t\t\t\t\tisEditing={isEditing}\n\t\t\t\t\tonSubmit={handleSubmit}\n\t\t\t\t\tonCancel={() => navigate(`${basePath}/cms/${typeSlug}`)}\n\t\t\t\t/>\n\n\t\t\t\t{/* Show inverse relations panel when editing (not creating) */}\n\t\t\t\t{isEditing && id && (\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, useEffect } from \"react\";\nimport { ArrowLeft } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tusePluginOverrides,\n\tuseBasePath,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport {\n\tuseSuspenseContentTypes,\n\tuseContentItem,\n\tuseContentItemForm,\n} from \"@btst/stack/plugins/cms/client/hooks\";\nimport { ContentForm } from \"../forms/content-form\";\nimport { InverseRelationsPanel } from \"../inverse-relations-panel\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EditorSkeleton } from \"../loading/editor-skeleton\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\n/**\n * Parse prefill query parameters from the URL.\n * Looks for query params with the format `prefill_=`\n * and returns a record of field names to values.\n *\n * Uses useState + useEffect pattern to work correctly with SSR/hydration.\n * During SSR, returns empty object. After hydration, parses URL params.\n * Also listens for popstate events to handle browser back/forward navigation.\n *\n * @example\n * URL: /cms/comment/new?prefill_resourceId=123&prefill_author=John\n * Returns: { resourceId: \"123\", author: \"John\" }\n */\nfunction usePrefillParams(): Record {\n\tconst [prefillData, setPrefillData] = useState>({});\n\n\tuseEffect(() => {\n\t\tif (typeof window === \"undefined\") {\n\t\t\treturn;\n\t\t}\n\n\t\tconst parseAndSetPrefillData = () => {\n\t\t\tconst params = new URLSearchParams(window.location.search);\n\t\t\tconst data: Record = {};\n\n\t\t\tfor (const [key, value] of params.entries()) {\n\t\t\t\tif (key.startsWith(\"prefill_\")) {\n\t\t\t\t\tconst fieldName = key.slice(\"prefill_\".length);\n\t\t\t\t\tif (fieldName) {\n\t\t\t\t\t\tdata[fieldName] = value;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Always update state to ensure stale data is cleared when navigating\n\t\t\t// to a URL without prefill params (e.g., via browser back/forward)\n\t\t\tsetPrefillData(data);\n\t\t};\n\n\t\t// Parse on mount\n\t\tparseAndSetPrefillData();\n\n\t\t// Listen for popstate events (browser back/forward navigation)\n\t\twindow.addEventListener(\"popstate\", parseAndSetPrefillData);\n\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\"popstate\", parseAndSetPrefillData);\n\t\t};\n\t}, []);\n\n\treturn prefillData;\n}\n\ninterface JsonSchemaProperty {\n\tfieldType?: string;\n\trelation?: {\n\t\ttype: \"belongsTo\" | \"hasMany\" | \"manyToMany\";\n\t\ttargetType: string;\n\t};\n}\n\n/**\n * Convert prefill params to the correct format for the form.\n * Relation fields need special handling:\n * - belongsTo: value should be { id: \"uuid\" }\n * - hasMany/manyToMany: value should be [{ id: \"uuid\" }]\n *\n * @param prefillParams - Raw prefill params from URL\n * @param jsonSchema - The content type's JSON schema\n * @returns Converted data suitable for initialData\n */\nfunction convertPrefillToFormData(\n\tprefillParams: Record,\n\tjsonSchema: Record,\n): Record {\n\tconst properties = jsonSchema.properties as\n\t\t| Record\n\t\t| undefined;\n\n\tif (!properties) {\n\t\treturn prefillParams;\n\t}\n\n\tconst result: Record = {};\n\n\tfor (const [fieldName, value] of Object.entries(prefillParams)) {\n\t\tconst fieldSchema = properties[fieldName];\n\n\t\tif (fieldSchema?.fieldType === \"relation\" && fieldSchema.relation) {\n\t\t\t// Convert relation field value to the correct format\n\t\t\tif (fieldSchema.relation.type === \"belongsTo\") {\n\t\t\t\t// belongsTo expects { id: \"uuid\" }\n\t\t\t\tresult[fieldName] = { id: value };\n\t\t\t} else {\n\t\t\t\t// hasMany/manyToMany expect [{ id: \"uuid\" }]\n\t\t\t\tresult[fieldName] = [{ id: value }];\n\t\t\t}\n\t\t} else {\n\t\t\t// Non-relation fields: pass through as-is\n\t\t\tresult[fieldName] = value;\n\t\t}\n\t}\n\n\treturn result;\n}\n\ninterface ContentEditorPageProps {\n\ttypeSlug: string;\n\tid?: string;\n}\n\nexport function ContentEditorPage({ typeSlug, id }: ContentEditorPageProps) {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides(\"cms\");\n\tconst { navigate, localization } = overrides;\n\tconst basePath = useBasePath();\n\n\t// Parse prefill query parameters for pre-populating fields when creating new items\n\t// This is used by the inverse relations panel to pre-fill the parent relation\n\tconst prefillParams = usePrefillParams();\n\n\t// Call lifecycle hooks for authorization\n\tuseRouteLifecycle({\n\t\trouteName: \"contentEditor\",\n\t\tcontext: {\n\t\t\tpath: id ? `/cms/${typeSlug}/${id}` : `/cms/${typeSlug}/new`,\n\t\t\tparams: id ? { typeSlug, id } : { typeSlug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforeEditorRendered) {\n\t\t\t\treturn overrides.onBeforeEditorRendered(typeSlug, id ?? null, context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\tconst { contentTypes } = useSuspenseContentTypes();\n\tconst contentType = contentTypes.find((ct) => ct.slug === typeSlug);\n\n\tconst isEditing = !!id;\n\n\t// useContentItem has enabled: !!id built-in, so it won't fetch when creating new items\n\t// This avoids conditional hook calls which violate React's Rules of Hooks\n\tconst { item, isLoading: isLoadingItem } = useContentItem(typeSlug, id ?? \"\");\n\n\t// Core resource form: submits the right mutation, awaits invalidation,\n\t// notifies success/error via useNotify(), and exposes server validation\n\t// issues as fieldErrors for inline display.\n\tconst resourceForm = useContentItemForm<{\n\t\tslug: string;\n\t\tdata: Record;\n\t}>({\n\t\taction: isEditing ? \"edit\" : \"create\",\n\t\trecord: isEditing ? item : null,\n\t\tsuccessMessage: (_result, action) =>\n\t\t\taction === \"create\"\n\t\t\t\t? (localization?.CMS_TOAST_CREATE_SUCCESS ??\n\t\t\t\t\tt(\"cms.toasts.createSuccess\", \"Item created successfully\"))\n\t\t\t\t: (localization?.CMS_TOAST_UPDATE_SUCCESS ??\n\t\t\t\t\tt(\"cms.toasts.updateSuccess\", \"Item updated successfully\")),\n\t\ttoCreateVars: (values) => ({\n\t\t\ttypeSlug,\n\t\t\tslug: values.slug,\n\t\t\tdata: values.data,\n\t\t}),\n\t\ttoUpdateVars: (values) => ({\n\t\t\ttypeSlug,\n\t\t\tid: id ?? \"\",\n\t\t\tdata: { slug: values.slug, data: values.data },\n\t\t}),\n\t\tonSuccess: () => {\n\t\t\tnavigate(`${basePath}/cms/${typeSlug}`);\n\t\t},\n\t});\n\n\tif (!contentType) {\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\t// Show loading skeleton while fetching item in edit mode\n\tif (isEditing && isLoadingItem) {\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\tif (isEditing && !item) {\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\t// resourceForm.submit never throws: success notifies + navigates via the\n\t// config above; errors land on resourceForm.error / fieldErrors.\n\tconst handleSubmit = async (data: {\n\t\tslug: string;\n\t\tdata: Record;\n\t}) => {\n\t\tawait resourceForm.submit(data);\n\t};\n\n\tconst hasFieldErrors = Object.keys(resourceForm.fieldErrors).length > 0;\n\n\tconst title = isEditing\n\t\t? (\n\t\t\t\tlocalization?.CMS_EDITOR_TITLE_EDIT ??\n\t\t\t\tt(\"cms.editor.titleEdit\", \"Edit {typeName}\")\n\t\t\t).replace(\"{typeName}\", contentType.name)\n\t\t: (\n\t\t\t\tlocalization?.CMS_EDITOR_TITLE_NEW ??\n\t\t\t\tt(\"cms.editor.titleNew\", \"New {typeName}\")\n\t\t\t).replace(\"{typeName}\", contentType.name);\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t navigate(`${basePath}/cms/${typeSlug}`)}\n\t\t\t\t\t>\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t

{title}

\n\t\t\t\t
\n\n\t\t\t\t 0\n\t\t\t\t\t\t\t\t? convertPrefillToFormData(\n\t\t\t\t\t\t\t\t\t\tprefillParams,\n\t\t\t\t\t\t\t\t\t\tJSON.parse(contentType.jsonSchema),\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t}\n\t\t\t\t\tinitialSlug={item?.slug}\n\t\t\t\t\tisEditing={isEditing}\n\t\t\t\t\tonSubmit={handleSubmit}\n\t\t\t\t\tonCancel={() => navigate(`${basePath}/cms/${typeSlug}`)}\n\t\t\t\t\tfieldErrors={resourceForm.fieldErrors}\n\t\t\t\t\terrorMessage={\n\t\t\t\t\t\thasFieldErrors ? undefined : resourceForm.error?.message\n\t\t\t\t\t}\n\t\t\t\t\tisSubmitting={resourceForm.isSubmitting}\n\t\t\t\t/>\n\n\t\t\t\t{/* Show inverse relations panel when editing (not creating) */}\n\t\t\t\t{isEditing && id && (\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/pages/content-editor-page.internal.tsx" }, { "path": "btst/cms/client/components/pages/content-editor-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { EditorSkeleton } from \"../loading\";\nimport { NotFoundPage } from \"./404-page\";\n\nconst ContentEditorPageInternal = lazy(() =>\n\timport(\"./content-editor-page.internal\").then((m) => ({\n\t\tdefault: m.ContentEditorPage,\n\t})),\n);\n\ninterface ContentEditorPageComponentProps {\n\ttypeSlug: string;\n\tid?: string;\n}\n\nexport function ContentEditorPageComponent({\n\ttypeSlug,\n\tid,\n}: ContentEditorPageComponentProps) {\n\tconst { onRouteError } = usePluginOverrides(\"cms\");\n\n\tconst isNew = !id;\n\tconst path = isNew ? `/cms/${typeSlug}/new` : `/cms/${typeSlug}/${id}`;\n\n\treturn (\n\t\t {\n\t\t\t\tif (onRouteError) {\n\t\t\t\t\tonRouteError(\"contentEditor\", error, {\n\t\t\t\t\t\tpath,\n\t\t\t\t\t\tparams: { typeSlug, id: id ?? \"\" },\n\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\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 { lazy } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { EditorSkeleton } from \"../loading\";\nimport { NotFoundPage } from \"./404-page\";\n\nconst ContentEditorPageInternal = lazy(() =>\n\timport(\"./content-editor-page.internal\").then((m) => ({\n\t\tdefault: m.ContentEditorPage,\n\t})),\n);\n\ninterface ContentEditorPageComponentProps {\n\ttypeSlug: string;\n\tid?: string;\n}\n\nexport function ContentEditorPageComponent({\n\ttypeSlug,\n\tid,\n}: ContentEditorPageComponentProps) {\n\tconst { onRouteError } = usePluginOverrides(\"cms\");\n\n\tconst isNew = !id;\n\tconst path = isNew ? `/cms/${typeSlug}/new` : `/cms/${typeSlug}/${id}`;\n\n\treturn (\n\t\t {\n\t\t\t\tif (onRouteError) {\n\t\t\t\t\tonRouteError(\"contentEditor\", error, {\n\t\t\t\t\t\tpath,\n\t\t\t\t\t\tparams: { typeSlug, id: id ?? \"\" },\n\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\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/pages/content-editor-page.tsx" }, { "path": "btst/cms/client/components/pages/content-list-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { Plus, ArrowLeft, Pencil, Trash2, Loader2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport {\n\tuseSuspenseContent,\n\tuseSuspenseContentTypes,\n\tuseDeleteContent,\n} from \"@btst/stack/plugins/cms/client/hooks\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { CMS_LOCALIZATION } from \"../../localization\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { toast } from \"sonner\";\n\ninterface ContentListPageProps {\n\ttypeSlug: string;\n}\n\nexport function ContentListPage({ typeSlug }: ContentListPageProps) {\n\tconst overrides = usePluginOverrides(\"cms\");\n\tconst { navigate, Link } = overrides;\n\tconst localization = { ...CMS_LOCALIZATION, ...overrides.localization };\n\tconst basePath = useBasePath();\n\n\t// Call lifecycle hooks for authorization\n\tuseRouteLifecycle({\n\t\trouteName: \"contentList\",\n\t\tcontext: {\n\t\t\tpath: `/cms/${typeSlug}`,\n\t\t\tparams: { typeSlug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforeListRendered) {\n\t\t\t\treturn overrides.onBeforeListRendered(typeSlug, context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\tconst limit = 20;\n\n\tconst { contentTypes } = useSuspenseContentTypes();\n\tconst contentType = contentTypes.find((ct) => ct.slug === typeSlug);\n\n\tconst { items, total, refetch, loadMore, hasMore, isLoadingMore } =\n\t\tuseSuspenseContent(typeSlug, {\n\t\t\tlimit,\n\t\t});\n\n\tconst deleteContent = useDeleteContent(typeSlug);\n\n\tconst LinkComponent = Link || \"a\";\n\n\tconst handleDelete = async (id: string) => {\n\t\ttry {\n\t\t\tawait deleteContent.mutateAsync(id);\n\t\t\ttoast.success(localization.CMS_TOAST_DELETE_SUCCESS);\n\t\t\tvoid refetch();\n\t\t} catch {\n\t\t\ttoast.error(localization.CMS_TOAST_ERROR);\n\t\t}\n\t};\n\n\tconst formatDate = (dateString: string) => {\n\t\treturn new Date(dateString).toLocaleDateString();\n\t};\n\n\tif (!contentType) {\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\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t navigate(`${basePath}/cms`)}\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
\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{contentType.name}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{contentType.description && (\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{contentType.description}\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
\n\n\t\t\t\t{items.length === 0 ? (\n\t\t\t\t\t navigate(`${basePath}/cms/${typeSlug}/new`)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.CMS_BUTTON_CREATE}\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\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.CMS_LIST_COLUMN_SLUG}\n\t\t\t\t\t\t\t\t\t{localization.CMS_LIST_COLUMN_CREATED}\n\t\t\t\t\t\t\t\t\t{localization.CMS_LIST_COLUMN_UPDATED}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization.CMS_LIST_COLUMN_ACTIONS}\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\t\n\t\t\t\t\t\t\t\t{items.map((item) => (\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\n\t\t\t\t\t\t\t\t\t\t\t\t{item.slug}\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{formatDate(item.createdAt)}\n\t\t\t\t\t\t\t\t\t\t{formatDate(item.updatedAt)}\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\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tnavigate(`${basePath}/cms/${typeSlug}/${item.id}`)\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t handleDelete(item.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={deleteContent.isPending}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\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\t{/* Load More and pagination info */}\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{localization.CMS_LIST_PAGINATION_SHOWING.replace(\"{from}\", \"1\")\n\t\t\t\t\t\t\t\t\t.replace(\"{to}\", String(items.length))\n\t\t\t\t\t\t\t\t\t.replace(\"{total}\", String(total))}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{hasMore && (\n\t\t\t\t\t\t\t\t loadMore()}\n\t\t\t\t\t\t\t\t\tdisabled={isLoadingMore}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{isLoadingMore && (\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\t{localization.CMS_LIST_PAGINATION_NEXT}\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)}\n\t\t\t
\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { Plus, ArrowLeft, Pencil, Trash2, Loader2, Search } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tCanAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseBasePath,\n\tuseTranslate,\n\ttype TranslateFn,\n} from \"@btst/stack/context\";\nimport { useListState, type ListStateSchema } from \"@btst/stack/client\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport type { SerializedContentItemWithType } from \"../../../types\";\nimport {\n\tuseContent,\n\tuseSuspenseContent,\n\tuseSuspenseContentTypes,\n\tuseDeleteContent,\n} from \"@btst/stack/plugins/cms/client/hooks\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\ninterface ContentListPageProps {\n\ttypeSlug: string;\n}\n\n// URL-synced search state: `?q=...` while typing (history: replace), clean\n// URL when the query is empty (the default is omitted from the URL).\nconst LIST_STATE_SCHEMA = {\n\tq: { type: \"string\", default: \"\", history: \"replace\" },\n} as const satisfies ListStateSchema;\n\nconst SEARCH_DEBOUNCE_MS = 300;\n\nexport function ContentListPage({ typeSlug }: ContentListPageProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst overrides = usePluginOverrides(\"cms\");\n\tconst { navigate, Link, localization } = overrides;\n\tconst basePath = useBasePath();\n\n\t// Call lifecycle hooks for authorization\n\tuseRouteLifecycle({\n\t\trouteName: \"contentList\",\n\t\tcontext: {\n\t\t\tpath: `/cms/${typeSlug}`,\n\t\t\tparams: { typeSlug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforeListRendered) {\n\t\t\t\treturn overrides.onBeforeListRendered(typeSlug, context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\tconst limit = 20;\n\n\tconst [{ q: search }, setListState] = useListState(\n\t\t`cms-${typeSlug}`,\n\t\tLIST_STATE_SCHEMA,\n\t);\n\n\t// Local input state debounced into the URL-synced query, so the list\n\t// query (and URL) only update after the user pauses typing.\n\tconst [searchInput, setSearchInput] = useState(search);\n\n\t// External `q` changes (hydration after SSR-empty search params,\n\t// back/forward navigation) re-seed the input instead of being clobbered\n\t// by the debounced write below, which only reflects user edits.\n\tconst lastSyncedSearch = useRef(search);\n\tuseEffect(() => {\n\t\tif (search !== lastSyncedSearch.current) {\n\t\t\tlastSyncedSearch.current = search;\n\t\t\tsetSearchInput(search);\n\t\t}\n\t}, [search]);\n\n\tuseEffect(() => {\n\t\tif (searchInput === search) return;\n\t\tconst timeout = setTimeout(() => {\n\t\t\tlastSyncedSearch.current = searchInput;\n\t\t\tsetListState({ q: searchInput });\n\t\t}, SEARCH_DEBOUNCE_MS);\n\t\treturn () => clearTimeout(timeout);\n\t}, [searchInput, search, setListState]);\n\n\tconst hasSearch = search.trim().length > 0;\n\n\tconst { contentTypes } = useSuspenseContentTypes();\n\tconst contentType = contentTypes.find((ct) => ct.slug === typeSlug);\n\n\t// The default (unsearched) list stays on the suspense hook so SSR/SSG\n\t// hydration works; the searched list uses the non-suspense hook so\n\t// typing shows an inline loading state instead of suspending the page.\n\tconst defaultList = useSuspenseContent(typeSlug, { limit });\n\tconst searchedList = useContent(typeSlug, {\n\t\tlimit,\n\t\tsearch,\n\t\tenabled: hasSearch,\n\t});\n\n\tconst activeList = hasSearch ? searchedList : defaultList;\n\tconst { items, total, loadMore, hasMore, isLoadingMore } = activeList;\n\tconst isSearchLoading = hasSearch && searchedList.isLoading;\n\n\tconst deleteContent = useDeleteContent(typeSlug);\n\n\tconst LinkComponent = Link || \"a\";\n\n\tconst handleDelete = async (id: string) => {\n\t\ttry {\n\t\t\tawait deleteContent.mutateAsync(id);\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.CMS_TOAST_ERROR ??\n\t\t\t\t\tt(\"cms.toasts.error\", \"An error occurred. Please try again.\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.CMS_TOAST_DELETE_SUCCESS ??\n\t\t\t\tt(\"cms.toasts.deleteSuccess\", \"Item deleted successfully\"),\n\t\t);\n\t};\n\n\tconst formatDate = (dateString: string) => {\n\t\treturn new Date(dateString).toLocaleDateString();\n\t};\n\n\tif (!contentType) {\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\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t navigate(`${basePath}/cms`)}\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
\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{contentType.name}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{contentType.description && (\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{contentType.description}\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\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t setSearchInput(e.target.value)}\n\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\tlocalization?.CMS_LIST_SEARCH_PLACEHOLDER ??\n\t\t\t\t\t\t\tt(\"cms.list.searchPlaceholder\", \"Search items...\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tclassName=\"pl-9\"\n\t\t\t\t\t/>\n\t\t\t\t\t{isSearchLoading && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{items.length === 0 ? (\n\t\t\t\t\tisSearchLoading ? null : hasSearch ? (\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\t\t navigate(`${basePath}/cms/${typeSlug}/new`)}\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{localization?.CMS_BUTTON_CREATE ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"cms.common.create\", \"Create\")}\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) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n\nfunction ContentTable({\n\titems,\n\ttotal,\n\ttypeSlug,\n\tbasePath,\n\tLinkComponent,\n\tnavigate,\n\tonDelete,\n\tisDeleting,\n\tformatDate,\n\tloadMore,\n\thasMore,\n\tisLoadingMore,\n\tlocalization,\n\tt,\n}: {\n\titems: SerializedContentItemWithType[];\n\ttotal: number;\n\ttypeSlug: string;\n\tbasePath: string;\n\tLinkComponent: React.ElementType;\n\tnavigate: (path: string) => void | Promise;\n\tonDelete: (id: string) => void;\n\tisDeleting: boolean;\n\tformatDate: (dateString: string) => string;\n\tloadMore: () => void;\n\thasMore: boolean;\n\tisLoadingMore: boolean;\n\tlocalization: CMSPluginOverrides[\"localization\"];\n\tt: TranslateFn;\n}) {\n\treturn (\n\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\t{localization?.CMS_LIST_COLUMN_SLUG ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnSlug\", \"Slug\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_CREATED ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnCreated\", \"Created\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_UPDATED ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnUpdated\", \"Updated\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_ACTIONS ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnActions\", \"Actions\")}\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{items.map((item) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{item.slug}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{formatDate(item.createdAt)}\n\t\t\t\t\t\t\t{formatDate(item.updatedAt)}\n\t\t\t\t\t\t\t\n\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\t\t\n\t\t\t\t\t\t\t\t\t\t\t\tnavigate(`${basePath}/cms/${typeSlug}/${item.id}`)\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\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\t\n\t\t\t\t\t\t\t\t\t\t onDelete(item.id)}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={isDeleting}\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
\n\t\t\t
\n\t\t\t{/* Load More and pagination info */}\n\t\t\t
\n\t\t\t\t

\n\t\t\t\t\t{(\n\t\t\t\t\t\tlocalization?.CMS_LIST_PAGINATION_SHOWING ??\n\t\t\t\t\t\tt(\"cms.list.paginationShowing\", \"Showing {from}-{to} of {total}\")\n\t\t\t\t\t)\n\t\t\t\t\t\t.replace(\"{from}\", \"1\")\n\t\t\t\t\t\t.replace(\"{to}\", String(items.length))\n\t\t\t\t\t\t.replace(\"{total}\", String(total))}\n\t\t\t\t

\n\t\t\t\t{hasMore && (\n\t\t\t\t\t loadMore()}\n\t\t\t\t\t\tdisabled={isLoadingMore}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isLoadingMore && }\n\t\t\t\t\t\t{localization?.CMS_LIST_PAGINATION_NEXT ??\n\t\t\t\t\t\t\tt(\"cms.list.paginationNext\", \"Next\")}\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/pages/content-list-page.internal.tsx" }, { @@ -127,7 +127,7 @@ { "path": "btst/cms/client/components/pages/dashboard-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { FileText } from \"lucide-react\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport { useSuspenseContentTypes } from \"@btst/stack/plugins/cms/client/hooks\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { CMS_LOCALIZATION } from \"../../localization\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\nexport function DashboardPage() {\n\tconst overrides = usePluginOverrides(\"cms\");\n\tconst { navigate } = overrides;\n\tconst localization = { ...CMS_LOCALIZATION, ...overrides.localization };\n\tconst basePath = useBasePath();\n\n\t// Call lifecycle hooks for authorization\n\tuseRouteLifecycle({\n\t\trouteName: \"dashboard\",\n\t\tcontext: {\n\t\t\tpath: \"/cms\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforeDashboardRendered) {\n\t\t\t\treturn overrides.onBeforeDashboardRendered(context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\tconst { contentTypes } = useSuspenseContentTypes();\n\n\tif (contentTypes.length === 0) {\n\t\treturn (\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\t{localization.CMS_DASHBOARD_TITLE}\n\t\t\t\t\t\t

\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization.CMS_DASHBOARD_SUBTITLE}\n\t\t\t\t\t\t

\n\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\tconst formatItemCount = (count: number) => {\n\t\tif (count === 0) return localization.CMS_DASHBOARD_ITEMS_COUNT_ZERO;\n\t\tif (count === 1) return localization.CMS_DASHBOARD_ITEMS_COUNT_ONE;\n\t\treturn localization.CMS_DASHBOARD_ITEMS_COUNT.replace(\n\t\t\t\"{count}\",\n\t\t\tString(count),\n\t\t);\n\t};\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t

\n\t\t\t\t\t\t{localization.CMS_DASHBOARD_TITLE}\n\t\t\t\t\t

\n\t\t\t\t\t

\n\t\t\t\t\t\t{localization.CMS_DASHBOARD_SUBTITLE}\n\t\t\t\t\t

\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t{contentTypes.map((ct) => (\n\t\t\t\t\t\t navigate(`${basePath}/cms/${ct.slug}`)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{ct.name}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
{ct.itemCount}
\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{formatItemCount(ct.itemCount)}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{ct.description && (\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{ct.description}\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
\n\t\t\t
\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { FileText } from \"lucide-react\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport {\n\tusePluginOverrides,\n\tuseBasePath,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport { useSuspenseContentTypes } from \"@btst/stack/plugins/cms/client/hooks\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\nexport function DashboardPage() {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides(\"cms\");\n\tconst { navigate, localization } = overrides;\n\tconst basePath = useBasePath();\n\n\t// Call lifecycle hooks for authorization\n\tuseRouteLifecycle({\n\t\trouteName: \"dashboard\",\n\t\tcontext: {\n\t\t\tpath: \"/cms\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforeDashboardRendered) {\n\t\t\t\treturn overrides.onBeforeDashboardRendered(context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\tconst { contentTypes } = useSuspenseContentTypes();\n\n\tconst title =\n\t\tlocalization?.CMS_DASHBOARD_TITLE ?? t(\"cms.dashboard.title\", \"Content\");\n\tconst subtitle =\n\t\tlocalization?.CMS_DASHBOARD_SUBTITLE ??\n\t\tt(\"cms.dashboard.subtitle\", \"Manage your content types\");\n\n\tif (contentTypes.length === 0) {\n\t\treturn (\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t

{title}

\n\t\t\t\t\t\t

{subtitle}

\n\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\tconst formatItemCount = (count: number) => {\n\t\tif (count === 0)\n\t\t\treturn (\n\t\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT_ZERO ??\n\t\t\t\tt(\"cms.dashboard.itemsCountZero\", \"No items\")\n\t\t\t);\n\t\tif (count === 1)\n\t\t\treturn (\n\t\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT_ONE ??\n\t\t\t\tt(\"cms.dashboard.itemsCountOne\", \"1 item\")\n\t\t\t);\n\t\treturn (\n\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT ??\n\t\t\tt(\"cms.dashboard.itemsCount\", \"{count} items\")\n\t\t).replace(\"{count}\", String(count));\n\t};\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t

{title}

\n\t\t\t\t\t

{subtitle}

\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t{contentTypes.map((ct) => (\n\t\t\t\t\t\t navigate(`${basePath}/cms/${ct.slug}`)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{ct.name}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
{ct.itemCount}
\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{formatItemCount(ct.itemCount)}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{ct.description && (\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{ct.description}\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
\n\t\t\t
\n\t\t
\n\t);\n}\n", "target": "src/components/btst/cms/client/components/pages/dashboard-page.internal.tsx" }, { @@ -154,16 +154,10 @@ "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { PageWrapper as SharedPageWrapper } from \"@/components/ui/page-wrapper\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\n\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n}: {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n}) {\n\tconst { showAttribution } = usePluginOverrides<\n\t\tCMSPluginOverrides,\n\t\tPartial\n\t>(\"cms\", {\n\t\tshowAttribution: true,\n\t});\n\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n", "target": "src/components/btst/cms/client/components/shared/page-wrapper.tsx" }, - { - "path": "btst/cms/client/components/shared/pagination.tsx", - "type": "registry:component", - "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_LOCALIZATION } from \"../../localization\";\nimport { PaginationControls } from \"@/components/ui/pagination-controls\";\n\ninterface PaginationProps {\n\tcurrentPage: number;\n\ttotalPages: number;\n\tonPageChange: (page: number) => void;\n\ttotal: number;\n\tlimit: number;\n\toffset: number;\n}\n\nexport function Pagination({\n\tcurrentPage,\n\ttotalPages,\n\tonPageChange,\n\ttotal,\n\tlimit,\n\toffset,\n}: PaginationProps) {\n\tconst { localization: customLocalization } =\n\t\tusePluginOverrides(\"cms\");\n\tconst localization = { ...CMS_LOCALIZATION, ...customLocalization };\n\n\treturn (\n\t\t\n\t);\n}\n", - "target": "src/components/btst/cms/client/components/shared/pagination.tsx" - }, { "path": "btst/cms/client/localization/cms-common.ts", "type": "registry:lib", - "content": "export const CMS_COMMON = {\n\t// Buttons\n\tCMS_BUTTON_SAVE: \"Save\",\n\tCMS_BUTTON_CANCEL: \"Cancel\",\n\tCMS_BUTTON_DELETE: \"Delete\",\n\tCMS_BUTTON_CREATE: \"Create\",\n\tCMS_BUTTON_BACK: \"Back\",\n\tCMS_BUTTON_NEW_ITEM: \"New Item\",\n\n\t// Labels\n\tCMS_LABEL_SLUG: \"Slug\",\n\tCMS_LABEL_SLUG_DESCRIPTION: \"URL-friendly identifier for this item\",\n\tCMS_LABEL_CREATED_AT: \"Created\",\n\tCMS_LABEL_UPDATED_AT: \"Last Updated\",\n\tCMS_LABEL_ACTIONS: \"Actions\",\n\n\t// Status\n\tCMS_STATUS_LOADING: \"Loading...\",\n\tCMS_STATUS_SAVING: \"Saving...\",\n\tCMS_STATUS_DELETING: \"Deleting...\",\n\n\t// Errors\n\tCMS_ERROR_GENERIC: \"Something went wrong\",\n\tCMS_ERROR_NOT_FOUND: \"Not found\",\n\tCMS_ERROR_VALIDATION: \"Please fix the errors above\",\n\n\t// Attribution\n\tCMS_ATTRIBUTION: \"Powered by BTST\",\n};\n", + "content": "export const CMS_COMMON = {\n\t// Buttons\n\tCMS_BUTTON_SAVE: \"Save\",\n\tCMS_BUTTON_CANCEL: \"Cancel\",\n\tCMS_BUTTON_DELETE: \"Delete\",\n\tCMS_BUTTON_CREATE: \"Create\",\n\tCMS_BUTTON_BACK: \"Back\",\n\tCMS_BUTTON_NEW_ITEM: \"New Item\",\n\n\t// Labels\n\tCMS_LABEL_SLUG: \"Slug\",\n\tCMS_LABEL_SLUG_DESCRIPTION: \"URL-friendly identifier for this item\",\n\tCMS_LABEL_CREATED_AT: \"Created\",\n\tCMS_LABEL_UPDATED_AT: \"Last Updated\",\n\tCMS_LABEL_ACTIONS: \"Actions\",\n\n\t// Status\n\tCMS_STATUS_LOADING: \"Loading...\",\n\tCMS_STATUS_SAVING: \"Saving...\",\n\tCMS_STATUS_DELETING: \"Deleting...\",\n\n\t// Errors\n\tCMS_ERROR_GENERIC: \"Something went wrong\",\n\tCMS_ERROR_NOT_FOUND: \"Not found\",\n\tCMS_ERROR_VALIDATION: \"Please fix the errors above\",\n\tCMS_ERROR_TYPE_NOT_FOUND_DESCRIPTION: \"Content type not found\",\n\tCMS_ERROR_ITEM_NOT_FOUND_DESCRIPTION: \"Content item not found\",\n\n\t// 404 page\n\tCMS_404_TITLE: \"Page not found\",\n\tCMS_404_DESCRIPTION:\n\t\t\"The page you're looking for doesn't exist or has been moved.\",\n\tCMS_404_BACK: \"Back to CMS\",\n\n\t// Attribution\n\tCMS_ATTRIBUTION: \"Powered by BTST\",\n};\n", "target": "src/components/btst/cms/client/localization/cms-common.ts" }, { @@ -175,15 +169,21 @@ { "path": "btst/cms/client/localization/cms-editor.ts", "type": "registry:lib", - "content": "export const CMS_EDITOR = {\n\tCMS_EDITOR_TITLE_NEW: \"New {typeName}\",\n\tCMS_EDITOR_TITLE_EDIT: \"Edit {typeName}\",\n\tCMS_EDITOR_SLUG_AUTO: \"Auto-generated from first field\",\n\tCMS_EDITOR_SLUG_MANUAL: \"Manually set\",\n\tCMS_EDITOR_DELETE_CONFIRM: \"Are you sure you want to delete this item?\",\n\tCMS_EDITOR_UNSAVED_CHANGES: \"You have unsaved changes\",\n};\n", + "content": "export const CMS_EDITOR = {\n\tCMS_EDITOR_TITLE_NEW: \"New {typeName}\",\n\tCMS_EDITOR_TITLE_EDIT: \"Edit {typeName}\",\n\tCMS_EDITOR_SLUG_AUTO: \"Auto-generated from first field\",\n\tCMS_EDITOR_SLUG_MANUAL: \"Manually set\",\n\tCMS_EDITOR_DELETE_CONFIRM: \"Are you sure you want to delete this item?\",\n\tCMS_EDITOR_UNSAVED_CHANGES: \"You have unsaved changes\",\n\tCMS_EDITOR_SLUG_PLACEHOLDER: \"Enter slug...\",\n\tCMS_EDITOR_SLUG_PLACEHOLDER_AUTO: \"Auto-generated from {field}\",\n\tCMS_EDITOR_SLUG_REQUIRED: \"Slug is required\",\n\tCMS_EDITOR_FILE_INVALID_TYPE: \"Please select an image file\",\n\tCMS_EDITOR_FILE_UPLOAD_FAILED: \"Failed to upload image\",\n};\n", "target": "src/components/btst/cms/client/localization/cms-editor.ts" }, { "path": "btst/cms/client/localization/cms-list.ts", "type": "registry:lib", - "content": "export const CMS_LIST = {\n\tCMS_LIST_TITLE: \"{typeName}\",\n\tCMS_LIST_EMPTY: \"No items yet\",\n\tCMS_LIST_EMPTY_DESCRIPTION: \"Create your first item to get started.\",\n\tCMS_LIST_COLUMN_SLUG: \"Slug\",\n\tCMS_LIST_COLUMN_CREATED: \"Created\",\n\tCMS_LIST_COLUMN_UPDATED: \"Updated\",\n\tCMS_LIST_COLUMN_ACTIONS: \"Actions\",\n\tCMS_LIST_ACTION_EDIT: \"Edit\",\n\tCMS_LIST_ACTION_DELETE: \"Delete\",\n\tCMS_LIST_PAGINATION_SHOWING: \"Showing {from}-{to} of {total}\",\n\tCMS_LIST_PAGINATION_PREVIOUS: \"Previous\",\n\tCMS_LIST_PAGINATION_NEXT: \"Next\",\n};\n", + "content": "export const CMS_LIST = {\n\tCMS_LIST_TITLE: \"{typeName}\",\n\tCMS_LIST_EMPTY: \"No items yet\",\n\tCMS_LIST_EMPTY_DESCRIPTION: \"Create your first item to get started.\",\n\tCMS_LIST_COLUMN_SLUG: \"Slug\",\n\tCMS_LIST_COLUMN_CREATED: \"Created\",\n\tCMS_LIST_COLUMN_UPDATED: \"Updated\",\n\tCMS_LIST_COLUMN_ACTIONS: \"Actions\",\n\tCMS_LIST_ACTION_EDIT: \"Edit\",\n\tCMS_LIST_ACTION_DELETE: \"Delete\",\n\tCMS_LIST_PAGINATION_SHOWING: \"Showing {from}-{to} of {total}\",\n\tCMS_LIST_PAGINATION_PREVIOUS: \"Previous\",\n\tCMS_LIST_PAGINATION_NEXT: \"Next\",\n\tCMS_LIST_SEARCH_PLACEHOLDER: \"Search items...\",\n\tCMS_LIST_SEARCH_EMPTY: \"No items match your search\",\n\tCMS_LIST_SEARCH_EMPTY_DESCRIPTION: \"Try a different search term.\",\n};\n", "target": "src/components/btst/cms/client/localization/cms-list.ts" }, + { + "path": "btst/cms/client/localization/cms-relations.ts", + "type": "registry:lib", + "content": "export const CMS_RELATIONS = {\n\t// Relation picker (RelationField)\n\tCMS_RELATION_LOADING: \"Loading...\",\n\tCMS_RELATION_SELECT_PLACEHOLDER: \"Select {targetType}...\",\n\tCMS_RELATION_SELECT_PLACEHOLDER_MULTI: \"Select {targetType}(s)...\",\n\tCMS_RELATION_EMPTY: \"No {targetType} items found\",\n\tCMS_RELATION_CREATE_TITLE: \"Create New {targetType}\",\n\tCMS_RELATION_NAME_PLACEHOLDER: \"Enter {field}...\",\n\tCMS_RELATION_DESCRIPTION_LABEL: \"Description (optional)\",\n\tCMS_RELATION_DESCRIPTION_PLACEHOLDER: \"Enter description...\",\n\tCMS_RELATION_CREATE_BUTTON: \"Create\",\n\tCMS_RELATION_CREATING: \"Creating...\",\n\tCMS_RELATION_CREATE_ERROR: \"Failed to create item. Please try again.\",\n\n\t// Inverse relations panel\n\tCMS_RELATED_ITEMS_TITLE: \"Related Items\",\n\tCMS_RELATED_EMPTY: \"No {sourceTypeName} items yet.\",\n\tCMS_RELATED_ADD: \"Add {sourceTypeName}\",\n\tCMS_RELATED_DELETE_TITLE: \"Delete {sourceTypeName}?\",\n\tCMS_RELATED_DELETE_DESCRIPTION:\n\t\t\"This action cannot be undone. This will permanently delete this {sourceTypeName}.\",\n\tCMS_RELATED_DELETE_ERROR: \"Failed to delete item. Please try again.\",\n};\n", + "target": "src/components/btst/cms/client/localization/cms-relations.ts" + }, { "path": "btst/cms/client/localization/cms-toasts.ts", "type": "registry:lib", @@ -193,7 +193,7 @@ { "path": "btst/cms/client/localization/index.ts", "type": "registry:lib", - "content": "import { CMS_COMMON } from \"./cms-common\";\nimport { CMS_TOASTS } from \"./cms-toasts\";\nimport { CMS_DASHBOARD } from \"./cms-dashboard\";\nimport { CMS_LIST } from \"./cms-list\";\nimport { CMS_EDITOR } from \"./cms-editor\";\n\nexport const CMS_LOCALIZATION = {\n\t...CMS_COMMON,\n\t...CMS_TOASTS,\n\t...CMS_DASHBOARD,\n\t...CMS_LIST,\n\t...CMS_EDITOR,\n};\n\nexport type CMSLocalization = typeof CMS_LOCALIZATION;\n", + "content": "import { CMS_COMMON } from \"./cms-common\";\nimport { CMS_TOASTS } from \"./cms-toasts\";\nimport { CMS_DASHBOARD } from \"./cms-dashboard\";\nimport { CMS_LIST } from \"./cms-list\";\nimport { CMS_EDITOR } from \"./cms-editor\";\nimport { CMS_RELATIONS } from \"./cms-relations\";\n\nexport const CMS_LOCALIZATION = {\n\t...CMS_COMMON,\n\t...CMS_TOASTS,\n\t...CMS_DASHBOARD,\n\t...CMS_LIST,\n\t...CMS_EDITOR,\n\t...CMS_RELATIONS,\n};\n\nexport type CMSLocalization = typeof CMS_LOCALIZATION;\n", "target": "src/components/btst/cms/client/localization/index.ts" }, { @@ -256,12 +256,6 @@ "content": "\"use client\";\n\nimport { PageLayout } from \"./page-layout\";\nimport { StackAttribution } from \"./stack-attribution\";\n\nexport interface PageWrapperProps {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n\t/**\n\t * Whether to show the \"Powered by BTST\" attribution.\n\t * Defaults to true.\n\t */\n\tshowAttribution?: boolean;\n}\n\n/**\n * Shared page wrapper component providing consistent layout and optional attribution\n * for plugin pages. Used by blog, CMS, and other plugins.\n *\n * @example\n * ```tsx\n * \n *
\n *

My Page

\n *
\n *
\n * ```\n */\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n\tshowAttribution = true,\n}: PageWrapperProps) {\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t\t{children}\n\t\t\t\n\n\t\t\t{showAttribution && }\n\t\t\n\t);\n}\n", "target": "src/components/ui/page-wrapper.tsx" }, - { - "path": "ui/components/pagination-controls.tsx", - "type": "registry:component", - "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\n\nexport interface PaginationControlsProps {\n\t/** Current page, 1-based */\n\tcurrentPage: number;\n\ttotalPages: number;\n\ttotal: number;\n\tlimit: number;\n\toffset: number;\n\tonPageChange: (page: number) => void;\n\tlabels?: {\n\t\tprevious?: string;\n\t\tnext?: string;\n\t\t/** Template string; use {from}, {to}, {total} as placeholders */\n\t\tshowing?: string;\n\t};\n}\n\n/**\n * Generic Prev/Next pagination control with a \"Showing X–Y of Z\" label.\n * Plugin-agnostic — pass localized labels as props.\n * Returns null when totalPages ≤ 1.\n */\nexport function PaginationControls({\n\tcurrentPage,\n\ttotalPages,\n\ttotal,\n\tlimit,\n\toffset,\n\tonPageChange,\n\tlabels,\n}: PaginationControlsProps) {\n\tconst previous = labels?.previous ?? \"Previous\";\n\tconst next = labels?.next ?? \"Next\";\n\tconst showingTemplate = labels?.showing ?? \"Showing {from}–{to} of {total}\";\n\n\tconst from = offset + 1;\n\tconst to = Math.min(offset + limit, total);\n\n\tconst showingText = showingTemplate\n\t\t.replace(\"{from}\", String(from))\n\t\t.replace(\"{to}\", String(to))\n\t\t.replace(\"{total}\", String(total));\n\n\tif (totalPages <= 1) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t
\n\t\t\t

{showingText}

\n\t\t\t
\n\t\t\t\t onPageChange(currentPage - 1)}\n\t\t\t\t\tdisabled={currentPage === 1}\n\t\t\t\t>\n\t\t\t\t\t\n\t\t\t\t\t{previous}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{currentPage} / {totalPages}\n\t\t\t\t\n\t\t\t\t onPageChange(currentPage + 1)}\n\t\t\t\t\tdisabled={currentPage === totalPages}\n\t\t\t\t>\n\t\t\t\t\t{next}\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/ui/pagination-controls.tsx" - }, { "path": "ui/hooks/use-route-lifecycle.ts", "type": "registry:hook", diff --git a/packages/stack/src/__tests__/cms-query-keys.test.ts b/packages/stack/src/__tests__/cms-query-keys.test.ts new file mode 100644 index 00000000..decbc39b --- /dev/null +++ b/packages/stack/src/__tests__/cms-query-keys.test.ts @@ -0,0 +1,62 @@ +/** + * SSG guard: the factory-generated CMS query keys must stay deep-equal to + * the `CMS_QUERY_KEYS` builders used by `prefetchForRoute` (DB path). + * Key drift breaks React Query cache hydration silently during `next build`. + */ +import { describe, expect, it, vi } from "vitest"; +import { CMS_QUERY_KEYS } from "../plugins/cms/api/query-key-defs"; +import { createCMSQueryKeys } from "../plugins/cms/query-keys"; + +const client = vi.fn() as any; + +describe("cms query keys match SSG prefetch keys", () => { + const queries = createCMSQueryKeys(client); + + it("types list keys match", () => { + expect([...queries.cmsTypes.list().queryKey]).toEqual([ + ...CMS_QUERY_KEYS.typesList(), + ]); + }); + + it("content list keys match for default params", () => { + expect([...queries.cmsContent.list({ typeSlug: "post" }).queryKey]).toEqual( + [...CMS_QUERY_KEYS.contentList({ typeSlug: "post" })], + ); + }); + + it("content list keys match for custom limits and offsets", () => { + expect([ + ...queries.cmsContent.list({ typeSlug: "post", limit: 5, offset: 10 }) + .queryKey, + ]).toEqual([ + ...CMS_QUERY_KEYS.contentList({ typeSlug: "post", limit: 5, offset: 10 }), + ]); + }); + + it("content list keys match for search terms", () => { + expect([ + ...queries.cmsContent.list({ typeSlug: "post", search: "hello" }) + .queryKey, + ]).toEqual([ + ...CMS_QUERY_KEYS.contentList({ typeSlug: "post", search: "hello" }), + ]); + }); + + it("normalizes a whitespace-only search the same way", () => { + expect([ + ...queries.cmsContent.list({ typeSlug: "post", search: " " }).queryKey, + ]).toEqual([...CMS_QUERY_KEYS.contentList({ typeSlug: "post" })]); + }); + + it("content detail keys match", () => { + expect([...queries.cmsContent.detail("post", "abc").queryKey]).toEqual([ + ...CMS_QUERY_KEYS.contentDetail("post", "abc"), + ]); + }); + + it("exposes the same _def prefixes as the previous factory", () => { + expect([...queries.cmsTypes._def]).toEqual(["cmsTypes"]); + expect([...queries.cmsTypes.list._def]).toEqual(["cmsTypes", "list"]); + expect([...queries.cmsContent.list._def]).toEqual(["cmsContent", "list"]); + }); +}); diff --git a/packages/stack/src/__tests__/resource-factory.test.tsx b/packages/stack/src/__tests__/resource-factory.test.tsx index 6fd98521..240c517e 100644 --- a/packages/stack/src/__tests__/resource-factory.test.tsx +++ b/packages/stack/src/__tests__/resource-factory.test.tsx @@ -52,6 +52,37 @@ const resources = { key: () => ["all"], select: (data: any): Item[] => data ?? [], }, + // Envelope pages ({ items, total }) with a custom nextPageParam + paged: { + path: "/paged/:scope", + params: (_params?: ListParams & { scope?: string }) => ({ + scope: _params?.scope ?? "default", + }), + query: (params?: ListParams & { scope?: string }) => ({ + limit: params?.limit ?? 10, + }), + key: (params?: ListParams & { scope?: string }) => [ + { scope: params?.scope ?? "default", limit: params?.limit ?? 10 }, + ], + select: (data: any): { items: Item[]; total: number } => data, + infinite: true, + pageSize: (params?: ListParams & { scope?: string }) => + params?.limit ?? 10, + nextPageParam: ( + lastPage: { items: Item[]; total: number }, + allPages: { items: Item[]; total: number }[], + params?: ListParams & { scope?: string }, + ) => { + const limit = params?.limit ?? 10; + if ((lastPage?.items?.length ?? 0) < limit) return undefined; + const loaded = allPages.reduce( + (sum, page) => sum + (page?.items?.length ?? 0), + 0, + ); + if (loaded >= (lastPage?.total ?? 0)) return undefined; + return loaded; + }, + }, }, mutations: { create: { @@ -144,6 +175,19 @@ describe("createResourceQueryKeys", () => { }); }); + it("passes declared path params to the client", async () => { + client.mockResolvedValue({ data: { items: [], total: 0 } }); + const keys = createResourceQueryKeys(client, resources); + + await keys.items.paged({ scope: "mine", limit: 5 }).queryFn(); + + expect(client).toHaveBeenCalledWith("/paged/:scope", { + method: "GET", + params: { scope: "mine" }, + query: { limit: 5, offset: 0 }, + }); + }); + it("throws a normalized StackError on error responses", async () => { client.mockResolvedValue({ error: { message: "denied", status: 403 }, @@ -368,6 +412,53 @@ describe("createResource hooks", () => { expect(String(fetchMock.mock.calls[1]?.[0])).toContain("offset=2"); }); + it("useInfinite() honors a custom nextPageParam for envelope pages", async () => { + // total 4 with limit 2: page 2 is full (2 items) so the default + // page-size heuristic would keep paging — the custom nextPageParam + // must stop because loaded (4) >= total (4). + const page1 = { + items: [ + { id: "a0", name: "a0" }, + { id: "a1", name: "a1" }, + ], + total: 4, + }; + const page2 = { + items: [ + { id: "b0", name: "b0" }, + { id: "b1", name: "b1" }, + ], + total: 4, + }; + fetchMock.mockImplementation(async (input: any) => { + const url = String(input); + return url.includes("offset=2") + ? jsonResponse(page2) + : jsonResponse(page1); + }); + + let captured: any; + function Probe() { + captured = items.items.paged.useInfinite([{ limit: 2 }]); + return null; + } + await render(); + await waitFor(() => captured.isSuccess); + + // Envelope survives (not flattened) and total is available + expect(captured.data.pages[0]).toEqual(page1); + expect(captured.hasNextPage).toBe(true); + + await act(async () => { + await captured.fetchNextPage(); + }); + await waitFor(() => captured.data.pages.length === 2); + + expect(String(fetchMock.mock.calls[1]?.[0])).toContain("offset=2"); + // 4 items loaded >= total 4 — custom nextPageParam reports no more pages + expect(captured.hasNextPage).toBe(false); + }); + it("mutations invalidate declared targets, seed detail data and refresh", async () => { const created: Item = { id: "42", name: "created" }; fetchMock.mockResolvedValue(jsonResponse(created)); diff --git a/packages/stack/src/plugins/client/resource/hooks.tsx b/packages/stack/src/plugins/client/resource/hooks.tsx index ee2f7977..ffcc0284 100644 --- a/packages/stack/src/plugins/client/resource/hooks.tsx +++ b/packages/stack/src/plugins/client/resource/hooks.tsx @@ -166,6 +166,9 @@ function createQueryHooks( return { initialPageParam: 0, getNextPageParam: (lastPage: unknown, allPages: unknown[]) => { + if (def.nextPageParam) { + return def.nextPageParam(lastPage, allPages, ...args); + } const items = (lastPage as unknown[]) ?? []; if (items.length < pageSize) return undefined; return allPages.length * pageSize; diff --git a/packages/stack/src/plugins/client/resource/queries.ts b/packages/stack/src/plugins/client/resource/queries.ts index 12008e6d..4d2a1a05 100644 --- a/packages/stack/src/plugins/client/resource/queries.ts +++ b/packages/stack/src/plugins/client/resource/queries.ts @@ -30,8 +30,10 @@ export interface ResourceQueryDef< TArgs extends readonly unknown[] = readonly any[], TData = unknown, > { - /** better-call endpoint path, e.g. `"/posts"` */ + /** better-call endpoint path, e.g. `"/posts"` or `"/content/:typeSlug"` */ path: string; + /** Maps hook args to the endpoint path params (for `:param` segments) */ + params?: (...args: TArgs) => Record; /** Maps hook args to the HTTP query object */ query?: (...args: TArgs) => Record | undefined; /** @@ -54,6 +56,17 @@ export interface ResourceQueryDef< * (default 10). A function form derives it from the hook args. */ pageSize?: number | ((...args: TArgs) => number); + /** + * Custom `getNextPageParam` for infinite queries whose pages are not + * plain item arrays (e.g. `{ items, total }` envelopes). Overrides the + * default page-size heuristic. Return `undefined` when there is no + * next page. + */ + nextPageParam?: ( + lastPage: TData, + allPages: TData[], + ...args: TArgs + ) => unknown | undefined; /** When true, skip fetching and resolve `null` (e.g. missing id) */ skip?: (...args: TArgs) => boolean; } @@ -107,11 +120,13 @@ export type ResourceQueryArgs = TDef extends { query: (...args: infer A) => any; } ? A - : TDef extends { key: (...args: infer A) => any } + : TDef extends { params: (...args: infer A) => any } ? A - : TDef extends { select: (data: any, ...args: infer A) => any } + : TDef extends { key: (...args: infer A) => any } ? A - : []; + : TDef extends { select: (data: any, ...args: infer A) => any } + ? A + : []; /** Extracts the (per-page, for infinite queries) data type from a query declaration. */ export type ResourceQueryData = TDef extends { @@ -197,9 +212,11 @@ export async function runResourceQuery( const query = def.infinite ? { ...baseQuery, [def.offsetParam ?? "offset"]: pageParam ?? 0 } : baseQuery; + const params = def.params?.(...args); const response = await client(def.path, { method: "GET", + ...(params !== undefined ? { params } : {}), ...(query !== undefined ? { query } : {}), ...(headers !== undefined ? { headers } : {}), }); diff --git a/packages/stack/src/plugins/cms/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/cms/__tests__/client-sweep.test.tsx new file mode 100644 index 00000000..b9cdf707 --- /dev/null +++ b/packages/stack/src/plugins/cms/__tests__/client-sweep.test.tsx @@ -0,0 +1,557 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useForm } from "react-hook-form"; +import { Form, FormField } from "@workspace/ui/components/form"; +// Core primitives MUST be imported from the package entry (not relative src +// paths) so they share module identity — and React context — with the cms +// components, which resolve `@btst/stack/*` via package self-reference. +import { + StackProvider, + type StackAuthProvider, + type StackI18nProvider, +} from "@btst/stack/context"; +import { CMSFileUpload } from "../client/components/forms/file-upload"; +import { ContentForm } from "../client/components/forms/content-form"; +import { ContentListPage } from "../client/components/pages/content-list-page.internal"; +import type { + SerializedContentItemWithType, + SerializedContentType, +} from "../types"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// jsdom lacks these APIs used by Radix / cmdk +(globalThis as any).ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +}; +Element.prototype.scrollIntoView ??= () => {}; + +const hooks = vi.hoisted(() => ({ + useContentTypes: vi.fn(), + useSuspenseContentTypes: vi.fn(), + useContent: vi.fn(), + useSuspenseContent: vi.fn(), + useDeleteContent: vi.fn(), +})); + +vi.mock("../client/hooks", () => hooks); + +const SIMPLE_JSON_SCHEMA = JSON.stringify({ + type: "object", + properties: { + title: { type: "string" }, + }, + required: ["title"], + autoFormVersion: 2, +}); + +const contentType: SerializedContentType & { itemCount: number } = { + id: "ct1", + name: "Post", + slug: "post", + description: "", + jsonSchema: SIMPLE_JSON_SCHEMA, + autoFormVersion: 2, + itemCount: 1, + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-01").toISOString(), +} as unknown as SerializedContentType & { itemCount: number }; + +const item: SerializedContentItemWithType = { + id: "i1", + slug: "hello-world", + contentTypeId: "ct1", + data: JSON.stringify({ title: "Hello" }), + parsedData: { title: "Hello" }, + contentType: { id: "ct1", name: "Post", slug: "post" }, + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-01").toISOString(), +} as unknown as SerializedContentItemWithType; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + hooks.useSuspenseContentTypes.mockReturnValue({ + contentTypes: [contentType], + refetch: vi.fn(), + }); + hooks.useSuspenseContent.mockReturnValue({ + items: [item], + total: 1, + loadMore: vi.fn(), + hasMore: false, + isLoadingMore: false, + refetch: vi.fn(), + }); + hooks.useContent.mockReturnValue({ + items: [], + total: 0, + isLoading: false, + error: null, + loadMore: vi.fn(), + hasMore: false, + isLoadingMore: false, + refetch: vi.fn(), + }); + hooks.useDeleteContent.mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue({ success: true }), + isPending: false, + }); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +async function render(ui: React.ReactElement) { + await act(async () => { + root.render(ui); + }); +} + +function texts(): string { + return container.textContent ?? ""; +} + +function createMockRouter(initial = "") { + let params = new URLSearchParams(initial); + const setSearchParams = vi.fn( + (next: URLSearchParams, _opts?: { replace?: boolean }) => { + params = new URLSearchParams(next.toString()); + }, + ); + return { + navigate: vi.fn(), + getSearchParams: () => new URLSearchParams(params.toString()), + setSearchParams, + }; +} + +const cmsOverrides = { + navigate: vi.fn(), + apiBaseURL: "http://test.local", + apiBasePath: "/api/data", +}; + +// Renders CMSFileUpload the way ContentForm does: inside a react-hook-form +// FormField so the shadcn form primitives have their context. +function FileUploadHarness({ + uploadImage, +}: { + uploadImage: (file: File) => Promise; +}) { + const form = useForm<{ image: string }>({ defaultValues: { image: "" } }); + return ( +
+ + ( + + )} + /> + + + ); +} + +async function selectFile(file: File) { + const input = container.querySelector( + 'input[type="file"]', + ) as HTMLInputElement; + expect(input).toBeTruthy(); + Object.defineProperty(input, "files", { value: [file], configurable: true }); + await act(async () => { + input.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +describe("CMSFileUpload notifications (useNotify)", () => { + it("notifies a single error when the upload fails", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const notify = { success: vi.fn(), error: vi.fn() }; + const uploadImage = vi.fn().mockRejectedValue(new Error("boom")); + + await render( + + + , + ); + + await selectFile(new File(["x"], "a.png", { type: "image/png" })); + + expect(notify.error).toHaveBeenCalledTimes(1); + expect(notify.error).toHaveBeenCalledWith("Failed to upload image"); + expect(notify.success).not.toHaveBeenCalled(); + }); + + it("rejects non-image files without uploading", async () => { + const notify = { success: vi.fn(), error: vi.fn() }; + const uploadImage = vi.fn(); + + await render( + + + , + ); + + await selectFile(new File(["x"], "a.txt", { type: "text/plain" })); + + expect(uploadImage).not.toHaveBeenCalled(); + expect(notify.error).toHaveBeenCalledWith("Please select an image file"); + }); +}); + +describe("ContentForm inline errors", () => { + it("shows a non-field error message above the form", async () => { + await render( + + {}} + errorMessage="An item with this slug already exists" + /> + , + ); + + expect(texts()).toContain("An item with this slug already exists"); + }); + + it("lists server field errors in the banner when no form instance was captured", async () => { + await render( + + {}} + fieldErrors={{ title: "Title is too short" }} + /> + , + ); + + expect(texts()).toContain("title: Title is too short"); + }); +}); + +describe("ContentListPage row actions (CanAccess)", () => { + function renderListPage( + auth?: StackAuthProvider, + router = createMockRouter(), + ) { + return render( + + + , + ); + } + + it("shows edit and delete buttons without an auth provider", async () => { + await renderListPage(); + + const actionButtons = container.querySelectorAll("table tbody tr button"); + expect(actionButtons).toHaveLength(2); + expect(texts()).toContain("New Item"); + }); + + it("hides the delete button when can() denies cms:content/delete", async () => { + const can = vi.fn( + ({ resource, action }: { resource: string; action: string }) => + !(resource === "cms:content" && action === "delete"), + ); + const auth: StackAuthProvider = { + getIdentity: () => ({ id: "user-1" }), + can, + }; + + await renderListPage(auth); + + const actionButtons = container.querySelectorAll("table tbody tr button"); + expect(actionButtons).toHaveLength(1); + expect(can).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "cms:content", + action: "delete", + params: { typeSlug: "post", id: item.id }, + }), + ); + }); + + it("hides the New Item button when can() denies cms:content/create", async () => { + const auth: StackAuthProvider = { + getIdentity: () => ({ id: "user-1" }), + can: ({ resource, action }) => + !(resource === "cms:content" && action === "create"), + }; + + await renderListPage(auth); + + expect(texts()).not.toContain("New Item"); + // The list itself still renders + expect(texts()).toContain("hello-world"); + }); + + it("notifies success through the notify provider after deleting", async () => { + const notify = { success: vi.fn(), error: vi.fn() }; + const router = createMockRouter(); + + await render( + + + , + ); + + const actionButtons = container.querySelectorAll( + "table tbody tr button", + ); + const deleteButton = actionButtons[actionButtons.length - 1]!; + await act(async () => { + deleteButton.click(); + }); + + expect( + hooks.useDeleteContent.mock.results[0]!.value.mutateAsync, + ).toHaveBeenCalledWith(item.id); + expect(notify.success).toHaveBeenCalledWith("Item deleted successfully"); + expect(notify.error).not.toHaveBeenCalled(); + }); +}); + +describe("ContentListPage search (useListState)", () => { + it("seeds the search from an initial ?q= URL param", async () => { + const router = createMockRouter("q=hello"); + + await render( + + + , + ); + + const input = container.querySelector( + '[data-testid="cms-list-search"]', + ) as HTMLInputElement; + expect(input.value).toBe("hello"); + expect(hooks.useContent).toHaveBeenLastCalledWith( + "post", + expect.objectContaining({ search: "hello", enabled: true }), + ); + // Nothing is written back for a read-only render + expect(router.setSearchParams).not.toHaveBeenCalled(); + }); + + it("writes typed queries to the URL with replace history after the debounce", async () => { + const router = createMockRouter(); + + await render( + + + , + ); + + const input = container.querySelector( + '[data-testid="cms-list-search"]', + ) as HTMLInputElement; + expect(input).toBeTruthy(); + const setValue = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!; + await act(async () => { + setValue.call(input, "rust"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + // Not written before the debounce elapses + expect(router.setSearchParams).not.toHaveBeenCalled(); + + // Wait out the debounce, then the microtask URL flush + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 400)); + }); + + expect(router.setSearchParams).toHaveBeenCalled(); + const [written, opts] = router.setSearchParams.mock.calls.at(-1)!; + expect(written.get("q")).toBe("rust"); + expect(opts).toEqual({ replace: true }); + expect(hooks.useContent).toHaveBeenLastCalledWith( + "post", + expect.objectContaining({ search: "rust", enabled: true }), + ); + }); + + it("re-seeds the input from external URL changes instead of clobbering them", async () => { + const router = createMockRouter(); + + await render( + + + , + ); + + const input = container.querySelector( + '[data-testid="cms-list-search"]', + ) as HTMLInputElement; + expect(input.value).toBe(""); + + // Simulate back/forward: `?q=ext` appears without this component + // writing it (popstate is how useListState observes such changes) + await act(async () => { + router.setSearchParams(new URLSearchParams("q=ext")); + window.dispatchEvent(new PopStateEvent("popstate")); + }); + router.setSearchParams.mockClear(); + + expect(input.value).toBe("ext"); + expect(hooks.useContent).toHaveBeenLastCalledWith( + "post", + expect.objectContaining({ search: "ext", enabled: true }), + ); + + // Wait out the debounce window: the stale (empty) input must not be + // written back over the externally-set query + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 400)); + }); + expect(router.setSearchParams).not.toHaveBeenCalled(); + }); +}); + +describe("cms i18n precedence (useTranslate + overrides.localization)", () => { + it("renders the English default without providers", async () => { + hooks.useSuspenseContent.mockReturnValue({ + items: [], + total: 0, + loadMore: vi.fn(), + hasMore: false, + isLoadingMore: false, + refetch: vi.fn(), + }); + + await render( + + + , + ); + + expect(texts()).toContain("No items yet"); + }); + + it("routes strings through the i18n provider when configured", async () => { + hooks.useSuspenseContent.mockReturnValue({ + items: [], + total: 0, + loadMore: vi.fn(), + hasMore: false, + isLoadingMore: false, + refetch: vi.fn(), + }); + const i18n: StackI18nProvider = { + translate: (key, defaultValue) => + key === "cms.list.empty" ? "Noch keine Einträge" : defaultValue, + }; + + await render( + + + , + ); + + expect(texts()).toContain("Noch keine Einträge"); + }); + + it("lets overrides.localization win over the i18n provider", async () => { + hooks.useSuspenseContent.mockReturnValue({ + items: [], + total: 0, + loadMore: vi.fn(), + hasMore: false, + isLoadingMore: false, + refetch: vi.fn(), + }); + const translate = vi.fn( + (key: string, _defaultValue: string) => `translated:${key}`, + ); + + await render( + + + , + ); + + expect(texts()).toContain("Custom empty state"); + // The description (a different key) still goes through translate; the + // overridden title key must not. + expect(texts()).not.toMatch(/translated:cms\.list\.empty(?!Description)/); + }); +}); diff --git a/packages/stack/src/plugins/cms/__tests__/getters.test.ts b/packages/stack/src/plugins/cms/__tests__/getters.test.ts index 7c44d692..1358122b 100644 --- a/packages/stack/src/plugins/cms/__tests__/getters.test.ts +++ b/packages/stack/src/plugins/cms/__tests__/getters.test.ts @@ -147,6 +147,106 @@ describe("cms getters", () => { expect(result.items).toHaveLength(1); expect(result.items[0]!.slug).toBe("first"); }); + + describe("search", () => { + const seedSearchItems = async () => { + const ct = (await adapter.create({ + model: "contentType", + data: { + name: "Post", + slug: "post", + jsonSchema: SIMPLE_SCHEMA, + autoFormVersion: 2, + createdAt: new Date(), + updatedAt: new Date(), + }, + })) as any; + + const seed = [ + { slug: "typescript-tips", data: { title: "TypeScript Tips" } }, + { slug: "rust-basics", data: { title: "Getting Started" } }, + { + slug: "misc", + data: { + title: "Other", + tags: ["rust", "systems"], + meta: { author: "Ferris" }, + }, + }, + ]; + for (const item of seed) { + await adapter.create({ + model: "contentItem", + data: { + contentTypeId: ct.id, + slug: item.slug, + data: JSON.stringify(item.data), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + } + }; + + it("matches items by slug, case-insensitively", async () => { + await seedSearchItems(); + + const result = await getAllContentItems(adapter, "post", { + search: "TYPESCRIPT", + }); + expect(result.items.map((i) => i.slug)).toEqual(["typescript-tips"]); + expect(result.total).toBe(1); + }); + + it("matches items by data values, including arrays and nested objects", async () => { + await seedSearchItems(); + + const byValue = await getAllContentItems(adapter, "post", { + search: "getting started", + }); + expect(byValue.items.map((i) => i.slug)).toEqual(["rust-basics"]); + + const byArray = await getAllContentItems(adapter, "post", { + search: "systems", + }); + expect(byArray.items.map((i) => i.slug)).toEqual(["misc"]); + + const byNested = await getAllContentItems(adapter, "post", { + search: "ferris", + }); + expect(byNested.items.map((i) => i.slug)).toEqual(["misc"]); + }); + + it("returns the filtered total and paginates search results", async () => { + await seedSearchItems(); + + const page1 = await getAllContentItems(adapter, "post", { + search: "rust", + limit: 1, + offset: 0, + }); + expect(page1.total).toBe(2); + expect(page1.items).toHaveLength(1); + + const page2 = await getAllContentItems(adapter, "post", { + search: "rust", + limit: 1, + offset: 1, + }); + expect(page2.total).toBe(2); + expect(page2.items).toHaveLength(1); + expect(page2.items[0]!.slug).not.toBe(page1.items[0]!.slug); + }); + + it("ignores a whitespace-only search", async () => { + await seedSearchItems(); + + const result = await getAllContentItems(adapter, "post", { + search: " ", + }); + expect(result.total).toBe(3); + }); + }); }); describe("getContentItemBySlug", () => { diff --git a/packages/stack/src/plugins/cms/api/getters.ts b/packages/stack/src/plugins/cms/api/getters.ts index 6db84390..da8a4a8b 100644 --- a/packages/stack/src/plugins/cms/api/getters.ts +++ b/packages/stack/src/plugins/cms/api/getters.ts @@ -1,4 +1,5 @@ import type { DBAdapter as Adapter } from "@btst/db"; +import { DEFAULT_MAX_PAGE_SIZE } from "../schemas"; import type { ContentType, ContentItem, @@ -107,7 +108,32 @@ export async function getAllContentTypes( } /** - * Retrieve all content items for a given content type, with optional pagination. + * Case-insensitive substring match against an item's slug and the string + * values of its parsed data (one level deep — nested objects/arrays of + * primitives are scanned, deeper structures are skipped). + */ +function contentItemMatchesSearch( + item: SerializedContentItemWithType, + searchLower: string, +): boolean { + if (item.slug.toLowerCase().includes(searchLower)) return true; + + const matchesValue = (value: unknown): boolean => + typeof value === "string" && value.toLowerCase().includes(searchLower); + + return Object.values(item.parsedData).some((value) => { + if (matchesValue(value)) return true; + if (Array.isArray(value)) return value.some(matchesValue); + if (typeof value === "object" && value !== null) { + return Object.values(value).some(matchesValue); + } + return false; + }); +} + +/** + * Retrieve all content items for a given content type, with optional pagination + * and free-text search. * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use. * * @remarks **Security:** Authorization hooks (e.g. `onBeforeListItems`) are NOT @@ -116,12 +142,13 @@ export async function getAllContentTypes( * * @param adapter - The database adapter * @param contentTypeSlug - The slug of the content type to query - * @param params - Optional filter/pagination parameters + * @param params - Optional filter/pagination parameters. `search` matches + * case-insensitively against item slugs and string values in the item data. */ export async function getAllContentItems( adapter: Adapter, contentTypeSlug: string, - params?: { slug?: string; limit?: number; offset?: number }, + params?: { slug?: string; limit?: number; offset?: number; search?: string }, ): Promise<{ items: SerializedContentItemWithType[]; total: number; @@ -168,24 +195,58 @@ export async function getAllContentItems( }); } + // Free-text search must remain in-memory: item data is stored as a JSON + // string, so the adapter cannot match individual field values. All other + // filters above are pushed to DB; when searching, pagination happens + // after the in-memory pass so `total` reflects the filtered set. + // The DB scan is capped at DEFAULT_MAX_PAGE_SIZE to bound memory use; + // items beyond the cap are not searched. + const search = params?.search?.trim(); + const needsInMemoryFilter = !!search; + // TODO: remove cast once @btst/db types expose adapter.count() - const total: number = await adapter.count({ - model: "contentItem", - where: whereConditions, - }); + const dbTotal: number | undefined = !needsInMemoryFilter + ? await adapter.count({ + model: "contentItem", + where: whereConditions, + }) + : undefined; const items = await adapter.findMany({ model: "contentItem", where: whereConditions, - limit: params?.limit, - offset: params?.offset, + limit: !needsInMemoryFilter ? params?.limit : DEFAULT_MAX_PAGE_SIZE, + offset: !needsInMemoryFilter ? params?.offset : undefined, sortBy: { field: "createdAt", direction: "desc" }, join: { contentType: true }, }); + let result = items.map(serializeContentItemWithType); + + if (needsInMemoryFilter) { + const searchLower = search.toLowerCase(); + result = result.filter((item) => + contentItemMatchesSearch(item, searchLower), + ); + + const total = result.length; + const offset = params?.offset ?? 0; + const limit = params?.limit; + result = result.slice( + offset, + limit !== undefined ? offset + limit : undefined, + ); + return { + items: result, + total, + limit: params?.limit, + offset: params?.offset, + }; + } + return { - items: items.map(serializeContentItemWithType), - total, + items: result, + total: dbTotal ?? result.length, limit: params?.limit, offset: params?.offset, }; diff --git a/packages/stack/src/plugins/cms/api/plugin.ts b/packages/stack/src/plugins/cms/api/plugin.ts index 1530a0a1..08c446f9 100644 --- a/packages/stack/src/plugins/cms/api/plugin.ts +++ b/packages/stack/src/plugins/cms/api/plugin.ts @@ -595,14 +595,19 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => { }, async (ctx) => { const { typeSlug } = ctx.params; - const { slug, limit, offset } = ctx.query; + const { slug, search, limit, offset } = ctx.query; const contentType = await getContentType(typeSlug); if (!contentType) { throw ctx.error(404, { message: "Content type not found" }); } - return getAllContentItems(adapter, typeSlug, { slug, limit, offset }); + return getAllContentItems(adapter, typeSlug, { + slug, + search, + limit, + offset, + }); }, ); @@ -680,9 +685,12 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => { const zodSchema = getContentTypeZodSchema(contentType); const validation = zodSchema.safeParse(dataWithResolvedRelations); if (!validation.success) { + // `issues` matches the shape better-call emits for body + // validation errors, so clients get field-level errors + // (StackError.errors) for inline form display. throw ctx.error(400, { message: "Validation failed", - errors: validation.error.issues, + issues: validation.error.issues, }); } @@ -837,9 +845,10 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => { const zodSchema = getContentTypeZodSchema(contentType); const validation = zodSchema.safeParse(mergedData); if (!validation.success) { + // See the create endpoint: `issues` → StackError.errors throw ctx.error(400, { message: "Validation failed", - errors: validation.error.issues, + issues: validation.error.issues, }); } validatedData = validation.data as Record; diff --git a/packages/stack/src/plugins/cms/api/query-key-defs.ts b/packages/stack/src/plugins/cms/api/query-key-defs.ts index 9cb3ba79..b023f8f6 100644 --- a/packages/stack/src/plugins/cms/api/query-key-defs.ts +++ b/packages/stack/src/plugins/cms/api/query-key-defs.ts @@ -8,21 +8,29 @@ export interface ContentListDiscriminator { typeSlug: string; limit: number; offset: number; + search: string | undefined; } /** * Builds the discriminator object used as the cache key for the content list. - * Mirrors the params object used in createContentQueries.list so both paths stay in sync. + * Mirrors the params object used in the cmsContent.list resource declaration + * so both paths stay in sync. An empty/whitespace search term is normalized + * to `undefined` so it hashes identically to "no search". */ export function contentListDiscriminator(params: { typeSlug: string; limit?: number; offset?: number; + search?: string; }): ContentListDiscriminator { return { typeSlug: params.typeSlug, limit: params.limit ?? 20, offset: params.offset ?? 0, + search: + params.search !== undefined && params.search.trim() === "" + ? undefined + : params.search, }; } @@ -36,12 +44,13 @@ export const CMS_QUERY_KEYS = { /** * Key for the cmsContent.list({ typeSlug, limit, offset }) query. - * Full key: ["cmsContent", "list", { typeSlug, limit, offset }] + * Full key: ["cmsContent", "list", { typeSlug, limit, offset, search }] */ contentList: (params: { typeSlug: string; limit?: number; offset?: number; + search?: string; }) => ["cmsContent", "list", contentListDiscriminator(params)] as const, /** 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 6eddcaad..e78fa058 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 @@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, useRef } from "react"; import { z } from "zod"; +import type { FieldPath, FieldValues, UseFormReturn } from "react-hook-form"; import { SteppedAutoForm } from "@workspace/ui/components/auto-form/stepped-auto-form"; import type { FieldConfig, @@ -12,11 +13,10 @@ import { formSchemaToZod } from "@workspace/ui/lib/schema-converter"; import { Input } from "@workspace/ui/components/input"; import { Label } from "@workspace/ui/components/label"; import { Badge } from "@workspace/ui/components/badge"; -import { usePluginOverrides } from "@btst/stack/context"; +import { usePluginOverrides, useTranslate } from "@btst/stack/context"; import type { CMSPluginOverrides } from "../../overrides"; import type { SerializedContentType, RelationConfig } from "../../../types"; import { slugify } from "../../../utils"; -import { CMS_LOCALIZATION } from "../../localization"; import { CMSFileUpload } from "./file-upload"; import { RelationField } from "./relation-field"; @@ -30,6 +30,49 @@ interface ContentFormProps { data: Record; }) => Promise; onCancel?: () => void; + /** + * Server-side field validation errors (`StackError.errors`), applied to + * the matching form fields for inline display. + */ + fieldErrors?: Record; + /** Non-field submit error to display above the form */ + errorMessage?: string; + /** External submit-in-flight state (e.g. from a resource `useForm`) */ + isSubmitting?: boolean; +} + +/** + * Applies server-side field validation errors onto react-hook-form field + * state, and clears previously applied server errors that are no longer + * present. The form instance arrives asynchronously (captured from + * SteppedAutoForm's `onValuesChange`) and is `null` for multi-step forms. + */ +function useServerFieldErrors( + form: UseFormReturn | null, + fieldErrors: Record, +) { + const appliedFieldsRef = useRef([]); + + useEffect(() => { + if (!form) return; + + // Clear stale server errors from fields that are no longer failing + for (const field of appliedFieldsRef.current) { + if (field in fieldErrors) continue; + const { error } = form.getFieldState(field as FieldPath); + if (error?.type === "server") { + form.clearErrors(field as FieldPath); + } + } + appliedFieldsRef.current = Object.keys(fieldErrors); + + for (const [field, message] of Object.entries(fieldErrors)) { + form.setError(field as FieldPath, { + type: "server", + message: Array.isArray(message) ? message.join(", ") : message, + }); + } + }, [fieldErrors, form]); } /** @@ -204,24 +247,39 @@ export function ContentForm({ isEditing = false, onSubmit, onCancel, + fieldErrors, + errorMessage, + isSubmitting: isSubmittingProp, }: ContentFormProps) { + const t = useTranslate(); const { - localization: customLocalization, + localization, uploadImage, imagePicker, imageInputField, fieldComponents, } = usePluginOverrides("cms"); - const localization = { ...CMS_LOCALIZATION, ...customLocalization }; const [slug, setSlug] = useState(initialSlug); const [slugManuallyEdited, setSlugManuallyEdited] = useState(isEditing); - const [isSubmitting, setIsSubmitting] = useState(false); + const [isSubmittingLocal, setIsSubmittingLocal] = useState(false); const [formData, setFormData] = useState>(initialData); const [slugError, setSlugError] = useState(null); const [submitError, setSubmitError] = useState(null); + const isSubmitting = isSubmittingProp || isSubmittingLocal; + + // Single-step forms pass their react-hook-form instance through + // onValuesChange; multi-step forms pass undefined (no single instance). + const [formInstance, setFormInstance] = useState + > | null>(null); + + const serverFieldErrors = useMemo(() => fieldErrors ?? {}, [fieldErrors]); + useServerFieldErrors(formInstance, serverFieldErrors); + const hasFieldErrors = Object.keys(serverFieldErrors).length > 0; + // Track if we've already synced prefill data to avoid overwriting user input const hasSyncedPrefillRef = useRef(false); @@ -290,7 +348,13 @@ export function ContentForm({ ); // Handle form value changes for slug auto-generation - const handleValuesChange = (values: Record) => { + const handleValuesChange = ( + values: Record, + form?: UseFormReturn>, + ) => { + if (form) { + setFormInstance((current) => (current === form ? current : form)); + } setFormData(values); // Auto-generate slug from source field if not manually edited @@ -308,33 +372,58 @@ export function ContentForm({ setSubmitError(null); if (!slug.trim()) { - setSlugError("Slug is required"); + setSlugError( + localization?.CMS_EDITOR_SLUG_REQUIRED ?? + t("cms.editor.slugRequired", "Slug is required"), + ); return; } - setIsSubmitting(true); + setIsSubmittingLocal(true); try { await onSubmit({ slug, data }); } catch (error) { const message = - error instanceof Error ? error.message : localization.CMS_TOAST_ERROR; + error instanceof Error + ? error.message + : (localization?.CMS_TOAST_ERROR ?? + t("cms.toasts.error", "An error occurred. Please try again.")); setSubmitError(message); } finally { - setIsSubmitting(false); + setIsSubmittingLocal(false); } }; + // Non-field error from the parent (resource form), or a local submit + // failure. Field errors display inline instead — unless the form + // instance is unavailable (multi-step), where they land in the banner. + const bannerMessage = + errorMessage ?? + submitError ?? + (hasFieldErrors && !formInstance + ? Object.entries(serverFieldErrors) + .map( + ([field, message]) => + `${field}: ${Array.isArray(message) ? message.join(", ") : message}`, + ) + .join(" · ") + : undefined); + return (
{/* Slug field */}
- + {!isEditing && ( {slugManuallyEdited - ? localization.CMS_EDITOR_SLUG_MANUAL - : localization.CMS_EDITOR_SLUG_AUTO} + ? (localization?.CMS_EDITOR_SLUG_MANUAL ?? + t("cms.editor.slugManual", "Manually set")) + : (localization?.CMS_EDITOR_SLUG_AUTO ?? + t("cms.editor.slugAuto", "Auto-generated from first field"))} )}
@@ -351,20 +440,31 @@ export function ContentForm({ disabled={isEditing} placeholder={ slugSourceField - ? `Auto-generated from ${slugSourceField}` - : "Enter slug..." + ? ( + localization?.CMS_EDITOR_SLUG_PLACEHOLDER_AUTO ?? + t( + "cms.editor.slugPlaceholderAuto", + "Auto-generated from {field}", + ) + ).replace("{field}", slugSourceField) + : (localization?.CMS_EDITOR_SLUG_PLACEHOLDER ?? + t("cms.editor.slugPlaceholder", "Enter slug...")) } /> {slugError &&

{slugError}

}

- {localization.CMS_LABEL_SLUG_DESCRIPTION} + {localization?.CMS_LABEL_SLUG_DESCRIPTION ?? + t( + "cms.common.slugDescription", + "URL-friendly identifier for this item", + )}

{/* Submit error message */} - {submitError && ( + {bannerMessage && (
-

{submitError}

+

{bannerMessage}

)} @@ -379,8 +479,9 @@ export function ContentForm({ isSubmitting={isSubmitting} submitButtonText={ isSubmitting - ? localization.CMS_STATUS_SAVING - : localization.CMS_BUTTON_SAVE + ? (localization?.CMS_STATUS_SAVING ?? + t("cms.common.saving", "Saving...")) + : (localization?.CMS_BUTTON_SAVE ?? t("cms.common.save", "Save")) } > {onCancel && ( @@ -389,7 +490,8 @@ export function ContentForm({ onClick={onCancel} className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground" > - {localization.CMS_BUTTON_CANCEL} + {localization?.CMS_BUTTON_CANCEL ?? + t("cms.common.cancel", "Cancel")} )} diff --git a/packages/stack/src/plugins/cms/client/components/forms/file-upload.tsx b/packages/stack/src/plugins/cms/client/components/forms/file-upload.tsx index b76f7a68..8245ac95 100644 --- a/packages/stack/src/plugins/cms/client/components/forms/file-upload.tsx +++ b/packages/stack/src/plugins/cms/client/components/forms/file-upload.tsx @@ -7,7 +7,12 @@ import { type ChangeEvent, type ComponentType, } from "react"; -import { toast } from "sonner"; +import { + useNotify, + usePluginOverrides, + useTranslate, +} from "@btst/stack/context"; +import type { CMSPluginOverrides } from "../../overrides"; import type { AutoFormInputComponentProps } from "@workspace/ui/components/auto-form/types"; import { Input } from "@workspace/ui/components/input"; import { Button } from "@workspace/ui/components/button"; @@ -87,6 +92,9 @@ export function CMSFileUpload({ const showLabel = _showLabel === undefined ? true : _showLabel; // All hooks must be called unconditionally before any early return. + const t = useTranslate(); + const notify = useNotify(); + const { localization } = usePluginOverrides("cms"); const [isUploading, setIsUploading] = useState(false); const [previewUrl, setPreviewUrl] = useState( field.value || null, @@ -105,7 +113,10 @@ export function CMSFileUpload({ if (!file) return; if (!file.type.startsWith("image/")) { - toast.error("Please select an image file"); + notify.error( + localization?.CMS_EDITOR_FILE_INVALID_TYPE ?? + t("cms.editor.fileInvalidType", "Please select an image file"), + ); return; } @@ -116,12 +127,15 @@ export function CMSFileUpload({ field.onChange(url); } catch (error) { console.error("Image upload failed:", error); - toast.error("Failed to upload image"); + notify.error( + localization?.CMS_EDITOR_FILE_UPLOAD_FAILED ?? + t("cms.editor.fileUploadFailed", "Failed to upload image"), + ); } finally { setIsUploading(false); } }, - [field, uploadImage], + [field, uploadImage, notify, localization, t], ); const handleRemove = useCallback(() => { diff --git a/packages/stack/src/plugins/cms/client/components/forms/relation-field.tsx b/packages/stack/src/plugins/cms/client/components/forms/relation-field.tsx index 702be7a0..b1d47c66 100644 --- a/packages/stack/src/plugins/cms/client/components/forms/relation-field.tsx +++ b/packages/stack/src/plugins/cms/client/components/forms/relation-field.tsx @@ -1,14 +1,10 @@ "use client"; import { useState, useCallback, useMemo } from "react"; -import { useQueries } from "@tanstack/react-query"; -import { createApiClient } from "@btst/stack/plugins/client"; -import { usePluginOverrides } from "@btst/stack/context"; -import { useContent, useCreateContent } from "../../hooks"; -import type { CMSApiRouter } from "../../../api"; +import { usePluginOverrides, useTranslate } from "@btst/stack/context"; +import { useCreateContent, useContentOptions } from "../../hooks"; import type { SerializedContentItemWithType } from "../../../types"; import type { CMSPluginOverrides } from "../../overrides"; -import { createCMSQueryKeys } from "../../../query-keys"; import MultipleSelector from "@workspace/ui/components/multi-select"; import type { Option } from "@workspace/ui/components/multi-select"; import { Button } from "@workspace/ui/components/button"; @@ -26,16 +22,6 @@ import { Textarea } from "@workspace/ui/components/textarea"; import type { AutoFormInputComponentProps } from "@workspace/ui/components/auto-form/types"; import type { RelationConfig } from "../../../types"; -/** Match cms-hooks SHARED_QUERY_CONFIG for detail fetches (deduped labels). */ -const RELATION_DETAIL_QUERY_OPTS = { - retry: false, - refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: false, - staleTime: 1000 * 60 * 5, - gcTime: 1000 * 60 * 10, -} as const; - interface RelationFieldProps extends AutoFormInputComponentProps { relation: RelationConfig; } @@ -44,6 +30,10 @@ interface RelationFieldProps extends AutoFormInputComponentProps { * A form field component for handling CMS content relationships. * Supports selecting existing items and optionally creating new items inline. * + * Options come from the resource `useSelect` hook: debounced server-side + * search over the target type, with selected values not present in the + * current results preloaded by id (for labels). + * * Handles two value formats: * - belongsTo: single object { id: string } or undefined * - hasMany/manyToMany: array of { id: string } @@ -55,27 +45,13 @@ export function RelationField({ isRequired, relation, }: RelationFieldProps) { + const t = useTranslate(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [newItemName, setNewItemName] = useState(""); const [newItemDescription, setNewItemDescription] = useState(""); const [createError, setCreateError] = useState(null); - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("cms"); - - const listClient = useMemo( - () => - createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }), - [apiBaseURL, apiBasePath], - ); - - const cmsQueries = useMemo( - () => createCMSQueryKeys(listClient, headers), - [listClient, headers], - ); + const { localization } = usePluginOverrides("cms"); // For belongsTo (single relation), we only allow one selection const isSingleSelect = relation.type === "belongsTo"; @@ -98,91 +74,30 @@ export function RelationField({ return (field.value as Array<{ id: string }>) || []; }, [field.value, isSingleSelect]); - // Fetch available items from the target content type (first page only) - const { items: availableItems, isLoading } = useContent(relation.targetType, { - limit: 500, - }); - - const missingDetailIds = useMemo(() => { - const loadedIds = new Set(availableItems.map((i) => i.id)); - return normalizedValue - .map((v) => v.id) - .filter((id) => id.length > 0 && !loadedIds.has(id)); - }, [availableItems, normalizedValue]); - - const hydrationResult = useQueries({ - queries: missingDetailIds.map((id) => ({ - ...cmsQueries.cmsContent.detail(relation.targetType, id), - ...RELATION_DETAIL_QUERY_OPTS, - enabled: Boolean(relation.targetType && id), - })), - combine: (results) => ({ - data: results.map( - (r) => r.data as SerializedContentItemWithType | null | undefined, + const { displayField } = relation; + const getOptionLabel = useCallback( + (item: SerializedContentItemWithType) => + String( + (item.parsedData as Record)?.[displayField] || + item.slug, ), - isHydrating: results.some((r) => r.isFetching), - }), - }); - - const isHydratingLabels = hydrationResult.isHydrating; - - const itemById = useMemo(() => { - const m = new Map(); - for (const it of availableItems) { - m.set(it.id, it as SerializedContentItemWithType); - } - for (let i = 0; i < missingDetailIds.length; i++) { - const row = hydrationResult.data[i]; - if (row?.id) { - m.set(row.id, row); - } - } - return m; - }, [availableItems, missingDetailIds, hydrationResult.data]); + [displayField], + ); - // Convert normalized value to Option[] for MultipleSelector - const selectedOptions: Option[] = normalizedValue.map((v) => { - const item = itemById.get(v.id); - if (item) { - const displayValue = - (item.parsedData as Record)?.[relation.displayField] || - item.slug; - return { - value: item.id, - label: String(displayValue), - }; - } - return { value: v.id, label: `ID: ${v.id.slice(0, 8)}...` }; + const select = useContentOptions({ + targetType: relation.targetType, + value: normalizedValue.map((v) => v.id), + getOptionLabel, }); - // Listed options + any selected partners loaded by id (not on first list page) - const options: Option[] = useMemo(() => { - const merged: SerializedContentItemWithType[] = [ - ...(availableItems as SerializedContentItemWithType[]), - ]; - const seen = new Set(merged.map((x) => x.id)); - for (let i = 0; i < missingDetailIds.length; i++) { - const row = hydrationResult.data[i]; - if (row?.id && !seen.has(row.id)) { - merged.push(row); - seen.add(row.id); - } - } - return merged.map((item) => { - const displayValue = - (item.parsedData as Record)?.[relation.displayField] || - item.slug; - return { - value: item.id, - label: String(displayValue), - }; - }); - }, [ - availableItems, - hydrationResult.data, - missingDetailIds, - relation.displayField, - ]); + const options: Option[] = select.options.map((option) => ({ + value: option.value, + label: option.label, + })); + const selectedOptions: Option[] = select.selectedOptions.map((option) => ({ + value: option.value, + label: option.label, + })); // Mutation for creating new items const createMutation = useCreateContent(relation.targetType); @@ -238,7 +153,11 @@ export function RelationField({ const message = error instanceof Error ? error.message - : "Failed to create item. Please try again."; + : (localization?.CMS_RELATION_CREATE_ERROR ?? + t( + "cms.relations.createError", + "Failed to create item. Please try again.", + )); setCreateError(message); } }; @@ -258,6 +177,10 @@ export function RelationField({ [normalizedValue, field, isSingleSelect], ); + const displayFieldLabel = + relation.displayField.charAt(0).toUpperCase() + + relation.displayField.slice(1); + return (