apostrophe 3.65.0 → 3.67.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.
package/CHANGELOG.md CHANGED
@@ -1,17 +1,36 @@
1
1
  # Changelog
2
2
 
3
- ## 3.65.0 (2024-05-06)
3
+ ## 3.67.0 (2024-06-12)
4
4
 
5
- ### Adds
5
+ ### Changes
6
+
7
+ * When moving a page, recognize when the slug of a new child
8
+ already contains the new parent's slug and not double it.
9
+ For example, given we have two pages as children of the home page, page A and page B.
10
+ Page A and page B are siblings.
11
+ Page A has the slug `/peer` and page B has the slug `/peer/page`.
12
+ Now we want page B to be the child of page A.
13
+ We will now end up with page B slug as `/peer/page` and not `/peer/peer/page` as before.
14
+
15
+ ### Fixes
16
+
17
+ * Updating schema fields as read-only no longer reset the value when updating the document.
18
+
19
+ ## 3.66.0 (2024-05-15)
6
20
 
7
- * Adds a `publicBundle` option to `@apostrophecms/asset`. When set to `false`, the `ui/src` public asset bundle is not built at all in most cases
21
+ ### Fixes
22
+
23
+ * Autocrop image attachments for referenced documents when replacing an image in the Media Manager.
24
+ * Backports some internal A4 UI logic for metadata to make the new `document-versions` comparison feature compatible with A3.
25
+
26
+ ## 3.65.0 (2024-05-06)
27
+
28
+ * Adds a `publicBundle` option to `@apostrophecms/asset`. When set to `false`, the `ui/src` public asset bundle is not built at all in most cases
8
29
  except as part of the admin UI bundle which depends on it. For use with external front ends such as [apostrophe-astro](https://github.com/apostrophecms/apostrophe-astro).
9
30
  Thanks to Michelin for contributing this feature.
10
31
 
11
32
  ## 3.64.0 (2024-04-18)
12
33
 
13
- ### Adds
14
-
15
34
  ### Fixes
16
35
 
17
36
  * Add the missing `metaType` property to newly inserted widgets.
@@ -42,8 +61,6 @@ that these keys would be present.
42
61
  * `field.help` and `field.htmlHelp` are now correctly translated when displayed in a tooltip.
43
62
  This was also an expectation for the multisite module.
44
63
 
45
- ## UNRELEASED
46
-
47
64
  ### Adds
48
65
 
49
66
  * Add side by side comparison support in AposSchema component.
@@ -194,6 +194,8 @@
194
194
  "hideInNavigation": "Hide in Navigation",
195
195
  "home": "Home",
196
196
  "image": "Image",
197
+ "imageOnReplaceAutocropMessage": "The replaced image has been autocropped for \"{{ title }}\".",
198
+ "imageOnReplaceAutocropError": "An error occurred while autocropping the replaced image for {{ title }}",
197
199
  "imageDescription": "Add an inline image",
198
200
  "imageFile": "Image File",
199
201
  "imagePlaceholder": "Image placeholder",
@@ -122,6 +122,19 @@ module.exports = {
122
122
  }
123
123
  }
124
124
  },
