Skip to content

fix: cms admin form#123

Open
olliethedev wants to merge 10 commits intomainfrom
fix/cms-admin-form
Open

fix: cms admin form#123
olliethedev wants to merge 10 commits intomainfrom
fix/cms-admin-form

Conversation

@olliethedev
Copy link
Copy Markdown
Collaborator

@olliethedev olliethedev commented Apr 30, 2026

Summary

  • cms admin forms primitive array
  • cms admin dash content pagination improvements

Type of change

  • Bug fix
  • New plugin
  • Feature / enhancement to an existing plugin
  • Documentation
  • Chore / refactor / tooling

Checklist

  • pnpm build passes
  • pnpm typecheck passes
  • pnpm lint passes
  • Tests added or updated (unit and/or E2E)
  • Docs updated (docs/content/docs/) if consumer-facing types or behavior changed
  • All three codegen-projects create successfully and pass E2E tests
  • New plugin: submission checklist in CONTRIBUTING.md completed

Screenshots


Note

Medium Risk
Changes core CMS admin form behavior and backend pagination limits, which could affect content editing and listing performance/compatibility if assumptions about array handling or page-size caps differ in downstream usage.

Overview
Fixes a CMS admin silent save failure by changing AutoFormArray to avoid react-hook-form’s useFieldArray for primitive arrays, preserving string[]/number[]/boolean[] values in form state and rendering appropriate primitive inputs.

Improves CMS listing/pagination by introducing configurable maxPageSize (default 1000) and applying it across list endpoints via schema factories; also replaces content-type item counting with adapter.count().

Enhances relation fields to hydrate labels for selected IDs not present in the first list page using React Query detail fetches (and exports createCMSQueryKeys via the CMS API index), adds regression tests for the primitive-array bug, and bumps @btst/stack to 2.12.1.

Reviewed by Cursor Bugbot for commit 499043c. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
better-stack-docs Ready Ready Preview, Comment Apr 30, 2026 11:32pm
better-stack-playground Ready Ready Preview, Comment Apr 30, 2026 11:32pm

Request Review

export const listContentQuerySchema = createListContentQuerySchema();

