@btst/stack 2.11.3 → 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 (68) hide show
  1. package/dist/packages/stack/src/plugins/cms/api/mutations.cjs +12 -1
  2. package/dist/packages/stack/src/plugins/cms/api/mutations.mjs +12 -1
  3. package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +11 -51
  4. package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +3 -43
  5. package/dist/packages/stack/src/plugins/cms/api/relations.cjs +76 -0
  6. package/dist/packages/stack/src/plugins/cms/api/relations.mjs +70 -0
  7. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.cjs +58 -34
  8. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.mjs +58 -34
  9. package/dist/packages/stack/src/plugins/cms/client/components/inverse-relations-panel.cjs +29 -7
  10. package/dist/packages/stack/src/plugins/cms/client/components/inverse-relations-panel.mjs +29 -7
  11. package/dist/packages/ui/src/components/auto-form/helpers.cjs +24 -1
  12. package/dist/packages/ui/src/components/auto-form/helpers.mjs +24 -1
  13. package/dist/plugins/blog/api/index.d.cts +3 -2
  14. package/dist/plugins/blog/api/index.d.mts +3 -2
  15. package/dist/plugins/blog/api/index.d.ts +3 -2
  16. package/dist/plugins/blog/client/components/index.cjs +21 -0
  17. package/dist/plugins/blog/client/components/index.d.cts +71 -1
  18. package/dist/plugins/blog/client/components/index.d.mts +71 -1
  19. package/dist/plugins/blog/client/components/index.d.ts +71 -1
  20. package/dist/plugins/blog/client/components/index.mjs +15 -1
  21. package/dist/plugins/blog/client/hooks/index.d.cts +3 -2
  22. package/dist/plugins/blog/client/hooks/index.d.mts +3 -2
  23. package/dist/plugins/blog/client/hooks/index.d.ts +3 -2
  24. package/dist/plugins/blog/client/index.d.cts +3 -2
  25. package/dist/plugins/blog/client/index.d.mts +3 -2
  26. package/dist/plugins/blog/client/index.d.ts +3 -2
  27. package/dist/plugins/blog/query-keys.d.cts +3 -2
  28. package/dist/plugins/blog/query-keys.d.mts +3 -2
  29. package/dist/plugins/blog/query-keys.d.ts +3 -2
  30. package/dist/plugins/cms/api/index.d.cts +1 -1
  31. package/dist/plugins/cms/api/index.d.mts +1 -1
  32. package/dist/plugins/cms/api/index.d.ts +1 -1
  33. package/dist/plugins/cms/query-keys.d.cts +1 -1
  34. package/dist/plugins/cms/query-keys.d.mts +1 -1
  35. package/dist/plugins/cms/query-keys.d.ts +1 -1
  36. package/dist/plugins/kanban/api/index.d.cts +1 -1
  37. package/dist/plugins/kanban/api/index.d.mts +1 -1
  38. package/dist/plugins/kanban/api/index.d.ts +1 -1
  39. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  40. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  41. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  42. package/dist/shared/{stack.DGsFF5qb.d.cts → stack.BnSAhKJi.d.ts} +6 -5
  43. package/dist/shared/{stack.CntKf20s.d.cts → stack.CLWhMntf.d.cts} +12 -11
  44. package/dist/shared/{stack.Sod_PZhB.d.cts → stack.CuhcFsAx.d.cts} +7 -42
  45. package/dist/shared/{stack.Sod_PZhB.d.mts → stack.CuhcFsAx.d.mts} +7 -42
  46. package/dist/shared/{stack.Sod_PZhB.d.ts → stack.CuhcFsAx.d.ts} +7 -42
  47. package/dist/shared/{stack.QrBE0Bc8.d.ts → stack.DNLuzDb3.d.cts} +6 -5
  48. package/dist/shared/{stack.BX7MHi0J.d.mts → stack.DbnCLdNF.d.mts} +34 -7
  49. package/dist/shared/{stack.NtflNnnH.d.mts → stack.Dfc44JiE.d.mts} +6 -5
  50. package/dist/shared/{stack.CLrGRIj0.d.mts → stack.DiodAFAa.d.mts} +12 -11
  51. package/dist/shared/stack.Dsatjh0B.d.cts +35 -0
  52. package/dist/shared/stack.Dsatjh0B.d.mts +35 -0
  53. package/dist/shared/stack.Dsatjh0B.d.ts +35 -0
  54. package/dist/shared/{stack.fQjVhw5a.d.ts → stack.fXlShnhv.d.ts} +34 -7
  55. package/dist/shared/{stack.C9zoS1TN.d.cts → stack.r6HohbyX.d.cts} +34 -7
  56. package/dist/shared/{stack.BQSy-NDc.d.ts → stack.ufcExQIR.d.ts} +12 -11
  57. package/package.json +14 -1
  58. package/src/plugins/blog/client/components/index.tsx +52 -0
  59. package/src/plugins/cms/__tests__/auto-form-nested-config.test.ts +177 -0
  60. package/src/plugins/cms/api/index.ts +1 -0
  61. package/src/plugins/cms/api/mutations.ts +48 -3
  62. package/src/plugins/cms/api/plugin.ts +11 -101
  63. package/src/plugins/cms/api/relations.ts +174 -0
  64. package/src/plugins/cms/client/components/forms/content-form.tsx +93 -47
  65. package/src/plugins/cms/client/components/inverse-relations-panel.tsx +49 -1
  66. package/dist/shared/{stack.CMbX8Q5C.d.ts → stack.D4Cea8II.d.ts} +13 -13
  67. package/dist/shared/{stack.Dj04W2c3.d.mts → stack.HE_IvqV5.d.mts} +13 -13
  68. package/dist/shared/{stack.eq5eg1yt.d.cts → stack.Rtcvl8sS.d.cts} +13 -13