125
+ handlers(self) {
126
+ return {
127
+ beforeUpdate: {
128
+ // Ensure the crop fields are updated on publishing an image
129
+ async autoCropRelations(req, piece, options) {
130
+ if (piece.aposMode !== 'published') {
131
+ return;
132
+ }
133
+ await self.updateImageCropRelationships(req, piece);
134
+ }
135
+ }
136
+ };
137
+ },
125
138
  commands(self) {
126
139
  return {
127
140
  remove: [
@@ -254,26 +267,7 @@ module.exports = {
254
267
  return withinOnePercent(testRatio, configuredRatio);
255
268
  }
256
269
  async function autocrop(image, widgetOptions) {
257
- const nativeRatio = image.attachment.width / image.attachment.height;
258
- const configuredRatio = widgetOptions.aspectRatio[0] / widgetOptions.aspectRatio[1];
259
- let crop;
260
- if (configuredRatio >= nativeRatio) {
261
- const height = image.attachment.width / configuredRatio;
262
- crop = {
263
- top: Math.floor((image.attachment.height - height) / 2),
264
- left: 0,
265
- width: image.attachment.width,
266
- height: Math.floor(height)
267
- };
268
- } else {
269
- const width = image.attachment.height * configuredRatio;
270
- crop = {
271
- top: 0,
272
- left: Math.floor((image.attachment.width - width) / 2),
273
- width: Math.floor(width),
274
- height: image.attachment.height
275
- };
276
- }
270
+ const crop = self.calculateAutocrop(image, widgetOptions.aspectRatio);
277
271
  await self.apos.attachment.crop(req, image.attachment._id, crop);
278
272
  image._fields = crop;
279
273
  // For ease of testing send back the cropped image URLs now
@@ -457,7 +451,169 @@ module.exports = {
457
451
  name: 'dummy',
458
452
  width: 0
459
453
  }).name;
454
+ },
455
+ // Given an image piece and a aspect ratio array, calculate the crop
456
+ // that would be applied to the image when autocropping it to the
457
+ // given aspect ratio.
458
+ calculateAutocrop(image, aspecRatio) {
459
+ let crop;
460
+ const configuredRatio = aspecRatio[0] / aspecRatio[1];
461
+ const nativeRatio = image.attachment.width / image.attachment.height;
462
+
463
+ if (configuredRatio >= nativeRatio) {
464
+ const height = image.attachment.width / configuredRatio;
465
+ crop = {
466
+ top: Math.floor((image.attachment.height - height) / 2),
467
+ left: 0,
468
+ width: image.attachment.width,
469
+ height: Math.floor(height)
470
+ };
471
+ } else {
472
+ const width = image.attachment.height * configuredRatio;
473
+ crop = {
474
+ top: 0,
475
+ left: Math.floor((image.attachment.width - width) / 2),
476
+ width: Math.floor(width),
477
+ height: image.attachment.height
478
+ };
479
+ }
480
+
481
+ return crop;
482
+ },
483
+ async updateImageCropRelationships(req, piece) {
484
+ if (!piece.relatedReverseIds?.length) {
485
+ return;
486
+ }
487
+ if (
488
+ !piece._prevAttachmentId ||
489
+ !piece.attachment ||
490
+ piece._prevAttachmentId === piece.attachment._id
491
+ ) {
492
+ return;
493
+ }
494
+ const croppedIndex = {};
495
+ for (const docId of piece.relatedReverseIds) {
496
+ await self.updateImageCropsForRelationship(req, docId, piece, croppedIndex);
497
+ }
498
+ },
499
+ // This handler operates on all documents of a given aposDocId. The `piece`
500
+ // argument is the image piece that has been updated.
501
+ // - Auto re-crop the image, using the same width/height ratio if the
502
+ // image has been cropped.
503
+ // - Remove any existing focal point data.
504
+ //
505
+ // `croppedIndex` is used to avoid re-cropping the same image when updating multiple
506
+ // documents. It's internally mutated by the handler.
507
+ async updateImageCropsForRelationship(req, aposDocId, piece, croppedIndex = {}) {
508
+ const dbDocs = await self.apos.doc.db.find({
509
+ aposDocId
510
+ }).toArray();
511
+ const changeSets = dbDocs.flatMap(doc => getDocRelations(doc, piece));
512
+ for (const changeSet of changeSets) {
513
+ try {
514
+ const cropFields = await autocrop(changeSet.image, changeSet.cropFields, croppedIndex);
515
+ const $set = {
516
+ [changeSet.docDotPath]: cropFields
517
+ };
518
+ self.logDebug(req, 'replace-autocrop', {
519
+ docId: changeSet.docId,
520
+ docTitle: changeSet.doc.title,
521
+ imageId: piece._id,
522
+ imageTitle: piece.title,
523
+ $set
524
+ });
525
+ await self.apos.doc.db.updateOne({
526
+ _id: changeSet.docId
527
+ }, {
528
+ $set
529
+ });
530
+ } catch (e) {
531
+ self.apos.util.error(e);
532
+ await self.apos.notify(
533
+ req,
534
+ req.t(
535
+ 'apostrophe:imageOnReplaceAutocropError',
536
+ {
537
+ title: changeSet.doc.title
538
+ }
539
+ ),
540
+ {
541
+ type: 'danger',
542
+ dismiss: true
543
+ }
544
+ );
545
+ }
546
+ }
547
+
548
+ if (changeSets.length) {
549
+ return self.apos.notify(
550
+ req,
551
+ req.t(
552
+ 'apostrophe:imageOnReplaceAutocropMessage',
553
+ {
554
+ title: changeSets[0].doc.title
555
+ }
556
+ ),
557
+ {
558
+ type: 'success',
559
+ dismiss: true
560
+ }
561
+ );
562
+ }
563
+
564
+ function getDocRelations(doc, imagePiece) {
565
+ const results = [];
566
+ self.apos.doc.walk(doc, function (o, key, value, dotPath, ancestors) {
567
+ if (!value || typeof value !== 'object' || !Array.isArray(value.imageIds)) {
568
+ return;
569
+ }
570
+ if (!value.imageIds.includes(imagePiece.aposDocId)) {
571
+ return;
572
+ }
573
+ if (!value.imageFields?.[imagePiece.aposDocId]) {
574
+ return;
575
+ }
576
+ const cropFields = value.imageFields[imagePiece.aposDocId];
577
+ // We check for crop OR focal point data (because
578
+ // focal point has to be reset when the image is replaced).
579
+ if (!cropFields.width && typeof cropFields.x !== 'number') {
580
+ return;
581
+ }
582
+ results.push({
583
+ docId: doc._id,
584
+ docDotPath: `${dotPath}.imageFields.${imagePiece.aposDocId}`,
585
+ doc,
586
+ cropFields,
587
+ image: imagePiece,
588
+ value
589
+ });
590
+ });
591
+ return results;
592
+ }
593
+
594
+ async function autocrop(image, oldFields, croppedIndex) {
595
+ let crop = { ...oldFields };
596
+ if (crop.width) {
597
+ crop = self.calculateAutocrop(image, [ crop.width, crop.height ]);
598
+ }
599
+
600
+ const hash = cropHash(image, crop);
601
+ if (crop.width && !croppedIndex[hash]) {
602
+ await self.apos.attachment.crop(req, image.attachment._id, crop);
603
+ croppedIndex[hash] = true;
604
+ }
605
+ return {
606
+ ...crop,
607
+ x: null,
608
+ y: null
609
+ };
610
+ }
611
+
612
+ function cropHash(image, crop) {
613
+ return `${image.attachment._id}-${crop.top}-${crop.left}-${crop.width}-${crop.height}`;
614
+ }
460
615
  }
616
+
461
617
  };
462
618
  },
