apostrophe 3.17.0 → 3.18.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.
Files changed (97) hide show
  1. package/.editorconfig +3 -0
  2. package/.eslintrc +4 -3
  3. package/.github/workflows/main.yml +2 -2
  4. package/.stylelintrc +12 -2
  5. package/CHANGELOG.md +34 -2
  6. package/defaults.js +2 -2
  7. package/index.js +124 -33
  8. package/lib/escape-host.js +8 -0
  9. package/lib/mongodb-connect.js +55 -0
  10. package/lib/opentelemetry.js +144 -0
  11. package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +2 -0
  12. package/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue +20 -8
  13. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +10 -0
  14. package/modules/@apostrophecms/asset/lib/globalIcons.js +1 -0
  15. package/modules/@apostrophecms/attachment/index.js +81 -29
  16. package/modules/@apostrophecms/db/index.js +7 -10
  17. package/modules/@apostrophecms/doc/index.js +138 -23
  18. package/modules/@apostrophecms/doc-type/index.js +162 -63
  19. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocContextMenu.vue +39 -1
  20. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +11 -1
  21. package/modules/@apostrophecms/email/index.js +1 -1
  22. package/modules/@apostrophecms/express/index.js +2 -2
  23. package/modules/@apostrophecms/http/index.js +2 -1
  24. package/modules/@apostrophecms/i18n/i18n/en.json +10 -0
  25. package/modules/@apostrophecms/i18n/i18n/es.json +7 -0
  26. package/modules/@apostrophecms/i18n/i18n/pt-BR.json +7 -0
  27. package/modules/@apostrophecms/i18n/i18n/sk.json +7 -0
  28. package/modules/@apostrophecms/image/index.js +182 -1
  29. package/modules/@apostrophecms/image/ui/apos/apps/AposImageRelationshipQueryFilter.js +13 -0
  30. package/modules/@apostrophecms/image/ui/apos/components/AposImageCropper.vue +460 -0
  31. package/modules/@apostrophecms/image/ui/apos/components/AposImageRelationshipEditor.vue +510 -0
  32. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +5 -1
  33. package/modules/@apostrophecms/image/ui/apos/lib/aspectRatios.js +26 -0
  34. package/modules/@apostrophecms/image-widget/views/widget.html +5 -2
  35. package/modules/@apostrophecms/modal/ui/apos/mixins/AposEditorMixin.js +45 -1
  36. package/modules/@apostrophecms/module/index.js +98 -17
  37. package/modules/@apostrophecms/module/lib/events.js +46 -11
  38. package/modules/@apostrophecms/page/index.js +55 -22
  39. package/modules/@apostrophecms/piece-page-type/index.js +1 -0
  40. package/modules/@apostrophecms/piece-type/index.js +13 -4
  41. package/modules/@apostrophecms/piece-type/ui/apos/components/AposRelationshipEditor.vue +2 -2
  42. package/modules/@apostrophecms/rich-text-widget/index.js +1 -3
  43. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +4 -0
  44. package/modules/@apostrophecms/schema/index.js +79 -73
  45. package/modules/@apostrophecms/schema/ui/apos/components/AposInputArea.vue +10 -0
  46. package/modules/@apostrophecms/schema/ui/apos/components/AposInputObject.vue +22 -3
  47. package/modules/@apostrophecms/schema/ui/apos/components/AposInputRelationship.vue +72 -36
  48. package/modules/@apostrophecms/schema/ui/apos/components/AposInputSelect.vue +7 -26
  49. package/modules/@apostrophecms/schema/ui/apos/components/AposInputString.vue +8 -0
  50. package/modules/@apostrophecms/schema/ui/apos/components/AposSchema.vue +45 -15
  51. package/modules/@apostrophecms/task/index.js +106 -52
  52. package/modules/@apostrophecms/template/index.js +111 -76
  53. package/modules/@apostrophecms/template/lib/custom-tags/component.js +42 -22
  54. package/modules/@apostrophecms/ui/ui/apos/components/AposSelect.vue +61 -0
  55. package/modules/@apostrophecms/ui/ui/apos/components/AposSlat.vue +46 -11
  56. package/modules/@apostrophecms/ui/ui/apos/components/AposSlatList.vue +10 -0
  57. package/modules/@apostrophecms/ui/ui/apos/components/AposTreeHeader.vue +2 -22
  58. package/modules/@apostrophecms/ui/ui/apos/utils/index.js +9 -0
  59. package/modules/@apostrophecms/widget-type/index.js +2 -23
  60. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidget.vue +1 -1
  61. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +20 -1
  62. package/modules/@apostrophecms/widget-type/ui/apos/mixins/AposWidgetMixin.js +0 -9
  63. package/package.json +16 -12
  64. package/scripts/lint-i18n.js +2 -2
  65. package/test/assets.js +2 -1
  66. package/test/attachments.js +119 -26
  67. package/test/bundle.js +1 -1
  68. package/test/content-i18n.js +6 -6
  69. package/test/docs.js +244 -4
  70. package/test/draft-published.js +41 -41
  71. package/test/express.js +1 -1
  72. package/test/http.js +2 -2
  73. package/test/images.js +94 -4
  74. package/test/job.js +1 -1
  75. package/test/locks.js +1 -1
  76. package/test/middleware-and-route-order.js +3 -3
  77. package/test/pages-public-api.js +48 -4
  78. package/test/pages-rest.js +20 -20
  79. package/test/pages.js +377 -11
  80. package/test/parked-pages.js +1 -1
  81. package/test/permissions.js +10 -10
  82. package/test/pieces-public-api.js +130 -6
  83. package/test/pieces.js +247 -60
  84. package/test/recursionGuard.js +6 -6
  85. package/test/restApiRoutes.js +6 -6
  86. package/test/schemaBuilders.js +7 -7
  87. package/test/schemas.js +59 -59
  88. package/test/search.js +3 -3
  89. package/test/soft-redirects.js +13 -13
  90. package/test/static-i18n.js +1 -1
  91. package/test/templates.js +10 -10
  92. package/test/urls.js +2 -2
  93. package/test/users.js +21 -21
  94. package/test/utils.js +13 -13
  95. package/test/widgets.js +2 -2
  96. package/test-lib/util.js +2 -5
  97. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidget.vue +0 -26