@@ -2,6 +2,11 @@ import type { DBAdapter as Adapter } from "@btst/db";
2
2
  import type { ContentType, ContentItem } from "../types";
3
3
  import { serializeContentItem } from "./getters";
4
4
  import type { SerializedContentItem } from "../types";
5
+ import {
6
+ collectExistingRelationIds,
7
+ extractRelationFields,
8
+ syncRelations,
9
+ } from "./relations";
5
10
 
6
11
  /**
7
12
  * Input for creating a new CMS content item.
@@ -13,12 +18,37 @@ export interface CreateCMSContentItemInput {
13
18
  data: Record<string, unknown>;
14
19
  }
15
20
 
21
+ /**
22
+ * Options for {@link createCMSContentItem}.
23
+ */
24
+ export interface CreateCMSContentItemOptions {
25
+ /**
26
+ * When `true`, persist relation fields (`belongsTo`, `hasMany`,
27
+ * `manyToMany`) into the `contentRelation` junction table in addition to
28
+ * the item's JSON payload.
29
+ *
30
+ * This is what the HTTP `POST /content/:typeSlug` route does, and is
31
+ * required for the admin "Related Items" panel / inverse-relation queries
32
+ * to find the item.
33
+ *
34
+ * Seeds and other programmatic callers that want the admin UI to work
35
+ * should enable this flag. Callers are still expected to pass
36
+ * pre-created target IDs (`{ id }` or `[{ id }, ...]`) — inline `_new`
37
+ * creation is only supported via the HTTP route.
38
+ *
39
+ * Defaults to `false` for backwards compatibility (a no-op for content
40
+ * types without relations).
41
+ */
42
+ syncRelations?: boolean;
43
+ }
44
+
16
45
  /**
17
46
  * Create a new content item for a content type (looked up by slug).
18
47
  *
19
- * Bypasses Zod schema validation and relation processing — the caller is
20
- * responsible for providing valid, relation-free data. For relation fields or
21
- * schema validation, use the HTTP endpoint instead.
48
+ * Bypasses Zod schema validation and inline `_new` relation creation — the
49
+ * caller is responsible for providing valid data and pre-created relation
50
+ * IDs. For schema validation or inline creation of related items, use the
51
+ * HTTP endpoint instead.
22
52
  *
23
53
  * Throws if the content type is not found or the slug is already taken within
24
54
  * that content type.
@@ -30,11 +60,15 @@ export interface CreateCMSContentItemInput {
30
60
  * @param adapter - The database adapter
31
61
  * @param contentTypeSlug - Slug of the target content type
32
62
  * @param input - Item slug and data payload
63
+ * @param options - See {@link CreateCMSContentItemOptions}. Pass
64
+ * `{ syncRelations: true }` to also populate the `contentRelation`
65
+ * junction table for relation fields in the payload.
33
66
  */