/**
* Schema for creating a content item
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security — Medium] DEFAULT_MAX_PAGE_SIZE = 1000 is a 10× increase over the previous hardcoded max(100). Any deployment that does not configure maxPageSize will silently allow far larger page requests, enabling server-side resource exhaustion and easier bulk data exfiltration by authenticated users.

Consider defaulting to 100 (matching the old behaviour) and letting operators opt in to a higher limit via explicit config.

import { createListContentQuerySchema } from "../schemas";
import { slugify } from "../utils";
import {
getAllContentTypes,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security — Low] offset has no upper bound. A client can supply an arbitrarily large value (e.g. offset=9999999999), which may cause runaway DB scans, integer overflow in the adapter layer, or expose adapter error details in the response.

Add .max(...) — a value proportional to maxPageSize is a reasonable choice.

disabled={isLoading || isHydratingLabels}
hidePlaceholderWhenSelected
emptyIndicator={
<p className="text-center text-sm text-muted-foreground py-4">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security — Uncertain] createApiClient is constructed without headers. The headers value (which may carry auth tokens) is only passed to createCMSQueryKeys. If createCMSQueryKeys injects headers at call-time into every query function, this is safe. If listClient is a closed client that does not accept per-call header overrides, auth headers will be silently dropped for relation-detail hydration requests.

Please verify that createCMSQueryKeys actually forwards headers to all outgoing HTTP requests.

Comment thread packages/ui/src/components/auto-form/fields/array.tsx Outdated
Co-authored-by: Ollie <olliethedev@users.noreply.github.com>
Comment thread packages/ui/src/components/auto-form/fields/array.tsx
cursoragent and others added 2 commits April 30, 2026 19:07
…atch on array append

Co-authored-by: Ollie <olliethedev@users.noreply.github.com>
Co-authored-by: Ollie <olliethedev@users.noreply.github.com>
Comment thread packages/stack/src/plugins/cms/client/components/forms/relation-field.tsx Outdated
…lationField

Co-authored-by: Ollie <olliethedev@users.noreply.github.com>
Comment thread packages/ui/src/components/auto-form/fields/array.tsx
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Hardcoded fallback duplicates exported constant value
    • Replaced the hardcoded 1000 fallback in paginationQuerySchema with the imported DEFAULT_MAX_PAGE_SIZE constant, ensuring all endpoints stay in sync if the default is ever changed.
Preview (6adf943f27)
diff --git a/packages/stack/package.json b/packages/stack/package.json
--- a/packages/stack/package.json
+++ b/packages/stack/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@btst/stack",
-  "version": "2.12.0",
+  "version": "2.12.1",
   "description": "A composable, plugin-based library for building full-stack applications.",
   "repository": {
     "type": "git",

diff --git a/packages/stack/src/plugins/cms/__tests__/schema-roundtrip.test.ts b/packages/stack/src/plugins/cms/__tests__/schema-roundtrip.test.ts
--- a/packages/stack/src/plugins/cms/__tests__/schema-roundtrip.test.ts
+++ b/packages/stack/src/plugins/cms/__tests__/schema-roundtrip.test.ts
@@ -804,3 +804,206 @@
 		});
 	});
 });
+
+/**
+ * These tests confirm and protect against a bug where `useFieldArray` in
+ * AutoFormArray corrupts primitive string values in arrays like
+ * `structuredContraindications: z.array(z.string()).default([])`.
+ *
+ * When react-hook-form's `useFieldArray` processes a primitive array
+ * (e.g. `["pregnancy", "active malignancy"]`), it wraps each element as a
+ * tracking object `{ id: "rhf_generated_id" }`, discarding the original
+ * string value. When `form.watch()` fires, it returns these objects. The
+ * `handleValuesChange` callback then calls `setFormData(values)` which stores
+ * the corrupted objects. On form submit, `zodResolver` validates the corrupted
+ * values against `z.array(z.string())` and FAILS — causing the Save button
+ * to do nothing (no error shown, no API request made).
+ *
+ * The fix: AutoFormArray must NOT use `useFieldArray` for primitive (non-object)
+ * arrays. Instead it uses `form.watch` + `form.setValue` directly so primitive
+ * values are always preserved in the form state.
+ */
+describe("Primitive string array — useFieldArray corruption bug", () => {
+	/**
+	 * Simulates what zodToFormSchema + formSchemaToZod does:
+	 * Zod schema → JSON Schema (stored in DB) → reconstructed Zod schema.
+	 */
+	function roundtripSchema(schema: z.ZodType): z.ZodType {
+		const jsonSchema = z.toJSONSchema(schema, { unrepresentable: "any" });
+		return z.fromJSONSchema(jsonSchema as z.core.JSONSchema.JSONSchema);
+	}
+
+	it("z.array(z.string()) survives JSON Schema roundtrip and accepts string values", () => {
+		const schema = z.object({
+			structuredContraindications: z.array(z.string()).default([]),
+		});
+
+		const reconstructed = roundtripSchema(schema);
+
+		// Actual compound data — should PASS
+		expect(
+			reconstructed.safeParse({
+				structuredContraindications: [
+					"pregnancy",
+					"active malignancy",
+					"active cancer",
+					"trying to conceive",
+				],
+			}).success,
+		).toBe(true);
+
+		// Empty array — should PASS (default)
+		expect(
+			reconstructed.safeParse({ structuredContraindications: [] }).success,
+		).toBe(true);
+	});
+
+	it("useFieldArray-corrupted values (objects) fail z.array(z.string()) validation — this is the root cause of the silent save failure", () => {
+		const schema = z.object({
+			structuredContraindications: z.array(z.string()).default([]),
+		});
+
+		const reconstructed = roundtripSchema(schema);
+
+		// Simulates what react-hook-form's useFieldArray returns when used with
+		// primitive string arrays: each string is replaced by a tracking object
+		// { id: "rhf_generated_id" } and the original value is lost.
+		const corruptedByUseFieldArray = {
+			structuredContraindications: [
+				{ id: "rhf_internal_id_1" },
+				{ id: "rhf_internal_id_2" },
+				{ id: "rhf_internal_id_3" },
+				{ id: "rhf_internal_id_4" },
+			],
+		};
+
+		// This is the actual validation error that occurs when Save is clicked:
+		// zodResolver validates the corrupted objects against z.array(z.string())
+		// and FAILS, so onSubmit is never called → no API request → "nothing happens"
+		const result = reconstructed.safeParse(corruptedByUseFieldArray);
+		expect(result.success).toBe(false);
+		if (!result.success) {
+			// Confirm the error is specifically about the string array elements
+			const paths = result.error.issues.map((i) => i.path.join("."));
+			expect(
+				paths.some((p) => p.startsWith("structuredContraindications")),
+			).toBe(true);
+		}
+	});
+
+	it("primitive string array values are preserved correctly (NOT corrupted) when form state is managed without useFieldArray", () => {
+		// After the fix, AutoFormArray uses form.watch + form.setValue for primitive
+		// arrays. The values remain as strings throughout the lifecycle:
+		// initialData → formData → form state → zodResolver → submit
+
+		const schema = z.object({
+			structuredContraindications: z.array(z.string()).default([]),
+		});
+
+		const reconstructed = roundtripSchema(schema);
+
+		// The correctly-preserved values (no useFieldArray wrapping)
+		const preservedValues = {
+			structuredContraindications: [
+				"pregnancy",
+				"active malignancy",
+				"active cancer",
+				"trying to conceive",
+			],
+		};
+
+		// With the fix applied, these values pass validation → Save works
+		expect(reconstructed.safeParse(preservedValues).success).toBe(true);
+	});
+
+	it("compound schema with structuredContraindications passes full validation with string array values", () => {
+		// Representative subset of CompoundSchema fields that appear on the
+		// Epitalon compound page — verifies the full schema round-trip for the
+		// fields that are relevant to the reported bug.
+		const compoundSchema = z.object({
+			name: z.string().min(1),
+			compoundType: z.enum([
+				"healing-peptide",
+				"gh-axis",
+				"metabolic-peptide",
+				"sarm",
+				"steroid",
+				"nootropic",
+				"supplement",
+				"ancillary-pct",
+				"longevity",
+				"hair",
+				"skin",
+				"sexual",
+				"other",
+			]),
+			researchStatus: z.enum([
+				"research-only",
+				"approved",
+				"banned",
+				"grey-market",
+				"supplement",
+			]),
+			legalStatus: z.enum([
+				"OTC",
+				"Research",
+				"Grey-Market",
+				"Rx-Only",
+				"Schedule-III",
+				"Banned",
+			]),
+			doseUnit: z.enum(["mcg", "mg", "IU", "ml", "g"]),
+			doseFrequency: z.enum([
+				"once-daily",
+				"twice-daily",
+				"three-times-daily",
+				"every-other-day",
+				"weekly",
+				"twice-weekly",
+				"three-times-weekly",
+				"as-needed",
+				"custom",
+			]),
+			structuredContraindications: z.array(z.string()).default([]),
+			affiliates: z
+				.array(
+					z.object({
+						partnerId: z.object({ id: z.string() }).optional(),
+						title: z.string().optional(),
+						url: z.string().min(1),
+					}),
+				)
+				.default([]),
+		});
+
+		const reconstructed = roundtripSchema(compoundSchema);
+
+		// Epitalon-like data — all values as they come from parsedData
+		const epitalon = {
+			name: "Epitalon",
+			compoundType: "longevity",
+			researchStatus: "research-only",
+			legalStatus: "Research",
+			doseUnit: "mg",
+			doseFrequency: "once-daily",
+			// The 4 string items that were causing the silent save failure
+			structuredContraindications: [
+				"pregnancy",
+				"active malignancy",
+				"active cancer",
+				"trying to conceive",
+			],
+			affiliates: [
+				{
+					partnerId: { id: "v6yAqOSO_example_id" },
+					title: "Buy Epitalon 10mg",
+					url: "https://swisschems.is/product/epitalon-10mg-price-is-per-vial/",
+				},
+			],
+		};
+
+		const result = reconstructed.safeParse(epitalon);
+		// After the fix, this should PASS (the save button works)
+		expect(result.success).toBe(true);
+	});
+});

