@strapi/content-releases 0.0.0-next.37dd1e3ff22e1635b69683abadd444912ae0dbff → 0.0.0-next.44f19b3d2f81d983c343a219aa2781ee0deecb5f

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,40 +49,47 @@ const ACTIONS = [
47
49
  pluginName: "content-releases"
48
50
  }
49
51
  ];
50
- const getService = (name, { strapi: strapi2 } = { strapi: global.strapi }) => {
51
- return strapi2.plugin("content-releases").service(name);
52
- };
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
+ }
53
78
  const { features: features$2 } = require("@strapi/strapi/dist/utils/ee");
54
79
  const register = async ({ strapi: strapi2 }) => {
55
- if (features$2.isEnabled("cms-content-releases") && strapi2.features.future.isEnabled("contentReleases")) {
80
+ if (features$2.isEnabled("cms-content-releases")) {
56
81
  await strapi2.admin.services.permission.actionProvider.registerMany(ACTIONS);
57
- const releaseActionService = getService("release-action", { strapi: strapi2 });
58
- const eventManager = getService("event-manager", { strapi: strapi2 });
59
- const destroyContentTypeUpdateListener = strapi2.eventHub.on(
60
- "content-type.update",
61
- async ({ contentType }) => {
62
- if (contentType.schema.options.draftAndPublish === false) {
63
- await releaseActionService.deleteManyForContentType(contentType.uid);
64
- }
65
- }
66
- );
67
- eventManager.addDestroyListenerCallback(destroyContentTypeUpdateListener);
68
- const destroyContentTypeDeleteListener = strapi2.eventHub.on(
69
- "content-type.delete",
70
- async ({ contentType }) => {
71
- await releaseActionService.deleteManyForContentType(contentType.uid);
72
- }
73
- );
74
- eventManager.addDestroyListenerCallback(destroyContentTypeDeleteListener);
82
+ strapi2.hook("strapi::content-types.beforeSync").register(deleteActionsOnDisableDraftAndPublish);
83
+ strapi2.hook("strapi::content-types.afterSync").register(deleteActionsOnDeleteContentType);
75
84
  }
76
85
  };
77
86
  const { features: features$1 } = require("@strapi/strapi/dist/utils/ee");
78
87
  const bootstrap = async ({ strapi: strapi2 }) => {
79
- if (features$1.isEnabled("cms-content-releases") && strapi2.features.future.isEnabled("contentReleases")) {
88
+ if (features$1.isEnabled("cms-content-releases")) {
80
89
  strapi2.db.lifecycles.subscribe({
81
90
  afterDelete(event) {
82
91
  const { model, result } = event;
83
- if (model.kind === "collectionType" && model.options.draftAndPublish) {
92
+ if (model.kind === "collectionType" && model.options?.draftAndPublish) {
84
93
  const { id } = result;
85
94
  strapi2.db.query(RELEASE_ACTION_MODEL_UID).deleteMany({
86
95
  where: {
@@ -96,7 +105,7 @@ const bootstrap = async ({ strapi: strapi2 }) => {
96
105
  */
97
106
  async beforeDeleteMany(event) {
98
107
  const { model, params } = event;
99
- if (model.kind === "collectionType" && model.options.draftAndPublish) {
108
+ if (model.kind === "collectionType" && model.options?.draftAndPublish) {
100
109
  const { where } = params;
101
110
  const entriesToDelete = await strapi2.db.query(model.uid).findMany({ select: ["id"], where });
102
111
  event.state.entriesToDelete = entriesToDelete;
@@ -211,30 +220,32 @@ const contentTypes = {
211
220
  release: release$1,
212
221
  "release-action": releaseAction$1
213
222
  };
214
- const createReleaseActionService = ({ strapi: strapi2 }) => ({
215
- async deleteManyForContentType(contentTypeUid) {
216
- return strapi2.db.query(RELEASE_ACTION_MODEL_UID).deleteMany({
217
- where: {
218
- target_type: contentTypeUid
219
- }
220
- });
221
- }
222
- });
223
+ const getService = (name, { strapi: strapi2 } = { strapi: global.strapi }) => {
224
+ return strapi2.plugin("content-releases").service(name);
225
+ };
223
226
  const getGroupName = (queryValue) => {
224
227
  switch (queryValue) {
225
228
  case "contentType":
226
- return "entry.contentType.displayName";
229
+ return "contentType.displayName";
227
230
  case "action":
228
231
  return "type";
229
232
  case "locale":
230
- return _.getOr("No locale", "entry.locale.name");
233
+ return _.getOr("No locale", "locale.name");
231
234
  default:
232
- return "entry.contentType.displayName";
235
+ return "contentType.displayName";
233
236
  }
234
237
  };
235
238
  const createReleaseService = ({ strapi: strapi2 }) => ({
236
239
  async create(releaseData, { user }) {
237
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
+ ]);
238
249
  return strapi2.entityService.create(RELEASE_MODEL_UID, {
239
250
  data: releaseWithCreatorFields
240
251
  });
@@ -256,51 +267,66 @@ const createReleaseService = ({ strapi: strapi2 }) => ({
256
267
  }
257
268
  });
258
269
  },
259
- async findManyForContentTypeEntry(contentTypeUid, entryId, {
260
- hasEntryAttached
261
- } = {
262
- hasEntryAttached: false
263
- }) {
264
- const whereActions = hasEntryAttached ? {
265
- // Find all Releases where the content type entry is present
266
- actions: {
267
- target_type: contentTypeUid,
268
- target_id: entryId
269
- }
270
- } : {
271
- // Find all Releases where the content type entry is not present
272
- $or: [
273
- {
274
- $not: {
275
- actions: {
276
- target_type: contentTypeUid,
277
- target_id: entryId
278
- }
279
- }
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
280
276
  },
281
- {
282
- actions: null
277
+ releasedAt: {
278
+ $null: true
283
279
  }
284
- ]
285
- };
286
- const populateAttachedAction = hasEntryAttached ? {
287
- // Filter the action to get only the content type entry
288
- actions: {
289
- 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: {
290
310
  target_type: contentTypeUid,
291
311
  target_id: entryId
292
312
  }
293
313
  }
294
- } : {};
314
+ });
295
315
  const releases = await strapi2.db.query(RELEASE_MODEL_UID).findMany({
296
316
  where: {
297
- ...whereActions,
317
+ $or: [
318
+ {
319
+ id: {
320
+ $notIn: releasesRelated.map((release2) => release2.id)
321
+ }
322
+ },
323
+ {
324
+ actions: null
325
+ }
326
+ ],
298
327
  releasedAt: {
299
328
  $null: true
300
329
  }
301
- },
302
- populate: {
303
- ...populateAttachedAction
304
330
  }
305
331
  });
306
332
  return releases.map((release2) => {
@@ -375,7 +401,9 @@ const createReleaseService = ({ strapi: strapi2 }) => ({
375
401
  return strapi2.entityService.findPage(RELEASE_ACTION_MODEL_UID, {
376
402
  ...query,
377
403
  populate: {
378
- entry: true
404
+ entry: {
405
+ populate: "*"
406
+ }
379
407
  },
380
408
  filters: {
381
409
  release: releaseId
@@ -395,29 +423,32 @@ const createReleaseService = ({ strapi: strapi2 }) => ({
395
423
  const allReleaseContentTypesDictionary = await this.getContentTypesDataForActions(
396
424
  contentTypeUids
397
425
  );
398
- const allLocales = await strapi2.plugin("i18n").service("locales").find();
399
- const allLocalesDictionary = allLocales.reduce((acc, locale) => {
400
- acc[locale.code] = { name: locale.name, code: locale.code };
401
- return acc;
402
- }, {});
426
+ const allLocalesDictionary = await this.getLocalesDataForActions();
403
427
  const formattedData = actions.map((action) => {
404
428
  const { mainField, displayName } = allReleaseContentTypesDictionary[action.contentType];
405
429
  return {
406
430
  ...action,
407
- entry: {
408
- id: action.entry.id,
409
- contentType: {
410
- displayName,
411
- mainFieldValue: action.entry[mainField]
412
- },
413
- locale: action.locale ? allLocalesDictionary[action.locale] : null,
414
- 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
415
436
  }
416
437
  };
417
438
  });
418
439
  const groupName = getGroupName(groupBy);
419
440
  return _.groupBy(groupName)(formattedData);
420
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
+ },
421
452
  async getContentTypesDataForActions(contentTypesUids) {
422
453
  const contentManagerContentTypeService = strapi2.plugin("content-manager").service("content-types");
423
454
  const contentTypesData = {};
@@ -432,6 +463,34 @@ const createReleaseService = ({ strapi: strapi2 }) => ({
432
463
  }
433
464
  return contentTypesData;
434
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
+ },
435
494
  async delete(releaseId) {
436
495
  const release2 = await strapi2.entityService.findOne(RELEASE_MODEL_UID, releaseId, {
437
496
  populate: {
@@ -466,7 +525,9 @@ const createReleaseService = ({ strapi: strapi2 }) => ({
466
525
  populate: {
467
526
  actions: {
468
527
  populate: {
469
- entry: true
528
+ entry: {
529
+ fields: ["id"]
530
+ }
470
531
  }
471
532
  }
472
533
  }
@@ -481,30 +542,80 @@ const createReleaseService = ({ strapi: strapi2 }) => ({
481
542
  if (releaseWithPopulatedActionEntries.actions.length === 0) {
482
543
  throw new errors.ValidationError("No entries to publish");
483
544
  }
484
- const actions = {};
545
+ const collectionTypeActions = {};
546
+ const singleTypeActions = [];
485
547
  for (const action of releaseWithPopulatedActionEntries.actions) {
486
548
  const contentTypeUid = action.contentType;
487
- if (!actions[contentTypeUid]) {
488
- actions[contentTypeUid] = {
489
- publish: [],
490
- unpublish: []
491
- };
492
- }
493
- if (action.type === "publish") {
494
- actions[contentTypeUid].publish.push(action.entry);
549
+ if (strapi2.contentTypes[contentTypeUid].kind === "collectionType") {
550
+ if (!collectionTypeActions[contentTypeUid]) {
551
+ collectionTypeActions[contentTypeUid] = {
552
+ entriestoPublishIds: [],
553
+ entriesToUnpublishIds: []
554
+ };
555
+ }
556
+ if (action.type === "publish") {
557
+ collectionTypeActions[contentTypeUid].entriestoPublishIds.push(action.entry.id);
558
+ } else {
559
+ collectionTypeActions[contentTypeUid].entriesToUnpublishIds.push(action.entry.id);
560
+ }
495
561
  } else {
496
- actions[contentTypeUid].unpublish.push(action.entry);
562
+ singleTypeActions.push({
563
+ uid: contentTypeUid,
564
+ action: action.type,
565
+ id: action.entry.id
566
+ });
497
567
  }
498
568
  }
499
569
  const entityManagerService = strapi2.plugin("content-manager").service("entity-manager");
570
+ const populateBuilderService = strapi2.plugin("content-manager").service("populate-builder");
500
571
  await strapi2.db.transaction(async () => {
501
- for (const contentTypeUid of Object.keys(actions)) {
502
- const { publish, unpublish } = actions[contentTypeUid];
503
- if (publish.length > 0) {
504
- await entityManagerService.publishMany(publish, contentTypeUid);
572
+ for (const { uid, action, id } of singleTypeActions) {
573
+ const populate = await populateBuilderService(uid).populateDeep(Infinity).build();
574
+ const entry = await strapi2.entityService.findOne(uid, id, { populate });
575
+ try {
576
+ if (action === "publish") {
577
+ await entityManagerService.publish(entry, uid);
578
+ } else {
579
+ await entityManagerService.unpublish(entry, uid);
580
+ }
581
+ } catch (error) {
582
+ if (error instanceof errors.ApplicationError && (error.message === "already.published" || error.message === "already.draft"))
583
+ ;
584
+ else {
585
+ throw error;
586
+ }
587
+ }
588
+ }
589
+ for (const contentTypeUid of Object.keys(collectionTypeActions)) {
590
+ const populate = await populateBuilderService(contentTypeUid).populateDeep(Infinity).build();
591
+ const { entriestoPublishIds, entriesToUnpublishIds } = collectionTypeActions[contentTypeUid];
592
+ const entriesToPublish = await strapi2.entityService.findMany(
593
+ contentTypeUid,
594
+ {
595
+ filters: {
596
+ id: {
597
+ $in: entriestoPublishIds
598
+ }
599
+ },
600
+ populate
601
+ }
602
+ );
603
+ const entriesToUnpublish = await strapi2.entityService.findMany(
604
+ contentTypeUid,
605
+ {
606
+ filters: {
607
+ id: {
608
+ $in: entriesToUnpublishIds
609
+ }
610
+ },
611
+ populate
612
+ }
613
+ );
614
+ if (entriesToPublish.length > 0) {
615
+ await entityManagerService.publishMany(entriesToPublish, contentTypeUid);
505
616
  }
506
- if (unpublish.length > 0) {
507
- await entityManagerService.unpublishMany(unpublish, contentTypeUid);
617
+ if (entriesToUnpublish.length > 0) {
618
+ await entityManagerService.unpublishMany(entriesToUnpublish, contentTypeUid);
508
619
  }
509
620
  }
510
621
  });
@@ -586,31 +697,41 @@ const createReleaseValidationService = ({ strapi: strapi2 }) => ({
586
697
  `Content type with uid ${contentTypeUid} does not have draftAndPublish enabled`
587
698
  );
588
699
  }
589
- }
590
- });
591
- const createEventManagerService = () => {
592
- const state = {
593
- destroyListenerCallbacks: []
594
- };
595
- return {
596
- addDestroyListenerCallback(destroyListenerCallback) {
597
- state.destroyListenerCallbacks.push(destroyListenerCallback);
598
- },
599
- destroyAllListeners() {
600
- if (!state.destroyListenerCallbacks.length) {
601
- return;
700
+ },
701
+ async validatePendingReleasesLimit() {
702
+ const maximumPendingReleases = (
703
+ // @ts-expect-error - options is not typed into features
704
+ EE.features.get("cms-content-releases")?.options?.maximumReleases || 3
705
+ );
706
+ const [, pendingReleasesCount] = await strapi2.db.query(RELEASE_MODEL_UID).findWithCount({
707
+ filters: {
708
+ releasedAt: {
709
+ $null: true
710
+ }
602
711
  }
603
- state.destroyListenerCallbacks.forEach((destroyListenerCallback) => {
604
- destroyListenerCallback();
605
- });
712
+ });
713
+ if (pendingReleasesCount >= maximumPendingReleases) {
714
+ throw new errors.ValidationError("You have reached the maximum number of pending releases");
606
715
  }
607
- };
608
- };
716
+ },
717
+ async validateUniqueNameForPendingRelease(name) {
718
+ const pendingReleases = await strapi2.entityService.findMany(RELEASE_MODEL_UID, {
719
+ filters: {
720
+ releasedAt: {
721
+ $null: true
722
+ },
723
+ name
724
+ }
725
+ });
726
+ const isNameUnique = pendingReleases.length === 0;
727
+ if (!isNameUnique) {
728
+ throw new errors.ValidationError(`Release with name ${name} already exists`);
729
+ }
730
+ }
731
+ });
609
732
  const services = {
610
733
  release: createReleaseService,
611
- "release-action": createReleaseActionService,
612
- "release-validation": createReleaseValidationService,
613
- "event-manager": createEventManagerService
734
+ "release-validation": createReleaseValidationService
614
735
  };
615
736
  const RELEASE_SCHEMA = yup.object().shape({
616
737
  name: yup.string().trim().required()
@@ -630,9 +751,7 @@ const releaseController = {
630
751
  const contentTypeUid = query.contentTypeUid;
631
752
  const entryId = query.entryId;
632
753
  const hasEntryAttached = typeof query.hasEntryAttached === "string" ? JSON.parse(query.hasEntryAttached) : false;
633
- const data = await releaseService.findManyForContentTypeEntry(contentTypeUid, entryId, {
634
- hasEntryAttached
635
- });
754
+ const data = hasEntryAttached ? await releaseService.findManyWithContentTypeEntryAttached(contentTypeUid, entryId) : await releaseService.findManyWithoutContentTypeEntryAttached(contentTypeUid, entryId);
636
755
  ctx.body = { data };
637
756
  } else {
638
757
  const query = await permissionsManager.sanitizeQuery(ctx.query);
@@ -720,8 +839,27 @@ const releaseController = {
720
839
  const id = ctx.params.id;
721
840
  const releaseService = getService("release", { strapi });
722
841
  const release2 = await releaseService.publish(id, { user });
842
+ const [countPublishActions, countUnpublishActions] = await Promise.all([
843
+ releaseService.countActions({
844
+ filters: {
845
+ release: id,
846
+ type: "publish"
847
+ }
848
+ }),
849
+ releaseService.countActions({
850
+ filters: {
851
+ release: id,
852
+ type: "unpublish"
853
+ }
854
+ })
855
+ ]);
723
856
  ctx.body = {
724
- data: release2
857
+ data: release2,
858
+ meta: {
859
+ totalEntries: countPublishActions + countUnpublishActions,
860
+ totalPublishedEntries: countPublishActions,
861
+ totalUnpublishedEntries: countUnpublishActions
862
+ }
725
863
  };
726
864
  }
727
865
  };
@@ -760,11 +898,30 @@ const releaseActionController = {
760
898
  sort: query.groupBy === "action" ? "type" : query.groupBy,
761
899
  ...query
762
900
  });
763
- const groupedData = await releaseService.groupActions(results, query.groupBy);
901
+ const contentTypeOutputSanitizers = results.reduce((acc, action) => {
902
+ if (acc[action.contentType]) {
903
+ return acc;
904
+ }
905
+ const contentTypePermissionsManager = strapi.admin.services.permission.createPermissionsManager({
906
+ ability: ctx.state.userAbility,
907
+ model: action.contentType
908
+ });
909
+ acc[action.contentType] = contentTypePermissionsManager.sanitizeOutput;
910
+ return acc;
911
+ }, {});
912
+ const sanitizedResults = await mapAsync(results, async (action) => ({
913
+ ...action,
914
+ entry: await contentTypeOutputSanitizers[action.contentType](action.entry)
915
+ }));
916
+ const groupedData = await releaseService.groupActions(sanitizedResults, query.groupBy);
917
+ const contentTypes2 = releaseService.getContentTypeModelsFromActions(results);
918
+ const components = await releaseService.getAllComponents();
764
919
  ctx.body = {
765
920
  data: groupedData,
766
921
  meta: {
767
- pagination
922
+ pagination,
923
+ contentTypes: contentTypes2,
924
+ components
768
925
  }
769
926
  };
770
927
  },
@@ -970,19 +1127,14 @@ const routes = {
970
1127
  };
971
1128
  const { features } = require("@strapi/strapi/dist/utils/ee");
972
1129
  const getPlugin = () => {
973
- if (features.isEnabled("cms-content-releases") && strapi.features.future.isEnabled("contentReleases")) {
1130
+ if (features.isEnabled("cms-content-releases")) {
974
1131
  return {
975
1132
  register,
976
1133
  bootstrap,
977
1134
  contentTypes,
978
1135
  services,
979
1136
  controllers,
980
- routes,
981
- destroy() {
982
- if (features.isEnabled("cms-content-releases") && strapi.features.future.isEnabled("contentReleases")) {
983
- getService("event-manager").destroyAllListeners();
984
- }
985
- }
1137
+ routes
986
1138
  };
987
1139
  }
988
1140
  return {