@@ -184,15 +184,20 @@ module.exports = {
184
184
  self.canUpload,
185
185
  async function (req) {
186
186
  const _id = self.apos.launder.id(req.body._id);
187
- let crop = req.body.crop;
188
- if (typeof crop !== 'object') {
187
+ const { crop } = req.body;
188
+
189
+ if (!_id || !crop || typeof crop !== 'object' || Array.isArray(crop)) {
189
190
  throw self.apos.error('invalid');
190
191
  }
191
- crop = self.sanitizeCrop(crop);
192
- if (!crop) {
192
+
193
+ const sanitizedCrop = self.sanitizeCrop(crop);
194
+
195
+ if (!sanitizedCrop) {
193
196
  throw self.apos.error('invalid');
194
197
  }
195
- await self.crop(req, _id, crop);
198
+
199
+ await self.crop(req, _id, sanitizedCrop);
200
+
196
201
  return true;
197
202
  }
198
203
  ]
@@ -455,49 +460,62 @@ module.exports = {
455
460
  });
456
461
  },
457
462
  async crop(req, _id, crop) {
458
- const info = await self.db.findOne({ _id: _id });
463
+ const info = await self.db.findOne({ _id });
464
+
459
465
  if (!info) {
460
466
  throw self.apos.error('notfound');
461
467
  }
468
+
462
469
  if (!self.croppable[info.extension]) {
463
- throw new Error(info.extension + ' files cannot be cropped, do not present cropping UI for this type');
470
+ throw self.apos.error('invalid', req.t('apostrophe:fileTypeCannotBeCropped', {
471
+ extension: info.extension
472
+ }));
464
473
  }
465
474
  const crops = info.crops || [];
466
475
  const existing = _.find(crops, crop);
476
+
467
477
  if (existing) {
468
478
  // We're done, this crop is already available
469
479
  return;
470
480
  }
471
481
  // Pull the original out of cloud storage to a temporary folder where
472
482
  // it can be cropped and popped back into uploadfs
473
- const originalFile = '/attachments/' + info._id + '-' + info.name + '.' + info.extension;
474
- const tempFile = self.uploadfs.getTempPath() + '/' + self.apos.util.generateId() + '.' + info.extension;
475
- const croppedFile = '/attachments/' + info._id + '-' + info.name + '.' + crop.left + '.' + crop.top + '.' + crop.width + '.' + crop.height + '.' + info.extension;
483
+ const originalFile = `/attachments/${info._id}-${info.name}.${info.extension}`;
484
+ const tempFile = `${self.uploadfs.getTempPath()}/${self.apos.util.generateId()}.${info.extension}`;
485
+ const croppedFile = `/attachments/${info._id}-${info.name}.${crop.left}.${crop.top}.${crop.width}.${crop.height}.${info.extension}`;
486
+
476
487
  await Promise.promisify(self.uploadfs.copyOut)(originalFile, tempFile);
477
488
  await Promise.promisify(self.uploadfs.copyImageIn)(tempFile, croppedFile, {
478
489
  crop: crop,
479
490
  sizes: self.imageSizes
480
491
  });
481
- crops.push(crop);
492
+
482
493
  await self.db.updateOne({
483
494
  _id: info._id
484
495
  }, {
485
496
  $set: {
486
- crops
497
+ crops: [
498
+ ...crops,
499
+ crop
500
+ ]
487
501
  }
488
502
  });
489
503
  await Promise.promisify(fs.unlink)(tempFile);
490
504
  },
