@btst/stack 1.8.0 → 1.9.1
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/better-stack/src/plugins/cms/api/plugin.cjs +445 -16
- package/dist/packages/better-stack/src/plugins/cms/api/plugin.mjs +445 -16
- package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.cjs +28 -11
- package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.mjs +26 -9
- package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.cjs +224 -0
- package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.mjs +222 -0
- package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.cjs +243 -0
- package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.mjs +241 -0
- package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.cjs +56 -2
- package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.mjs +56 -2
- package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.cjs +190 -0
- package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.mjs +187 -1
- package/dist/packages/better-stack/src/plugins/cms/db.cjs +38 -0
- package/dist/packages/better-stack/src/plugins/cms/db.mjs +38 -0
- package/dist/packages/better-stack/src/plugins/form-builder/client/components/forms/form-renderer.cjs +2 -2
- package/dist/packages/better-stack/src/plugins/form-builder/client/components/forms/form-renderer.mjs +1 -1
- package/dist/packages/ui/src/components/auto-form/fields/array.cjs +2 -2
- package/dist/packages/ui/src/components/auto-form/fields/array.mjs +1 -1
- package/dist/packages/ui/src/components/auto-form/fields/date.cjs +2 -2
- package/dist/packages/ui/src/components/auto-form/fields/date.mjs +1 -1
- package/dist/packages/ui/src/components/auto-form/fields/enum.cjs +2 -2
- package/dist/packages/ui/src/components/auto-form/fields/enum.mjs +1 -1
- package/dist/packages/ui/src/components/auto-form/fields/object.cjs +88 -8
- package/dist/packages/ui/src/components/auto-form/fields/object.mjs +82 -2
- package/dist/packages/ui/src/components/auto-form/fields/radio-group.cjs +2 -2
- package/dist/packages/ui/src/components/auto-form/fields/radio-group.mjs +1 -1
- package/dist/packages/ui/src/components/auto-form/index.cjs +5 -5
- package/dist/packages/ui/src/components/auto-form/index.mjs +1 -1
- package/dist/packages/ui/src/components/button.cjs +4 -2
- package/dist/packages/ui/src/components/button.mjs +4 -2
- package/dist/packages/ui/src/components/dialog.cjs +7 -1
- package/dist/packages/ui/src/components/dialog.mjs +7 -2
- package/dist/packages/ui/src/components/form-builder/edit-field-dialog.cjs +2 -2
- package/dist/packages/ui/src/components/form-builder/edit-field-dialog.mjs +1 -1
- package/dist/packages/ui/src/components/form-builder/form-preview.cjs +5 -5
- package/dist/packages/ui/src/components/form-builder/form-preview.mjs +1 -1
- package/dist/packages/ui/src/components/select.cjs +9 -2
- package/dist/packages/ui/src/components/select.mjs +9 -2
- package/dist/plugins/blog/api/index.d.cts +1 -1
- package/dist/plugins/blog/api/index.d.mts +1 -1
- package/dist/plugins/blog/api/index.d.ts +1 -1
- package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
- package/dist/plugins/blog/client/index.d.cts +1 -1
- package/dist/plugins/blog/client/index.d.mts +1 -1
- package/dist/plugins/blog/client/index.d.ts +1 -1
- package/dist/plugins/blog/query-keys.d.cts +2 -2
- package/dist/plugins/blog/query-keys.d.mts +2 -2
- package/dist/plugins/blog/query-keys.d.ts +2 -2
- package/dist/plugins/cms/api/index.d.cts +66 -2
- package/dist/plugins/cms/api/index.d.mts +66 -2
- package/dist/plugins/cms/api/index.d.ts +66 -2
- package/dist/plugins/cms/client/hooks/index.cjs +4 -0
- package/dist/plugins/cms/client/hooks/index.d.cts +82 -3
- package/dist/plugins/cms/client/hooks/index.d.mts +82 -3
- package/dist/plugins/cms/client/hooks/index.d.ts +82 -3
- package/dist/plugins/cms/client/hooks/index.mjs +1 -1
- package/dist/plugins/cms/client/index.d.cts +2 -2
- package/dist/plugins/cms/client/index.d.mts +2 -2
- package/dist/plugins/cms/client/index.d.ts +2 -2
- 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/form-builder/client/components/index.d.cts +1 -1
- package/dist/plugins/form-builder/client/components/index.d.mts +1 -1
- package/dist/plugins/form-builder/client/components/index.d.ts +1 -1
- package/dist/plugins/form-builder/client/index.d.cts +2 -2
- package/dist/plugins/form-builder/client/index.d.mts +2 -2
- package/dist/plugins/form-builder/client/index.d.ts +2 -2
- package/dist/shared/{stack.AX5nZ6A3.d.ts → stack.Co034Fpm.d.cts} +0 -21
- package/dist/shared/{stack.AX5nZ6A3.d.cts → stack.Co034Fpm.d.mts} +0 -21
- package/dist/shared/{stack.AX5nZ6A3.d.mts → stack.Co034Fpm.d.ts} +0 -21
- package/dist/shared/{stack.BIh2AXaW.d.cts → stack.DGjhPqmF.d.cts} +0 -9
- package/dist/shared/{stack.BIh2AXaW.d.mts → stack.DGjhPqmF.d.mts} +0 -9
- package/dist/shared/{stack.BIh2AXaW.d.ts → stack.DGjhPqmF.d.ts} +0 -9
- package/dist/shared/{stack.L-UFwz2G.d.mts → stack.oGOteE6g.d.cts} +27 -5
- package/dist/shared/{stack.L-UFwz2G.d.cts → stack.oGOteE6g.d.mts} +27 -5
- package/dist/shared/{stack.L-UFwz2G.d.ts → stack.oGOteE6g.d.ts} +27 -5
- package/package.json +1 -1
- package/src/plugins/cms/api/plugin.ts +667 -21
- package/src/plugins/cms/client/components/forms/content-form.tsx +62 -20
- package/src/plugins/cms/client/components/forms/relation-field.tsx +299 -0
- package/src/plugins/cms/client/components/inverse-relations-panel.tsx +329 -0
- package/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx +127 -1
- package/src/plugins/cms/client/hooks/cms-hooks.tsx +344 -0
- package/src/plugins/cms/db.ts +38 -0
- package/src/plugins/cms/types.ts +99 -10
- package/src/plugins/form-builder/client/components/forms/form-renderer.tsx +1 -1
- package/dist/packages/ui/src/components/auto-form/{utils.cjs → helpers.cjs} +0 -0
- package/dist/packages/ui/src/components/auto-form/{utils.mjs → helpers.mjs} +0 -0
- package/dist/shared/{stack.DLhzx1-D.d.mts → stack.CcI4sYJP.d.cts} +1 -1
- package/dist/shared/{stack.DLhzx1-D.d.cts → stack.CcI4sYJP.d.mts} +1 -1
- package/dist/shared/{stack.DLhzx1-D.d.ts → stack.CcI4sYJP.d.ts} +1 -1
|
@@ -109,6 +109,173 @@ function getContentTypeZodSchema(contentType) {
|
|
|
109
109
|
const jsonSchema = JSON.parse(contentType.jsonSchema);
|
|
110
110
|
return formSchemaToZod(jsonSchema);
|
|
111
111
|
}
|
|
112
|
+
function extractRelationFields(contentType) {
|
|
113
|
+
const jsonSchema = JSON.parse(
|
|
114
|
+
contentType.jsonSchema
|
|
115
|
+
);
|
|
116
|
+
const properties = jsonSchema.properties || {};
|
|
117
|
+
const relationFields = {};
|
|
118
|
+
for (const [fieldName, fieldSchema] of Object.entries(properties)) {
|
|
119
|
+
if (fieldSchema.fieldType === "relation" && fieldSchema.relation) {
|
|
120
|
+
relationFields[fieldName] = fieldSchema.relation;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return relationFields;
|
|
124
|
+
}
|
|
125
|
+
function isNewRelationValue(value) {
|
|
126
|
+
return typeof value === "object" && value !== null && "_new" in value && value._new === true && "data" in value;
|
|
127
|
+
}
|
|
128
|
+
function isExistingRelationValue(value) {
|
|
129
|
+
return typeof value === "object" && value !== null && "id" in value && typeof value.id === "string";
|
|
130
|
+
}
|
|
131
|
+
async function processRelationsInData(adapter, contentType, data, getContentTypeFn) {
|
|
132
|
+
const relationFields = extractRelationFields(contentType);
|
|
133
|
+
const processedData = { ...data };
|
|
134
|
+
const relationIds = {};
|
|
135
|
+
for (const [fieldName, relationConfig] of Object.entries(relationFields)) {
|
|
136
|
+
if (!(fieldName in data)) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const fieldValue = data[fieldName];
|
|
140
|
+
if (!fieldValue) {
|
|
141
|
+
relationIds[fieldName] = [];
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const targetContentType = await getContentTypeFn(relationConfig.targetType);
|
|
145
|
+
if (!targetContentType) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Target content type "${relationConfig.targetType}" not found for relation field "${fieldName}"`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const ids = [];
|
|
151
|
+
if (relationConfig.type === "belongsTo") {
|
|
152
|
+
const value = fieldValue;
|
|
153
|
+
if (isNewRelationValue(value)) {
|
|
154
|
+
const newItem = await createRelatedItem(
|
|
155
|
+
adapter,
|
|
156
|
+
targetContentType,
|
|
157
|
+
value.data
|
|
158
|
+
);
|
|
159
|
+
ids.push(newItem.id);
|
|
160
|
+
processedData[fieldName] = { id: newItem.id };
|
|
161
|
+
} else if (isExistingRelationValue(value)) {
|
|
162
|
+
ids.push(value.id);
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
const values = Array.isArray(fieldValue) ? fieldValue : [];
|
|
166
|
+
const processedValues = [];
|
|
167
|
+
for (const value of values) {
|
|
168
|
+
if (isNewRelationValue(value)) {
|
|
169
|
+
const newItem = await createRelatedItem(
|
|
170
|
+
adapter,
|
|
171
|
+
targetContentType,
|
|
172
|
+
value.data
|
|
173
|
+
);
|
|
174
|
+
ids.push(newItem.id);
|
|
175
|
+
processedValues.push({ id: newItem.id });
|
|
176
|
+
} else if (isExistingRelationValue(value)) {
|
|
177
|
+
ids.push(value.id);
|
|
178
|
+
processedValues.push({ id: value.id });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
processedData[fieldName] = processedValues;
|
|
182
|
+
}
|
|
183
|
+
relationIds[fieldName] = ids;
|
|
184
|
+
}
|
|
185
|
+
return { processedData, relationIds };
|
|
186
|
+
}
|
|
187
|
+
async function createRelatedItem(adapter, targetContentType, data) {
|
|
188
|
+
const slug = slugify(
|
|
189
|
+
data.slug || data.name || data.title || `item-${Date.now()}`
|
|
190
|
+
);
|
|
191
|
+
const zodSchema = getContentTypeZodSchema(targetContentType);
|
|
192
|
+
const validation = zodSchema.safeParse(data);
|
|
193
|
+
if (!validation.success) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Validation failed for new ${targetContentType.slug}: ${JSON.stringify(validation.error.issues)}`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const existing = await adapter.findOne({
|
|
199
|
+
model: "contentItem",
|
|
200
|
+
where: [
|
|
201
|
+
{
|
|
202
|
+
field: "contentTypeId",
|
|
203
|
+
value: targetContentType.id,
|
|
204
|
+
operator: "eq"
|
|
205
|
+
},
|
|
206
|
+
{ field: "slug", value: slug, operator: "eq" }
|
|
207
|
+
]
|
|
208
|
+
});
|
|
209
|
+
if (existing) {
|
|
210
|
+
return existing;
|
|
211
|
+
}
|
|
212
|
+
const item = await adapter.create({
|
|
213
|
+
model: "contentItem",
|
|
214
|
+
data: {
|
|
215
|
+
contentTypeId: targetContentType.id,
|
|
216
|
+
slug,
|
|
217
|
+
data: JSON.stringify(validation.data),
|
|
218
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
219
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
return item;
|
|
223
|
+
}
|
|
224
|
+
async function syncRelations(adapter, sourceId, relationIds) {
|
|
225
|
+
for (const [fieldName, targetIds] of Object.entries(relationIds)) {
|
|
226
|
+
await adapter.delete({
|
|
227
|
+
model: "contentRelation",
|
|
228
|
+
where: [
|
|
229
|
+
{ field: "sourceId", value: sourceId, operator: "eq" },
|
|
230
|
+
{ field: "fieldName", value: fieldName, operator: "eq" }
|
|
231
|
+
]
|
|
232
|
+
});
|
|
233
|
+
for (const targetId of targetIds) {
|
|
234
|
+
await adapter.create({
|
|
235
|
+
model: "contentRelation",
|
|
236
|
+
data: {
|
|
237
|
+
sourceId,
|
|
238
|
+
targetId,
|
|
239
|
+
fieldName,
|
|
240
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async function populateRelations(adapter, item) {
|
|
247
|
+
const relations = {};
|
|
248
|
+
const contentRelations = await adapter.findMany({
|
|
249
|
+
model: "contentRelation",
|
|
250
|
+
where: [{ field: "sourceId", value: item.id, operator: "eq" }]
|
|
251
|
+
});
|
|
252
|
+
const relationsByField = {};
|
|
253
|
+
for (const rel of contentRelations) {
|
|
254
|
+
if (!relationsByField[rel.fieldName]) {
|
|
255
|
+
relationsByField[rel.fieldName] = [];
|
|
256
|
+
}
|
|
257
|
+
relationsByField[rel.fieldName].push(rel.targetId);
|
|
258
|
+
}
|
|
259
|
+
for (const [fieldName, targetIds] of Object.entries(relationsByField)) {
|
|
260
|
+
if (targetIds.length === 0) {
|
|
261
|
+
relations[fieldName] = [];
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const relatedItems = [];
|
|
265
|
+
for (const targetId of targetIds) {
|
|
266
|
+
const relatedItem = await adapter.findOne({
|
|
267
|
+
model: "contentItem",
|
|
268
|
+
where: [{ field: "id", value: targetId, operator: "eq" }],
|
|
269
|
+
join: { contentType: true }
|
|
270
|
+
});
|
|
271
|
+
if (relatedItem) {
|
|
272
|
+
relatedItems.push(serializeContentItemWithType(relatedItem));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
relations[fieldName] = relatedItems;
|
|
276
|
+
}
|
|
277
|
+
return relations;
|
|
278
|
+
}
|
|
112
279
|
const cmsBackendPlugin = (config) => defineBackendPlugin({
|
|
113
280
|
name: "cms",
|
|
114
281
|
dbPlugin: cmsSchema,
|
|
@@ -276,8 +443,14 @@ const cmsBackendPlugin = (config) => defineBackendPlugin({
|
|
|
276
443
|
if (!contentType) {
|
|
277
444
|
throw ctx.error(404, { message: "Content type not found" });
|
|
278
445
|
}
|
|
446
|
+
const { processedData: dataWithResolvedRelations, relationIds } = await processRelationsInData(
|
|
447
|
+
adapter,
|
|
448
|
+
contentType,
|
|
449
|
+
data,
|
|
450
|
+
getContentType
|
|
451
|
+
);
|
|
279
452
|
const zodSchema = getContentTypeZodSchema(contentType);
|
|
280
|
-
const validation = zodSchema.safeParse(
|
|
453
|
+
const validation = zodSchema.safeParse(dataWithResolvedRelations);
|
|
281
454
|
if (!validation.success) {
|
|
282
455
|
throw ctx.error(400, {
|
|
283
456
|
message: "Validation failed",
|
|
@@ -300,36 +473,34 @@ const cmsBackendPlugin = (config) => defineBackendPlugin({
|
|
|
300
473
|
message: "Content item with this slug already exists"
|
|
301
474
|
});
|
|
302
475
|
}
|
|
303
|
-
|
|
476
|
+
const processedData = validation.data;
|
|
304
477
|
if (config.hooks?.onBeforeCreate) {
|
|
305
478
|
const result = await config.hooks.onBeforeCreate(
|
|
306
|
-
|
|
479
|
+
processedData,
|
|
307
480
|
context
|
|
308
481
|
);
|
|
309
482
|
if (result === false) {
|
|
310
483
|
throw ctx.error(403, { message: "Create operation denied" });
|
|
311
484
|
}
|
|
312
|
-
if (result && typeof result === "object") {
|
|
313
|
-
finalData = result;
|
|
314
|
-
}
|
|
315
485
|
}
|
|
316
486
|
const item = await adapter.create({
|
|
317
487
|
model: "contentItem",
|
|
318
488
|
data: {
|
|
319
489
|
contentTypeId: contentType.id,
|
|
320
490
|
slug,
|
|
321
|
-
data: JSON.stringify(
|
|
491
|
+
data: JSON.stringify(processedData),
|
|
322
492
|
createdAt: /* @__PURE__ */ new Date(),
|
|
323
493
|
updatedAt: /* @__PURE__ */ new Date()
|
|
324
494
|
}
|
|
325
495
|
});
|
|
496
|
+
await syncRelations(adapter, item.id, relationIds);
|
|
326
497
|
const serialized = serializeContentItem(item);
|
|
327
498
|
if (config.hooks?.onAfterCreate) {
|
|
328
499
|
await config.hooks.onAfterCreate(serialized, context);
|
|
329
500
|
}
|
|
330
501
|
return {
|
|
331
502
|
...serialized,
|
|
332
|
-
parsedData:
|
|
503
|
+
parsedData: processedData
|
|
333
504
|
};
|
|
334
505
|
}
|
|
335
506
|
);
|
|
@@ -383,10 +554,27 @@ const cmsBackendPlugin = (config) => defineBackendPlugin({
|
|
|
383
554
|
});
|
|
384
555
|
}
|
|
385
556
|
}
|
|
386
|
-
let
|
|
557
|
+
let dataWithResolvedRelations;
|
|
558
|
+
let relationIds;
|
|
387
559
|
if (data) {
|
|
560
|
+
const result = await processRelationsInData(
|
|
561
|
+
adapter,
|
|
562
|
+
contentType,
|
|
563
|
+
data,
|
|
564
|
+
getContentType
|
|
565
|
+
);
|
|
566
|
+
dataWithResolvedRelations = result.processedData;
|
|
567
|
+
relationIds = result.relationIds;
|
|
568
|
+
}
|
|
569
|
+
let validatedData = dataWithResolvedRelations;
|
|
570
|
+
if (dataWithResolvedRelations) {
|
|
571
|
+
const existingData = existing.data ? JSON.parse(existing.data) : {};
|
|
572
|
+
const mergedData = {
|
|
573
|
+
...existingData,
|
|
574
|
+
...dataWithResolvedRelations
|
|
575
|
+
};
|
|
388
576
|
const zodSchema = getContentTypeZodSchema(contentType);
|
|
389
|
-
const validation = zodSchema.safeParse(
|
|
577
|
+
const validation = zodSchema.safeParse(mergedData);
|
|
390
578
|
if (!validation.success) {
|
|
391
579
|
throw ctx.error(400, {
|
|
392
580
|
message: "Validation failed",
|
|
@@ -395,7 +583,7 @@ const cmsBackendPlugin = (config) => defineBackendPlugin({
|
|
|
395
583
|
}
|
|
396
584
|
validatedData = validation.data;
|
|
397
585
|
}
|
|
398
|
-
|
|
586
|
+
const processedData = validatedData;
|
|
399
587
|
if (config.hooks?.onBeforeUpdate && validatedData) {
|
|
400
588
|
const result = await config.hooks.onBeforeUpdate(
|
|
401
589
|
id,
|
|
@@ -405,15 +593,15 @@ const cmsBackendPlugin = (config) => defineBackendPlugin({
|
|
|
405
593
|
if (result === false) {
|
|
406
594
|
throw ctx.error(403, { message: "Update operation denied" });
|
|
407
595
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
596
|
+
}
|
|
597
|
+
if (relationIds) {
|
|
598
|
+
await syncRelations(adapter, id, relationIds);
|
|
411
599
|
}
|
|
412
600
|
const updateData = {
|
|
413
601
|
updatedAt: /* @__PURE__ */ new Date()
|
|
414
602
|
};
|
|
415
603
|
if (slug) updateData.slug = slug;
|
|
416
|
-
if (
|
|
604
|
+
if (processedData) updateData.data = JSON.stringify(processedData);
|
|
417
605
|
await adapter.update({
|
|
418
606
|
model: "contentItem",
|
|
419
607
|
where: [{ field: "id", value: id, operator: "eq" }],
|
|
@@ -470,6 +658,243 @@ const cmsBackendPlugin = (config) => defineBackendPlugin({
|
|
|
470
658
|
return { success: true };
|
|
471
659
|
}
|
|
472
660
|
);
|
|
661
|
+
const getContentItemPopulated = createEndpoint(
|
|
662
|
+
"/content/:typeSlug/:id/populated",
|
|
663
|
+
{
|
|
664
|
+
method: "GET",
|
|
665
|
+
params: z.object({ typeSlug: z.string(), id: z.string() })
|
|
666
|
+
},
|
|
667
|
+
async (ctx) => {
|
|
668
|
+
const { typeSlug, id } = ctx.params;
|
|
669
|
+
const contentType = await getContentType(typeSlug);
|
|
670
|
+
if (!contentType) {
|
|
671
|
+
throw ctx.error(404, { message: "Content type not found" });
|
|
672
|
+
}
|
|
673
|
+
const item = await adapter.findOne({
|
|
674
|
+
model: "contentItem",
|
|
675
|
+
where: [{ field: "id", value: id, operator: "eq" }],
|
|
676
|
+
join: { contentType: true }
|
|
677
|
+
});
|
|
678
|
+
if (!item || item.contentTypeId !== contentType.id) {
|
|
679
|
+
throw ctx.error(404, { message: "Content item not found" });
|
|
680
|
+
}
|
|
681
|
+
const _relations = await populateRelations(adapter, item);
|
|
682
|
+
return {
|
|
683
|
+
...serializeContentItemWithType(item),
|
|
684
|
+
_relations
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
);
|
|
688
|
+
const listContentByRelation = createEndpoint(
|
|
689
|
+
"/content/:typeSlug/by-relation",
|
|
690
|
+
{
|
|
691
|
+
method: "GET",
|
|
692
|
+
params: z.object({ typeSlug: z.string() }),
|
|
693
|
+
query: z.object({
|
|
694
|
+
field: z.string(),
|
|
695
|
+
targetId: z.string(),
|
|
696
|
+
limit: z.coerce.number().min(1).max(100).optional().default(20),
|
|
697
|
+
offset: z.coerce.number().min(0).optional().default(0)
|
|
698
|
+
})
|
|
699
|
+
},
|
|
700
|
+
async (ctx) => {
|
|
701
|
+
const { typeSlug } = ctx.params;
|
|
702
|
+
const { field, targetId, limit, offset } = ctx.query;
|
|
703
|
+
const contentType = await getContentType(typeSlug);
|
|
704
|
+
if (!contentType) {
|
|
705
|
+
throw ctx.error(404, { message: "Content type not found" });
|
|
706
|
+
}
|
|
707
|
+
const contentRelations = await adapter.findMany({
|
|
708
|
+
model: "contentRelation",
|
|
709
|
+
where: [
|
|
710
|
+
{ field: "targetId", value: targetId, operator: "eq" },
|
|
711
|
+
{ field: "fieldName", value: field, operator: "eq" }
|
|
712
|
+
]
|
|
713
|
+
});
|
|
714
|
+
const sourceIds = [
|
|
715
|
+
...new Set(contentRelations.map((r) => r.sourceId))
|
|
716
|
+
];
|
|
717
|
+
if (sourceIds.length === 0) {
|
|
718
|
+
return {
|
|
719
|
+
items: [],
|
|
720
|
+
total: 0,
|
|
721
|
+
limit,
|
|
722
|
+
offset
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
const allItems = [];
|
|
726
|
+
for (const sourceId of sourceIds) {
|
|
727
|
+
const item = await adapter.findOne({
|
|
728
|
+
model: "contentItem",
|
|
729
|
+
where: [
|
|
730
|
+
{ field: "id", value: sourceId, operator: "eq" },
|
|
731
|
+
{
|
|
732
|
+
field: "contentTypeId",
|
|
733
|
+
value: contentType.id,
|
|
734
|
+
operator: "eq"
|
|
735
|
+
}
|
|
736
|
+
],
|
|
737
|
+
join: { contentType: true }
|
|
738
|
+
});
|
|
739
|
+
if (item) {
|
|
740
|
+
allItems.push(item);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
allItems.sort(
|
|
744
|
+
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
|
|
745
|
+
);
|
|
746
|
+
const total = allItems.length;
|
|
747
|
+
const paginatedItems = allItems.slice(offset, offset + limit);
|
|
748
|
+
return {
|
|
749
|
+
items: paginatedItems.map(serializeContentItemWithType),
|
|
750
|
+
total,
|
|
751
|
+
limit,
|
|
752
|
+
offset
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
);
|
|
756
|
+
const getInverseRelations = createEndpoint(
|
|
757
|
+
"/content-types/:slug/inverse-relations",
|
|
758
|
+
{
|
|
759
|
+
method: "GET",
|
|
760
|
+
params: z.object({ slug: z.string() }),
|
|
761
|
+
query: z.object({
|
|
762
|
+
itemId: z.string().optional()
|
|
763
|
+
})
|
|
764
|
+
},
|
|
765
|
+
async (ctx) => {
|
|
766
|
+
const { slug } = ctx.params;
|
|
767
|
+
const { itemId } = ctx.query;
|
|
768
|
+
await ensureSynced();
|
|
769
|
+
const targetContentType = await getContentType(slug);
|
|
770
|
+
if (!targetContentType) {
|
|
771
|
+
throw ctx.error(404, { message: "Content type not found" });
|
|
772
|
+
}
|
|
773
|
+
const allContentTypes = await adapter.findMany({
|
|
774
|
+
model: "contentType"
|
|
775
|
+
});
|
|
776
|
+
const inverseRelations = [];
|
|
777
|
+
for (const contentType of allContentTypes) {
|
|
778
|
+
const relationFields = extractRelationFields(contentType);
|
|
779
|
+
for (const [fieldName, relationConfig] of Object.entries(
|
|
780
|
+
relationFields
|
|
781
|
+
)) {
|
|
782
|
+
if (relationConfig.type === "belongsTo" && relationConfig.targetType === slug) {
|
|
783
|
+
let count = 0;
|
|
784
|
+
if (itemId) {
|
|
785
|
+
const relations = await adapter.findMany({
|
|
786
|
+
model: "contentRelation",
|
|
787
|
+
where: [
|
|
788
|
+
{
|
|
789
|
+
field: "targetId",
|
|
790
|
+
value: itemId,
|
|
791
|
+
operator: "eq"
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
field: "fieldName",
|
|
795
|
+
value: fieldName,
|
|
796
|
+
operator: "eq"
|
|
797
|
+
}
|
|
798
|
+
]
|
|
799
|
+
});
|
|
800
|
+
const itemIds = relations.map((r) => r.sourceId);
|
|
801
|
+
for (const sourceId of itemIds) {
|
|
802
|
+
const item = await adapter.findOne({
|
|
803
|
+
model: "contentItem",
|
|
804
|
+
where: [
|
|
805
|
+
{
|
|
806
|
+
field: "id",
|
|
807
|
+
value: sourceId,
|
|
808
|
+
operator: "eq"
|
|
809
|
+
},
|
|
810
|
+
{
|
|
811
|
+
field: "contentTypeId",
|
|
812
|
+
value: contentType.id,
|
|
813
|
+
operator: "eq"
|
|
814
|
+
}
|
|
815
|
+
]
|
|
816
|
+
});
|
|
817
|
+
if (item) count++;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
inverseRelations.push({
|
|
821
|
+
sourceType: contentType.slug,
|
|
822
|
+
sourceTypeName: contentType.name,
|
|
823
|
+
fieldName,
|
|
824
|
+
count
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return { inverseRelations };
|
|
830
|
+
}
|
|
831
|
+
);
|
|
832
|
+
const listInverseRelationItems = createEndpoint(
|
|
833
|
+
"/content-types/:slug/inverse-relations/:sourceType",
|
|
834
|
+
{
|
|
835
|
+
method: "GET",
|
|
836
|
+
params: z.object({
|
|
837
|
+
slug: z.string(),
|
|
838
|
+
sourceType: z.string()
|
|
839
|
+
}),
|
|
840
|
+
query: z.object({
|
|
841
|
+
itemId: z.string(),
|
|
842
|
+
fieldName: z.string(),
|
|
843
|
+
limit: z.coerce.number().min(1).max(100).optional().default(20),
|
|
844
|
+
offset: z.coerce.number().min(0).optional().default(0)
|
|
845
|
+
})
|
|
846
|
+
},
|
|
847
|
+
async (ctx) => {
|
|
848
|
+
const { slug, sourceType } = ctx.params;
|
|
849
|
+
const { itemId, fieldName, limit, offset } = ctx.query;
|
|
850
|
+
await ensureSynced();
|
|
851
|
+
const targetContentType = await getContentType(slug);
|
|
852
|
+
if (!targetContentType) {
|
|
853
|
+
throw ctx.error(404, { message: "Target content type not found" });
|
|
854
|
+
}
|
|
855
|
+
const sourceContentType = await getContentType(sourceType);
|
|
856
|
+
if (!sourceContentType) {
|
|
857
|
+
throw ctx.error(404, { message: "Source content type not found" });
|
|
858
|
+
}
|
|
859
|
+
const relations = await adapter.findMany({
|
|
860
|
+
model: "contentRelation",
|
|
861
|
+
where: [
|
|
862
|
+
{ field: "targetId", value: itemId, operator: "eq" },
|
|
863
|
+
{ field: "fieldName", value: fieldName, operator: "eq" }
|
|
864
|
+
]
|
|
865
|
+
});
|
|
866
|
+
const sourceIds = [...new Set(relations.map((r) => r.sourceId))];
|
|
867
|
+
const allItems = [];
|
|
868
|
+
for (const sourceId of sourceIds) {
|
|
869
|
+
const item = await adapter.findOne({
|
|
870
|
+
model: "contentItem",
|
|
871
|
+
where: [
|
|
872
|
+
{ field: "id", value: sourceId, operator: "eq" },
|
|
873
|
+
{
|
|
874
|
+
field: "contentTypeId",
|
|
875
|
+
value: sourceContentType.id,
|
|
876
|
+
operator: "eq"
|
|
877
|
+
}
|
|
878
|
+
],
|
|
879
|
+
join: { contentType: true }
|
|
880
|
+
});
|
|
881
|
+
if (item) {
|
|
882
|
+
allItems.push(item);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
allItems.sort(
|
|
886
|
+
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
|
|
887
|
+
);
|
|
888
|
+
const total = allItems.length;
|
|
889
|
+
const paginatedItems = allItems.slice(offset, offset + limit);
|
|
890
|
+
return {
|
|
891
|
+
items: paginatedItems.map(serializeContentItemWithType),
|
|
892
|
+
total,
|
|
893
|
+
limit,
|
|
894
|
+
offset
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
);
|
|
473
898
|
return {
|
|
474
899
|
listContentTypes,
|
|
475
900
|
getContentTypeBySlug,
|
|
@@ -477,7 +902,11 @@ const cmsBackendPlugin = (config) => defineBackendPlugin({
|
|
|
477
902
|
getContentItem,
|
|
478
903
|
createContentItem,
|
|
479
904
|
updateContentItem,
|
|
480
|
-
deleteContentItem
|
|
905
|
+
deleteContentItem,
|
|
906
|
+
getContentItemPopulated,
|
|
907
|
+
listContentByRelation,
|
|
908
|
+
getInverseRelations,
|
|
909
|
+
listInverseRelationItems
|
|
481
910
|
};
|
|
482
911
|
}
|
|
483
912
|
});
|
|
@@ -4,20 +4,20 @@
|
|
|
4
4
|
const jsxRuntime = require('react/jsx-runtime');
|
|
5
5
|
const React = require('react');
|
|
6
6
|
const z = require('zod');
|
|
7
|
-
const sonner = require('sonner');
|
|
8
7
|
const steppedAutoForm = require('../../../../../../../ui/src/components/auto-form/stepped-auto-form.cjs');
|
|
9
|
-
const
|
|
8
|
+
const helpers = require('../../../../../../../ui/src/components/auto-form/helpers.cjs');
|
|
10
9
|
const schemaConverter = require('../../../../../../../ui/src/lib/schema-converter.cjs');
|
|
11
10
|
const input = require('../../../../../../../ui/src/components/input.cjs');
|
|
12
11
|
const label = require('../../../../../../../ui/src/components/label.cjs');
|
|
13
12
|
const badge = require('../../../../../../../ui/src/components/badge.cjs');
|
|
14
13
|
const context = require('@btst/stack/context');
|
|
15
|
-
const utils
|
|
14
|
+
const utils = require('../../../utils.cjs');
|
|
16
15
|
const index = require('../../localization/index.cjs');
|
|
17
16
|
const fileUpload = require('./file-upload.cjs');
|
|
17
|
+
const relationField = require('./relation-field.cjs');
|
|
18
18
|
|
|
19
19
|
function buildFieldConfigFromJsonSchema(jsonSchema, uploadImage, fieldComponents) {
|
|
20
|
-
const baseConfig =
|
|
20
|
+
const baseConfig = helpers.buildFieldConfigFromJsonSchema(jsonSchema, fieldComponents);
|
|
21
21
|
const properties = jsonSchema.properties;
|
|
22
22
|
if (!properties) return baseConfig;
|
|
23
23
|
for (const [key, prop] of Object.entries(properties)) {
|
|
@@ -38,6 +38,13 @@ function buildFieldConfigFromJsonSchema(jsonSchema, uploadImage, fieldComponents
|
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
if (prop.fieldType === "relation" && prop.relation && !fieldComponents?.["relation"]) {
|
|
42
|
+
const relationConfig = prop.relation;
|
|
43
|
+
baseConfig[key] = {
|
|
44
|
+
...baseConfig[key],
|
|
45
|
+
fieldType: (props) => /* @__PURE__ */ jsxRuntime.jsx(relationField.RelationField, { ...props, relation: relationConfig })
|
|
46
|
+
};
|
|
47
|
+
}
|
|
41
48
|
}
|
|
42
49
|
return baseConfig;
|
|
43
50
|
}
|
|
@@ -75,9 +82,17 @@ function ContentForm({
|
|
|
75
82
|
const [slugManuallyEdited, setSlugManuallyEdited] = React.useState(isEditing);
|
|
76
83
|
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
|
77
84
|
const [formData, setFormData] = React.useState(initialData);
|
|
85
|
+
const [slugError, setSlugError] = React.useState(null);
|
|
86
|
+
const [submitError, setSubmitError] = React.useState(null);
|
|
87
|
+
const hasSyncedPrefillRef = React.useRef(false);
|
|
78
88
|
React.useEffect(() => {
|
|
79
|
-
|
|
89
|
+
const hasData = Object.keys(initialData).length > 0;
|
|
90
|
+
const shouldSync = hasData && (isEditing || !hasSyncedPrefillRef.current);
|
|
91
|
+
if (shouldSync) {
|
|
80
92
|
setFormData(initialData);
|
|
93
|
+
if (!isEditing) {
|
|
94
|
+
hasSyncedPrefillRef.current = true;
|
|
95
|
+
}
|
|
81
96
|
}
|
|
82
97
|
}, [initialData, isEditing]);
|
|
83
98
|
React.useEffect(() => {
|
|
@@ -112,24 +127,23 @@ function ContentForm({
|
|
|
112
127
|
if (!isEditing && !slugManuallyEdited && slugSourceField) {
|
|
113
128
|
const sourceValue = values[slugSourceField];
|
|
114
129
|
if (typeof sourceValue === "string" && sourceValue.trim()) {
|
|
115
|
-
setSlug(utils
|
|
130
|
+
setSlug(utils.slugify(sourceValue));
|
|
116
131
|
}
|
|
117
132
|
}
|
|
118
133
|
};
|
|
119
134
|
const handleSubmit = async (data) => {
|
|
135
|
+
setSlugError(null);
|
|
136
|
+
setSubmitError(null);
|
|
120
137
|
if (!slug.trim()) {
|
|
121
|
-
|
|
138
|
+
setSlugError("Slug is required");
|
|
122
139
|
return;
|
|
123
140
|
}
|
|
124
141
|
setIsSubmitting(true);
|
|
125
142
|
try {
|
|
126
143
|
await onSubmit({ slug, data });
|
|
127
|
-
sonner.toast.success(
|
|
128
|
-
isEditing ? localization.CMS_TOAST_UPDATE_SUCCESS : localization.CMS_TOAST_CREATE_SUCCESS
|
|
129
|
-
);
|
|
130
144
|
} catch (error) {
|
|
131
145
|
const message = error instanceof Error ? error.message : localization.CMS_TOAST_ERROR;
|
|
132
|
-
|
|
146
|
+
setSubmitError(message);
|
|
133
147
|
} finally {
|
|
134
148
|
setIsSubmitting(false);
|
|
135
149
|
}
|
|
@@ -147,6 +161,7 @@ function ContentForm({
|
|
|
147
161
|
value: slug,
|
|
148
162
|
onChange: (e) => {
|
|
149
163
|
setSlug(e.target.value);
|
|
164
|
+
setSlugError(null);
|
|
150
165
|
if (!isEditing) {
|
|
151
166
|
setSlugManuallyEdited(true);
|
|
152
167
|
}
|
|
@@ -155,8 +170,10 @@ function ContentForm({
|
|
|
155
170
|
placeholder: slugSourceField ? `Auto-generated from ${slugSourceField}` : "Enter slug..."
|
|
156
171
|
}
|
|
157
172
|
),
|
|
173
|
+
slugError && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-destructive", children: slugError }),
|
|
158
174
|
/* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-muted-foreground", children: localization.CMS_LABEL_SLUG_DESCRIPTION })
|
|
159
175
|
] }),
|
|
176
|
+
submitError && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rounded-md border border-destructive/50 bg-destructive/10 p-3", children: /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-destructive", children: submitError }) }),
|
|
160
177
|
/* @__PURE__ */ jsxRuntime.jsx(
|
|
161
178
|
steppedAutoForm.default,
|
|
162
179
|
{
|