diff --git a/packages/stack/src/plugins/cms/api/plugin.ts b/packages/stack/src/plugins/cms/api/plugin.ts
--- a/packages/stack/src/plugins/cms/api/plugin.ts
+++ b/packages/stack/src/plugins/cms/api/plugin.ts
@@ -18,7 +18,7 @@
 	RelationValue,
 	InverseRelation,
 } from "../types";
-import { listContentQuerySchema } from "../schemas";
+import { createListContentQuerySchema } from "../schemas";
 import { slugify } from "../utils";
 import {
 	getAllContentTypes,
@@ -492,6 +492,20 @@
 		}),
 
 		routes: (adapter: Adapter) => {
+			// Build pagination schemas once — honours config.maxPageSize
+			const listContentQuerySchema = createListContentQuerySchema(
+				config.maxPageSize,
+			);
+			const paginationQuerySchema = z.object({
+				limit: z.coerce
+					.number()
+					.min(1)
+					.max(config.maxPageSize ?? DEFAULT_MAX_PAGE_SIZE)
+					.optional()
+					.default(20),
+				offset: z.coerce.number().min(0).optional().default(0),
+			});
+
 			// Helper to get content type by slug
 			const getContentType = async (
 				slug: string,
@@ -525,10 +539,10 @@
 						sortBy: { field: "name", direction: "asc" },
 					});
 