491
505
  sanitizeCrop(crop) {
492
- crop = _.pick(crop, 'top', 'left', 'width', 'height');
493
- crop.top = self.apos.launder.integer(crop.top, 0, 0, 10000);
494
- crop.left = self.apos.launder.integer(crop.left, 0, 0, 10000);
495
- crop.width = self.apos.launder.integer(crop.width, 1, 1, 10000);
496
- crop.height = self.apos.launder.integer(crop.height, 1, 1, 10000);
497
- if (_.keys(crop).length < 4) {
498
- return undefined;
506
+ const neededProps = [ 'top', 'left', 'width', 'height' ];
507
+ const { integer: sanitizeInteger } = self.apos.launder;
508
+
509
+ if (neededProps.some((prop) => !Object.keys(crop).includes(prop))) {
510
+ return null;
499
511
  }
500
- return crop;
512
+
513
+ return {
514
+ top: sanitizeInteger(crop.top, 0, 0, 10000),
515
+ left: sanitizeInteger(crop.left, 0, 0, 10000),
516
+ width: sanitizeInteger(crop.width, 0, 0, 10000),
517
+ height: sanitizeInteger(crop.height, 0, 0, 10000)
518
+ };
501
519
  },
502
520
  // This method return a default icon url if an attachment is missing
503
521
  // to avoid template errors
@@ -669,6 +687,9 @@ module.exports = {
669
687
  if (o.alt) {
670
688
  value._alt = o.alt;
671
689
  }
690
+
691
+ value._isCroppable = self.isCroppable(value);
692
+
672
693
  o[key] = value;
673
694
 
674
695
  // If one of our ancestors has a relationship to the piece that
@@ -678,11 +699,14 @@ module.exports = {
678
699
  // apos.attachment.url with the returned object
679
700
  for (let i = ancestors.length - 1; i >= 0; i--) {
680
701
  const ancestor = ancestors[i];
681
- const fields = ancestor.imagesFields && ancestor.imagesFields[o._id];
682
- if (fields) {
702
+ const ancestorFields = ancestor.attachment &&
703
+ ancestor.attachment._id === value._id && ancestor._fields;
704
+
705
+ if (ancestorFields) {
683
706
  value = _.clone(value);
684
- value._crop = _.pick(fields, 'top', 'left', 'width', 'height');
685
- value._focalPoint = _.pick(fields, 'x', 'y');
707
+ o.attachment = value;
708
+ value._crop = _.pick(ancestorFields, 'width', 'height', 'top', 'left');
709
+ value._focalPoint = _.pick(ancestorFields, 'x', 'y');
686
710
  break;
687
711
  }
688
712
  }
@@ -690,15 +714,31 @@ module.exports = {
690
714
  if (options.annotate) {
691
715
  // Add URLs
692
716
  value._urls = {};
717
+ if (value._crop) {
718
+ value._urls.uncropped = {};
719
+ }
693
720
  if (value.group === 'images') {
694
721
  _.each(self.imageSizes, function (size) {
695
722
  value._urls[size.name] = self.url(value, { size: size.name });
723
+ if (value._crop) {
724
+ value._urls.uncropped[size.name] = self.url(value, {
725
+ size: size.name,
726
+ crop: false
727
+ });
728
+ }
696
729
  });
697
730
  value._urls.original = self.url(value, { size: 'original' });
731
+ if (value._crop) {
732
+ value._urls.uncropped.original = self.url(value, {
733
+ size: 'original',
734
+ crop: false
735
+ });
736
+ }
698
737
  } else {
699
738
  value._url = self.url(value);
700
739
  }
701
740
  }
741
+
702
742
  winners.push(value);
703
743
  }
704
744
  });
@@ -762,14 +802,22 @@ module.exports = {
762
802
  return attachment._focalPoint && typeof attachment._focalPoint.x === 'number';
763
803
  },
764
804
  // If a focal point is present on the attachment, convert it to
765
- // CSS syntax for `background-position`. No trailing `;` is returned.
805
+ // CSS syntax for `object-position`. No trailing `;` is returned.
766
806
  // The coordinates are in percentage terms.
767
- focalPointToBackgroundPosition(attachment) {
807
+ focalPointToObjectPosition(attachment) {
768
808
  if (!self.hasFocalPoint(attachment)) {
769
809
  return 'center center';
770
810
  }
771
811
  const point = self.getFocalPoint(attachment);
772
- return point.x + '% ' + point.y + '%';
812
+ return `${point.x}% ${point.y}%`;
813
+ },
814
+ // Returns the effective attachment width.
815
+ getWidth(attachment) {
816
+ return attachment._crop ? attachment._crop.width : attachment.width;
817
+ },
818
+ // Returns the effective attachment height.
819
+ getHeight(attachment) {
820
+ return attachment._crop ? attachment._crop.height : attachment.height;
773
821
  },
774
822
  // Returns an object with `x` and `y` properties containing the
775
823
  // focal point chosen by the user, as percentages. If there is no
@@ -788,7 +836,9 @@ module.exports = {
788
836
  // Returns true if this type of attachment is croppable.
789
837
  // Available as a template helper.
790
838
  isCroppable(attachment) {
791
- return attachment && self.croppable[self.resolveExtension(attachment.extension)];
839
+ return (attachment &&
840
+ self.croppable[self.resolveExtension(attachment.extension)]) ||
841
+ false;
792
842
  },
793
843
  // Returns true if this type of attachment is sized,
794
844
  // i.e. uploadfs produces versions of it for each configured
@@ -1139,7 +1189,9 @@ module.exports = {
1139
1189
  'all',
1140
1190
  'hasFocalPoint',
1141
1191
  'getFocalPoint',
1142
- 'focalPointToBackgroundPosition',
1192
+ 'focalPointToObjectPosition',
1193
+ 'getWidth',
1194
+ 'getHeight',
1143
1195
  'isCroppable'
1144
1196
  ]
1145
1197
  };
@@ -49,7 +49,8 @@
49
49
  // in your project. However you may find it easier to just use the
50
50
  // `client` option.
51
51
 
52
- const mongo = require('mongodb');
52
+ const mongodbConnect = require('../../../lib/mongodb-connect');
53
+ const escapeHost = require('../../../lib/escape-host');
53
54
 
54
55
  module.exports = {
55
56
  options: {
@@ -119,18 +120,14 @@ module.exports = {
119
120
  if (!self.options.name) {
120
121
  self.options.name = self.apos.shortName;
121
122
  }
122
- uri += self.options.host + ':' + self.options.port + '/' + self.options.name;
123
+ uri += escapeHost(self.options.host) + ':' + self.options.port + '/' + self.options.name;
123
124
  }
124
125
 
125
- const connectOptions = {
126
- useUnifiedTopology: true,
127
- useNewUrlParser: true,
128
- ...Object(self.options.connect || {})
129
- };
130
- self.apos.dbClient = await mongo.MongoClient.connect(uri, connectOptions);
131
- const parsed = new URL(uri);
126
+ self.apos.dbClient = await mongodbConnect(uri, self.options.connect);
132
127
  self.uri = uri;
133
- self.apos.db = self.apos.dbClient.db(parsed.pathname.substr(1));
128
+ const parsed = new URL(uri);
129
+ self.apos.db = self.apos.dbClient.db(parsed.pathname.substring(1));
130
+
134
131
  },
135
132
  async versionCheck() {
136
133
  if (!self.options.versionCheck) {
@@ -1,5 +1,6 @@
1
1
  const _ = require('lodash');
2
2
  const cuid = require('cuid');
3
+ const { SemanticAttributes } = require('@opentelemetry/semantic-conventions');
3
4
 
4
5
  // This module is responsible for managing all of the documents (apostrophe "docs")
5
6
  // in the `aposDocs` mongodb collection.
@@ -29,11 +30,13 @@ module.exports = {
29
30
  },
30
31
  async init(self) {
31
32
  self.managers = {};
33
+ self.contextOperations = [];
32
34
  self.enableBrowserData();
33
35
  await self.enableCollection();
34
36
  self.apos.isNew = await self.detectNew();
35
37
  await self.createIndexes();
36
38
  self.addLegacyMigrations();
39
+ self.addCacheFieldMigration();
37
40
  },
38
41
  restApiRoutes(self) {
39
42
  return {
@@ -198,7 +201,9 @@ module.exports = {
198
201
  }
199
202
  });
200
203
  if (options.setUpdatedAtAndBy !== false) {
201
- doc.updatedAt = new Date();
204
+ const date = new Date();
205
+ doc.updatedAt = date;
206
+ doc.cacheInvalidatedAt = date;
202
207
  doc.updatedBy = req.user ? {
203
208
  _id: req.user._id,
204
209
  title: req.user.title || null,
@@ -268,7 +273,9 @@ module.exports = {
268
273
  // deletes both the published and previous docs.
269
274
  async deleteOtherModes(req, doc, options) {
270
275
  if (doc.aposLocale && doc.aposLocale.endsWith(':draft')) {
271
- return cleanup('published');
276
+ await cleanup('published');
277
+ await self.emit('afterAllModesDeleted', req, doc, options);
278
+ return;
272
279
  }
273
280
  if (doc.aposLocale && doc.aposLocale.endsWith(':published')) {
274
281
  return cleanup('previous');
@@ -436,16 +443,49 @@ module.exports = {
436
443
  // If `options.permissions` is set explicitly to
437
444
  // `false`, permissions checks are bypassed.
438
445
  async insert(req, doc, options) {
439
- options = options || {};
440
- const m = self.getManager(doc.type);
441
- await m.emit('beforeInsert', req, doc, options);
442
- await m.emit('beforeSave', req, doc, options);
443
- await self.insertBody(req, doc, options);
444
- await m.emit('afterInsert', req, doc, options);
445
- await m.emit('afterSave', req, doc, options);
446
- // TODO: Remove `afterLoad` in next major version. Deprecated.
447
- await m.emit('afterLoad', req, [ doc ]);
448
- return doc;
446
+ const telemetry = self.apos.telemetry;
447
+ return telemetry.startActiveSpan(`model:${doc.type}:insert`, async (span) => {
448
+ span.setAttribute(SemanticAttributes.CODE_FUNCTION, 'insert');
449
+ span.setAttribute(SemanticAttributes.CODE_NAMESPACE, self.__meta.name);
450
+ span.setAttribute(telemetry.Attributes.TARGET_NAMESPACE, doc.type);
451
+ span.setAttribute(telemetry.Attributes.TARGET_FUNCTION, 'insert');
452
+
453
+ try {
454
+ options = options || {};
455
+ const m = self.getManager(doc.type);
456
+ await m.emit('beforeInsert', req, doc, options);
457
+ await m.emit('beforeSave', req, doc, options);
458
+
459
+ await telemetry.startActiveSpan(`db:${doc.type}:insert`, async (spanInsert) => {
460
+ spanInsert.setAttribute(SemanticAttributes.CODE_FUNCTION, 'insertBody');
461
+ spanInsert.setAttribute(SemanticAttributes.CODE_NAMESPACE, self.__meta.name);
462
+ spanInsert.setAttribute(telemetry.Attributes.TARGET_NAMESPACE, doc.type);
463
+ spanInsert.setAttribute(telemetry.Attributes.TARGET_FUNCTION, 'insert');
464
+ try {
465
+ const result = await self.insertBody(req, doc, options);
466
+ spanInsert.setStatus({ code: telemetry.api.SpanStatusCode.OK });
467
+ return result;
468
+ } catch (e) {
469
+ telemetry.handleError(spanInsert, e);
470
+ throw e;
471
+ } finally {
472
+ spanInsert.end();
473
+ }
474
+ }, span, {});
475
+
476
+ await m.emit('afterInsert', req, doc, options);
477
+ await m.emit('afterSave', req, doc, options);
478
+ // TODO: Remove `afterLoad` in next major version. Deprecated.
479
+ await m.emit('afterLoad', req, [ doc ]);
480
+ span.setStatus({ code: telemetry.api.SpanStatusCode.OK });
481
+ return doc;
482
+ } catch (err) {
483
+ telemetry.handleError(span, err);
484
+ throw err;
485
+ } finally {
486
+ span.end();
487
+ }
488
+ });
449
489
  },
450
490
  // Updates the given document. If the slug is not
451
491
  // unique it is made unique. `beforeUpdate`, `beforeSave`,
@@ -472,16 +512,49 @@ module.exports = {
472
512
  // If `options.permissions` is set explicitly to
473
513
  // `false`, permissions checks are bypassed.
474
514
  async update(req, doc, options) {
475
- options = options || {};
476
- const m = self.getManager(doc.type);
477
- await m.emit('beforeUpdate', req, doc, options);
478
- await m.emit('beforeSave', req, doc, options);
479
- await self.updateBody(req, doc, options);
480
- await m.emit('afterUpdate', req, doc, options);
481
- await m.emit('afterSave', req, doc, options);
482
- // TODO: Remove `afterLoad` in next major version. Deprecated.
483
- await m.emit('afterLoad', req, [ doc ]);
484
- return doc;
515
+ const telemetry = self.apos.telemetry;
516
+ return telemetry.startActiveSpan(`model:${doc.type}:update`, async (span) => {
517
+ span.setAttribute(SemanticAttributes.CODE_FUNCTION, 'update');
518
+ span.setAttribute(SemanticAttributes.CODE_NAMESPACE, self.__meta.name);
519
+ span.setAttribute(telemetry.Attributes.TARGET_NAMESPACE, doc.type);
520
+ span.setAttribute(telemetry.Attributes.TARGET_FUNCTION, 'update');
521
+
522
+ try {
523
+ options = options || {};
524
+ const m = self.getManager(doc.type);
525
+ await m.emit('beforeUpdate', req, doc, options);
526
+ await m.emit('beforeSave', req, doc, options);
527
+
528
+ await telemetry.startActiveSpan(`db:${doc.type}:update`, async (spanUpdate) => {
529
+ spanUpdate.setAttribute(SemanticAttributes.CODE_FUNCTION, 'updateBody');
530
+ spanUpdate.setAttribute(SemanticAttributes.CODE_NAMESPACE, self.__meta.name);
531
+ spanUpdate.setAttribute(telemetry.Attributes.TARGET_NAMESPACE, doc.type);
532
+ spanUpdate.setAttribute(telemetry.Attributes.TARGET_FUNCTION, 'update');
533
+ try {
534
+ const result = await self.updateBody(req, doc, options);
535
+ spanUpdate.setStatus({ code: telemetry.api.SpanStatusCode.OK });
536
+ return result;
537
+ } catch (e) {
538
+ telemetry.handleError(spanUpdate, e);
539
+ throw e;
540
+ } finally {
541
+ spanUpdate.end();
542
+ }
543
+ }, span, {});
544
+
545
+ await m.emit('afterUpdate', req, doc, options);
546
+ await m.emit('afterSave', req, doc, options);
547
+ // TODO: Remove `afterLoad` in next major version. Deprecated.
548
+ await m.emit('afterLoad', req, [ doc ]);
549
+ span.setStatus({ code: telemetry.api.SpanStatusCode.OK });
550
+ return doc;
551
+ } catch (err) {
552
+ telemetry.handleError(span, err);
553
+ throw err;
554
+ } finally {
555
+ span.end();
556
+ }
557
+ });
485
558
  },
486
559
 
487
560
  // True delete. To place a document in the archive,
@@ -915,9 +988,39 @@ module.exports = {
915
988
  'slug'
916
989
  ];
917
990
  },
991
+ // Add context menu operation to be used in AposDocContextMenu.
992
+ // Expected operation format is:
993
+ // {
994
+ // context: 'update',
995
+ // action: 'someAction',
996
+ // modal: 'ModalComponent',
997
+ // label: 'Context Menu Label'
998
+ // }
999
+ // All properties are required.
1000
+ // The only supported `context` for now is `update`.
1001
+ // `action` is the operation idefntifier and should be globally unique.
1002
+ // Overriding existing custom actions is possible (the last wins).
1003
+ // `modal` is the name of the modal component to be opened.
1004
+ // `label` is the menu label to be shown when expanding the context menu.
1005
+ // Additional optional `modifiers` property is supported - button modifiers
1006
+ // as supported by `AposContextMenu` (e.g. modifiers: [ 'danger' ]).
1007
+ // An optional `manuallyPublished` boolean property is supported - if true
1008
+ // the menu will be shown only for docs which have `autopublish: false` and
1009
+ // `localized: true` options.
1010
+ addContextOperation(moduleName, operation) {
1011
+ self.contextOperations = [
1012
+ ...self.contextOperations
1013
+ .filter(op => op.action !== operation.action),
1014
+ {
1015
+ ...operation,
1016
+ moduleName
1017
+ }
1018
+ ];
1019
+ },
918
1020
  getBrowserData(req) {
919
1021
  return {
920
- action: self.action
1022
+ action: self.action,
1023
+ contextOperations: self.contextOperations
921
1024
  };
922
1025
  },
923
1026
  migrateRelationshipIds(doc) {
@@ -1157,6 +1260,18 @@ module.exports = {
1157
1260
  }
1158
1261
  }
1159
1262
  },
1263
+ // Add the "cacheInvalidatedAt" field to the documents that do not have it yet,
1264
+ // and set it to equal doc.updatedAt.
1265
+ setCacheField() {
1266
+ return self.apos.migration.eachDoc({ cacheInvalidatedAt: { $exists: 0 } }, 5, async doc => {
1267
+ await self.apos.doc.db.updateOne({ _id: doc._id }, {
1268
+ $set: { cacheInvalidatedAt: doc.updatedAt }
1269
+ });
1270
+ });
1271
+ },
1272
+ addCacheFieldMigration() {
1273
+ self.apos.migration.add('add-cache-invalidated-at-field', self.setCacheField);
1274
+ },
1160
1275
  ...require('./lib/legacy-migrations')(self)
1161
1276
  };
1162
1277
  }