463
619
  extendMethods(self) {
@@ -515,6 +671,15 @@ module.exports = {
515
671
  }
516
672
  return [ self.apos.launder.integer(a[0]), self.apos.launder.integer(a[1]) ];
517
673
  }
674
+ },
675
+ prevAttachment: {
676
+ after(results) {
677
+ for (const result of results) {
678
+ if (result.attachment) {
679
+ result._prevAttachmentId = result.attachment._id;
680
+ }
681
+ }
682
+ }
518
683
  }
519
684
  }
520
685
  };
@@ -1275,11 +1275,14 @@ database.`);
1275
1275
  if (parent._id !== oldParent._id) {
1276
1276
  const matchOldParentSlugPrefix = new RegExp('^' + self.apos.util.regExpQuote(self.apos.util.addSlashIfNeeded(oldParent.slug)));
1277
1277
  if (moved.slug.match(matchOldParentSlugPrefix)) {
1278
- let slugStem = parent.slug;
1279
- if (slugStem !== '/') {
1280
- slugStem += '/';
1281
- }
1282
- moved.slug = moved.slug.replace(matchOldParentSlugPrefix, self.apos.util.addSlashIfNeeded(parent.slug));
1278
+ const movedSlugCandidate = moved.slug
1279
+ .split('/')
1280
+ .slice(0, -1)
1281
+ .join('/');
1282
+
1283
+ moved.slug = parent.slug.endsWith(movedSlugCandidate)
1284
+ ? parent.slug.replace(movedSlugCandidate, '').concat(moved.slug)
1285
+ : moved.slug.replace(matchOldParentSlugPrefix, self.apos.util.addSlashIfNeeded(parent.slug));
1283
1286
  changed.push({
1284
1287
  _id: moved._id,
1285
1288
  slug: moved.slug
@@ -2300,7 +2303,7 @@ database.`);
2300
2303
  // Apostrophe queries used to fetch Apostrophe pages
2301
2304
  // consult this method.
2302
2305
  getBaseUrl(req) {
2303
- const hostname = self.apos.i18n.locales[req.locale].hostname;
2306
+ const hostname = self.apos.i18n.locales[req.locale]?.hostname;
2304
2307
 
2305
2308
  return hostname
2306
2309
  ? `${req.protocol}://${hostname}`
@@ -441,6 +441,13 @@ module.exports = {
441
441
  });
442
442
  },
443
443
 
444
+ // Wrapper around isEqual method to get modified fields between two documents
445
+ // instead of just getting a boolean, it will return an array of the modified fields
446
+
447
+ getChanges(req, schema, one, two) {
448
+ return self.isEqual(req, schema, one, two, { getChanges: true });
449
+ },
450
+
444
451
  // Compare two objects and return true only if their schema fields are equal.
445
452
  //
446
453
  // Note that for relationship fields this comparison is based on the idsStorage
