@strapi/content-releases 4.18.1-experimental.0 → 4.19.0

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.
@@ -1,5 +1,7 @@
1
- import { setCreatorFields, errors, validateYupSchema, yup as yup$1 } from "@strapi/utils";
1
+ import { contentTypes as contentTypes$1, mapAsync, setCreatorFields, errors, validateYupSchema, yup as yup$1 } from "@strapi/utils";
2
+ import { difference, keys } from "lodash";
2
3
  import _ from "lodash/fp";
4
+ import EE from "@strapi/strapi/dist/utils/ee";
3
5
  import * as yup from "yup";
4
6
  const RELEASE_MODEL_UID = "plugin::content-releases.release";
5
7
  const RELEASE_ACTION_MODEL_UID = "plugin::content-releases.release-action";
@@ -47,10 +49,87 @@ const ACTIONS = [
47
49
  pluginName: "content-releases"
48
50
  }
49
51
  ];
50
- const { features: features$1 } = require("@strapi/strapi/dist/utils/ee");
52
+ async function deleteActionsOnDisableDraftAndPublish({
53
+ oldContentTypes,
54
+ contentTypes: contentTypes2
55
+ }) {
56
+ if (!oldContentTypes) {
57
+ return;
58
+ }
59
+ for (const uid in contentTypes2) {
60
+ if (!oldContentTypes[uid]) {
61
+ continue;
62
+ }
63
+ const oldContentType = oldContentTypes[uid];
64
+ const contentType = contentTypes2[uid];
65
+ if (contentTypes$1.hasDraftAndPublish(oldContentType) && !contentTypes$1.hasDraftAndPublish(contentType)) {
66
+ await strapi.db?.queryBuilder(RELEASE_ACTION_MODEL_UID).delete().where({ contentType: uid }).execute();
67
+ }
68
+ }
69
+ }
70
+ async function deleteActionsOnDeleteContentType({ oldContentTypes, contentTypes: contentTypes2 }) {
71
+ const deletedContentTypes = difference(keys(oldContentTypes), keys(contentTypes2)) ?? [];
72
+ if (deletedContentTypes.length) {
73
+ await mapAsync(deletedContentTypes, async (deletedContentTypeUID) => {
74
+ return strapi.db?.queryBuilder(RELEASE_ACTION_MODEL_UID).delete().where({ contentType: deletedContentTypeUID }).execute();
75
+ });
76
+ }
77
+ }
78
+ const { features: features$2 } = require("@strapi/strapi/dist/utils/ee");
51
79
  const register = async ({ strapi: strapi2 }) => {
52
- if (features$1.isEnabled("cms-content-releases") && strapi2.features.future.isEnabled("contentReleases")) {
80
+ if (features$2.isEnabled("cms-content-releases")) {
53
81
  await strapi2.admin.services.permission.actionProvider.registerMany(ACTIONS);
82
+ strapi2.hook("strapi::content-types.beforeSync").register(deleteActionsOnDisableDraftAndPublish);
83
+ strapi2.hook("strapi::content-types.afterSync").register(deleteActionsOnDeleteContentType);
84
+ }
85
+ };
86
+ const { features: features$1 } = require("@strapi/strapi/dist/utils/ee");
87
+ const bootstrap = async ({ strapi: strapi2 }) => {
88
+ if (features$1.isEnabled("cms-content-releases")) {
89
+ strapi2.db.lifecycles.subscribe({
90
+ afterDelete(event) {
91
+ const { model, result } = event;
92
+ if (model.kind === "collectionType" && model.options?.draftAndPublish) {
93
+ const { id } = result;
94
+ strapi2.db.query(RELEASE_ACTION_MODEL_UID).deleteMany({
95
+ where: {
96
+ target_type: model.uid,
97
+ target_id: id
98
+ }
99
+ });
100
+ }
101
+ },
102
+ /**
103
+ * deleteMany hook doesn't return the deleted entries ids
104
+ * so we need to fetch them before deleting the entries to save the ids on our state
105
+ */
106
+ async beforeDeleteMany(event) {
107
+ const { model, params } = event;
108
+ if (model.kind === "collectionType" && model.options?.draftAndPublish) {
109
+ const { where } = params;
110
+ const entriesToDelete = await strapi2.db.query(model.uid).findMany({ select: ["id"], where });
111
+ event.state.entriesToDelete = entriesToDelete;
112
+ }
113
+ },
114
+ /**
115
+ * We delete the release actions related to deleted entries
116
+ * We make this only after deleteMany is succesfully executed to avoid errors
117
+ */
118
+ async afterDeleteMany(event) {
119
+ const { model, state } = event;
120
+ const entriesToDelete = state.entriesToDelete;
121
+ if (entriesToDelete) {
122
+ await strapi2.db.query(RELEASE_ACTION_MODEL_UID).deleteMany({
123
+ where: {
124
+ target_type: model.uid,
125
+ target_id: {
126
+ $in: entriesToDelete.map((entry) => entry.id)
127
+ }
128
+ }
129
+ });
130
+ }
131
+ }
132
+ });
54
133
  }
55
134
  };
56
135
  const schema$1 = {
@@ -147,18 +226,26 @@ const getService = (name, { strapi: strapi2 } = { strapi: global.strapi }) => {
147
226
  const getGroupName = (queryValue) => {
148
227
  switch (queryValue) {
149
228
  case "contentType":
150
- return "entry.contentType.displayName";
229
+ return "contentType.displayName";
151
230
  case "action":
152
231
  return "type";
153
232
  case "locale":
154
- return _.getOr("No locale", "entry.locale.name");
233
+ return _.getOr("No locale", "locale.name");
155
234
  default:
156
- return "entry.contentType.displayName";
235
+ return "contentType.displayName";
157
236
  }
158
237
  };
159
238
  const createReleaseService = ({ strapi: strapi2 }) => ({
160
239
  async create(releaseData, { user }) {
161
240
  const releaseWithCreatorFields = await setCreatorFields({ user })(releaseData);
241
+ const { validatePendingReleasesLimit, validateUniqueNameForPendingRelease } = getService(
242
+ "release-validation",
243
+ { strapi: strapi2 }
244
+ );
245
+ await Promise.all([
246
+ validatePendingReleasesLimit(),
247
+ validateUniqueNameForPendingRelease(releaseWithCreatorFields.name)
248
+ ]);
162
249
  return strapi2.entityService.create(RELEASE_MODEL_UID, {
163
250
  data: releaseWithCreatorFields
164
251
  });
@@ -180,51 +267,66 @@ const createReleaseService = ({ strapi: strapi2 }) => ({
180
267
  }
181
268
  });
182
269
  },
183
- async findManyForContentTypeEntry(contentTypeUid, entryId, {
184
- hasEntryAttached
185
- } = {
186
- hasEntryAttached: false
187
- }) {
188
- const whereActions = hasEntryAttached ? {
189
- // Find all Releases where the content type entry is present
190
- actions: {
191
- target_type: contentTypeUid,
192
- target_id: entryId
193
- }
194
- } : {
195
- // Find all Releases where the content type entry is not present
196
- $or: [
197
- {
198
- $not: {
199
- actions: {
200
- target_type: contentTypeUid,
201
- target_id: entryId
202
- }
203
- }
270
+ async findManyWithContentTypeEntryAttached(contentTypeUid, entryId) {
271
+ const releases = await strapi2.db.query(RELEASE_MODEL_UID).findMany({
272
+ where: {
273
+ actions: {
274
+ target_type: contentTypeUid,
275
+ target_id: entryId
204
276
  },
205
- {
206
- actions: null
277
+ releasedAt: {
278
+ $null: true
207
279
  }
208
- ]
209
- };
210
- const populateAttachedAction = hasEntryAttached ? {
211
- // Filter the action to get only the content type entry
212
- actions: {
213
- where: {
280
+ },
281
+ populate: {
282
+ // Filter the action to get only the content type entry
283
+ actions: {
284
+ where: {
285
+ target_type: contentTypeUid,
286
+ target_id: entryId
287
+ }
288
+ }
289
+ }
290
+ });
291
+ return releases.map((release2) => {
292
+ if (release2.actions?.length) {
293
+ const [actionForEntry] = release2.actions;
294
+ delete release2.actions;
295
+ return {
296
+ ...release2,
297
+ action: actionForEntry
298
+ };
299
+ }
300
+ return release2;
301
+ });
302
+ },
303
+ async findManyWithoutContentTypeEntryAttached(contentTypeUid, entryId) {
304
+ const releasesRelated = await strapi2.db.query(RELEASE_MODEL_UID).findMany({
305
+ where: {
306
+ releasedAt: {
307
+ $null: true
308
+ },
309
+ actions: {
214
310
  target_type: contentTypeUid,
215
311
  target_id: entryId
216
312
  }
217
313
  }
218
- } : {};
314
+ });
219
315
  const releases = await strapi2.db.query(RELEASE_MODEL_UID).findMany({
220
316
  where: {
221
- ...whereActions,
317
+ $or: [
318
+ {
319
+ id: {
320
+ $notIn: releasesRelated.map((release2) => release2.id)
321
+ }
322
+ },
323
+ {
324
+ actions: null
325
+ }
326
+ ],
222
327
  releasedAt: {
223
328
  $null: true
224
329
  }
225
- },
226
- populate: {
227
- ...populateAttachedAction
228
330
  }
229
331
  });
230
332
  return releases.map((release2) => {
@@ -240,19 +342,23 @@ const createReleaseService = ({ strapi: strapi2 }) => ({
240
342
  });
241
343
  },
242
344
  async update(id, releaseData, { user }) {
243
- const updatedRelease = await setCreatorFields({ user, isEdition: true })(releaseData);
244
- const release2 = await strapi2.entityService.update(RELEASE_MODEL_UID, id, {
345
+ const releaseWithCreatorFields = await setCreatorFields({ user, isEdition: true })(releaseData);
346
+ const release2 = await strapi2.entityService.findOne(RELEASE_MODEL_UID, id);
347
+ if (!release2) {
348
+ throw new errors.NotFoundError(`No release found for id ${id}`);
349
+ }
350
+ if (release2.releasedAt) {
351
+ throw new errors.ValidationError("Release already published");
352
+ }
353
+ const updatedRelease = await strapi2.entityService.update(RELEASE_MODEL_UID, id, {
245
354
  /*
246
355
  * The type returned from the entity service: Partial<Input<"plugin::content-releases.release">>
247
356
  * is not compatible with the type we are passing here: UpdateRelease.Request['body']
248
357
  */
249
358
  // @ts-expect-error see above
250
- data: updatedRelease
359
+ data: releaseWithCreatorFields
251
360
  });
252
- if (!release2) {
253
- throw new errors.NotFoundError(`No release found for id ${id}`);
254
- }
255
- return release2;
361
+ return updatedRelease;
256
362
  },
257
363
  async createAction(releaseId, action) {
258
364
  const { validateEntryContentType, validateUniqueEntry } = getService("release-validation", {
@@ -262,6 +368,13 @@ const createReleaseService = ({ strapi: strapi2 }) => ({
262
368
  validateEntryContentType(action.entry.contentType),
263
369
  validateUniqueEntry(releaseId, action)
264
370
  ]);
371
+ const release2 = await strapi2.entityService.findOne(RELEASE_MODEL_UID, releaseId);
372
+ if (!release2) {
373
+ throw new errors.NotFoundError(`No release found for id ${releaseId}`);
374
+ }
375
+ if (release2.releasedAt) {
376
+ throw new errors.ValidationError("Release already published");
377
+ }
265
378
  const { entry, type } = action;
266
379
  return strapi2.entityService.create(RELEASE_ACTION_MODEL_UID, {
267
380
  data: {
@@ -288,7 +401,9 @@ const createReleaseService = ({ strapi: strapi2 }) => ({
288
401
  return strapi2.entityService.findPage(RELEASE_ACTION_MODEL_UID, {
289
402
  ...query,
290
403
  populate: {
291
- entry: true
404
+ entry: {
405
+ populate: "*"
406
+ }
292
407
  },
293
408
  filters: {
294
409
  release: releaseId
@@ -308,29 +423,32 @@ const createReleaseService = ({ strapi: strapi2 }) => ({
308
423
  const allReleaseContentTypesDictionary = await this.getContentTypesDataForActions(
309
424
  contentTypeUids
310
425
  );
311
- const allLocales = await strapi2.plugin("i18n").service("locales").find();
312
- const allLocalesDictionary = allLocales.reduce((acc, locale) => {
313
- acc[locale.code] = { name: locale.name, code: locale.code };
314
- return acc;
315
- }, {});
426
+ const allLocalesDictionary = await this.getLocalesDataForActions();
316
427
  const formattedData = actions.map((action) => {
317
428
  const { mainField, displayName } = allReleaseContentTypesDictionary[action.contentType];
318
429
  return {
319
430
  ...action,
320
- entry: {
321
- id: action.entry.id,
322
- contentType: {
323
- displayName,
324
- mainFieldValue: action.entry[mainField]
325
- },
326
- locale: action.locale ? allLocalesDictionary[action.locale] : null,
327
- status: action.entry.publishedAt ? "published" : "draft"
431
+ locale: action.locale ? allLocalesDictionary[action.locale] : null,
432
+ contentType: {
433
+ displayName,
434
+ mainFieldValue: action.entry[mainField],
435
+ uid: action.contentType
328
436
  }
329
437
  };
330
438
  });
331
439
  const groupName = getGroupName(groupBy);
332
440
  return _.groupBy(groupName)(formattedData);
333
441
  },
442
+ async getLocalesDataForActions() {
443
+ if (!strapi2.plugin("i18n")) {
444
+ return {};
445
+ }
446
+ const allLocales = await strapi2.plugin("i18n").service("locales").find() || [];
447
+ return allLocales.reduce((acc, locale) => {
448
+ acc[locale.code] = { name: locale.name, code: locale.code };
449
+ return acc;
450
+ }, {});
451
+ },
334
452
  async getContentTypesDataForActions(contentTypesUids) {
335
453
  const contentManagerContentTypeService = strapi2.plugin("content-manager").service("content-types");
336
454
  const contentTypesData = {};
@@ -345,6 +463,34 @@ const createReleaseService = ({ strapi: strapi2 }) => ({
345
463
  }
346
464
  return contentTypesData;
347
465
  },
466
+ getContentTypeModelsFromActions(actions) {
467
+ const contentTypeUids = actions.reduce((acc, action) => {
468
+ if (!acc.includes(action.contentType)) {
469
+ acc.push(action.contentType);
470
+ }
471
+ return acc;
472
+ }, []);
473
+ const contentTypeModelsMap = contentTypeUids.reduce(
474
+ (acc, contentTypeUid) => {
475
+ acc[contentTypeUid] = strapi2.getModel(contentTypeUid);
476
+ return acc;
477
+ },
478
+ {}
479
+ );
480
+ return contentTypeModelsMap;
481
+ },
482
+ async getAllComponents() {
483
+ const contentManagerComponentsService = strapi2.plugin("content-manager").service("components");
484
+ const components = await contentManagerComponentsService.findAllComponents();
485
+ const componentsMap = components.reduce(
486
+ (acc, component) => {
487
+ acc[component.uid] = component;
488
+ return acc;
489
+ },
490
+ {}
491
+ );
492
+ return componentsMap;
493
+ },
348
494
  async delete(releaseId) {
349
495
  const release2 = await strapi2.entityService.findOne(RELEASE_MODEL_UID, releaseId, {
350
496
  populate: {
@@ -379,7 +525,9 @@ const createReleaseService = ({ strapi: strapi2 }) => ({
379
525
  populate: {
380
526
  actions: {
381
527
  populate: {
382
- entry: true
528
+ entry: {
529
+ fields: ["id"]
530
+ }
383
531
  }
384
532
  }
385
533
  }
@@ -399,25 +547,49 @@ const createReleaseService = ({ strapi: strapi2 }) => ({
399
547
  const contentTypeUid = action.contentType;
400
548
  if (!actions[contentTypeUid]) {
401
549
  actions[contentTypeUid] = {
402
- publish: [],
403
- unpublish: []
550
+ entriestoPublishIds: [],
551
+ entriesToUnpublishIds: []
404
552
  };
405
553
  }
406
554
  if (action.type === "publish") {
407
- actions[contentTypeUid].publish.push(action.entry);
555
+ actions[contentTypeUid].entriestoPublishIds.push(action.entry.id);
408
556
  } else {
409
- actions[contentTypeUid].unpublish.push(action.entry);
557
+ actions[contentTypeUid].entriesToUnpublishIds.push(action.entry.id);
410
558
  }
411
559
  }
412
560
  const entityManagerService = strapi2.plugin("content-manager").service("entity-manager");
561
+ const populateBuilderService = strapi2.plugin("content-manager").service("populate-builder");
413
562
  await strapi2.db.transaction(async () => {
414
563
  for (const contentTypeUid of Object.keys(actions)) {
415
- const { publish, unpublish } = actions[contentTypeUid];
416
- if (publish.length > 0) {
417
- await entityManagerService.publishMany(publish, contentTypeUid);
564
+ const populate = await populateBuilderService(contentTypeUid).populateDeep(Infinity).build();
565
+ const { entriestoPublishIds, entriesToUnpublishIds } = actions[contentTypeUid];
566
+ const entriesToPublish = await strapi2.entityService.findMany(
567
+ contentTypeUid,
568
+ {
569
+ filters: {
570
+ id: {
571
+ $in: entriestoPublishIds
572
+ }
573
+ },
574
+ populate
575
+ }
576
+ );
577
+ const entriesToUnpublish = await strapi2.entityService.findMany(
578
+ contentTypeUid,
579
+ {
580
+ filters: {
581
+ id: {
582
+ $in: entriesToUnpublishIds
583
+ }
584
+ },
585
+ populate
586
+ }
587
+ );
588
+ if (entriesToPublish.length > 0) {
589
+ await entityManagerService.publishMany(entriesToPublish, contentTypeUid);
418
590
  }
419
- if (unpublish.length > 0) {
420
- await entityManagerService.unpublishMany(unpublish, contentTypeUid);
591
+ if (entriesToUnpublish.length > 0) {
592
+ await entityManagerService.unpublishMany(entriesToUnpublish, contentTypeUid);
421
593
  }
422
594
  }
423
595
  });
@@ -436,13 +608,18 @@ const createReleaseService = ({ strapi: strapi2 }) => ({
436
608
  const updatedAction = await strapi2.db.query(RELEASE_ACTION_MODEL_UID).update({
437
609
  where: {
438
610
  id: actionId,
439
- release: releaseId
611
+ release: {
612
+ id: releaseId,
613
+ releasedAt: {
614
+ $null: true
615
+ }
616
+ }
440
617
  },
441
618
  data: update
442
619
  });
443
620
  if (!updatedAction) {
444
621
  throw new errors.NotFoundError(
445
- `Action with id ${actionId} not found in release with id ${releaseId}`
622
+ `Action with id ${actionId} not found in release with id ${releaseId} or it is already published`
446
623
  );
447
624
  }
448
625
  return updatedAction;
@@ -451,12 +628,17 @@ const createReleaseService = ({ strapi: strapi2 }) => ({
451
628
  const deletedAction = await strapi2.db.query(RELEASE_ACTION_MODEL_UID).delete({
452
629
  where: {
453
630
  id: actionId,
454
- release: releaseId
631
+ release: {
632
+ id: releaseId,
633
+ releasedAt: {
634
+ $null: true
635
+ }
636
+ }
455
637
  }
456
638
  });
457
639
  if (!deletedAction) {
458
640
  throw new errors.NotFoundError(
459
- `Action with id ${actionId} not found in release with id ${releaseId}`
641
+ `Action with id ${actionId} not found in release with id ${releaseId} or it is already published`
460
642
  );
461
643
  }
462
644
  return deletedAction;
@@ -489,9 +671,42 @@ const createReleaseValidationService = ({ strapi: strapi2 }) => ({
489
671
  `Content type with uid ${contentTypeUid} does not have draftAndPublish enabled`
490
672
  );
491
673
  }
674
+ },
675
+ async validatePendingReleasesLimit() {
676
+ const maximumPendingReleases = (
677
+ // @ts-expect-error - options is not typed into features
678
+ EE.features.get("cms-content-releases")?.options?.maximumReleases || 3
679
+ );
680
+ const [, pendingReleasesCount] = await strapi2.db.query(RELEASE_MODEL_UID).findWithCount({
681
+ filters: {
682
+ releasedAt: {
683
+ $null: true
684
+ }
685
+ }
686
+ });
687
+ if (pendingReleasesCount >= maximumPendingReleases) {
688
+ throw new errors.ValidationError("You have reached the maximum number of pending releases");
689
+ }
690
+ },
691
+ async validateUniqueNameForPendingRelease(name) {
692
+ const pendingReleases = await strapi2.entityService.findMany(RELEASE_MODEL_UID, {
693
+ filters: {
694
+ releasedAt: {
695
+ $null: true
696
+ },
697
+ name
698
+ }
699
+ });
700
+ const isNameUnique = pendingReleases.length === 0;
701
+ if (!isNameUnique) {
702
+ throw new errors.ValidationError(`Release with name ${name} already exists`);
703
+ }
492
704
  }
493
705
  });
494
- const services = { release: createReleaseService, "release-validation": createReleaseValidationService };
706
+ const services = {
707
+ release: createReleaseService,
708
+ "release-validation": createReleaseValidationService
709
+ };
495
710
  const RELEASE_SCHEMA = yup.object().shape({
496
711
  name: yup.string().trim().required()
497
712
  }).required().noUnknown();
@@ -510,9 +725,7 @@ const releaseController = {
510
725
  const contentTypeUid = query.contentTypeUid;
511
726
  const entryId = query.entryId;
512
727
  const hasEntryAttached = typeof query.hasEntryAttached === "string" ? JSON.parse(query.hasEntryAttached) : false;
513
- const data = await releaseService.findManyForContentTypeEntry(contentTypeUid, entryId, {
514
- hasEntryAttached
515
- });
728
+ const data = hasEntryAttached ? await releaseService.findManyWithContentTypeEntryAttached(contentTypeUid, entryId) : await releaseService.findManyWithoutContentTypeEntryAttached(contentTypeUid, entryId);
516
729
  ctx.body = { data };
517
730
  } else {
518
731
  const query = await permissionsManager.sanitizeQuery(ctx.query);
@@ -640,11 +853,30 @@ const releaseActionController = {
640
853
  sort: query.groupBy === "action" ? "type" : query.groupBy,
641
854
  ...query
642
855
  });
643
- const groupedData = await releaseService.groupActions(results, query.groupBy);
856
+ const contentTypeOutputSanitizers = results.reduce((acc, action) => {
857
+ if (acc[action.contentType]) {
858
+ return acc;
859
+ }
860
+ const contentTypePermissionsManager = strapi.admin.services.permission.createPermissionsManager({
861
+ ability: ctx.state.userAbility,
862
+ model: action.contentType
863
+ });
864
+ acc[action.contentType] = contentTypePermissionsManager.sanitizeOutput;
865
+ return acc;
866
+ }, {});
867
+ const sanitizedResults = await mapAsync(results, async (action) => ({
868
+ ...action,
869
+ entry: await contentTypeOutputSanitizers[action.contentType](action.entry)
870
+ }));
871
+ const groupedData = await releaseService.groupActions(sanitizedResults, query.groupBy);
872
+ const contentTypes2 = releaseService.getContentTypeModelsFromActions(results);
873
+ const components = await releaseService.getAllComponents();
644
874
  ctx.body = {
645
875
  data: groupedData,
646
876
  meta: {
647
- pagination
877
+ pagination,
878
+ contentTypes: contentTypes2,
879
+ components
648
880
  }
649
881
  };
650
882
  },
@@ -666,10 +898,8 @@ const releaseActionController = {
666
898
  async delete(ctx) {
667
899
  const actionId = ctx.params.actionId;
668
900
  const releaseId = ctx.params.releaseId;
669
- const deletedReleaseAction = await getService("release", { strapi }).deleteAction(
670
- actionId,
671
- releaseId
672
- );
901
+ const releaseService = getService("release", { strapi });
902
+ const deletedReleaseAction = await releaseService.deleteAction(actionId, releaseId);
673
903
  ctx.body = {
674
904
  data: deletedReleaseAction
675
905
  };
@@ -852,9 +1082,10 @@ const routes = {
852
1082
  };
853
1083
  const { features } = require("@strapi/strapi/dist/utils/ee");
854
1084
  const getPlugin = () => {
855
- if (features.isEnabled("cms-content-releases") && strapi.features.future.isEnabled("contentReleases")) {
1085
+ if (features.isEnabled("cms-content-releases")) {
856
1086
  return {
857
1087
  register,
1088
+ bootstrap,
858
1089
  contentTypes,
859
1090
  services,
860
1091
  controllers,