34
67
  export async function createCMSContentItem(
35
68
  adapter: Adapter,
36
69
  contentTypeSlug: string,
37
70
  input: CreateCMSContentItemInput,
71
+ options: CreateCMSContentItemOptions = {},
38
72
  ): Promise<SerializedContentItem> {
39
73
  const contentType = await adapter.findOne<ContentType>({
40
74
  model: "contentType",
@@ -80,5 +114,16 @@ export async function createCMSContentItem(
80
114
  },
81
115
  });
82
116
 
117
+ if (options.syncRelations) {
118
+ const relationFields = extractRelationFields(contentType);
119
+ if (Object.keys(relationFields).length > 0) {
120
+ const relationIds = collectExistingRelationIds(
121
+ input.data,
122
+ relationFields,
123
+ );
124
+ await syncRelations(adapter, item.id, relationIds);
125
+ }
126
+ }
127
+
83
128
  return serializeContentItem(item);
84
129
  }
@@ -15,7 +15,6 @@ import type {
15
15
  CMSBackendConfig,
16
16
  CMSHookContext,
17
17
  SerializedContentItemWithType,
18
- RelationConfig,
19
18
  RelationValue,
20
19
  InverseRelation,
21
20
  } from "../types";
@@ -31,6 +30,12 @@ import {
31
30
  serializeContentItemWithType,
32
31
  } from "./getters";
33
32
  import { createCMSContentItem } from "./mutations";
33
+ import {
34
+ extractRelationFields,
35
+ isExistingRelationValue,
36
+ isNewRelationValue,
37
+ syncRelations,
38
+ } from "./relations";
34
39
  import type { QueryClient } from "@tanstack/react-query";
35
40
  import { CMS_QUERY_KEYS } from "./query-key-defs";
36
41
  import { runHookWithShim } from "../../utils";
@@ -145,67 +150,9 @@ function getContentTypeZodSchema(contentType: ContentType): z.ZodTypeAny {
145
150
  }
146
151
 
147
152
  // ========== Relation Helpers ==========
148
-
149
- interface JsonSchemaProperty {
150
- fieldType?: string;
151
- relation?: RelationConfig;
152
- type?: string;
153
- items?: JsonSchemaProperty;
154
- [key: string]: unknown;
155
- }
156
-
157
- interface JsonSchemaWithProperties {
158
- properties?: Record<string, JsonSchemaProperty>;
159
- [key: string]: unknown;
160
- }
161
-
162
- /**
163
- * Extract relation field configurations from a content type's JSON Schema
164
- */
165
- function extractRelationFields(
166
- contentType: ContentType,
167
- ): Record<string, RelationConfig> {
168
- const jsonSchema = JSON.parse(
169
- contentType.jsonSchema,
170
- ) as JsonSchemaWithProperties;
171
- const properties = jsonSchema.properties || {};
172
- const relationFields: Record<string, RelationConfig> = {};
173
-
174
- for (const [fieldName, fieldSchema] of Object.entries(properties)) {
175
- if (fieldSchema.fieldType === "relation" && fieldSchema.relation) {
176
- relationFields[fieldName] = fieldSchema.relation;
177
- }
178
- }
179
-
180
- return relationFields;
181
- }
182
-
183
- /**
184
- * Check if a value is a "new" relation item (to be created)
185
- */
186
- function isNewRelationValue(
187
- value: unknown,
188
- ): value is { _new: true; data: Record<string, unknown> } {
189
- return (
190
- typeof value === "object" &&
191
- value !== null &&
192
- "_new" in value &&
193
- (value as { _new: unknown })._new === true &&
194
- "data" in value
195
- );
196
- }
197
-
198
- /**
199
- * Check if a value is an existing relation reference
200
- */
201
- function isExistingRelationValue(value: unknown): value is { id: string } {
202
- return (
203
- typeof value === "object" &&
204
- value !== null &&
205
- "id" in value &&
206
- typeof (value as { id: unknown }).id === "string"
207
- );
208
- }
153
+ // `extractRelationFields`, `isNewRelationValue`, `isExistingRelationValue`,
154
+ // and `syncRelations` live in ./relations so both this HTTP route module
155
+ // and the programmatic mutations helper can share them.
209
156
 