@@ -451,22 +458,40 @@ module.exports = {
451
458
  // This method is invoked by the doc module to compare draft and published
452
459
  // documents and set the modified property of the draft, just before updating the
453
460
  // published version.
461
+ //
462
+ // When passing the option `getChange: true` it'll return an array of changed fields
463
+ // in this case the method won't short circuit by directly returning false
464
+ // when finding a changed field
454
465
 
455
- isEqual(req, schema, one, two) {
466
+ isEqual(req, schema, one, two, options = {}) {
467
+ const changedFields = [];
456
468
  for (const field of schema) {
457
469
  const fieldType = self.fieldTypes[field.type];
458
- if (!fieldType.isEqual) {
459
- if ((!_.isEqual(one[field.name], two[field.name])) &&
460
- !((one[field.name] == null) && (two[field.name] == null))) {
461
- return false;
462
- }
463
- } else {
470
+
471
+ if (fieldType.isEqual) {
464
472
  if (!fieldType.isEqual(req, field, one, two)) {
473
+ if (options.getChanges) {
474
+ changedFields.push(field.name);
475
+ } else {
476
+ return false;
477
+ }
478
+ }
479
+ continue;
480
+ }
481
+
482
+ if (
483
+ !_.isEqual(one[field.name], two[field.name]) &&
484
+ !((one[field.name] == null) && (two[field.name] == null))
485
+ ) {
486
+ if (options.getChanges) {
487
+ changedFields.push(field.name);
488
+ } else {
465
489
  return false;
466
490
  }
467
491
  }
468
492
  }
469
- return true;
493
+
494
+ return options.getChanges ? changedFields : true;
470
495
  },
471
496
 
472
497
  // Index the object's fields for participation in Apostrophe search unless
@@ -786,8 +786,12 @@ module.exports = (self) => {
786
786
  }
787
787
  const errors = [];
788
788
  for (const datum of data) {
789
- const result = {};
790
- result._id = self.apos.launder.id(datum._id) || self.apos.util.generateId();
789
+ const _id = self.apos.launder.id(datum._id) || self.apos.util.generateId();
790
+ const [ found ] = destination[field.name]?.filter?.(item => item._id === _id) || [];
791
+ const result = {
792
+ ...(found || {}),
793
+ _id
794
+ };
791
795
  result.metaType = 'arrayItem';
792
796
  result.scopedArrayName = field.scopedArrayName;
793
797
  try {
@@ -859,7 +863,7 @@ module.exports = (self) => {
859
863
  if (one[field.name].length !== two[field.name].length) {
860
864
  return false;
861
865
  }
862
- for (let i = 0; (i < one.length); i++) {
866
+ for (let i = 0; (i < one[field.name].length); i++) {
863
867
  if (!self.isEqual(req, field.schema, one[field.name][i], two[field.name][i])) {
864
868
  return false;
865
869
  }
@@ -876,6 +880,7 @@ module.exports = (self) => {
876
880
  const schema = field.schema;
877
881
  const errors = [];
878
882
  const result = {
883
+ ...(destination[field.name] || {}),
879
884
  _id: self.apos.launder.id(data && data._id) || self.apos.util.generateId()
880
885
  };
881
886
  if (data == null || typeof data !== 'object' || Array.isArray(data)) {
@@ -1027,7 +1032,7 @@ module.exports = (self) => {
1027
1032
  const result = results.find(doc => (doc._id === item._id));
1028
1033
  if (result) {
1029
1034
  if (field.schema) {
1030
- result._fields = {};
1035
+ result._fields = { ...(destination[field.name]?.find?.(doc => doc._id === item._id)?._fields || {}) };
1031
1036
  if (item && ((typeof item._fields === 'object'))) {
1032
1037
  await self.convert(req, field.schema, item._fields || {}, result._fields);
1033
1038
  }
@@ -40,6 +40,7 @@
40
40
  <component
41
41
  v-show="displayComponent(field)"
42
42
  v-model="fieldState[field.name]"
43
+ :class="{ 'apos-field__wrapper--highlight': highlight(field.name) }"
43
44
  :is="fieldComponentMap[field.type]"
44
45
  :following-values="followingValues[field.name]"
45
46
  :condition-met="conditionalFields?.if[field.name]"
@@ -59,6 +60,7 @@
59
60
  v-if="hasCompareMeta"
60
61
  v-show="displayComponent(field)"
61
62
  v-model="compareMetaState[field.name]"
63
+ :class="{ 'apos-field__wrapper--highlight': highlight(field.name) }"
62
64
  :is="fieldComponentMap[field.type]"
63
65
  :following-values="followingValues[field.name]"
64
66
  :condition-met="conditionalFields?.if[field.name]"
@@ -139,4 +141,9 @@ export default {
139
141
  }
140
142
  }
141
143
  }
144
+
145
+ :deep(.apos-field__wrapper--highlight > .apos-field) {
146
+ padding: 10px;
147
+ background: var(--a-highlight);
148
+ }
142
149
  </style>
@@ -328,6 +328,9 @@ export default {
328
328
  },
329
329
  onUpdateDocData(data) {
330
330
  this.$emit('update-doc-data', data);
331
+ },
332
+ highlight(fieldName) {
333
+ return this.meta[fieldName]?.['@apostrophecms/schema:highlight'];
331
334
  }
332
335
  }
333
336
  };
@@ -8,6 +8,7 @@
8
8
  --a-warning-dark: #a75c07;
9
9
  --a-warning-fade: #ffce0030;
10
10
  --a-progress-bg: #2c354d;
11
+ --a-highlight: #fff6e8;
11
12
 
12
13
  --a-danger-button-hover: #c00717;
13
14
  --a-danger-button-active: #a10000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.65.0",
3
+ "version": "3.67.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/test/images.js CHANGED
@@ -1,9 +1,12 @@
1
1
  const t = require('../test-lib/test.js');
2
- const assert = require('assert');
2
+ const assert = require('assert/strict');
3
3
  const fs = require('fs');
4
+ const fsp = require('fs/promises');
4
5
  const path = require('path');
5
6
  const FormData = require('form-data');
6
7
 
8
+ const publicFolderPath = path.join(process.cwd(), 'test/public');
9
+
7
10
  describe('Images', function() {
8
11
 
9
12
  let apos;
@@ -73,7 +76,8 @@ describe('Images', function() {
73
76
  // Test pieces.list()
74
77
  it('should clean up any existing images for testing', async function() {
75
78
  try {
76
- const response = await apos.doc.db.deleteMany({ type: '@apostrophecms/image' }
79
+ const response = await apos.doc.db.deleteMany(
80
+ { type: '@apostrophecms/image' }
77
81
  );
78
82
  assert(response.result.ok === 1);
79
83
  } catch (e) {
@@ -263,6 +267,140 @@ describe('Images', function() {
263
267
  assert.strictEqual(fields.height, 225);
264
268
  });
265
269
 
270
+ it('should update crop fields when replacing an image attachment', async function () {
271
+ await t.destroy(apos);
272
+ await fsp.rm(path.join(publicFolderPath, 'uploads'), {
273
+ recursive: true,
274
+ force: true
275
+ });
276
+ apos = await t.create({
277
+ root: module,
278
+ modules: {
279
+ 'test-piece': {
280
+ extend: '@apostrophecms/piece-type',
281
+ fields: {
282
+ add: {
283
+ main: {
284
+ type: 'area',
285
+ options: {
286
+ widgets: {
287
+ '@apostrophecms/image': {
288
+ aspectRatio: [ 3, 2 ]
289
+ }
290
+ }
291
+ }
292
+ }
293
+ }
294
+ }
295
+ }
296
+ }
297
+ });
298
+ await insertUser({
299
+ title: 'admin',
300
+ username: 'admin',
301
+ password: 'admin',
302
+ email: 'ad@min.com',
303
+ role: 'admin'
304
+ });
305
+
306
+ // Upload an image (landscape), crop it, insert a piece with the cropped image
307
+ jar = await login('admin');
308
+ const formData = new FormData();
309
+ const stream = fs.createReadStream(
310
+ path.join(apos.rootDir, '/public/test-image-landscape.jpg')
311
+ );
312
+ formData.append('file', stream);
313
+ const attachment = await apos.http.post('/api/v1/@apostrophecms/attachment/upload', {
314
+ body: formData,
315
+ jar
316
+ });
317
+ stream.close();
318
+ image = await apos.http.post('/api/v1/@apostrophecms/image', {
319
+ body: {
320
+ title: 'Test Image Landscape',
321
+ attachment
322
+ },
323
+ jar
324
+ });
325
+ assert.equal(image._prevAttachmentId, attachment._id);
326
+ const crop = await apos.http.post('/api/v1/@apostrophecms/image/autocrop', {
327
+ body: {
328
+ relationship: [ image ],
329
+ widgetOptions: {
330
+ aspectRatio: [ 3, 2 ]
331
+ }
332
+ },
333
+ jar
334
+ });
335
+ let piece = await apos.http.post('/api/v1/test-piece', {
336
+ jar,
337
+ body: {
338
+ title: 'Test Piece',
339
+ slug: 'test-piece',
340
+ type: 'test-piece',
341
+ main: {
342
+ metaType: 'area',
343
+ items: [
344
+ {
345
+ type: '@apostrophecms/image',
346
+ metaType: 'widget',
347
+ imageIds: [ image.aposDocId ],
348
+ imageFields: {
349
+ [image.aposDocId]: crop.relationship[0]._fields
350
+ },
351
+ _image: [ crop.relationship[0] ]
352
+ }
353
+ ]
354
+ }
355
+ }
356
+ });
357
+
358
+ let imageFields = piece.main.items[0].imageFields[image.aposDocId];
359
+ assert(imageFields, 'imageFields should be present when creating the piece');
360
+ assert.equal(imageFields.width / imageFields.height, 3 / 2, 'aspect ratio should be 3:2');
361
+ await fsp.access(
362
+ path.join(
363
+ publicFolderPath,
364
+ attachment._urls.original.replace(
365
+ '.jpg',
366
+ `.${imageFields.left}.${imageFields.top}.${imageFields.width}.${imageFields.height}.jpg`
367
+ )
368
+ )
369
+ );
370
+
371
+ // Replace the image with portrait orientation, verify that the aspect ratio is preserved
372
+ const formDataPortrait = new FormData();
373
+ const streamPortrait = fs.createReadStream(path.join(apos.rootDir, '/public/test-image.jpg'));
374
+ formDataPortrait.append('file', streamPortrait);
375
+ const attachmentPortrait = await apos.http.post('/api/v1/@apostrophecms/attachment/upload', {
376
+ body: formDataPortrait,
377
+ jar
378
+ });
379
+ image = await apos.http.put(`/api/v1/@apostrophecms/image/${image._id}`, {
380
+ body: {
381
+ title: 'Test Image Portrait',
382
+ attachment: attachmentPortrait
383
+ },
384
+ jar
385
+ });
386
+ streamPortrait.close();
387
+ piece = await apos.http.get(`/api/v1/test-piece/${piece._id}`, {
388
+ jar
389
+ });
390
+ imageFields = piece.main.items[0].imageFields[image.aposDocId];
391
+ assert(imageFields, 'imageFields should be present after replacing the image attachment');
392
+ assert.equal(imageFields.width / imageFields.height, 3 / 2, 'aspect ratio should be 3:2');
393
+ await fsp.access(
394
+ path.join(
395
+ publicFolderPath,
396
+ attachmentPortrait._urls.original.replace(
397
+ '.jpg',
398
+ `.${imageFields.left}.${imageFields.top}.${imageFields.width}.${imageFields.height}.jpg`
399
+ )
400
+ )
401
+ );
402
+ });
403
+
266
404
  async function insertUser(info) {
267
405
  const user = apos.user.newInstance();
268
406
  assert(user);
package/test/pages.js CHANGED
@@ -457,6 +457,233 @@ describe('Pages', function() {
457
457
  assert.strictEqual(page.path, `${homeId.replace(':en:published', '')}/another-parent/parent/sibling`);
458
458
  });
459
459
 
460
+ describe('move peer pages', function () {
461
+ this.afterEach(async function() {
462
+ await apos.doc.db.deleteMany({
463
+ type: 'test-page'
464
+ });
465
+ });
466
+
467
+ it('moving /bar under /foo should wind up with /foo/bar', async function() {
468
+ const fooPage = await apos.page.insert(
469
+ apos.task.getReq(),
470
+ '_home',
471
+ 'lastChild',
472
+ {
473
+ slug: '/foo',
474
+ visibility: 'public',
475
+ type: 'test-page',
476
+ title: 'Foo Page'
477
+ }
478
+ );
479
+ const barPage = await apos.page.insert(
480
+ apos.task.getReq(),
481
+ '_home',
482
+ 'lastChild',
483
+ {
484
+ slug: '/bar',
485
+ visibility: 'public',
486
+ type: 'test-page',
487
+ title: 'Bar Page'
488
+ }
489
+ );
490
+ const childPage = await apos.page.insert(
491
+ apos.task.getReq(),
492
+ barPage._id,
493
+ 'lastChild',
494
+ {
495
+ slug: '/bar/child',
496
+ visibility: 'public',
497
+ type: 'test-page',
498
+ title: 'Child Page'
499
+ }
500
+ );
501
+
502
+ await apos.page.move(
503
+ apos.task.getReq(),
504
+ barPage._id,
505
+ fooPage._id,
506
+ 'lastChild'
507
+ );
508
+
509
+ const movedPage = await apos.page.find(apos.task.getAnonReq(), { _id: barPage._id }).toObject();
510
+ const movedChildPage = await apos.page.find(apos.task.getAnonReq(), { _id: childPage._id }).toObject();
511
+
512
+ const actual = {
513
+ bar: {
514
+ path: movedPage.path,
515
+ rank: movedPage.rank,
516
+ slug: movedPage.slug
517
+ },
518
+ child: {
519
+ path: movedChildPage.path,
520
+ rank: movedChildPage.rank,
521
+ slug: movedChildPage.slug
522
+ }
523
+ };
524
+ const expected = {
525
+ bar: {
526
+ path: fooPage.path.concat('/', barPage.aposDocId),
527
+ rank: 0,
528
+ slug: '/foo/bar'
529
+ },
530
+ child: {
531
+ path: movedPage.path.concat('/', childPage.aposDocId),
532
+ rank: 0,
533
+ slug: '/foo/bar/child'
534
+ }
535
+ };
536
+
537
+ assert.deepEqual(actual, expected);
538
+ });
539
+
540
+ it('moving peer /foo/bar under /foo should wind up with /foo/bar', async function() {
541
+ const fooPage = await apos.page.insert(
542
+ apos.task.getReq(),
543
+ '_home',
544
+ 'lastChild',
545
+ {
546
+ slug: '/foo',
547
+ visibility: 'public',
548
+ type: 'test-page',
549
+ title: 'Foo Page'
550
+ }
551
+ );
552
+ const barPage = await apos.page.insert(
553
+ apos.task.getReq(),
554
+ '_home',
555
+ 'lastChild',
556
+ {
557
+ slug: '/foo/bar',
558
+ visibility: 'public',
559
+ type: 'test-page',
560
+ title: 'Bar Page'
561
+ }
562
+ );
563
+ const childPage = await apos.page.insert(
564
+ apos.task.getReq(),
565
+ barPage._id,
566
+ 'lastChild',
567
+ {
568
+ slug: '/foo/bar/child',
569
+ visibility: 'public',
570
+ type: 'test-page',
571
+ title: 'Child Page'
572
+ }
573
+ );
574
+
575
+ await apos.page.move(
576
+ apos.task.getReq(),
577
+ barPage._id,
578
+ fooPage._id,
579
+ 'lastChild'
580
+ );
581
+
582
+ const movedPage = await apos.page.find(apos.task.getAnonReq(), { _id: barPage._id }).toObject();
583
+ const movedChildPage = await apos.page.find(apos.task.getAnonReq(), { _id: childPage._id }).toObject();
584
+
585
+ const actual = {
586
+ bar: {
587
+ path: movedPage.path,
588
+ rank: movedPage.rank,
589
+ slug: movedPage.slug
590
+ },
591
+ child: {
592
+ path: movedChildPage.path,
593
+ rank: movedChildPage.rank,
594
+ slug: movedChildPage.slug
595
+ }
596
+ };
597
+ const expected = {
598
+ bar: {
599
+ path: fooPage.path.concat('/', barPage.aposDocId),
600
+ rank: 0,
601
+ slug: '/foo/bar'
602
+ },
603
+ child: {
604
+ path: movedPage.path.concat('/', childPage.aposDocId),
605
+ rank: 0,
606
+ slug: '/foo/bar/child'
607
+ }
608
+ };
609
+
610
+ assert.deepEqual(actual, expected);
611
+ });
612
+
613
+ it('moving /foobar under /foo should wind up with /foo/foobar', async function() {
614
+ const fooPage = await apos.page.insert(
615
+ apos.task.getReq(),
616
+ '_home',
617
+ 'lastChild',
618
+ {
619
+ slug: '/foo',
620
+ visibility: 'public',
621
+ type: 'test-page',
622
+ title: 'Foo Page'
623
+ }
624
+ );
625
+ const foobarPage = await apos.page.insert(
626
+ apos.task.getReq(),
627
+ '_home',
628
+ 'lastChild',
629
+ {
630
+ slug: '/foobar',
631
+ visibility: 'public',
632
+ type: 'test-page',
633
+ title: 'Foobar Page'
634
+ }
635
+ );
636
+ const childPage = await apos.page.insert(
637
+ apos.task.getReq(),
638
+ foobarPage._id,
639
+ 'lastChild',
640
+ {
641
+ slug: '/foobar/child',
642
+ visibility: 'public',
643
+ type: 'test-page',
644
+ title: 'Child Page'
645
+ }
646
+ );
647
+
648
+ await apos.page.move(
649
+ apos.task.getReq(),
650
+ foobarPage._id,
651
+ fooPage._id,
652
+ 'lastChild'
653
+ );
654
+
655
+ const movedPage = await apos.page.find(apos.task.getAnonReq(), { _id: foobarPage._id }).toObject();
656
+ const movedChildPage = await apos.page.find(apos.task.getAnonReq(), { _id: childPage._id }).toObject();
657
+
658
+ const actual = {
659
+ foobar: {
660
+ path: movedPage.path,
661
+ rank: movedPage.rank,
662
+ slug: movedPage.slug
663
+ },
664
+ child: {
665
+ path: movedChildPage.path,
666
+ rank: movedChildPage.rank,
667
+ slug: movedChildPage.slug
668
+ }
669
+ };
670
+ const expected = {
671
+ foobar: {
672
+ path: fooPage.path.concat('/', foobarPage.aposDocId),
673
+ rank: 0,
674
+ slug: '/foo/foobar'
675
+ },
676
+ child: {
677
+ path: movedPage.path.concat('/', childPage.aposDocId),
678
+ rank: 0,
679
+ slug: '/foo/foobar/child'
680
+ }
681
+ };
682
+
683
+ assert.deepEqual(actual, expected);
684
+ });
685
+ });
686
+
460
687
  it('inferred page relationships are correct', async function() {
461
688
  const req = apos.task.getReq();
462
689
  const pages = await apos.page.find(req, {}).toArray();
package/test/schemas.js CHANGED
@@ -288,6 +288,44 @@ describe('Schemas', function() {
288
288
  }
289
289
  };
290
290
  }
291
+ },
292
+ article: {
293
+ extend: '@apostrophecms/piece-type',
294
+ options: {
295
+ alias: 'article'
296
+ },
297
+ fields(self) {
298
+ return {
299
+ add: {
300
+ title: {
301
+ label: '',
302
+ type: 'string',
303
+ required: true
304
+ },
305
+ area: {
306
+ label: 'Area',
307
+ type: 'area',
308
+ options: {
309
+ widgets: {
310
+ '@apostrophecms/rich-text': {}
311
+ }
312
+ }
313
+ },
314
+ array: {
315
+ label: 'Array',
316
+ type: 'array',
317
+ fields: {
318
+ add: {
319
+ arrayTitle: {
320
+ label: 'Array Title',
321
+ type: 'string'
322
+ }
323
+ }
324
+ }
325
+ }
326
+ }
327
+ };
328
+ }
291
329
  }
292
330
  }
293
331
  });
@@ -2263,6 +2301,290 @@ describe('Schemas', function() {
2263
2301
  assert(output.goodValue === '2022-05-09T22:36:00.000Z');
2264
2302
  });
2265
2303
 
2304
+ it('should compare two document properly with the method getChanges', async function() {
2305
+ const req = apos.task.getReq();
2306
+ const instance = apos.article.newInstance();
2307
+ const article1 = {
2308
+ ...instance,
2309
+ title: 'article 1',
2310
+ area: {
2311
+ _id: 'clrth36680007mnmd3jj7cta0',
2312
+ items: [
2313
+ {
2314
+ _id: 'clt79l48g001h2061j5ihxjkv',
2315
+ metaType: 'widget',
2316
+ type: '@apostrophecms/rich-text',
2317
+ aposPlaceholder: false,
2318
+ content: '<p>Some text here.</p>',
2319
+ permalinkIds: [],
2320
+ imageIds: []
2321
+ }
2322
+ ],
2323
+ metaType: 'area'
2324
+ },
2325
+ array: [
2326
+ {
2327
+ _id: 'clt79llm800242061v4dx9kv5',
2328
+ metaType: 'arrayItem',
2329
+ scopedArrayName: 'doc.article.array',
2330
+ arrayTitle: 'array title 1'
2331
+ },
2332
+ {
2333
+ _id: 'clt79llm800242061v4d47364',
2334
+ metaType: 'arrayItem',
2335
+ scopedArrayName: 'doc.article.array',
2336
+ arrayTitle: 'array title 2'
2337
+ }
2338
+ ]
2339
+ };
2340
+ const article2 = {
2341
+ ...article1,
2342
+ title: 'article 2'
2343
+ };
2344
+ const article3 = {
2345
+ ...instance,
2346
+ title: 'article 3',
2347
+
2348
+ area: {
2349
+ _id: 'clrth36680007mnmd3jj7cta0',
2350
+ items: [
2351
+ {
2352
+ _id: 'clt79l48g001h2061j5ihxjkv',
2353
+ metaType: 'widget',
2354
+ type: '@apostrophecms/rich-text',
2355
+ aposPlaceholder: false,
2356
+ content: '<p>Some text here changed.</p>',
2357
+ permalinkIds: [],
2358
+ imageIds: []
2359
+ }
2360
+ ],
2361
+ metaType: 'area'
2362
+ },
2363
+ array: [
2364
+ {
2365
+ _id: 'clt79llm800242061v4dx9kv5',
2366
+ metaType: 'arrayItem',
2367
+ scopedArrayName: 'doc.article.array',
2368
+ arrayTitle: 'array title 1 changed'
2369
+ },
2370
+ {
2371
+ _id: 'clt79llm800242061v4d47364',
2372
+ metaType: 'arrayItem',
2373
+ scopedArrayName: 'doc.article.array',
2374
+ arrayTitle: 'array title 2'
2375
+ }
2376
+ ]
2377
+ };
2378
+
2379
+ await apos.article.insert(req, article1);
2380
+ await apos.article.insert(req, article2);
2381
+ await apos.article.insert(req, article3);
2382
+
2383
+ const art1 = await apos.doc.db.findOne({ title: 'article 1' });
2384
+ const art2 = await apos.doc.db.findOne({ title: 'article 2' });
2385
+ const art3 = await apos.doc.db.findOne({ title: 'article 3' });
2386
+
2387
+ const changes11 = apos.schema.getChanges(req, apos.article.schema, art1, art1);
2388
+ const changes12 = apos.schema.getChanges(req, apos.article.schema, art1, art2);
2389
+ const changes23 = apos.schema.getChanges(req, apos.article.schema, art2, art3);
2390
+ const actual = {
2391
+ changes11,
2392
+ changes12,
2393
+ changes23
2394
+ };
2395
+ const expected = {
2396
+ changes11: [],
2397
+ changes12: [ 'title', 'slug' ],
2398
+ changes23: [ 'title', 'slug', 'area', 'array' ]
2399
+ };
2400
+
2401
+ assert.deepEqual(actual, expected);
2402
+ });
2403
+
2404
+ describe('field.readOnly with default value', function() {
2405
+ const givenSchema = [
2406
+ {
2407
+ name: 'title',
2408
+ type: 'string'
2409
+ },
2410
+ {
2411
+ name: 'array',
2412
+ type: 'array',
2413
+ schema: [
2414
+ {
2415
+ name: 'planet',
2416
+ type: 'string',
2417
+ def: 'Earth',
2418
+ readOnly: true
2419
+ },
2420
+ {
2421
+ name: 'moon',
2422
+ type: 'string'
2423
+ }
2424
+ ]
2425
+ },
2426
+ {
2427
+ name: 'object',
2428
+ type: 'object',
2429
+ schema: [
2430
+ {
2431
+ name: 'planet',
2432
+ type: 'string',
2433
+ def: 'Earth',
2434
+ readOnly: true
2435
+ },
2436
+ {
2437
+ name: 'moon',
2438
+ type: 'string'
2439
+ }
2440
+ ]
2441
+ },
2442
+ {
2443
+ name: '_relationship',
2444
+ type: 'relationship',
2445
+ limit: 1,
2446
+ withType: '@apostrophecms/any-page-type',
2447
+ label: 'Page Title',
2448
+ idsStorage: 'pageId',
2449
+ schema: [
2450
+ {
2451
+ name: 'planet',
2452
+ type: 'string',
2453
+ def: 'Earth',
2454
+ readOnly: true
2455
+ },
2456
+ {
2457
+ name: 'moon',
2458
+ type: 'string'
2459
+ }
2460
+ ]
2461
+ }
2462
+ ];
2463
+
2464
+ it('should keep read only values when editing a document', async function() {
2465
+ const req = apos.task.getReq();
2466
+ const schema = apos.schema.compose({
2467
+ addFields: givenSchema
2468
+ });
2469
+ const home = await apos.page.find(req, { slug: '/' }).toObject();
2470
+
2471
+ const data = {
2472
+ _relationship: [
2473
+ {
2474
+ ...home,
2475
+ _fields: {
2476
+ planet: 'Saturn',
2477
+ moon: 'Titan'
2478
+ }
2479
+ }
2480
+ ],
2481
+ array: [
2482
+ {
2483
+ _id: 'Jupiter-Io',
2484
+ moon: 'Io'
2485
+ },
2486
+ {
2487
+ _id: 'Mars-Phobos',
2488
+ moon: 'Phobos'
2489
+ }
2490
+ ],
2491
+ object: {
2492
+ _id: 'Neptune-Triton',
2493
+ moon: 'Triton'
2494
+ },
2495
+ pageId: [ home._id ],
2496
+ pageFields: {
2497
+ [home._id]: {
2498
+ planet: 'Saturn'
2499
+ }
2500
+ },
2501
+ title: 'Sol'
2502
+ };
2503
+ const destination = {
2504
+ _relationship: [
2505
+ {
2506
+ ...home,
2507
+ _fields: {
2508
+ planet: 'Saturn'
2509
+ }
2510
+ }
2511
+ ],
2512
+ array: [
2513
+ {
2514
+ _id: 'Jupiter-Io',
2515
+ planet: 'Jupiter'
2516
+ },
2517
+ {
2518
+ _id: 'Mars-Phobos',
2519
+ planet: 'Mars'
2520
+ }
2521
+ ],
2522
+ object: {
2523
+ _id: 'Neptune-Triton',
2524
+ planet: 'Neptune'
2525
+ },
2526
+ pageId: [ home._id ],
2527
+ pageFields: {
2528
+ [home._id]: {
2529
+ planet: 'Saturn'
2530
+ }
2531
+ },
2532
+ title: 'Default'
2533
+ };
2534
+ await apos.schema.convert(
2535
+ req,
2536
+ schema,
2537
+ data,
2538
+ destination
2539
+ );
2540
+
2541
+ const actual = destination;
2542
+ const expected = {
2543
+ _relationship: [
2544
+ {
2545
+ _fields: {
2546
+ planet: 'Saturn',
2547
+ moon: 'Titan'
2548
+ },
2549
+ ...home
2550
+ }
2551
+ ],
2552
+ array: [
2553
+ {
2554
+ _id: 'Jupiter-Io',
2555
+ metaType: 'arrayItem',
2556
+ moon: 'Io',
2557
+ planet: 'Jupiter',
2558
+ scopedArrayName: undefined
2559
+ },
2560
+ {
2561
+ _id: 'Mars-Phobos',
2562
+ metaType: 'arrayItem',
2563
+ moon: 'Phobos',
2564
+ planet: 'Mars',
2565
+ scopedArrayName: undefined
2566
+ }
2567
+ ],
2568
+ object: {
2569
+ _id: 'Neptune-Triton',
2570
+ metaType: 'objectItem',
2571
+ moon: 'Triton',
2572
+ planet: 'Neptune',
2573
+ scopedObjectName: undefined
2574
+ },
2575
+ pageId: [ home._id ],
2576
+ pageFields: {
2577
+ [home._id]: {
2578
+ planet: 'Saturn'
2579
+ }
2580
+ },
2581
+ title: 'Sol'
2582
+ };
2583
+
2584
+ assert.deepEqual(actual, expected);
2585
+ });
2586
+ });
2587
+
2266
2588
  describe('field editPermission|viewPermission', function() {
2267
2589
  const schema = [
2268
2590
  {