@btst/stack 2.11.4 → 2.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.cjs +58 -34
  2. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.mjs +58 -34
  3. package/dist/packages/stack/src/plugins/cms/client/components/inverse-relations-panel.cjs +29 -7
  4. package/dist/packages/stack/src/plugins/cms/client/components/inverse-relations-panel.mjs +29 -7
  5. package/dist/packages/ui/src/components/auto-form/helpers.cjs +24 -1
  6. package/dist/packages/ui/src/components/auto-form/helpers.mjs +24 -1
  7. package/dist/plugins/blog/api/index.d.cts +3 -2
  8. package/dist/plugins/blog/api/index.d.mts +3 -2
  9. package/dist/plugins/blog/api/index.d.ts +3 -2
  10. package/dist/plugins/blog/client/components/index.cjs +21 -0
  11. package/dist/plugins/blog/client/components/index.d.cts +71 -1
  12. package/dist/plugins/blog/client/components/index.d.mts +71 -1
  13. package/dist/plugins/blog/client/components/index.d.ts +71 -1
  14. package/dist/plugins/blog/client/components/index.mjs +15 -1
  15. package/dist/plugins/blog/client/hooks/index.d.cts +3 -2
  16. package/dist/plugins/blog/client/hooks/index.d.mts +3 -2
  17. package/dist/plugins/blog/client/hooks/index.d.ts +3 -2
  18. package/dist/plugins/blog/client/index.d.cts +3 -2
  19. package/dist/plugins/blog/client/index.d.mts +3 -2
  20. package/dist/plugins/blog/client/index.d.ts +3 -2
  21. package/dist/plugins/blog/query-keys.d.cts +3 -2
  22. package/dist/plugins/blog/query-keys.d.mts +3 -2
  23. package/dist/plugins/blog/query-keys.d.ts +3 -2
  24. package/dist/plugins/kanban/api/index.d.cts +1 -1
  25. package/dist/plugins/kanban/api/index.d.mts +1 -1
  26. package/dist/plugins/kanban/api/index.d.ts +1 -1
  27. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  28. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  29. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  30. package/dist/shared/{stack.6mEHS2WH.d.mts → stack.BnSAhKJi.d.ts} +4 -3
  31. package/dist/shared/{stack.C1jLzFZ6.d.mts → stack.CLWhMntf.d.cts} +6 -5
  32. package/dist/shared/{stack.BQmuNl5p.d.mts → stack.CuhcFsAx.d.cts} +3 -38
  33. package/dist/shared/{stack.BQmuNl5p.d.ts → stack.CuhcFsAx.d.mts} +3 -38
  34. package/dist/shared/{stack.BQmuNl5p.d.cts → stack.CuhcFsAx.d.ts} +3 -38
  35. package/dist/shared/{stack.AJTXI7kw.d.cts → stack.DNLuzDb3.d.cts} +4 -3
  36. package/dist/shared/{stack.QYn-Px94.d.ts → stack.Dfc44JiE.d.mts} +4 -3
  37. package/dist/shared/{stack.DF1ewpVE.d.ts → stack.DiodAFAa.d.mts} +6 -5
  38. package/dist/shared/stack.Dsatjh0B.d.cts +35 -0
  39. package/dist/shared/stack.Dsatjh0B.d.mts +35 -0
  40. package/dist/shared/stack.Dsatjh0B.d.ts +35 -0
  41. package/dist/shared/{stack.XulPeAD3.d.cts → stack.ufcExQIR.d.ts} +6 -5
  42. package/package.json +14 -1
  43. package/src/plugins/blog/client/components/index.tsx +52 -0
  44. package/src/plugins/cms/__tests__/auto-form-nested-config.test.ts +177 -0
  45. package/src/plugins/cms/client/components/forms/content-form.tsx +93 -47
  46. package/src/plugins/cms/client/components/inverse-relations-panel.tsx +49 -1
  47. package/dist/shared/{stack.j5SFLC1d.d.ts → stack.D4Cea8II.d.ts} +13 -13
  48. package/dist/shared/{stack.Bzfx-_lq.d.mts → stack.HE_IvqV5.d.mts} +13 -13
  49. package/dist/shared/{stack.B6S3cgwN.d.cts → stack.Rtcvl8sS.d.cts} +13 -13
@@ -1,6 +1,7 @@
1
1
  import * as _tanstack_react_query from '@tanstack/react-query';
2
- import { S as SerializedPost, c as createPostSchema, u as updatePostSchema, a as SerializedTag } from './stack.BQmuNl5p.cjs';
2
+ import { S as SerializedPost, a as SerializedTag } from './stack.Dsatjh0B.cjs';
3
3
  import { z } from 'zod';