210
157
  /**
211
158
  * Process relation fields in content data:
@@ -363,44 +310,6 @@ async function createRelatedItem(
363
310
  return item;
364
311
  }
365
312
 
366
- /**
367
- * Sync relations in the junction table for a content item.
368
- *
369
- * Only updates relations for fields explicitly present in relationIds.
370
- * Fields not in relationIds are left unchanged - this preserves existing
371
- * relations during partial updates.
372
- */
373
- async function syncRelations(
374
- adapter: Adapter,
375
- sourceId: string,
376
- relationIds: Record<string, string[]>,
377
- ): Promise<void> {
378
- // Only sync fields that are explicitly included in relationIds
379
- for (const [fieldName, targetIds] of Object.entries(relationIds)) {
380
- // Delete existing relations for this specific field only
381
- await adapter.delete({
382
- model: "contentRelation",
383
- where: [
384
- { field: "sourceId", value: sourceId, operator: "eq" as const },
385
- { field: "fieldName", value: fieldName, operator: "eq" as const },
386
- ],
387
- });
388
-
389
- // Create new relations for this field
390
- for (const targetId of targetIds) {
391
- await adapter.create<ContentRelation>({
392
- model: "contentRelation",
393
- data: {
394
- sourceId,
395
- targetId,
396
- fieldName,
397
- createdAt: new Date(),
398
- },
399
- });
400
- }
401
- }
402
- }
403
-
404
313
  /**
405
314
  * Populate relations for a content item by fetching related items
406
315
  */
@@ -575,9 +484,10 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
575
484
  createContentItem: async (
576
485
  typeSlug: string,
577
486
  input: Parameters<typeof createCMSContentItem>[2],
487
+ options?: Parameters<typeof createCMSContentItem>[3],
578
488
  ) => {
579
489
  await ensureSynced(adapter);
580
- return createCMSContentItem(adapter, typeSlug, input);
490
+ return createCMSContentItem(adapter, typeSlug, input, options);
581
491
  },
582
492
  }),
583
493
 
