Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@
"!**/.btst-stack-src/**",
"!**/.btst-stack-ui/**",
"!**/packages/stack/registry/**",
"!**/codegen-projects/**"
"!**/codegen-projects/**",
"!**/playwright-report-codegen/trace/**"
]
}
}
42 changes: 42 additions & 0 deletions docs/content/docs/plugins/cms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,48 @@ cms: {

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

### Repeating Groups (Arrays of Objects)

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.

`.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.

```ts
const ProductSchema = z.object({
name: z.string().min(1),
variants: z
.array(
z.object({
sku: z.string().meta({ placeholder: "SKU-001" }),
price: z.coerce.number().min(0).meta({ placeholder: "0.00" }),
notes: z.string().optional().meta({ fieldType: "textarea" }),
// Nested file uploads work when `uploadImage` is provided in overrides.
image: z.string().optional().meta({ fieldType: "file" }),
// Nested belongsTo relations render the searchable picker inside the row.
categoryId: z
.object({ id: z.string() })
.optional()
.meta({
fieldType: "relation",
relation: {
type: "belongsTo",
targetType: "category",
displayField: "name",
},
}),
}),
)
.default([])
.meta({ description: "Product variants" }),
});
```

A few rules worth remembering:

- New rows are created with `append({})` — fields with no default render empty until the user fills them in.
- 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.
- Validation, defaults, and required-vs-optional behavior follow the inner Zod schema as usual.

## Admin Routes

The CMS plugin provides these admin routes:
Expand Down
15 changes: 14 additions & 1 deletion packages/stack/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@btst/stack",
"version": "2.11.4",
"version": "2.11.5",
"description": "A composable, plugin-based library for building full-stack applications.",
"repository": {
"type": "git",
Expand Down Expand Up @@ -127,6 +127,16 @@
"default": "./dist/plugins/blog/client/index.cjs"
}
},
"./plugins/blog/client/components": {
"import": {
"types": "./dist/plugins/blog/client/components/index.d.ts",
"default": "./dist/plugins/blog/client/components/index.mjs"
},
"require": {
"types": "./dist/plugins/blog/client/components/index.d.cts",
"default": "./dist/plugins/blog/client/components/index.cjs"
}
},
"./plugins/blog/client/hooks": {
"import": {
"types": "./dist/plugins/blog/client/hooks/index.d.ts",
Expand Down Expand Up @@ -607,6 +617,9 @@
"plugins/blog/client": [
"./dist/plugins/blog/client/index.d.ts"
],
"plugins/blog/client/components": [
"./dist/plugins/blog/client/components/index.d.ts"
],
"plugins/blog/client/hooks": [
"./dist/plugins/blog/client/hooks/index.d.ts"
],
Expand Down
6 changes: 3 additions & 3 deletions packages/stack/registry/btst-cms.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/stack/registry/btst-form-builder.json

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions packages/stack/src/plugins/blog/client/components/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,61 @@ import { HomePageComponent as PostListPageImpl } from "./pages/home-page";
import { NewPostPageComponent as NewPostPageImpl } from "./pages/new-post-page";
import { PostPageComponent as PostPageImpl } from "./pages/post-page";
import { EditPostPageComponent as EditPostPageImpl } from "./pages/edit-post-page";
import { PostCard as PostCardImpl } from "./shared/post-card";
import { PostsList as PostsListImpl } from "./shared/posts-list";
import { EmptyList as EmptyListImpl } from "./shared/empty-list";
import { RecentPostsCarousel as RecentPostsCarouselImpl } from "./shared/recent-posts-carousel";
import { PostNavigation as PostNavigationImpl } from "./shared/post-navigation";
import { TagsList as TagsListImpl } from "./shared/tags-list";
import { PostCardSkeleton as PostCardSkeletonImpl } from "./loading/post-card-skeleton";

// Re-export to ensure the client boundary is preserved
export const PostListPage = PostListPageImpl;
export const NewPostPage = NewPostPageImpl;
export const PostPage = PostPageImpl;
export const EditPostPage = EditPostPageImpl;

/**
* Card component that renders a single blog post summary
* (cover image, title, date, tags). Used by the built-in posts list and
* available for composing custom blog landing pages.
*
* Requires a `StackProvider` with the blog plugin registered so that
* `Link` and `Image` overrides are resolved correctly.
*/
export const PostCard = PostCardImpl;

/**
* Skeleton placeholder that mirrors the layout of {@link PostCard}.
* Use inside a `<Suspense>` fallback or while data is loading.
*/
export const PostCardSkeleton = PostCardSkeletonImpl;

/**
* Grid of {@link PostCard}s with optional load-more pagination and a
* built-in search input. Pass an array of `SerializedPost`s.
*/
export const PostsList = PostsListImpl;

/**
* Empty-state placeholder used by the built-in blog list pages.
* Renders a centered icon and a customisable message.
*/
export const EmptyList = EmptyListImpl;

/**
* Horizontal carousel of recent posts. Drop-in component for "you might
* also like" sections on a blog post page.
*/
export const RecentPostsCarousel = RecentPostsCarouselImpl;

/**
* Previous/next post navigation strip, typically rendered at the bottom
* of a post page.
*/
export const PostNavigation = PostNavigationImpl;

/**
* Renders a list of tags for a post or for the whole blog.
*/
export const TagsList = TagsListImpl;
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { buildFieldConfigFromJsonSchema } from "@workspace/ui/components/auto-form/helpers";

/**
* Verifies that buildFieldConfigFromJsonSchema recurses into nested objects AND
* array items.properties so that per-property metadata (label, placeholder,
* fieldType, etc.) reaches the AutoForm renderer for fields nested inside
* arrays-of-objects.
*
* The CMS auto-form pipeline relies on this for blend-style schemas like:
* components: z.array(z.object({
* name: z.string(),
* compoundId: z.object({ id: z.string() }).meta({ fieldType: "relation" }),
* }))
*
* Without this recursion, AutoFormArray would render the inner items with no
* field config — placeholders and custom field components on nested array
* properties would silently disappear.
*/

type ConfigMap = Record<string, Record<string, unknown> | undefined>;

function getConfig(map: ConfigMap, key: string): Record<string, unknown> {
const value = map[key];
if (!value) {
throw new Error(`Expected config entry for "${key}" but got undefined`);
}
return value;
}

describe("buildFieldConfigFromJsonSchema — nested + array recursion", () => {
it("propagates per-item placeholders into the array's field config", () => {
const schema = z.object({
components: z
.array(
z.object({
name: z.string().meta({ placeholder: "e.g. GHK-Cu" }),
doseLow: z.coerce.number().meta({ placeholder: "1" }),
}),
)
.default([])
.meta({ description: "Blend components" }),
});

const jsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;
const config = buildFieldConfigFromJsonSchema(jsonSchema) as ConfigMap;
const components = getConfig(config, "components") as ConfigMap & {
description?: string;
};

expect(components.description).toBe("Blend components");

// Per-item configs must live as keys on the array's FieldConfigObject so
// that AutoFormObject (rendered per array item) can look them up by name.
const nameConfig = getConfig(components, "name") as {
inputProps?: { placeholder?: string };
};
expect(nameConfig.inputProps?.placeholder).toBe("e.g. GHK-Cu");

const doseLowConfig = getConfig(components, "doseLow") as {
inputProps?: { placeholder?: string };
};
expect(doseLowConfig.inputProps?.placeholder).toBe("1");
});

it("propagates fieldType (textarea) into array items", () => {
const schema = z.object({
components: z
.array(
z.object({
notes: z.string().meta({ fieldType: "textarea" }),
}),
)
.default([]),
});

const jsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;
const config = buildFieldConfigFromJsonSchema(jsonSchema) as ConfigMap;
const components = getConfig(config, "components") as ConfigMap;
const notesConfig = getConfig(components, "notes") as {
fieldType?: string;
};

expect(notesConfig.fieldType).toBe("textarea");
});

it("invokes a custom fieldComponent for nested array fields", () => {
const schema = z.object({
components: z
.array(
z.object({
compoundId: z
.object({ id: z.string() })
.meta({ fieldType: "relation" }),
}),
)
.default([]),
});

const jsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;

const RelationStub = () => null;
const config = buildFieldConfigFromJsonSchema(jsonSchema, {
relation: RelationStub,
}) as ConfigMap;

const components = getConfig(config, "components") as ConfigMap;
const compoundIdConfig = getConfig(components, "compoundId") as {
fieldType?: unknown;
};

// fieldComponents["relation"] should be wired up for the nested array
// element field, not just top-level fields.
expect(typeof compoundIdConfig.fieldType).toBe("function");
});

it("still propagates per-property configs into nested objects (regression)", () => {
const schema = z.object({
seo: z.object({
title: z.string().meta({ placeholder: "Page title" }),
}),
});

const jsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;
const config = buildFieldConfigFromJsonSchema(jsonSchema) as ConfigMap;
const seo = getConfig(config, "seo") as ConfigMap;
const titleConfig = getConfig(seo, "title") as {
inputProps?: { placeholder?: string };
};

expect(titleConfig.inputProps?.placeholder).toBe("Page title");
});

it("warns and skips item properties whose names collide with reserved FieldConfigItem keys", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

const schema = z.object({
items: z
.array(
z.object({
// "label" is a reserved key on FieldConfigItem and would
// silently overwrite the array's own label if not guarded.
label: z.string().meta({ placeholder: "Item label" }),
value: z.string().meta({ placeholder: "Item value" }),
}),
)
.default([])
.meta({ description: "An array" }),
});

const jsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;
const config = buildFieldConfigFromJsonSchema(jsonSchema) as ConfigMap;
const items = getConfig(config, "items") as ConfigMap & {
description?: string;
};

// The array's own description should remain intact (not overwritten by
// a nested item field also named "description").
expect(items.description).toBe("An array");

// Non-reserved item properties propagate normally.
const valueConfig = getConfig(items, "value") as {
inputProps?: { placeholder?: string };
};
expect(valueConfig.inputProps?.placeholder).toBe("Item value");

// The "label" item property collides with a reserved FieldConfigItem
// key and must be skipped + warned about.
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(
'Array field "items" has an item property named "label"',
),
);
warn.mockRestore();
});
});
Loading