4
+ import { c as createPostSchema, u as updatePostSchema } from './stack.CuhcFsAx.cjs';
4
5
 
5
6
  /**
6
7
  * Options for the usePosts hook
@@ -135,14 +136,14 @@ declare function useCreatePost(): _tanstack_react_query.UseMutationResult<Serial
135
136
  name: string;
136
137
  slug: string;
137
138
  })[];
139
+ published: boolean;
138
140
  title: string;
139
141
  content: string;
140
142
  excerpt: string;
141
- published: boolean;
142
143
  slug?: string | undefined;
144
+ publishedAt?: Date | undefined;
143
145
  createdAt?: Date | undefined;
144
146
  updatedAt?: Date | undefined;
145
- publishedAt?: Date | undefined;
146
147
  image?: string | undefined;
147
148
  }, unknown>;
148
149
  /** Update an existing post by id */
@@ -1,6 +1,7 @@
1
1
  import * as _tanstack_react_query from '@tanstack/react-query';
2
- import { S as SerializedPost, c as createPostSchema, u as updatePostSchema, a as SerializedTag } from './stack.BQmuNl5p.js';
2
+ import { S as SerializedPost, a as SerializedTag } from './stack.Dsatjh0B.mjs';
3
3
  import { z } from 'zod';
4
+ import { c as createPostSchema, u as updatePostSchema } from './stack.CuhcFsAx.mjs';
4
5
 
5
6
  /**
6
7
  * Options for the usePosts hook
@@ -135,14 +136,14 @@ declare function useCreatePost(): _tanstack_react_query.UseMutationResult<Serial
135
136
  name: string;
136
137
  slug: string;
137
138
  })[];
139
+ published: boolean;
138
140
  title: string;
139
141
  content: string;
140
142
  excerpt: string;
141
- published: boolean;
142
143
  slug?: string | undefined;
144
+ publishedAt?: Date | undefined;
143
145
  createdAt?: Date | undefined;
144
146
  updatedAt?: Date | undefined;
145
- publishedAt?: Date | undefined;
146
147
  image?: string | undefined;
147
148
  }, unknown>;
148
149
  /** Update an existing post by id */
@@ -1,11 +1,12 @@
1
1
  import * as _tanstack_react_query from '@tanstack/react-query';
2
2
  import { QueryClient } from '@tanstack/react-query';
3
3
  import { createApiClient } from '@btst/stack/plugins/client';
4
- import { P as Post, T as Tag, c as createPostSchema, u as updatePostSchema, S as SerializedPost, a as SerializedTag } from './stack.BQmuNl5p.js';
4
+ import { P as Post, T as Tag, S as SerializedPost, a as SerializedTag } from './stack.Dsatjh0B.mjs';
5
5
  import * as _btst_stack_plugins_api from '@btst/stack/plugins/api';
6
6
  import { DBAdapter } from '@btst/db';
7
7
  import * as better_call from 'better-call';
8
8
  import { z } from 'zod';
9
+ import { c as createPostSchema, u as updatePostSchema } from './stack.CuhcFsAx.mjs';
9
10
 