@@ -0,0 +1,174 @@
1
+ import type { DBAdapter as Adapter } from "@btst/db";
2
+ import type {
3
+ ContentType,
4
+ ContentRelation,
5
+ RelationConfig,
6
+ RelationValue,
7
+ } from "../types";
8
+
9
+ /**
10
+ * Shape of a property inside a content type's stored JSON Schema.
11
+ * Relation fields carry `fieldType: "relation"` and a `relation` descriptor.
12
+ */
13
+ interface JsonSchemaProperty {
14
+ fieldType?: string;
15
+ relation?: RelationConfig;
16
+ type?: string;
17
+ items?: JsonSchemaProperty;
18
+ [key: string]: unknown;
19
+ }
20
+
21
+ interface JsonSchemaWithProperties {
22
+ properties?: Record<string, JsonSchemaProperty>;
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ /**
27
+ * Extract relation field configurations from a content type's JSON Schema.
28
+ *
29
+ * Returns a map of field name -> RelationConfig for every field declared
30
+ * with `fieldType: "relation"` and a populated `relation` descriptor.
31
+ */
32
+ export function extractRelationFields(
33
+ contentType: ContentType,
34
+ ): Record<string, RelationConfig> {
35
+ const jsonSchema = JSON.parse(
36
+ contentType.jsonSchema,
37
+ ) as JsonSchemaWithProperties;
38
+ const properties = jsonSchema.properties || {};
39
+ const relationFields: Record<string, RelationConfig> = {};
40
+
41
+ for (const [fieldName, fieldSchema] of Object.entries(properties)) {
42
+ if (fieldSchema.fieldType === "relation" && fieldSchema.relation) {
43
+ relationFields[fieldName] = fieldSchema.relation;
44
+ }
45
+ }
46
+
47
+ return relationFields;
48
+ }
49
+
50
+ /**
51
+ * Type guard: value is a `{ _new: true, data: ... }` descriptor used by the
52
+ * HTTP endpoints to request inline creation of a related item on save.
53
+ */
54
+ export function isNewRelationValue(
55
+ value: unknown,
56
+ ): value is { _new: true; data: Record<string, unknown> } {
57
+ return (
58
+ typeof value === "object" &&
59
+ value !== null &&
60
+ "_new" in value &&
61
+ (value as { _new: unknown })._new === true &&
62
+ "data" in value
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Type guard: value is an existing relation reference (`{ id: string }`).
68
+ */
69
+ export function isExistingRelationValue(
70
+ value: unknown,
71
+ ): value is { id: string } {
72
+ return (
73
+ typeof value === "object" &&
74
+ value !== null &&
75
+ "id" in value &&
76
+ typeof (value as { id: unknown }).id === "string"
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Collect relation target IDs out of a content item's data payload,
82
+ * grouped by relation field name.
83
+ *
84
+ * Only processes fields that are:
85
+ * - present in `data`
86
+ * - declared as relations in `relationFields`
87
+ * - hold existing `{ id }` references (or arrays of them)
88
+ *
89
+ * Unlike `processRelationsInData` in the HTTP route, this does NOT create
90
+ * inline `_new` items — callers passing `_new` values will have them
91
+ * silently ignored. Programmatic callers (seeds, imports) are expected to
92
+ * supply pre-created IDs.
93
+ *
94
+ * Returns a map of `{ fieldName: targetIds[] }` ready to hand to
95
+ * {@link syncRelations}.
96
+ */
97
+ export function collectExistingRelationIds(
98
+ data: Record<string, unknown>,
99
+ relationFields: Record<string, RelationConfig>,
100
+ ): Record<string, string[]> {
101
+ const relationIds: Record<string, string[]> = {};
102
+
103
+ for (const [fieldName, relationConfig] of Object.entries(relationFields)) {
104
+ if (!(fieldName in data)) continue;
105
+
106
+ const fieldValue = data[fieldName];
107
+ if (!fieldValue) {
108
+ relationIds[fieldName] = [];
109
+ continue;
110
+ }
111
+
112
+ const ids: string[] = [];
113
+
114
+ if (relationConfig.type === "belongsTo") {
115
+ const value = fieldValue as RelationValue;
116
+ if (isExistingRelationValue(value)) {
117
+ ids.push(value.id);
118
+ }
119
+ } else {
120
+ // hasMany / manyToMany — expect an array of { id }
121
+ const values = (
122
+ Array.isArray(fieldValue) ? fieldValue : []
123
+ ) as RelationValue[];
124
+
125
+ for (const value of values) {
126
+ if (isExistingRelationValue(value)) {
127
+ ids.push(value.id);
128
+ }
129
+ }
130
+ }
131
+
132
+ relationIds[fieldName] = ids;
133
+ }
134
+
135
+ return relationIds;
136
+ }
137
+
138
+ /**
139
+ * Sync relations in the junction table for a content item.
140
+ *
141
+ * Only updates relations for fields explicitly present in `relationIds`.
142
+ * Fields not included are left untouched — this preserves existing
143
+ * relations during partial updates.
144
+ *
145
+ * For each included field, existing junction rows (matching sourceId +
146
+ * fieldName) are deleted and replaced with the provided target IDs.
147
+ */
148
+ export async function syncRelations(
149
+ adapter: Adapter,
150
+ sourceId: string,
151
+ relationIds: Record<string, string[]>,
152
+ ): Promise<void> {
153
+ for (const [fieldName, targetIds] of Object.entries(relationIds)) {
154
+ await adapter.delete({
155
+ model: "contentRelation",
156
+ where: [
157
+ { field: "sourceId", value: sourceId, operator: "eq" as const },
158
+ { field: "fieldName", value: fieldName, operator: "eq" as const },
159
+ ],
160
+ });
161
+
162
+ for (const targetId of targetIds) {
163
+ await adapter.create<ContentRelation>({
164
+ model: "contentRelation",
165
+ data: {
166
+ sourceId,
167
+ targetId,
168
+ fieldName,
169
+ createdAt: new Date(),
170
+ },
171
+ });
172
+ }
173
+ }
174
+ }
@@ -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
  }
@@ -95,6 +95,19 @@ export function InverseRelationsPanel({
95
95
  return null;
96
96
  }
97
97
 
98
+ // When a single source content type has multiple belongsTo fields pointing
99
+ // at this target type (e.g. StackSynergy has both compoundAId and
100
+ // compoundBId → compound), the section title alone ("Stack Synergy") is
101
+ // ambiguous — two cards would render with identical headings. Mark those
102
+ // relations so we can disambiguate them by field name.
103
+ const sourceTypeCounts = new Map<string, number>();
104
+ for (const rel of inverseRelations) {
105
+ sourceTypeCounts.set(
106
+ rel.sourceType,
107
+ (sourceTypeCounts.get(rel.sourceType) ?? 0) + 1,
108
+ );
109
+ }
110
+
98
111
  return (
99
112
  <div className="space-y-4">
100
113
  <h3 className="text-lg font-semibold">Related Items</h3>
@@ -109,12 +122,32 @@ export function InverseRelationsPanel({
109
122
  Link={Link}
110
123
  client={client}
111
124
  headers={headers}
125
+ ambiguous={(sourceTypeCounts.get(relation.sourceType) ?? 0) > 1}
112
126
  />
113
127
  ))}
114
128
  </div>
115
129
  );
116
130
  }
117
131
 
132
+ /**
133
+ * Turn a relation field name like `compoundAId` / `categoryIds` into a
134
+ * friendlier label like `Compound A` / `Category` for display in the
135
+ * inverse-relations panel when two sections would otherwise share a title.
136
+ *
137
+ * Strips a trailing `Id` or `Ids`, splits camelCase boundaries, and
138
+ * title-cases the result. Leaves unrecognised shapes as-is so we never
139
+ * produce an empty string.
140
+ */
141
+ function humanizeFieldName(fieldName: string): string {
142
+ const stripped = fieldName.replace(/Ids?$/, "") || fieldName;
143
+ const words = stripped
144
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
145
+ .replace(/[_-]+/g, " ")
146
+ .trim()
147
+ .split(/\s+/);
148
+ return words.map((w) => (w ? w[0]!.toUpperCase() + w.slice(1) : w)).join(" ");
149
+ }
150
+
118
151
  interface InverseRelationSectionProps {
119
152
  relation: InverseRelation;
120
153
  contentTypeSlug: string;
@@ -128,6 +161,12 @@ interface InverseRelationSectionProps {
128
161
  }>;
129
162
  client: ReturnType<typeof createApiClient<CMSApiRouter>>;
130
163
  headers?: HeadersInit;
164
+ /**
165
+ * True when another inverse relation from the same `sourceType` is also
166
+ * being rendered — in which case the field-name suffix is shown so the
167
+ * user can tell the two cards apart.
168
+ */
169
+ ambiguous: boolean;
131
170
  }
132
171
 
133
172
  function InverseRelationSection({
@@ -139,6 +178,7 @@ function InverseRelationSection({
139
178
  Link,
140
179
  client,
141
180
  headers,
181
+ ambiguous,
142
182
  }: InverseRelationSectionProps) {
143
183
  const [isExpanded, setIsExpanded] = useState(true);
144
184
  const [deleteItemId, setDeleteItemId] = useState<string | null>(null);
@@ -205,6 +245,7 @@ function InverseRelationSection({
205
245
  };
206
246
 
207
247
  const LinkComponent = Link ?? "a";
248
+ const fieldLabel = ambiguous ? humanizeFieldName(relation.fieldName) : null;
208
249
 
209
250
  return (
210
251
  <Card>
@@ -220,7 +261,13 @@ function InverseRelationSection({
220
261
  ) : (
221
262
  <ChevronRight className="h-4 w-4" />
222
263
  )}
223
- {relation.sourceTypeName} ({total})
264
+ <span>{relation.sourceTypeName}</span>
265
+ {fieldLabel && (
266
+ <span className="font-normal text-muted-foreground">
267
+ · {fieldLabel}
268
+ </span>
269
+ )}
270
+ <span className="font-normal text-muted-foreground">({total})</span>
224
271
  </CardTitle>
225
272
  </button>
226
273
  </CardHeader>
@@ -269,6 +316,7 @@ function InverseRelationSection({
269
316
  >
270
317
  <Plus className="h-4 w-4 mr-2" />
271
318
  Add {relation.sourceTypeName}
319
+ {fieldLabel ? ` (${fieldLabel})` : ""}
272
320
  </Button>
273
321
  </div>
274
322
  </CardContent>