@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.
- package/dist/packages/stack/src/plugins/cms/api/mutations.cjs +12 -1
- package/dist/packages/stack/src/plugins/cms/api/mutations.mjs +12 -1
- package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +11 -51
- package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +3 -43
- package/dist/packages/stack/src/plugins/cms/api/relations.cjs +76 -0
- package/dist/packages/stack/src/plugins/cms/api/relations.mjs +70 -0
- package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.cjs +58 -34
- package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.mjs +58 -34
- package/dist/packages/stack/src/plugins/cms/client/components/inverse-relations-panel.cjs +29 -7
- package/dist/packages/stack/src/plugins/cms/client/components/inverse-relations-panel.mjs +29 -7
- package/dist/packages/ui/src/components/auto-form/helpers.cjs +24 -1
- package/dist/packages/ui/src/components/auto-form/helpers.mjs +24 -1
- package/dist/plugins/blog/api/index.d.cts +3 -2
- package/dist/plugins/blog/api/index.d.mts +3 -2
- package/dist/plugins/blog/api/index.d.ts +3 -2
- package/dist/plugins/blog/client/components/index.cjs +21 -0
- package/dist/plugins/blog/client/components/index.d.cts +71 -1
- package/dist/plugins/blog/client/components/index.d.mts +71 -1
- package/dist/plugins/blog/client/components/index.d.ts +71 -1
- package/dist/plugins/blog/client/components/index.mjs +15 -1
- package/dist/plugins/blog/client/hooks/index.d.cts +3 -2
- package/dist/plugins/blog/client/hooks/index.d.mts +3 -2
- package/dist/plugins/blog/client/hooks/index.d.ts +3 -2
- package/dist/plugins/blog/client/index.d.cts +3 -2
- package/dist/plugins/blog/client/index.d.mts +3 -2
- package/dist/plugins/blog/client/index.d.ts +3 -2
- package/dist/plugins/blog/query-keys.d.cts +3 -2
- package/dist/plugins/blog/query-keys.d.mts +3 -2
- package/dist/plugins/blog/query-keys.d.ts +3 -2
- package/dist/plugins/cms/api/index.d.cts +1 -1
- package/dist/plugins/cms/api/index.d.mts +1 -1
- package/dist/plugins/cms/api/index.d.ts +1 -1
- package/dist/plugins/cms/query-keys.d.cts +1 -1
- package/dist/plugins/cms/query-keys.d.mts +1 -1
- package/dist/plugins/cms/query-keys.d.ts +1 -1
- package/dist/plugins/kanban/api/index.d.cts +1 -1
- package/dist/plugins/kanban/api/index.d.mts +1 -1
- package/dist/plugins/kanban/api/index.d.ts +1 -1
- package/dist/plugins/kanban/query-keys.d.cts +1 -1
- package/dist/plugins/kanban/query-keys.d.mts +1 -1
- package/dist/plugins/kanban/query-keys.d.ts +1 -1
- package/dist/shared/{stack.DGsFF5qb.d.cts → stack.BnSAhKJi.d.ts} +6 -5
- package/dist/shared/{stack.CntKf20s.d.cts → stack.CLWhMntf.d.cts} +12 -11
- package/dist/shared/{stack.Sod_PZhB.d.cts → stack.CuhcFsAx.d.cts} +7 -42
- package/dist/shared/{stack.Sod_PZhB.d.mts → stack.CuhcFsAx.d.mts} +7 -42
- package/dist/shared/{stack.Sod_PZhB.d.ts → stack.CuhcFsAx.d.ts} +7 -42
- package/dist/shared/{stack.QrBE0Bc8.d.ts → stack.DNLuzDb3.d.cts} +6 -5
- package/dist/shared/{stack.BX7MHi0J.d.mts → stack.DbnCLdNF.d.mts} +34 -7
- package/dist/shared/{stack.NtflNnnH.d.mts → stack.Dfc44JiE.d.mts} +6 -5
- package/dist/shared/{stack.CLrGRIj0.d.mts → stack.DiodAFAa.d.mts} +12 -11
- package/dist/shared/stack.Dsatjh0B.d.cts +35 -0
- package/dist/shared/stack.Dsatjh0B.d.mts +35 -0
- package/dist/shared/stack.Dsatjh0B.d.ts +35 -0
- package/dist/shared/{stack.fQjVhw5a.d.ts → stack.fXlShnhv.d.ts} +34 -7
- package/dist/shared/{stack.C9zoS1TN.d.cts → stack.r6HohbyX.d.cts} +34 -7
- package/dist/shared/{stack.BQSy-NDc.d.ts → stack.ufcExQIR.d.ts} +12 -11
- package/package.json +14 -1
- package/src/plugins/blog/client/components/index.tsx +52 -0
- package/src/plugins/cms/__tests__/auto-form-nested-config.test.ts +177 -0
- package/src/plugins/cms/api/index.ts +1 -0
- package/src/plugins/cms/api/mutations.ts +48 -3
- package/src/plugins/cms/api/plugin.ts +11 -101
- package/src/plugins/cms/api/relations.ts +174 -0
- package/src/plugins/cms/client/components/forms/content-form.tsx +93 -47
- package/src/plugins/cms/client/components/inverse-relations-panel.tsx +49 -1
- package/dist/shared/{stack.CMbX8Q5C.d.ts → stack.D4Cea8II.d.ts} +13 -13
- package/dist/shared/{stack.Dj04W2c3.d.mts → stack.HE_IvqV5.d.mts} +13 -13
- 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
|
|
20
|
-
* responsible for providing valid
|
|
21
|
-
* schema validation, use the
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
prop.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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}
|
|
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>
|