@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.
Files changed (94) hide show
  1. package/dist/packages/better-stack/src/plugins/cms/api/plugin.cjs +445 -16
  2. package/dist/packages/better-stack/src/plugins/cms/api/plugin.mjs +445 -16
  3. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.cjs +28 -11
  4. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.mjs +26 -9
  5. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.cjs +224 -0
  6. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.mjs +222 -0
  7. package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.cjs +243 -0
  8. package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.mjs +241 -0
  9. package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.cjs +56 -2
  10. package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.mjs +56 -2
  11. package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.cjs +190 -0
  12. package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.mjs +187 -1
  13. package/dist/packages/better-stack/src/plugins/cms/db.cjs +38 -0
  14. package/dist/packages/better-stack/src/plugins/cms/db.mjs +38 -0
  15. package/dist/packages/better-stack/src/plugins/form-builder/client/components/forms/form-renderer.cjs +2 -2
  16. package/dist/packages/better-stack/src/plugins/form-builder/client/components/forms/form-renderer.mjs +1 -1
  17. package/dist/packages/ui/src/components/auto-form/fields/array.cjs +2 -2
  18. package/dist/packages/ui/src/components/auto-form/fields/array.mjs +1 -1
  19. package/dist/packages/ui/src/components/auto-form/fields/date.cjs +2 -2
  20. package/dist/packages/ui/src/components/auto-form/fields/date.mjs +1 -1
  21. package/dist/packages/ui/src/components/auto-form/fields/enum.cjs +2 -2
  22. package/dist/packages/ui/src/components/auto-form/fields/enum.mjs +1 -1
  23. package/dist/packages/ui/src/components/auto-form/fields/object.cjs +88 -8
  24. package/dist/packages/ui/src/components/auto-form/fields/object.mjs +82 -2
  25. package/dist/packages/ui/src/components/auto-form/fields/radio-group.cjs +2 -2
  26. package/dist/packages/ui/src/components/auto-form/fields/radio-group.mjs +1 -1
  27. package/dist/packages/ui/src/components/auto-form/index.cjs +5 -5
  28. package/dist/packages/ui/src/components/auto-form/index.mjs +1 -1
  29. package/dist/packages/ui/src/components/button.cjs +4 -2
  30. package/dist/packages/ui/src/components/button.mjs +4 -2
  31. package/dist/packages/ui/src/components/dialog.cjs +7 -1
  32. package/dist/packages/ui/src/components/dialog.mjs +7 -2
  33. package/dist/packages/ui/src/components/form-builder/edit-field-dialog.cjs +2 -2
  34. package/dist/packages/ui/src/components/form-builder/edit-field-dialog.mjs +1 -1
  35. package/dist/packages/ui/src/components/form-builder/form-preview.cjs +5 -5
  36. package/dist/packages/ui/src/components/form-builder/form-preview.mjs +1 -1
  37. package/dist/packages/ui/src/components/select.cjs +9 -2
  38. package/dist/packages/ui/src/components/select.mjs +9 -2
  39. package/dist/plugins/blog/api/index.d.cts +1 -1
  40. package/dist/plugins/blog/api/index.d.mts +1 -1
  41. package/dist/plugins/blog/api/index.d.ts +1 -1
  42. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  43. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  44. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  45. package/dist/plugins/blog/client/index.d.cts +1 -1
  46. package/dist/plugins/blog/client/index.d.mts +1 -1
  47. package/dist/plugins/blog/client/index.d.ts +1 -1
  48. package/dist/plugins/blog/query-keys.d.cts +2 -2
  49. package/dist/plugins/blog/query-keys.d.mts +2 -2
  50. package/dist/plugins/blog/query-keys.d.ts +2 -2
  51. package/dist/plugins/cms/api/index.d.cts +66 -2
  52. package/dist/plugins/cms/api/index.d.mts +66 -2
  53. package/dist/plugins/cms/api/index.d.ts +66 -2
  54. package/dist/plugins/cms/client/hooks/index.cjs +4 -0
  55. package/dist/plugins/cms/client/hooks/index.d.cts +82 -3
  56. package/dist/plugins/cms/client/hooks/index.d.mts +82 -3
  57. package/dist/plugins/cms/client/hooks/index.d.ts +82 -3
  58. package/dist/plugins/cms/client/hooks/index.mjs +1 -1
  59. package/dist/plugins/cms/client/index.d.cts +2 -2
  60. package/dist/plugins/cms/client/index.d.mts +2 -2
  61. package/dist/plugins/cms/client/index.d.ts +2 -2
  62. package/dist/plugins/cms/query-keys.d.cts +1 -1
  63. package/dist/plugins/cms/query-keys.d.mts +1 -1
  64. package/dist/plugins/cms/query-keys.d.ts +1 -1
  65. package/dist/plugins/form-builder/client/components/index.d.cts +1 -1
  66. package/dist/plugins/form-builder/client/components/index.d.mts +1 -1
  67. package/dist/plugins/form-builder/client/components/index.d.ts +1 -1
  68. package/dist/plugins/form-builder/client/index.d.cts +2 -2
  69. package/dist/plugins/form-builder/client/index.d.mts +2 -2
  70. package/dist/plugins/form-builder/client/index.d.ts +2 -2
  71. package/dist/shared/{stack.AX5nZ6A3.d.ts → stack.Co034Fpm.d.cts} +0 -21
  72. package/dist/shared/{stack.AX5nZ6A3.d.cts → stack.Co034Fpm.d.mts} +0 -21
  73. package/dist/shared/{stack.AX5nZ6A3.d.mts → stack.Co034Fpm.d.ts} +0 -21
  74. package/dist/shared/{stack.BIh2AXaW.d.cts → stack.DGjhPqmF.d.cts} +0 -9
  75. package/dist/shared/{stack.BIh2AXaW.d.mts → stack.DGjhPqmF.d.mts} +0 -9
  76. package/dist/shared/{stack.BIh2AXaW.d.ts → stack.DGjhPqmF.d.ts} +0 -9
  77. package/dist/shared/{stack.L-UFwz2G.d.mts → stack.oGOteE6g.d.cts} +27 -5
  78. package/dist/shared/{stack.L-UFwz2G.d.cts → stack.oGOteE6g.d.mts} +27 -5
  79. package/dist/shared/{stack.L-UFwz2G.d.ts → stack.oGOteE6g.d.ts} +27 -5
  80. package/package.json +1 -1
  81. package/src/plugins/cms/api/plugin.ts +667 -21
  82. package/src/plugins/cms/client/components/forms/content-form.tsx +62 -20
  83. package/src/plugins/cms/client/components/forms/relation-field.tsx +299 -0
  84. package/src/plugins/cms/client/components/inverse-relations-panel.tsx +329 -0
  85. package/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx +127 -1
  86. package/src/plugins/cms/client/hooks/cms-hooks.tsx +344 -0
  87. package/src/plugins/cms/db.ts +38 -0
  88. package/src/plugins/cms/types.ts +99 -10
  89. package/src/plugins/form-builder/client/components/forms/form-renderer.tsx +1 -1
  90. package/dist/packages/ui/src/components/auto-form/{utils.cjs → helpers.cjs} +0 -0
  91. package/dist/packages/ui/src/components/auto-form/{utils.mjs → helpers.mjs} +0 -0
  92. package/dist/shared/{stack.DLhzx1-D.d.mts → stack.CcI4sYJP.d.cts} +1 -1
  93. package/dist/shared/{stack.DLhzx1-D.d.cts → stack.CcI4sYJP.d.mts} +1 -1
  94. 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(data);
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
- let finalData = validation.data;
476
+ const processedData = validation.data;
304
477
  if (config.hooks?.onBeforeCreate) {
305
478
  const result = await config.hooks.onBeforeCreate(
306
- validation.data,
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(finalData),
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: finalData
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 validatedData = data;
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(data);
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
- let finalData = validatedData;
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
- if (result && typeof result === "object") {
409
- finalData = result;
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 (finalData) updateData.data = JSON.stringify(finalData);
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 utils = require('../../../../../../../ui/src/components/auto-form/utils.cjs');
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$1 = require('../../../utils.cjs');
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 = utils.buildFieldConfigFromJsonSchema(jsonSchema, fieldComponents);
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
- if (isEditing && Object.keys(initialData).length > 0) {
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$1.slugify(sourceValue));
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
- sonner.toast.error("Slug is required");
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
- sonner.toast.error(message);
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
  {