10
11
  /**
11
12
  * Parameters for filtering/paginating posts.
@@ -324,11 +325,11 @@ declare const blogBackendPlugin: (hooks?: BlogBackendHooks) => _btst_stack_plugi
324
325
  slug: string;
325
326
  })[] | undefined;
326
327
  slug?: string | undefined;
328
+ published?: boolean | undefined;
329
+ publishedAt?: unknown;
327
330
  createdAt?: unknown;
328
331
  updatedAt?: unknown;
329
- publishedAt?: unknown;
330
332
  image?: string | undefined;
331
- published?: boolean | undefined;
332
333
  }, {
333
334
  title: string;
334
335
  content: string;
@@ -341,11 +342,11 @@ declare const blogBackendPlugin: (hooks?: BlogBackendHooks) => _btst_stack_plugi
341
342
  slug: string;
342
343
  })[] | undefined;
343
344
  slug?: string | undefined;
345
+ published?: boolean | undefined;
346
+ publishedAt?: unknown;
344
347
  createdAt?: unknown;
345
348
  updatedAt?: unknown;
346
- publishedAt?: unknown;
347
349
  image?: string | undefined;
348
- published?: boolean | undefined;
349
350
  }>;
350
351
  }, Post>;
351
352
  readonly updatePost: better_call.StrictEndpoint<"/posts/:id", {} & {
@@ -0,0 +1,35 @@
1
+ type Post = {
2
+ id: string;
3
+ authorId?: string;
4
+ defaultLocale?: string;
5
+ slug: string;
6
+ title: string;
7
+ content: string;
8
+ excerpt: string;
9
+ image?: string;
10
+ published: boolean;
11
+ status?: "DRAFT" | "PUBLISHED";
12
+ tags: Tag[];
13
+ publishedAt?: Date;
14
+ createdAt: Date;
15
+ updatedAt: Date;
16
+ };
17
+ type Tag = {
18
+ id: string;
19
+ slug: string;
20
+ name: string;
21
+ createdAt: Date;
22
+ updatedAt: Date;
23
+ };
24
+ interface SerializedPost extends Omit<Post, "createdAt" | "updatedAt" | "publishedAt" | "tags"> {
25
+ tags: SerializedTag[];
26
+ publishedAt?: string;
27
+ createdAt: string;
28
+ updatedAt: string;
29
+ }
30
+ interface SerializedTag extends Omit<Tag, "createdAt" | "updatedAt"> {
31
+ createdAt: string;
32
+ updatedAt: string;
33
+ }
34
+
35
+ export type { Post as P, SerializedPost as S, Tag as T, SerializedTag as a };
@@ -0,0 +1,35 @@
1
+ type Post = {
2
+ id: string;
3
+ authorId?: string;
4
+ defaultLocale?: string;
5
+ slug: string;
6
+ title: string;
7
+ content: string;
8
+ excerpt: string;
9
+ image?: string;
10
+ published: boolean;
11
+ status?: "DRAFT" | "PUBLISHED";
12
+ tags: Tag[];
13
+ publishedAt?: Date;
14
+ createdAt: Date;
15
+ updatedAt: Date;
16
+ };
17
+ type Tag = {
18
+ id: string;
19
+ slug: string;
20
+ name: string;
21
+ createdAt: Date;
22
+ updatedAt: Date;
23
+ };
24
+ interface SerializedPost extends Omit<Post, "createdAt" | "updatedAt" | "publishedAt" | "tags"> {
25
+ tags: SerializedTag[];
26
+ publishedAt?: string;
27
+ createdAt: string;
28
+ updatedAt: string;
29
+ }
30
+ interface SerializedTag extends Omit<Tag, "createdAt" | "updatedAt"> {
31
+ createdAt: string;
32
+ updatedAt: string;
33
+ }
34
+
35
+ export type { Post as P, SerializedPost as S, Tag as T, SerializedTag as a };
@@ -0,0 +1,35 @@
1
+ type Post = {
2
+ id: string;
3
+ authorId?: string;
4
+ defaultLocale?: string;
5
+ slug: string;
6
+ title: string;
7
+ content: string;
8
+ excerpt: string;
9
+ image?: string;
10
+ published: boolean;
11
+ status?: "DRAFT" | "PUBLISHED";
12
+ tags: Tag[];
13
+ publishedAt?: Date;
14
+ createdAt: Date;
15
+ updatedAt: Date;
16
+ };
17
+ type Tag = {
18
+ id: string;
19
+ slug: string;
20
+ name: string;
21
+ createdAt: Date;
22
+ updatedAt: Date;
23
+ };
24
+ interface SerializedPost extends Omit<Post, "createdAt" | "updatedAt" | "publishedAt" | "tags"> {
25
+ tags: SerializedTag[];
26
+ publishedAt?: string;
27
+ createdAt: string;
28
+ updatedAt: string;
29
+ }
30
+ interface SerializedTag extends Omit<Tag, "createdAt" | "updatedAt"> {
31
+ createdAt: string;
32
+ updatedAt: string;
33
+ }
34
+
35
+ export type { Post as P, SerializedPost as S, Tag as T, SerializedTag as a };
@@ -1,11 +1,12 @@
1
1
  import * as _tanstack_react_query from '@tanstack/react-query';
2
2
  import { QueryClient } from '@tanstack/react-query';
3
3
  import { createApiClient } from '@btst/stack/plugins/client';
4
- import { P as Post, T as Tag, c as createPostSchema, u as updatePostSchema, S as SerializedPost, a as SerializedTag } from './stack.BQmuNl5p.cjs';
4
+ import { P as Post, T as Tag, S as SerializedPost, a as SerializedTag } from './stack.Dsatjh0B.js';
5
5
  import * as _btst_stack_plugins_api from '@btst/stack/plugins/api';
6
6
  import { DBAdapter } from '@btst/db';
7
7
  import * as better_call from 'better-call';
8
8
  import { z } from 'zod';
9
+ import { c as createPostSchema, u as updatePostSchema } from './stack.CuhcFsAx.js';
9
10
 
10
11
  /**
11
12
  * Parameters for filtering/paginating posts.
@@ -324,11 +325,11 @@ declare const blogBackendPlugin: (hooks?: BlogBackendHooks) => _btst_stack_plugi
324
325
  slug: string;
325
326
  })[] | undefined;
326
327
  slug?: string | undefined;
328
+ published?: boolean | undefined;
329
+ publishedAt?: unknown;
327
330
  createdAt?: unknown;
328
331
  updatedAt?: unknown;
329
- publishedAt?: unknown;
330
332
  image?: string | undefined;
331
- published?: boolean | undefined;
332
333
  }, {
333
334
  title: string;
334
335
  content: string;
@@ -341,11 +342,11 @@ declare const blogBackendPlugin: (hooks?: BlogBackendHooks) => _btst_stack_plugi
341
342
  slug: string;
342
343
  })[] | undefined;
343
344
  slug?: string | undefined;
345
+ published?: boolean | undefined;
346
+ publishedAt?: unknown;
344
347
  createdAt?: unknown;
345
348
  updatedAt?: unknown;
346
- publishedAt?: unknown;
347
349
  image?: string | undefined;
348
- published?: boolean | undefined;
349
350
  }>;
350
351
  }, Post>;
351
352
  readonly updatePost: better_call.StrictEndpoint<"/posts/:id", {} & {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btst/stack",
3
- "version": "2.11.4",
3
+ "version": "2.11.5",
4
4
  "description": "A composable, plugin-based library for building full-stack applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -127,6 +127,16 @@
127
127
  "default": "./dist/plugins/blog/client/index.cjs"
128
128
  }
129
129
  },
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
+ },
130
140
  "./plugins/blog/client/hooks": {
131
141
  "import": {
132
142
  "types": "./dist/plugins/blog/client/hooks/index.d.ts",
@@ -607,6 +617,9 @@
607
617
  "plugins/blog/client": [
608
618
  "./dist/plugins/blog/client/index.d.ts"
609
619
  ],
620
+ "plugins/blog/client/components": [
621
+ "./dist/plugins/blog/client/components/index.d.ts"
622
+ ],
610
623
  "plugins/blog/client/hooks": [
611
624
  "./dist/plugins/blog/client/hooks/index.d.ts"
612
625
  ],
@@ -3,9 +3,61 @@ import { HomePageComponent as PostListPageImpl } from "./pages/home-page";
3
3
  import { NewPostPageComponent as NewPostPageImpl } from "./pages/new-post-page";
4
4
  import { PostPageComponent as PostPageImpl } from "./pages/post-page";
5
5
  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";
6
13
 
7
14
  // Re-export to ensure the client boundary is preserved
8
15
  export const PostListPage = PostListPageImpl;
9
16
  export const NewPostPage = NewPostPageImpl;
10
17
  export const PostPage = PostPageImpl;
11
18
  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;
@@ -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
+ });
@@ -63,63 +63,109 @@ function buildFieldConfigFromJsonSchema(
63
63
  isRequired?: boolean;
64
64
  }>,
65
65
  ): FieldConfig<Record<string, unknown>> {
66
- // Get base config from shared utility (handles fieldType from JSON Schema)
66
+ // Get base config from shared utility (handles fieldType from JSON Schema,
67
+ // including per-item configs for arrays of objects).
67
68
  const baseConfig = buildFieldConfigBase(jsonSchema, fieldComponents);
68
69
 
69
- // Apply CMS-specific handling for special fieldTypes ONLY if no custom component exists
70
- // Custom fieldComponents take priority - don't override if user provided one
71
- const properties = jsonSchema.properties as Record<
72
- string,
73
- JsonSchemaProperty
74
- >;
70
+ const properties = jsonSchema.properties as
71
+ | Record<string, JsonSchemaProperty>
72
+ | undefined;
75
73
 
76
74
  if (!properties) return baseConfig;
77
75
 
78
- for (const [key, prop] of Object.entries(properties)) {
79
- // Handle "file" fieldType when there's NO custom component for "file"
80
- if (prop.fieldType === "file" && !fieldComponents?.["file"]) {
81
- // Use CMSFileUpload as the default file component
82
- if (!uploadImage && !imageInputField) {
83
- // Show a clear error message if neither uploadImage nor imageInputField is provided
84
- baseConfig[key] = {
85
- ...baseConfig[key],
86
- fieldType: () => (
87
- <div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
88
- File upload requires an <code>uploadImage</code> or{" "}
89
- <code>imageInputField</code> function in CMS overrides.
90
- </div>
91
- ),
92
- };
93
- } else {
94
- baseConfig[key] = {
95
- ...baseConfig[key],
96
- fieldType: (props: AutoFormInputComponentProps) => (
97
- <CMSFileUpload
98
- {...props}
99
- uploadImage={uploadImage ?? (() => Promise.resolve(""))}
100
- imageInputField={imageInputField}
101
- imagePicker={imagePicker}
102
- />
76
+ // Recursively walk the JSON Schema properties and inject CMS-specific custom
77
+ // components (file upload, relation picker) for any field with a matching
78
+ // fieldType, regardless of nesting depth. Targets:
79
+ // - top-level fields
80
+ // - properties of nested object fields
81
+ // - properties of array items (e.g. `components: z.array(z.object({...}))`)
82
+ //
83
+ // `targetConfig` is the FieldConfigObject slot to mutate for the property at
84
+ // `key`. The recursion mirrors how AutoFormObject + AutoFormArray look up
85
+ // per-property configs: nested object/array per-item configs live as keys
86
+ // alongside their parent's meta on the same FieldConfigObject.
87
+ const injectCustomFieldTypes = (
88
+ props: Record<string, JsonSchemaProperty>,
89
+ targetConfig: Record<string, unknown>,
90
+ ) => {
91
+ for (const [key, prop] of Object.entries(props)) {
92
+ // Ensure a slot exists so we can mutate it whether or not the base
93
+ // helper produced an entry for this key.
94
+ const existing =
95
+ (targetConfig[key] as Record<string, unknown> | undefined) ?? {};
96
+
97
+ let updated = existing;
98
+
99
+ // Handle "file" fieldType when there's NO custom component for "file"
100
+ if (prop.fieldType === "file" && !fieldComponents?.["file"]) {
101
+ if (!uploadImage && !imageInputField) {
102
+ updated = {
103
+ ...updated,
104
+ fieldType: () => (
105
+ <div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
106
+ File upload requires an <code>uploadImage</code> or{" "}
107
+ <code>imageInputField</code> function in CMS overrides.
108
+ </div>
109
+ ),
110
+ };
111
+ } else {
112
+ updated = {
113
+ ...updated,
114
+ fieldType: (componentProps: AutoFormInputComponentProps) => (
115
+ <CMSFileUpload
116
+ {...componentProps}
117
+ uploadImage={uploadImage ?? (() => Promise.resolve(""))}
118
+ imageInputField={imageInputField}
119
+ imagePicker={imagePicker}
120
+ />
121
+ ),
122
+ };
123
+ }
124
+ }
125
+
126
+ // Handle "relation" fieldType when there's NO custom component for "relation"
127
+ if (
128
+ prop.fieldType === "relation" &&
129
+ prop.relation &&
130
+ !fieldComponents?.["relation"]
131
+ ) {
132
+ const relationConfig = prop.relation;
133
+ updated = {
134
+ ...updated,
135
+ fieldType: (componentProps: AutoFormInputComponentProps) => (
136
+ <RelationField {...componentProps} relation={relationConfig} />
103
137
  ),
104
138
  };
105
139
  }
106
- }
107
140
 
108
- // Handle "relation" fieldType when there's NO custom component for "relation"
109
- if (
110
- prop.fieldType === "relation" &&
111
- prop.relation &&
112
- !fieldComponents?.["relation"]
113
- ) {
114
- const relationConfig = prop.relation;
115
- baseConfig[key] = {
116
- ...baseConfig[key],
117
- fieldType: (props: AutoFormInputComponentProps) => (
118
- <RelationField {...props} relation={relationConfig} />
119
- ),
120
- };
141
+ // Recurse into nested objects — their per-property configs live as
142
+ // keys on the same parent FieldConfigObject.
143
+ if (prop.properties) {
144
+ injectCustomFieldTypes(
145
+ prop.properties as Record<string, JsonSchemaProperty>,
146
+ updated,
147
+ );
148
+ }
149
+
150
+ // Recurse into array items — same convention as nested objects.
151
+ const items = prop.items as JsonSchemaProperty | undefined;
152
+ if (items?.properties) {
153
+ injectCustomFieldTypes(
154
+ items.properties as Record<string, JsonSchemaProperty>,
155
+ updated,
156
+ );
157
+ }
158
+
159
+ if (Object.keys(updated).length > 0) {
160
+ targetConfig[key] = updated;
161
+ }
121
162
  }
122
- }
163
+ };
164
+
165
+ injectCustomFieldTypes(
166
+ properties,
167
+ baseConfig as unknown as Record<string, unknown>,
168
+ );
123
169
 
124
170
  return baseConfig;
125
171
  }