-					// Get item counts for each content type
+					// Get item counts for each content type via adapter.count() (avoids N+1 scan)
 					const typesWithCounts = await Promise.all(
 						contentTypes.map(async (ct) => {
-							const items = await adapter.findMany<ContentItem>({
+							const itemCount: number = await adapter.count({
 								model: "contentItem",
 								where: [
 									{
@@ -540,7 +554,7 @@
 							});
 							return {
 								...serializeContentType(ct),
-								itemCount: items.length,
+								itemCount,
 							};
 						}),
 					);
@@ -964,12 +978,12 @@
 				{
 					method: "GET",
 					params: z.object({ typeSlug: z.string() }),
-					query: z.object({
-						field: z.string(),
-						targetId: z.string(),
-						limit: z.coerce.number().min(1).max(100).optional().default(20),
-						offset: z.coerce.number().min(0).optional().default(0),
-					}),
+					query: z
+						.object({
+							field: z.string(),
+							targetId: z.string(),
+						})
+						.merge(paginationQuerySchema),
 				},
 				async (ctx) => {
 					const { typeSlug } = ctx.params;
@@ -1144,12 +1158,12 @@
 						slug: z.string(),
 						sourceType: z.string(),
 					}),
-					query: z.object({
-						itemId: z.string(),
-						fieldName: z.string(),
-						limit: z.coerce.number().min(1).max(100).optional().default(20),
-						offset: z.coerce.number().min(0).optional().default(0),
-					}),
+					query: z
+						.object({
+							itemId: z.string(),
+							fieldName: z.string(),
+						})
+						.merge(paginationQuerySchema),
 				},
 				async (ctx) => {
 					const { slug, sourceType } = ctx.params;

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
--- 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,7 +1,14 @@
 "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 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";
@@ -19,6 +26,16 @@
 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;
 }
@@ -43,17 +60,26 @@
 	const [newItemDescription, setNewItemDescription] = useState("");
 	const [createError, setCreateError] = useState<string | null>(null);
 
+	const { apiBaseURL, apiBasePath, headers } =
+		usePluginOverrides<CMSPluginOverrides>("cms");
+
+	const listClient = useMemo(
+		() =>
+			createApiClient<CMSApiRouter>({
+				baseURL: apiBaseURL,
+				basePath: apiBasePath,
+			}),
+		[apiBaseURL, apiBasePath],
+	);
+
+	const cmsQueries = useMemo(
+		() => createCMSQueryKeys(listClient, headers),
+		[listClient, headers],
+	);
+
 	// For belongsTo (single relation), we only allow one selection
 	const isSingleSelect = relation.type === "belongsTo";
 
-	// Fetch available items from the target content type
-	const { items: availableItems, isLoading } = useContent(relation.targetType, {
-		limit: 100, // Load a good chunk for the dropdown
-	});
-
-	// Mutation for creating new items
-	const createMutation = useCreateContent(relation.targetType);
-
 	// Normalize the field value to an array for internal use
 	// belongsTo stores as single object { id }, hasMany/manyToMany store as array
 	const normalizedValue = useMemo((): Array<{ id: string }> => {
@@ -72,36 +98,95 @@
 		return (field.value as Array<{ id: string }>) || [];
 	}, [field.value, isSingleSelect]);
 
-	// Convert normalized value to Option[] for MultipleSelector
-	const selectedOptions: Option[] = normalizedValue
-		.map((v) => {
-			const item = availableItems.find((item) => item.id === v.id);
-			if (item) {
-				const displayValue =
-					(item.parsedData as Record<string, unknown>)?.[
-						relation.displayField
-					] || item.slug;
-				return {
-					value: item.id,
-					label: String(displayValue),
-				};
+	// 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,
+			),
+			isHydrating: results.some((r) => r.isFetching),
+		}),
+	});
+
+	const isHydratingLabels = hydrationResult.isHydrating;
+
+	const itemById = useMemo(() => {
+		const m = new Map<string, SerializedContentItemWithType>();
+		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);
 			}
