Skip to content

Commit a6f48d5

Browse files
authored
Merge pull request #123 from better-stack-ai/fix/cms-admin-form
fix: cms admin form
2 parents 2c22945 + 3fa6f6e commit a6f48d5

11 files changed

Lines changed: 892 additions & 3304 deletions

File tree

packages/stack/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@btst/stack",
3-
"version": "2.12.0",
3+
"version": "2.12.1",
44
"description": "A composable, plugin-based library for building full-stack applications.",
55
"repository": {
66
"type": "git",

packages/stack/registry/btst-cms.json

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

packages/stack/src/plugins/cms/__tests__/schema-roundtrip.test.ts

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,3 +804,206 @@ describe("Zod to JSON Schema roundtrip", () => {
804804
});
805805
});
806806
});
807+
808+
/**
809+
* These tests confirm and protect against a bug where `useFieldArray` in
810+
* AutoFormArray corrupts primitive string values in arrays like
811+
* `structuredContraindications: z.array(z.string()).default([])`.
812+
*
813+
* When react-hook-form's `useFieldArray` processes a primitive array
814+
* (e.g. `["pregnancy", "active malignancy"]`), it wraps each element as a
815+
* tracking object `{ id: "rhf_generated_id" }`, discarding the original
816+
* string value. When `form.watch()` fires, it returns these objects. The
817+
* `handleValuesChange` callback then calls `setFormData(values)` which stores
818+
* the corrupted objects. On form submit, `zodResolver` validates the corrupted
819+
* values against `z.array(z.string())` and FAILS — causing the Save button
820+
* to do nothing (no error shown, no API request made).
821+
*
822+
* The fix: AutoFormArray must NOT use `useFieldArray` for primitive (non-object)
823+
* arrays. Instead it uses `form.watch` + `form.setValue` directly so primitive
824+
* values are always preserved in the form state.
825+
*/
826+
describe("Primitive string array — useFieldArray corruption bug", () => {
827+
/**
828+
* Simulates what zodToFormSchema + formSchemaToZod does:
829+
* Zod schema → JSON Schema (stored in DB) → reconstructed Zod schema.
830+
*/
831+
function roundtripSchema(schema: z.ZodType): z.ZodType {
832+
const jsonSchema = z.toJSONSchema(schema, { unrepresentable: "any" });
833+
return z.fromJSONSchema(jsonSchema as z.core.JSONSchema.JSONSchema);
834+
}
835+
836+
it("z.array(z.string()) survives JSON Schema roundtrip and accepts string values", () => {
837+
const schema = z.object({
838+
structuredContraindications: z.array(z.string()).default([]),
839+
});
840+
841+
const reconstructed = roundtripSchema(schema);
842+
843+
// Actual compound data — should PASS
844+
expect(
845+
reconstructed.safeParse({
846+
structuredContraindications: [
847+
"pregnancy",
848+
"active malignancy",
849+
"active cancer",
850+
"trying to conceive",
851+
],
852+
}).success,
853+
).toBe(true);
854+
855+
// Empty array — should PASS (default)
856+
expect(
857+
reconstructed.safeParse({ structuredContraindications: [] }).success,
858+
).toBe(true);
859+
});
860+
861+
it("useFieldArray-corrupted values (objects) fail z.array(z.string()) validation — this is the root cause of the silent save failure", () => {
862+
const schema = z.object({
863+
structuredContraindications: z.array(z.string()).default([]),
864+
});
865+
866+
const reconstructed = roundtripSchema(schema);
867+
868+
// Simulates what react-hook-form's useFieldArray returns when used with
869+
// primitive string arrays: each string is replaced by a tracking object
870+
// { id: "rhf_generated_id" } and the original value is lost.
871+
const corruptedByUseFieldArray = {
872+
structuredContraindications: [
873+
{ id: "rhf_internal_id_1" },
874+
{ id: "rhf_internal_id_2" },
875+
{ id: "rhf_internal_id_3" },
876+
{ id: "rhf_internal_id_4" },
877+
],
878+
};
879+
880+
// This is the actual validation error that occurs when Save is clicked:
881+
// zodResolver validates the corrupted objects against z.array(z.string())
882+
// and FAILS, so onSubmit is never called → no API request → "nothing happens"
883+
const result = reconstructed.safeParse(corruptedByUseFieldArray);
884+
expect(result.success).toBe(false);
885+
if (!result.success) {
886+
// Confirm the error is specifically about the string array elements
887+
const paths = result.error.issues.map((i) => i.path.join("."));
888+
expect(
889+
paths.some((p) => p.startsWith("structuredContraindications")),
890+
).toBe(true);
891+
}
892+
});
893+
894+
it("primitive string array values are preserved correctly (NOT corrupted) when form state is managed without useFieldArray", () => {
895+
// After the fix, AutoFormArray uses form.watch + form.setValue for primitive
896+
// arrays. The values remain as strings throughout the lifecycle:
897+
// initialData → formData → form state → zodResolver → submit
898+
899+
const schema = z.object({
900+
structuredContraindications: z.array(z.string()).default([]),
901+
});
902+
903+
const reconstructed = roundtripSchema(schema);
904+
905+
// The correctly-preserved values (no useFieldArray wrapping)
906+
const preservedValues = {
907+
structuredContraindications: [
908+
"pregnancy",
909+
"active malignancy",
910+
"active cancer",
911+
"trying to conceive",
912+
],
913+
};
914+
915+
// With the fix applied, these values pass validation → Save works
916+
expect(reconstructed.safeParse(preservedValues).success).toBe(true);
917+
});
918+
919+
it("compound schema with structuredContraindications passes full validation with string array values", () => {
920+
// Representative subset of CompoundSchema fields that appear on the
921+
// Epitalon compound page — verifies the full schema round-trip for the
922+
// fields that are relevant to the reported bug.
923+
const compoundSchema = z.object({
924+
name: z.string().min(1),
925+
compoundType: z.enum([
926+
"healing-peptide",
927+
"gh-axis",
928+
"metabolic-peptide",
929+
"sarm",
930+
"steroid",
931+
"nootropic",
932+
"supplement",
933+
"ancillary-pct",
934+
"longevity",
935+
"hair",
936+
"skin",
937+
"sexual",
938+
"other",
939+
]),
940+
researchStatus: z.enum([
941+
"research-only",
942+
"approved",
943+
"banned",
944+
"grey-market",
945+
"supplement",
946+
]),
947+
legalStatus: z.enum([
948+
"OTC",
949+
"Research",
950+
"Grey-Market",
951+
"Rx-Only",
952+
"Schedule-III",
953+
"Banned",
954+
]),
955+
doseUnit: z.enum(["mcg", "mg", "IU", "ml", "g"]),
956+
doseFrequency: z.enum([
957+
"once-daily",
958+
"twice-daily",
959+
"three-times-daily",
960+
"every-other-day",
961+
"weekly",
962+
"twice-weekly",
963+
"three-times-weekly",
964+
"as-needed",
965+
"custom",
966+
]),
967+
structuredContraindications: z.array(z.string()).default([]),
968+
affiliates: z
969+
.array(
970+
z.object({
971+
partnerId: z.object({ id: z.string() }).optional(),
972+
title: z.string().optional(),
973+
url: z.string().min(1),
974+
}),
975+
)
976+
.default([]),
977+
});
978+
979+
const reconstructed = roundtripSchema(compoundSchema);
980+
981+
// Epitalon-like data — all values as they come from parsedData
982+
const epitalon = {
983+
name: "Epitalon",
984+
compoundType: "longevity",
985+
researchStatus: "research-only",
986+
legalStatus: "Research",
987+
doseUnit: "mg",
988+
doseFrequency: "once-daily",
989+
// The 4 string items that were causing the silent save failure
990+
structuredContraindications: [
991+
"pregnancy",
992+
"active malignancy",
993+
"active cancer",
994+
"trying to conceive",
995+
],
996+
affiliates: [
997+
{
998+
partnerId: { id: "v6yAqOSO_example_id" },
999+
title: "Buy Epitalon 10mg",
1000+
url: "https://swisschems.is/product/epitalon-10mg-price-is-per-vial/",
1001+
},
1002+
],
1003+
};
1004+
1005+
const result = reconstructed.safeParse(epitalon);
1006+
// After the fix, this should PASS (the save button works)
1007+
expect(result.success).toBe(true);
1008+
});
1009+
});

packages/stack/src/plugins/cms/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ export {
1818
type CreateCMSContentItemOptions,
1919
} from "./mutations";
2020
export { CMS_QUERY_KEYS } from "./query-key-defs";
21+
export { createCMSQueryKeys } from "../query-keys";

packages/stack/src/plugins/cms/api/plugin.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import type {
1818
RelationValue,
1919
InverseRelation,
2020
} from "../types";
21-
import { listContentQuerySchema } from "../schemas";
21+
import {
22+
createListContentQuerySchema,
23+
DEFAULT_MAX_PAGE_SIZE,
24+
} from "../schemas";
2225
import { slugify } from "../utils";
2326
import {
2427
getAllContentTypes,
@@ -492,6 +495,20 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
492495
}),
493496

494497
routes: (adapter: Adapter) => {
498+
// Build pagination schemas once — honours config.maxPageSize
499+
const listContentQuerySchema = createListContentQuerySchema(
500+
config.maxPageSize,
501+
);
502+
const paginationQuerySchema = z.object({
503+
limit: z.coerce
504+
.number()
505+
.min(1)
506+
.max(config.maxPageSize ?? DEFAULT_MAX_PAGE_SIZE)
507+
.optional()
508+
.default(20),
509+
offset: z.coerce.number().min(0).optional().default(0),
510+
});
511+
495512
// Helper to get content type by slug
496513
const getContentType = async (
497514
slug: string,
@@ -525,10 +542,10 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
525542
sortBy: { field: "name", direction: "asc" },
526543
});
527544

528-
// Get item counts for each content type
545+
// Get item counts for each content type via adapter.count() (avoids N+1 scan)
529546
const typesWithCounts = await Promise.all(
530547
contentTypes.map(async (ct) => {
531-
const items = await adapter.findMany<ContentItem>({
548+
const itemCount: number = await adapter.count({
532549
model: "contentItem",
533550
where: [
534551
{
@@ -540,7 +557,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
540557
});
541558
return {
542559
...serializeContentType(ct),
543-
itemCount: items.length,
560+
itemCount,
544561
};
545562
}),
546563
);
@@ -964,12 +981,12 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
964981
{
965982
method: "GET",
966983
params: z.object({ typeSlug: z.string() }),
967-
query: z.object({
968-
field: z.string(),
969-
targetId: z.string(),
970-
limit: z.coerce.number().min(1).max(100).optional().default(20),
971-
offset: z.coerce.number().min(0).optional().default(0),
972-
}),
984+
query: z
985+
.object({
986+
field: z.string(),
987+
targetId: z.string(),
988+
})
989+
.merge(paginationQuerySchema),
973990
},
974991
async (ctx) => {
975992
const { typeSlug } = ctx.params;
@@ -1144,12 +1161,12 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
11441161
slug: z.string(),
11451162
sourceType: z.string(),
11461163
}),
1147-
query: z.object({
1148-
itemId: z.string(),
1149-
fieldName: z.string(),
1150-
limit: z.coerce.number().min(1).max(100).optional().default(20),
1151-
offset: z.coerce.number().min(0).optional().default(0),
1152-
}),
1164+
query: z
1165+
.object({
1166+
itemId: z.string(),
1167+
fieldName: z.string(),
1168+
})
1169+
.merge(paginationQuerySchema),
11531170
},
11541171
async (ctx) => {
11551172
const { slug, sourceType } = ctx.params;

0 commit comments

Comments
 (0)