Skip to content

Commit 0081473

Browse files
authored
Merge pull request #118 from better-stack-ai/fix/form-builder-cms
fix: form builder + cms
2 parents 9406c1d + fe5a309 commit 0081473

10 files changed

Lines changed: 475 additions & 64 deletions

File tree

biome.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@
6464
"!**/.btst-stack-src/**",
6565
"!**/.btst-stack-ui/**",
6666
"!**/packages/stack/registry/**",
67-
"!**/codegen-projects/**"
67+
"!**/codegen-projects/**",
68+
"!**/playwright-report-codegen/trace/**"
6869
]
6970
}
7071
}

docs/content/docs/plugins/cms.mdx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,48 @@ cms: {
276276

277277
The built-in file component will use your `uploadImage` function to upload files and store the returned URL.
278278

279+
### Repeating Groups (Arrays of Objects)
280+
281+
You can model repeating sub-records — variants, line items, blend components, FAQ entries, etc. — with `z.array(z.object({...}))`. The admin renders each item as a sub-form inside an accordion with **Add** and **Remove** buttons, and `useFieldArray` from react-hook-form drives the row state.
282+
283+
`.meta()` placeholders, `fieldType` overrides, and custom `fieldComponents` (including `"file"` and `"relation"`) are propagated **into the array items**, so you can build rich nested forms without writing a custom field component.
284+
285+
```ts
286+
const ProductSchema = z.object({
287+
name: z.string().min(1),
288+
variants: z
289+
.array(
290+
z.object({
291+
sku: z.string().meta({ placeholder: "SKU-001" }),
292+
price: z.coerce.number().min(0).meta({ placeholder: "0.00" }),
293+
notes: z.string().optional().meta({ fieldType: "textarea" }),
294+
// Nested file uploads work when `uploadImage` is provided in overrides.
295+
image: z.string().optional().meta({ fieldType: "file" }),
296+
// Nested belongsTo relations render the searchable picker inside the row.
297+
categoryId: z
298+
.object({ id: z.string() })
299+
.optional()
300+
.meta({
301+
fieldType: "relation",
302+
relation: {
303+
type: "belongsTo",
304+
targetType: "category",
305+
displayField: "name",
306+
},
307+
}),
308+
}),
309+
)
310+
.default([])
311+
.meta({ description: "Product variants" }),
312+
});
313+
```
314+
315+
A few rules worth remembering:
316+
317+
- New rows are created with `append({})` — fields with no default render empty until the user fills them in.
318+
- Inside item objects, do **not** name properties using reserved `FieldConfigItem` keys (`label`, `description`, `inputProps`, `fieldType`, `renderParent`, `order`). The admin will warn and skip those properties to avoid clobbering the array's own metadata.
319+
- Validation, defaults, and required-vs-optional behavior follow the inner Zod schema as usual.
320+
279321
## Admin Routes
280322

281323
The CMS plugin provides these admin routes:

packages/stack/package.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@btst/stack",
3-
"version": "2.11.4",
3+
"version": "2.11.5",
44
"description": "A composable, plugin-based library for building full-stack applications.",
55
"repository": {
66
"type": "git",
@@ -127,6 +127,16 @@
127127
"default": "./dist/plugins/blog/client/index.cjs"
128128
}
129129
},
130+
"./plugins/blog/client/components": {
131+
"import": {
132+
"types": "./dist/plugins/blog/client/components/index.d.ts",
133+
"default": "./dist/plugins/blog/client/components/index.mjs"
134+
},
135+
"require": {
136+
"types": "./dist/plugins/blog/client/components/index.d.cts",
137+
"default": "./dist/plugins/blog/client/components/index.cjs"
138+
}
139+
},
130140
"./plugins/blog/client/hooks": {
131141
"import": {
132142
"types": "./dist/plugins/blog/client/hooks/index.d.ts",
@@ -607,6 +617,9 @@
607617
"plugins/blog/client": [
608618
"./dist/plugins/blog/client/index.d.ts"
609619
],
620+
"plugins/blog/client/components": [
621+
"./dist/plugins/blog/client/components/index.d.ts"
622+
],
610623
"plugins/blog/client/hooks": [
611624
"./dist/plugins/blog/client/hooks/index.d.ts"
612625
],

packages/stack/registry/btst-cms.json

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

packages/stack/registry/btst-form-builder.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

packages/stack/src/plugins/blog/client/components/index.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,61 @@ import { HomePageComponent as PostListPageImpl } from "./pages/home-page";
33
import { NewPostPageComponent as NewPostPageImpl } from "./pages/new-post-page";
44
import { PostPageComponent as PostPageImpl } from "./pages/post-page";
55
import { EditPostPageComponent as EditPostPageImpl } from "./pages/edit-post-page";
6+
import { PostCard as PostCardImpl } from "./shared/post-card";
7+
import { PostsList as PostsListImpl } from "./shared/posts-list";
8+
import { EmptyList as EmptyListImpl } from "./shared/empty-list";
9+
import { RecentPostsCarousel as RecentPostsCarouselImpl } from "./shared/recent-posts-carousel";
10+
import { PostNavigation as PostNavigationImpl } from "./shared/post-navigation";
11+
import { TagsList as TagsListImpl } from "./shared/tags-list";
12+
import { PostCardSkeleton as PostCardSkeletonImpl } from "./loading/post-card-skeleton";
613

714
// Re-export to ensure the client boundary is preserved
815
export const PostListPage = PostListPageImpl;
916
export const NewPostPage = NewPostPageImpl;
1017
export const PostPage = PostPageImpl;
1118
export const EditPostPage = EditPostPageImpl;
19+
20+
/**
21+
* Card component that renders a single blog post summary
22+
* (cover image, title, date, tags). Used by the built-in posts list and
23+
* available for composing custom blog landing pages.
24+
*
25+
* Requires a `StackProvider` with the blog plugin registered so that
26+
* `Link` and `Image` overrides are resolved correctly.
27+
*/
28+
export const PostCard = PostCardImpl;
29+
30+
/**
31+
* Skeleton placeholder that mirrors the layout of {@link PostCard}.
32+
* Use inside a `<Suspense>` fallback or while data is loading.
33+
*/
34+
export const PostCardSkeleton = PostCardSkeletonImpl;
35+
36+
/**
37+
* Grid of {@link PostCard}s with optional load-more pagination and a
38+
* built-in search input. Pass an array of `SerializedPost`s.
39+
*/
40+
export const PostsList = PostsListImpl;
41+
42+
/**
43+
* Empty-state placeholder used by the built-in blog list pages.
44+
* Renders a centered icon and a customisable message.
45+
*/
46+
export const EmptyList = EmptyListImpl;
47+
48+
/**
49+
* Horizontal carousel of recent posts. Drop-in component for "you might
50+
* also like" sections on a blog post page.
51+
*/
52+
export const RecentPostsCarousel = RecentPostsCarouselImpl;
53+
54+
/**
55+
* Previous/next post navigation strip, typically rendered at the bottom
56+
* of a post page.
57+
*/
58+
export const PostNavigation = PostNavigationImpl;
59+
60+
/**
61+
* Renders a list of tags for a post or for the whole blog.
62+
*/
63+
export const TagsList = TagsListImpl;
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { z } from "zod";
3+
import { buildFieldConfigFromJsonSchema } from "@workspace/ui/components/auto-form/helpers";
4+
5+
/**
6+
* Verifies that buildFieldConfigFromJsonSchema recurses into nested objects AND
7+
* array items.properties so that per-property metadata (label, placeholder,
8+
* fieldType, etc.) reaches the AutoForm renderer for fields nested inside
9+
* arrays-of-objects.
10+
*
11+
* The CMS auto-form pipeline relies on this for blend-style schemas like:
12+
* components: z.array(z.object({
13+
* name: z.string(),
14+
* compoundId: z.object({ id: z.string() }).meta({ fieldType: "relation" }),
15+
* }))
16+
*
17+
* Without this recursion, AutoFormArray would render the inner items with no
18+
* field config — placeholders and custom field components on nested array
19+
* properties would silently disappear.
20+
*/
21+
22+
type ConfigMap = Record<string, Record<string, unknown> | undefined>;
23+
24+
function getConfig(map: ConfigMap, key: string): Record<string, unknown> {
25+
const value = map[key];
26+
if (!value) {
27+
throw new Error(`Expected config entry for "${key}" but got undefined`);
28+
}
29+
return value;
30+
}
31+
32+
describe("buildFieldConfigFromJsonSchema — nested + array recursion", () => {
33+
it("propagates per-item placeholders into the array's field config", () => {
34+
const schema = z.object({
35+
components: z
36+
.array(
37+
z.object({
38+
name: z.string().meta({ placeholder: "e.g. GHK-Cu" }),
39+
doseLow: z.coerce.number().meta({ placeholder: "1" }),
40+
}),
41+
)
42+
.default([])
43+
.meta({ description: "Blend components" }),
44+
});
45+
46+
const jsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;
47+
const config = buildFieldConfigFromJsonSchema(jsonSchema) as ConfigMap;
48+
const components = getConfig(config, "components") as ConfigMap & {
49+
description?: string;
50+
};
51+
52+
expect(components.description).toBe("Blend components");
53+
54+
// Per-item configs must live as keys on the array's FieldConfigObject so
55+
// that AutoFormObject (rendered per array item) can look them up by name.
56+
const nameConfig = getConfig(components, "name") as {
57+
inputProps?: { placeholder?: string };
58+
};
59+
expect(nameConfig.inputProps?.placeholder).toBe("e.g. GHK-Cu");
60+
61+
const doseLowConfig = getConfig(components, "doseLow") as {
62+
inputProps?: { placeholder?: string };
63+
};
64+
expect(doseLowConfig.inputProps?.placeholder).toBe("1");
65+
});
66+
67+
it("propagates fieldType (textarea) into array items", () => {
68+
const schema = z.object({
69+
components: z
70+
.array(
71+
z.object({
72+
notes: z.string().meta({ fieldType: "textarea" }),
73+
}),
74+
)
75+
.default([]),
76+
});
77+
78+
const jsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;
79+
const config = buildFieldConfigFromJsonSchema(jsonSchema) as ConfigMap;
80+
const components = getConfig(config, "components") as ConfigMap;
81+
const notesConfig = getConfig(components, "notes") as {
82+
fieldType?: string;
83+
};
84+
85+
expect(notesConfig.fieldType).toBe("textarea");
86+
});
87+
88+
it("invokes a custom fieldComponent for nested array fields", () => {
89+
const schema = z.object({
90+
components: z
91+
.array(
92+
z.object({
93+
compoundId: z
94+
.object({ id: z.string() })
95+
.meta({ fieldType: "relation" }),
96+
}),
97+
)
98+
.default([]),
99+
});
100+
101+
const jsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;
102+
103+
const RelationStub = () => null;
104+
const config = buildFieldConfigFromJsonSchema(jsonSchema, {
105+
relation: RelationStub,
106+
}) as ConfigMap;
107+
108+
const components = getConfig(config, "components") as ConfigMap;
109+
const compoundIdConfig = getConfig(components, "compoundId") as {
110+
fieldType?: unknown;
111+
};
112+
113+
// fieldComponents["relation"] should be wired up for the nested array
114+
// element field, not just top-level fields.
115+
expect(typeof compoundIdConfig.fieldType).toBe("function");
116+
});
117+
118+
it("still propagates per-property configs into nested objects (regression)", () => {
119+
const schema = z.object({
120+
seo: z.object({
121+
title: z.string().meta({ placeholder: "Page title" }),
122+
}),
123+
});
124+
125+
const jsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;
126+
const config = buildFieldConfigFromJsonSchema(jsonSchema) as ConfigMap;
127+
const seo = getConfig(config, "seo") as ConfigMap;
128+
const titleConfig = getConfig(seo, "title") as {
129+
inputProps?: { placeholder?: string };
130+
};
131+
132+
expect(titleConfig.inputProps?.placeholder).toBe("Page title");
133+
});
134+
135+
it("warns and skips item properties whose names collide with reserved FieldConfigItem keys", () => {
136+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
137+
138+
const schema = z.object({
139+
items: z
140+
.array(
141+
z.object({
142+
// "label" is a reserved key on FieldConfigItem and would
143+
// silently overwrite the array's own label if not guarded.
144+
label: z.string().meta({ placeholder: "Item label" }),
145+
value: z.string().meta({ placeholder: "Item value" }),
146+
}),
147+
)
148+
.default([])
149+
.meta({ description: "An array" }),
150+
});
151+
152+
const jsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;
153+
const config = buildFieldConfigFromJsonSchema(jsonSchema) as ConfigMap;
154+
const items = getConfig(config, "items") as ConfigMap & {
155+
description?: string;
156+
};
157+
158+
// The array's own description should remain intact (not overwritten by
159+
// a nested item field also named "description").
160+
expect(items.description).toBe("An array");
161+
162+
// Non-reserved item properties propagate normally.
163+
const valueConfig = getConfig(items, "value") as {
164+
inputProps?: { placeholder?: string };
165+
};
166+
expect(valueConfig.inputProps?.placeholder).toBe("Item value");
167+
168+
// The "label" item property collides with a reserved FieldConfigItem
169+
// key and must be skipped + warned about.
170+
expect(warn).toHaveBeenCalledWith(
171+
expect.stringContaining(
172+
'Array field "items" has an item property named "label"',
173+
),
174+
);
175+
warn.mockRestore();
176+
});
177+
});

0 commit comments

Comments
 (0)