-			// Item not found in loaded items - show ID
-			return { value: v.id, label: `ID: ${v.id.slice(0, 8)}...` };
-		})
-		.filter(Boolean);
+		}
+		return m;
+	}, [availableItems, missingDetailIds, hydrationResult.data]);
 
-	// Convert available items to options
-	const options: Option[] = availableItems.map((item) => {
-		const displayValue =
-			(item.parsedData as Record<string, unknown>)?.[relation.displayField] ||
-			item.slug;
-		return {
-			value: item.id,
-			label: String(displayValue),
-		};
+	// 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<string, unknown>)?.[relation.displayField] ||
+				item.slug;
+			return {
+				value: item.id,
+				label: String(displayValue),
+			};
+		}
+		return { value: v.id, label: `ID: ${v.id.slice(0, 8)}...` };
 	});
 
+	// 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<string, unknown>)?.[relation.displayField] ||
+				item.slug;
+			return {
+				value: item.id,
+				label: String(displayValue),
+			};
+		});
+	}, [
+		availableItems,
+		hydrationResult.data,
+		missingDetailIds,
+		relation.displayField,
+	]);
+
+	// Mutation for creating new items
+	const createMutation = useCreateContent(relation.targetType);
+
 	// Handle selection change - convert back to appropriate format
 	const handleChange = useCallback(
 		(newOptions: Option[]) => {
@@ -187,11 +272,11 @@
 						onChange={handleChange}
 						options={options}
 						placeholder={
-							isLoading
+							isLoading || isHydratingLabels
 								? "Loading..."
 								: `Select ${relation.targetType}${isSingleSelect ? "" : "(s)"}...`
 						}
-						disabled={isLoading}
+						disabled={isLoading || isHydratingLabels}
 						hidePlaceholderWhenSelected
 						emptyIndicator={
 							<p className="text-center text-sm text-muted-foreground py-4">

diff --git a/packages/stack/src/plugins/cms/schemas.ts b/packages/stack/src/plugins/cms/schemas.ts
--- a/packages/stack/src/plugins/cms/schemas.ts
+++ b/packages/stack/src/plugins/cms/schemas.ts
@@ -1,15 +1,35 @@
 import { z } from "zod";
 
+/** Default upper bound for a single page when no maxPageSize is configured. */
+export const DEFAULT_MAX_PAGE_SIZE = 1000;
+
 /**
- * Schema for listing content items with pagination
+ * Factory that creates the list-content query schema with a configurable
+ * upper bound on the `limit` parameter.
+ *
+ * Use this inside the backend plugin factory (where `config.maxPageSize` is
+ * available) so the cap is set at registration time rather than hardcoded.
  */
-export const listContentQuerySchema = z.object({
-	slug: z.string().optional(),
-	limit: z.coerce.number().min(1).max(100).optional().default(20),
-	offset: z.coerce.number().min(0).optional().default(0),
-});
+export function createListContentQuerySchema(
+	maxPageSize = DEFAULT_MAX_PAGE_SIZE,
+) {
+	return z.object({
+		slug: z.string().optional(),
+		limit: z.coerce.number().min(1).max(maxPageSize).optional().default(20),
+		offset: z.coerce.number().min(0).optional().default(0),
+	});
+}
 
 /**
+ * Schema for listing content items with pagination.
+ * Uses the default maxPageSize (1000).
+ *
+ * @deprecated Prefer {@link createListContentQuerySchema} inside plugin
+ * factories so consumers can configure the upper bound via `maxPageSize`.
+ */
+export const listContentQuerySchema = createListContentQuerySchema();
+
+/**
  * Schema for creating a content item
  * Note: The actual data validation is done dynamically based on the content type's schema
  */

diff --git a/packages/stack/src/plugins/cms/types.ts b/packages/stack/src/plugins/cms/types.ts
--- a/packages/stack/src/plugins/cms/types.ts
+++ b/packages/stack/src/plugins/cms/types.ts
@@ -303,4 +303,14 @@
 	contentTypes: ContentTypeConfig[];
 	/** Optional hooks for customizing behavior */
 	hooks?: CMSBackendHooks;
+	/**
+	 * Maximum number of items that can be requested in a single page.
+	 * Applied to all list endpoints (`/content/:typeSlug`, by-relation, and inverse-relation).
+	 *
+	 * Raise this when your content types have many items and you need to fetch
+	 * large pages (e.g. for SSG, CSV export, or programmatic access).
+	 *
+	 * @default 1000
+	 */
+	maxPageSize?: number;
 }

diff --git a/packages/ui/src/components/auto-form/fields/array.tsx b/packages/ui/src/components/auto-form/fields/array.tsx
--- a/packages/ui/src/components/auto-form/fields/array.tsx
+++ b/packages/ui/src/components/auto-form/fields/array.tsx
@@ -4,35 +4,33 @@
   AccordionTrigger,
 } from "@workspace/ui/components/accordion";
 import { Button } from "@workspace/ui/components/button";
+import {
+  FormControl,
+  FormField,
+  FormItem,
+  FormLabel,
+  FormMessage,
+} from "@workspace/ui/components/form";
+import { Input } from "@workspace/ui/components/input";
 import { Separator } from "@workspace/ui/components/separator";
 import { Plus, Trash } from "lucide-react";
-import { useFieldArray, useForm } from "react-hook-form";
+import { useFieldArray, useForm, useWatch } from "react-hook-form";
 import * as z from "zod";
-import { beautifyObjectName } from "../helpers";
+import { beautifyObjectName, getBaseSchema, getBaseType } from "../helpers";
 import AutoFormObject from "./object";
 
-/**
- * Get the def type from a Zod schema (Zod v4 compatible).
- */
 function getDefType(schema: z.ZodType): string {
   return (schema as any)._zod?.def?.type || "";
 }
 
-/**
- * Get the element type from an array or wrapped array schema.
- * Handles: array, optional array, default array, nullable array
- * In Zod v4, array element type is at _zod.def.element
- */
 function getArrayElementType(item: z.ZodType): z.ZodType | null {
   const def = (item as any)._zod?.def;
   const defType = getDefType(item);
 
-  // Direct array
   if (defType === "array") {
     return def?.element || null;
   }
 
-  // Wrapped types (default, optional, nullable) - unwrap and recurse
   if (["default", "optional", "nullable"].includes(defType)) {
     const innerType = def?.innerType;
     if (innerType) {
@@ -43,6 +41,19 @@
   return null;
 }
 
+function getPrimitiveDefault(itemSchema: z.ZodType): unknown {
+  const base = getBaseSchema(itemSchema as z.ZodType);
+  if (!base) return "";
+  switch (getBaseType(base)) {
+    case "ZodBoolean":
+      return false;
+    case "ZodNumber":
+      return 0;
+    default:
+      return "";
+  }
+}
+
 export default function AutoFormArray({
   name,
   item,
@@ -56,17 +67,71 @@
   path?: string[];
   fieldConfig?: any;
 }) {
-  // The full path for useFieldArray - path already includes the array name
+  const itemDefType = getArrayElementType(item);
+  const elementBaseSchema = itemDefType
+    ? getBaseSchema(itemDefType as z.ZodType)
+    : null;
+  const elementPrimitiveType = elementBaseSchema
+    ? getBaseType(elementBaseSchema as z.ZodType)
+    : "";
+  const isObjectArray = !!itemDefType && elementPrimitiveType === "ZodObject";
+
+  const title = fieldConfig?.label ?? beautifyObjectName(name);
+
+  if (isObjectArray) {
+    return (
+      <ObjectAutoFormArray
+        name={name}
+        item={item}
+        form={form}
+        path={path}
+        fieldConfig={fieldConfig}
+        itemDefType={itemDefType as z.ZodObject<any, any>}
+        title={title}
+      />
+    );
+  }
+
+  return (
+    <PrimitiveAutoFormArray
+      name={name}
+      item={item}
+      form={form}
+      path={path}
+      fieldConfig={fieldConfig}
+      itemDefType={itemDefType}
+      title={title}
+    />
+  );
+}
+
+/**
+ * ObjectAutoFormArray — uses useFieldArray (safe for object arrays because
+ * react-hook-form only wraps objects; the `id` field it injects doesn't affect
+ * validation since the schema doesn't include it).
+ */
+function ObjectAutoFormArray({
+  name,
+  form,
+  path = [],
+  fieldConfig,
+  itemDefType,
+  title,
+}: {
+  name: string;
+  item: z.ZodArray<any> | z.ZodDefault<any>;
+  form: ReturnType<typeof useForm>;
+  path?: string[];
+  fieldConfig?: any;
+  itemDefType: z.ZodObject<any, any>;
+  title: string;
+}) {
   const fieldPath = path.join(".");
-  
   const { fields, append, remove } = useFieldArray({
     control: form.control,
     name: fieldPath,
   });
-  const title = fieldConfig?.label ?? beautifyObjectName(name);
 
-  const itemDefType = getArrayElementType(item);
-
   return (
     <AccordionItem value={name} className="border-none">
       <AccordionTrigger>{title}</AccordionTrigger>
@@ -74,9 +139,9 @@
         {fields.map((_field, index) => {
           const key = _field.id;
           return (
-            <div className="mt-4 flex flex-col" key={`${key}`}>
+            <div className="mt-4 flex flex-col" key={key}>
               <AutoFormObject
-                schema={itemDefType as z.ZodObject<any, any>}
+                schema={itemDefType}
                 form={form}
                 fieldConfig={fieldConfig}
                 path={[...path, index.toString()]}
@@ -89,10 +154,9 @@
                   className="hover:bg-zinc-300 hover:text-black focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-white dark:text-black dark:hover:bg-zinc-300 dark:hover:text-black dark:hover:ring-0 dark:hover:ring-offset-0 dark:focus-visible:ring-0 dark:focus-visible:ring-offset-0"
                   onClick={() => remove(index)}
                 >
-                  <Trash className="size-4 " />
+                  <Trash className="size-4" />
                 </Button>
               </div>
-
               <Separator />
             </div>
           );
@@ -110,3 +174,167 @@
     </AccordionItem>
   );
 }
+
+/**
+ * PrimitiveAutoFormArray — does NOT use useFieldArray.
+ *
+ * useFieldArray wraps every element in an object `{ id: "...", <value> }` which
+ * corrupts primitive arrays (string[], number[], boolean[]). Instead we use
+ * useWatch to observe the raw array and form.setValue to mutate it, keeping
+ * the values as plain primitives that will pass Zod validation on submit.
+ */
+function PrimitiveAutoFormArray({
+  name,
+  item,
+  form,
+  path = [],
+  fieldConfig,
+  itemDefType,
+  title,
+}: {
+  name: string;
+  item: z.ZodArray<any> | z.ZodDefault<any>;
+  form: ReturnType<typeof useForm>;
+  path?: string[];
+  fieldConfig?: any;
+  itemDefType: z.ZodType | null;
+  title: string;
+}) {
+  const fieldPath = path.join(".");
+  const rawValues: unknown[] = useWatch({ control: form.control, name: fieldPath }) ?? [];
+  const values = Array.isArray(rawValues) ? rawValues : [];
+
+  const appendItem = () => {
+    const def = itemDefType ? getPrimitiveDefault(itemDefType) : "";
+    form.setValue(fieldPath as any, [...values, def] as any, {
+      shouldDirty: true,
+      shouldValidate: false,
+    });
+  };
... diff truncated: showing 800 of 6357 lines

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 79d8dea. Configure here.

Comment thread packages/stack/src/plugins/cms/api/plugin.ts Outdated
…0 in paginationQuerySchema

Co-authored-by: Ollie <olliethedev@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown
Contributor

Shadcn registry updated — registry JSON files were rebuilt and committed to this branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants