fix: cms admin form#123
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| export const listContentQuerySchema = createListContentQuerySchema(); | ||
|
|
||
| /** | ||
| * Schema for creating a content item |
There was a problem hiding this comment.
[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, |
There was a problem hiding this comment.
[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"> |
There was a problem hiding this comment.
[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.
Co-authored-by: Ollie <olliethedev@users.noreply.github.com>
…atch on array append Co-authored-by: Ollie <olliethedev@users.noreply.github.com>
Co-authored-by: Ollie <olliethedev@users.noreply.github.com>
…lationField Co-authored-by: Ollie <olliethedev@users.noreply.github.com>
…re proper state updates
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Hardcoded fallback duplicates exported constant value
- Replaced the hardcoded
1000fallback inpaginationQuerySchemawith the importedDEFAULT_MAX_PAGE_SIZEconstant, ensuring all endpoints stay in sync if the default is ever changed.
- Replaced the hardcoded
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 linesYou can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 79d8dea. Configure here.
…0 in paginationQuerySchema Co-authored-by: Ollie <olliethedev@users.noreply.github.com>
|
✅ Shadcn registry updated — registry JSON files were rebuilt and committed to this branch. |

Summary
Type of change
Checklist
pnpm buildpassespnpm typecheckpassespnpm lintpassesdocs/content/docs/) if consumer-facing types or behavior changedScreenshots
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
AutoFormArrayto avoidreact-hook-form’suseFieldArrayfor primitive arrays, preservingstring[]/number[]/boolean[]values in form state and rendering appropriate primitive inputs.Improves CMS listing/pagination by introducing configurable
maxPageSize(default1000) and applying it across list endpoints via schema factories; also replaces content-type item counting withadapter.count().Enhances relation fields to hydrate labels for selected IDs not present in the first list page using React Query detail fetches (and exports
createCMSQueryKeysvia the CMS API index), adds regression tests for the primitive-array bug, and bumps@btst/stackto2.12.1.Reviewed by Cursor Bugbot for commit 499043c. Bugbot is set up for automated code reviews on this repo. Configure here.