apostrophe 4.2.3 → 4.3.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,14 +1,32 @@
1
1
  # Changelog
2
2
 
3
- ## 4.2.3 (2024-05-07)
3
+ ## 4.3.0 (2024-05-15)
4
4
 
5
- No changes. Released to reset the `latest` tag.
5
+ ### Adds
6
6
 
7
- ## 4.2.2 (2024-05-06)
7
+ * Allows to disable page refresh on content changed for page types.
8
+ * Widget editor can now have tabs.
9
+ * Adds prop to `AposInputMixin` to disable blur emit.
10
+ * Adds `throttle` function in ui module utils.
11
+ * 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
12
+ 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).
13
+ Thanks to Michelin for contributing this feature.
8
14
 
9
- ### Adds
15
+ ### Fixes
16
+
17
+ * Do not show widget editor tabs when the developer hasn't created any groups.
18
+ * `npm link` now works again for Apostrophe modules that are dependencies of a project.
19
+ * Re-crop image attachments found in image widgets, etc. when replacing an image in the Media Manager.
20
+ * Fixes visual transitions between modals, as well as slider transition on overlay opacity.
21
+ * Changing the aspect ratio multiple times in the image cropper modal no longer makes the stencil smaller and smaller.
22
+
23
+ ### Changes
24
+
25
+ * Improves `debounce` function to handle async properly (waiting for previous async call to finish before triggering a new one).
26
+ * Adds the `copyOfId` property to be passed to the `apos.doc.edit()` method, while still allowing the entire `copyOf` object for backwards compatibility.
27
+
28
+ ### Fixes
10
29
 
11
- * 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 except as part of the admin UI bundle which depends on it. For use with external front ends such as [apostrophe-astro](https://apostrophecms.com/extensions/astro-integration). Thanks to Michelin for contributing this feature.
12
30
 
13
31
  ## 4.2.1 (2024-04-29)
14
32
 
@@ -63,7 +81,7 @@ watching behavior by Vue 3.
63
81
 
64
82
  * Don't crash if a document of a type no longer corresponding to any module is present
65
83
  together with the advanced permission module.
66
- * AposLoginForm.js now pulls its schema from the user module rather than hardcoding it. Includes the
84
+ * AposLoginForm.js now pulls its schema from the user module rather than hardcoding it. Includes the
67
85
  addition of `enterUsername` and `enterPassword` i18n fields for front end customization and localization.
68
86
  * Simulated Express requests returned by `apos.task.getReq` now include a `req.headers` property, for
69
87
  greater accuracy and to prevent unexpected bugs in other code.
@@ -72,7 +90,7 @@ actually exists before calling `attachment.url` still lies with the developer.
72
90
 
73
91
  ### Adds
74
92
 
75
- * Add new `getChanges` method to the schema module to get an array of document changed field names instead of just a boolean like does the `isEqual` method.
93
+ * Add new `getChanges` method to the schema module to get an array of document changed field names instead of just a boolean like does the `isEqual` method.
76
94
  * Add highlight class in UI when comparing documents.
77
95
 
78
96
  ## 4.0.0 (2024-03-12)
@@ -493,11 +493,12 @@ export default {
493
493
  });
494
494
  }
495
495
  }
496
- const refreshOptions = {
497
- refresh: true
498
- };
499
- apos.bus.$emit('apos-refreshing', refreshOptions);
500
- if (refreshOptions.refresh) {
496
+
497
+ // Check that refresh hasn't been disbled for this page type
498
+ const contextOptions = this.context
499
+ ? apos.modules[this.context.type]
500
+ : { contentChangedRefresh: true };
501
+ if (contextOptions.contentChangedRefresh) {
501
502
  await this.refresh({
502
503
  scrollcheck: e.action === 'history'
503
504
  });
@@ -1,3 +1,4 @@
1
+ const fs = require('fs');
1
2
  const path = require('path');
2
3
  const merge = require('webpack-merge').merge;
3
4
  const scss = require('./webpack.scss');
@@ -22,6 +23,7 @@ module.exports = ({
22
23
  )
23
24
  );
24
25
 
26
+ const mode = process.env.NODE_ENV || 'development';
25
27
  const pnpmModulePath = apos.isPnpm ? [ path.join(apos.selfDir, '../') ] : [];
26
28
  const config = {
27
29
  performance: {
@@ -30,7 +32,7 @@ module.exports = ({
30
32
  entry: importFile,
31
33
  // Ensure that the correct version of vue-loader is found
32
34
  context: __dirname,
33
- mode: process.env.NODE_ENV || 'development',
35
+ mode,
34
36
  optimization: {
35
37
  minimize: process.env.NODE_ENV === 'production'
36
38
  },
@@ -64,7 +66,7 @@ module.exports = ({
64
66
  resolve: {
65
67
  extensions: [ '.*', '.js', '.vue', '.json' ],
66
68
  alias: {
67
- vue$: '@vue/runtime-dom',
69
+ vue$: getVueAlias(mode),
68
70
  // resolve apostrophe modules
69
71
  Modules: path.resolve(modulesDir)
70
72
  },
@@ -86,3 +88,16 @@ module.exports = ({
86
88
 
87
89
  return merge(config, ...tasks);
88
90
  };
91
+
92
+ function getVueAlias(mode) {
93
+ if (mode !== 'development') {
94
+ return '@vue/runtime-dom';
95
+ }
96
+
97
+ const vueProjectLevelPath = path.resolve('./node_modules/@vue/runtime-dom');
98
+ const vueProjectLevelInstalled = fs.existsSync(vueProjectLevelPath);
99
+
100
+ return vueProjectLevelInstalled
101
+ ? vueProjectLevelPath
102
+ : path.resolve(__dirname, '../../../../../../node_modules/@vue/runtime-dom');
103
+ }
@@ -10,13 +10,18 @@ export default () => {
10
10
  // `_id` should be the `_id` of the existing document to edit; leave
11
11
  // blank to create a new document.
12
12
  //
13
+ // `copyOfId` is an optional `_id` of an existing document from which properties
14
+ // should be copied.
15
+ //
13
16
  // `copyOf` is an optional, existing document from which properties should be copied.
17
+ // It is present for BC.
14
18
  //
15
19
  // On success, returns the new or updated document. If the modal is cancelled,
16
20
  // `undefined` is returned. Be sure to `await` the result.
17
21
  apos.doc.edit = async ({
18
22
  type,
19
23
  _id,
24
+ copyOfId,
20
25
  copyOf
21
26
  }) => {
22
27
  if (!type) {
@@ -29,10 +34,14 @@ export default () => {
29
34
  if (!modal) {
30
35
  throw new Error(`${type} is not a valid piece or page type, or cannot be edited`);
31
36
  }
37
+ copyOfId = copyOfId ?? copyOf?._id;
38
+ if (copyOf && !copyOfId) {
39
+ throw new Error('copyOf (deprecated) must be an object with a `_id` property, if possible pass `copyOfId` instead');
40
+ }
32
41
  return apos.modal.execute(modal, {
33
42
  moduleName: type,
34
43
  docId: _id,
35
- copyOf
44
+ copyOfId
36
45
  });
37
46
  };
38
47
  // If you don't care about the returned value, you can emit an
@@ -205,6 +205,8 @@
205
205
  "hideInNavigation": "Hide in Navigation",
206
206
  "home": "Home",
207
207
  "image": "Image",
208
+ "imageOnReplaceAutocropMessage": "The replaced image has been autocropped for \"{{ title }}\".",
209
+ "imageOnReplaceAutocropError": "An error occurred while autocropping the replaced image for {{ title }}",
208
210
  "imageDescription": "Add an inline image",
209
211
  "imageFile": "Image File",
210
212
  "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
  };
@@ -105,6 +105,7 @@ export default {
105
105
  },
106
106
  aspectRatio: {
107
107
  handler(newVal) {
108
+ this.$refs.cropper.reset();
108
109
  this.stencilProps.aspectRatio = newVal;
109
110
  this.$refs.cropper.refresh();
110
111
  }
@@ -139,9 +139,9 @@ const transitionType = computed(() => {
139
139
 
140
140
  if (props.modal.origin === 'left') {
141
141
  return 'slide-right';
142
- } else {
143
- return 'slide-left';
144
142
  }
143
+
144
+ return 'slide-left';
145
145
  });
146
146
 
147
147
  const shouldTrapFocus = computed(() => {
@@ -226,14 +226,13 @@ function onKeydown(e) {
226
226
  cycleElementsToFocus(e);
227
227
  }
228
228
 
229
- function onEnter() {
229
+ async function onEnter() {
230
230
  emit('show-modal');
231
231
  apos.modal.stack = apos.modal.stack || [];
232
232
 
233
233
  apos.modal.stack.push(state);
234
- nextTick(() => {
235
- emit('ready');
236
- });
234
+ await nextTick();
235
+ emit('ready');
237
236
  }
238
237
 
239
238
  function onLeave() {
@@ -346,12 +345,12 @@ function close() {
346
345
  }
347
346
  }
348
347
 
349
- &.slide-left-enter,
348
+ &.slide-left-enter-from,
350
349
  &.slide-left-leave-to {
351
350
  transform: translateX(100%);
352
351
  }
353
352
 
354
- &.slide-right-enter,
353
+ &.slide-right-enter-from,
355
354
  &.slide-right-leave-to {
356
355
  transform: translateX(-100%);
357
356
  }
@@ -361,7 +360,7 @@ function close() {
361
360
  transition: opacity 0.15s ease, transform 0.15s ease;
362
361
  }
363
362
 
364
- &.fade-enter,
363
+ &.fade-enter-from,
365
364
  &.fade-leave-to {
366
365
  opacity: 0;
367
366
  transform: scale(0.95);
@@ -396,9 +395,11 @@ function close() {
396
395
  transition: opacity 0.15s ease;
397
396
  }
398
397
 
399
- &.slide-enter,
400
- &.slide-leave-to,
401
- &.fade-enter,
398
+ &.slide-left-enter-from,
399
+ &.slide-left-leave-to,
400
+ &.slide-right-enter-from,
401
+ &.slide-right-leave-to,
402
+ &.fade-enter-from,
402
403
  &.fade-leave-to {
403
404
  opacity: 0;
404
405
  }
@@ -0,0 +1,282 @@
1
+ <template>
2
+ <div class="apos-modal-tabs" :class="{ 'apos-modal-tabs--horizontal': orientation === 'horizontal' }">
3
+ <ul class="apos-modal-tabs__tabs">
4
+ <li
5
+ v-for="tab in visibleTabs"
6
+ v-show="tab.isVisible !== false"
7
+ :key="tab.name"
8
+ class="apos-modal-tabs__tab"
9
+ >
10
+ <button
11
+ :id="tab.name"
12
+ class="apos-modal-tabs__btn"
13
+ :aria-selected="tab.name === current ? true : false"
14
+ @click="selectTab"
15
+ >
16
+ {{ $t(tab.label) }}
17
+ <span
18
+ v-if="tabErrors[tab.name] && tabErrors[tab.name].length"
19
+ class="apos-modal-tabs__label apos-modal-tabs__label--error"
20
+ >
21
+ {{ tabErrors[tab.name].length }} {{ generateErrorLabel(tabErrors[tab.name].length) }}
22
+ </span>
23
+ </button>
24
+ </li>
25
+ <li
26
+ v-if="hiddenTabs.length"
27
+ key="placeholder-for-hidden-tabs"
28
+ class="apos-modal-tabs__tab apos-modal-tabs__tab--small"
29
+ />
30
+ </ul>
31
+ <AposContextMenu
32
+ v-if="hiddenTabs.length"
33
+ :menu="hiddenTabs"
34
+ menu-placement="bottom-end"
35
+ :button="moreMenuButton"
36
+ @item-clicked="moreMenuHandler($event)"
37
+ />
38
+ </div>
39
+ </template>
40
+
41
+ <script>
42
+ export default {
43
+ name: 'AposWidgetModalTabs',
44
+ props: {
45
+ tabs: {
46
+ required: true,
47
+ type: Array
48
+ },
49
+ current: {
50
+ type: String,
51
+ default: ''
52
+ },
53
+ errors: {
54
+ type: Object,
55
+ default() {
56
+ return {};
57
+ }
58
+ },
59
+ orientation: {
60
+ type: String,
61
+ default: 'vertical'
62
+ }
63
+ },
64
+ emits: [ 'select-tab' ],
65
+ data() {
66
+ const visibleTabs = [];
67
+ const hiddenTabs = [];
68
+
69
+ for (let i = 0; i < this.tabs.length; i++) {
70
+ // Shallow clone is sufficient to make mutating
71
+ // a top-level property safe
72
+ const tab = { ...this.tabs[i] };
73
+ tab.action = tab.name;
74
+ if (i < 5) {
75
+ visibleTabs.push(tab);
76
+ } else {
77
+ hiddenTabs.push(tab);
78
+ }
79
+ }
80
+
81
+ return {
82
+ visibleTabs,
83
+ hiddenTabs,
84
+ moreMenuButton: {
85
+ icon: 'dots-vertical-icon',
86
+ iconOnly: true,
87
+ type: 'subtle'
88
+ }
89
+ };
90
+ },
91
+ computed: {
92
+ tabErrors() {
93
+ const errors = {};
94
+ for (const key in this.errors) {
95
+ errors[key] = [];
96
+ for (const errorKey in this.errors[key]) {
97
+ if (this.errors[key][errorKey]) {
98
+ errors[key].push(key);
99
+ }
100
+ }
101
+ }
102
+ return errors;
103
+ }
104
+ },
105
+ methods: {
106
+ generateErrorLabel(errorCount) {
107
+ let label = 'Error';
108
+ if (errorCount > 1) {
109
+ label += 's';
110
+ }
111
+ return label;
112
+ },
113
+ selectTab: function (e) {
114
+ const tab = e.target;
115
+ const id = tab.id;
116
+ this.$emit('select-tab', id);
117
+ },
118
+ moreMenuHandler(item) {
119
+ const lastVisibleTab = this.visibleTabs[this.visibleTabs.length - 1];
120
+ const selectedItem = this.hiddenTabs.find((tab) => tab.name === item);
121
+
122
+ this.hiddenTabs.splice(this.hiddenTabs.indexOf(selectedItem), 1, lastVisibleTab);
123
+ this.visibleTabs.splice(this.visibleTabs.length - 1, 1, selectedItem);
124
+
125
+ this.$emit('select-tab', item);
126
+ }
127
+ }
128
+ };
129
+ </script>
130
+
131
+ <style lang="scss" scoped>
132
+ .apos-modal-tabs {
133
+ display: flex;
134
+ height: 100%;
135
+ }
136
+
137
+ :deep(.apos-context-menu) {
138
+ position: absolute;
139
+ top: 10px;
140
+ right: 0;
141
+
142
+ svg {
143
+ width: 20px;
144
+ height: 20px;
145
+ color: var(--a-base-1);
146
+ }
147
+
148
+ .apos-button--subtle:hover {
149
+ background-color: initial;
150
+ }
151
+ }
152
+
153
+ .apos-modal-tabs--horizontal {
154
+ position: relative;
155
+
156
+ .apos-modal-tabs__tabs {
157
+ flex-direction: row;
158
+ border-top: 1px solid var(--a-base-7);
159
+ border-bottom: 1px solid var(--a-base-7);
160
+ }
161
+
162
+ .apos-modal-tabs__tab {
163
+ display: flex;
164
+ width: 100%;
165
+ }
166
+
167
+ .apos-modal-tabs__tab--small {
168
+ width: 50%;
169
+ color: var(--a-base-1);
170
+ background-color: var(--a-base-10);
171
+ border-bottom: 1px solid var(--a-base-7);
172
+ }
173
+
174
+ .apos-modal-tabs__btn {
175
+ justify-content: center;
176
+ color: var(--a-base-1);
177
+ background-color: var(--a-base-10);
178
+
179
+ &:hover, &:focus {
180
+ color: var(--a-primary-light-40);
181
+ background-color: var(--a-base-10);
182
+ }
183
+
184
+ &[aria-selected='true'], &[aria-selected='true']:hover, &[aria-selected='true']:focus {
185
+ color: var(--a-primary);
186
+ background-color: var(--a-base-10);
187
+ border-bottom: 3px solid var(--a-primary);
188
+ }
189
+ }
190
+
191
+ .apos-modal-tabs__btn::before {
192
+ content: none;
193
+ }
194
+ }
195
+
196
+ .apos-modal-tabs__tabs {
197
+ display: flex;
198
+ flex-direction: column;
199
+ width: 100%;
200
+ margin: 0;
201
+ padding: 0;
202
+ background-color: var(--a-base-9);
203
+ }
204
+
205
+ .apos-modal-tabs__tab {
206
+ display: block;
207
+ }
208
+
209
+ .apos-modal-tabs__label {
210
+ display: inline-block;
211
+ padding: 3px;
212
+ border: 1px solid var(--a-base-0);
213
+ font-size: var(--a-type-tiny);
214
+ border-radius: 4px 3px;
215
+ text-transform: uppercase;
216
+ letter-spacing: 1px;
217
+ pointer-events: none;
218
+ }
219
+
220
+ .apos-modal-tabs__label--error {
221
+ border: 1px solid var(--a-danger);
222
+ }
223
+
224
+ .apos-modal-tabs__btn {
225
+ @include apos-button-reset();
226
+ @include type-base;
227
+ position: relative;
228
+ display: flex;
229
+ justify-content: space-between;
230
+ align-items: center;
231
+ width: 100%;
232
+ height: 60px;
233
+ padding: 25px 10px;
234
+ border-bottom: 1px solid var(--a-base-7);
235
+ color: var(--a-text-primary);
236
+ background-color: var(--a-base-9);
237
+ text-align: left;
238
+ cursor: pointer;
239
+ box-sizing: border-box;
240
+ transition: all 0.2s ease;
241
+
242
+ @include media-up(lap) {
243
+ padding: 25px 10px 25px 20px;
244
+ }
245
+
246
+ &::before {
247
+ content: '';
248
+ position: absolute;
249
+ top: 0;
250
+ bottom: 0;
251
+ left: 0;
252
+ width: 0;
253
+ background-color: var(--a-primary);
254
+ transition: width 0.25s cubic-bezier(0, 1.61, 1, 1.23);
255
+ }
256
+
257
+ &[aria-selected='true'],
258
+ &[aria-selected='true']:hover,
259
+ &[aria-selected='true']:focus {
260
+ padding-left: 15px;
261
+ background-color: var(--a-background-primary);
262
+ &::before {
263
+ background-color: var(--a-primary);
264
+ }
265
+ }
266
+
267
+ &:hover,
268
+ &:focus {
269
+ background-color: var(--a-base-10);
270
+ &::before {
271
+ width: 3px;
272
+ background-color: var(--a-base-5);
273
+ }
274
+ }
275
+
276
+ &[aria-selected='true'] {
277
+ &::before {
278
+ width: 6px;
279
+ }
280
+ }
281
+ }
282
+ </style>
@@ -2301,7 +2301,7 @@ database.`);
2301
2301
  // Apostrophe queries used to fetch Apostrophe pages
2302
2302
  // consult this method.
2303
2303
  getBaseUrl(req) {
2304
- const hostname = self.apos.i18n.locales[req.locale].hostname;
2304
+ const hostname = self.apos.i18n.locales[req.locale]?.hostname;
2305
2305
 
2306
2306
  return hostname
2307
2307
  ? `${req.protocol}://${hostname}`
@@ -474,7 +474,7 @@ module.exports = {
474
474
  }
475
475
  };
476
476
  },
477
- extendMethods(self) {
477
+ extendMethods(self, options) {
478
478
  return {
479
479
  enableAction() {
480
480
  self.action = self.apos.modules['@apostrophecms/page'].action;
@@ -553,6 +553,7 @@ module.exports = {
553
553
 
554
554
  browserOptions.filters = self.filters;
555
555
  browserOptions.columns = self.columns;
556
+ browserOptions.contentChangedRefresh = options.contentChangedRefresh !== false;
556
557
 
557
558
  // Sets manager modal to AposDocsManager
558
559
  // for browsing specific page types:
@@ -55,6 +55,11 @@ export default {
55
55
  serverError: {
56
56
  type: Object,
57
57
  required: false
58
+ },
59
+
60
+ noBlurEmit: {
61
+ type: Boolean,
62
+ default: false
58
63
  }
59
64
  },
60
65
  data () {
@@ -122,7 +127,7 @@ export default {
122
127
  }
123
128
  },
124
129
  focus(value) {
125
- if (!value) {
130
+ if (!this.noBlurEmit && !value) {
126
131
  this.validateAndEmit();
127
132
  }
128
133
  }
@@ -1,9 +1,53 @@
1
- export const debounce = (func, timeout) => {
2
- let timer;
3
- return (...args) => {
4
- clearTimeout(timer);
5
- timer = setTimeout(() => {
6
- func.apply(this, args);
7
- }, timeout);
8
- };
1
+ module.exports = {
2
+ debounce(fn, delay) {
3
+ let timer;
4
+ let previousDone = true;
5
+
6
+ const setTimer = (res, rej, args, delay) => {
7
+ return setTimeout(() => {
8
+ if (!previousDone) {
9
+ clearTimeout(timer);
10
+ timer = setTimer(res, rej, args, delay);
11
+ return;
12
+ }
13
+
14
+ previousDone = false;
15
+ const returned = fn.apply(this, args);
16
+ if (returned instanceof Promise) {
17
+ return returned
18
+ .then(res)
19
+ .catch(rej)
20
+ .finally(() => {
21
+ previousDone = true;
22
+ });
23
+ }
24
+
25
+ previousDone = true;
26
+ res(returned);
27
+ }, delay);
28
+ };
29
+
30
+ return (...args) => {
31
+ return new Promise((resolve, reject) => {
32
+ clearTimeout(timer);
33
+ timer = setTimer(resolve, reject, args, delay);
34
+ });
35
+ };
36
+ },
37
+
38
+ throttle(fn, delay) {
39
+ let inThrottle;
40
+
41
+ return (...args) => {
42
+ return new Promise((resolve) => {
43
+ if (!inThrottle) {
44
+ inThrottle = true;
45
+ setTimeout(() => {
46
+ inThrottle = false;
47
+ resolve(fn.apply(this, args));
48
+ }, delay);
49
+ }
50
+ });
51
+ };
52
+ }
9
53
  };
@@ -9,9 +9,15 @@
9
9
  @no-modal="$emit('safe-close')"
10
10
  >
11
11
  <template #breadcrumbs>
12
- <AposModalBreadcrumbs
13
- v-if="breadcrumbs && breadcrumbs.length"
14
- :items="breadcrumbs"
12
+ <AposModalBreadcrumbs v-if="breadcrumbs && breadcrumbs.length" :items="breadcrumbs" />
13
+ <AposWidgetModalTabs
14
+ v-if="tabs.length && tabs[0].name !== 'ungrouped'"
15
+ :key="tabKey"
16
+ :current="currentTab"
17
+ :tabs="tabs"
18
+ orientation="horizontal"
19
+ :errors="fieldErrors"
20
+ @select-tab="switchPane"
15
21
  />
16
22
  </template>
17
23
  <template #main>
@@ -19,9 +25,13 @@
19
25
  <template #bodyMain>
20
26
  <div class="apos-widget-editor__body">
21
27
  <AposSchema
22
- ref="schema"
28
+ v-for="tab in tabs"
29
+ v-show="tab.name === currentTab"
30
+ :key="tab.name"
31
+ :ref="tab.name"
23
32
  :trigger-validation="triggerValidation"
24
- :schema="schema"
33
+ :current-fields="groups[tab.name].fields"
34
+ :schema="groups[tab.name].schema"
25
35
  :model-value="docFields"
26
36
  :meta="meta"
27
37
  :following-values="followingValues()"
@@ -52,13 +62,14 @@
52
62
  <script>
53
63
  import AposModifiedMixin from 'Modules/@apostrophecms/ui/mixins/AposModifiedMixin';
54
64
  import AposEditorMixin from 'Modules/@apostrophecms/modal/mixins/AposEditorMixin';
65
+ import AposModalTabsMixin from 'Modules/@apostrophecms/modal/mixins/AposModalTabsMixin';
55
66
  import { detectDocChange } from 'Modules/@apostrophecms/schema/lib/detectChange';
56
67
  import cuid from 'cuid';
57
68
  import { klona } from 'klona';
58
69
 
59
70
  export default {
60
71
  name: 'AposWidgetEditor',
61
- mixins: [ AposModifiedMixin, AposEditorMixin ],
72
+ mixins: [ AposModifiedMixin, AposEditorMixin, AposModalTabsMixin ],
62
73
  props: {
63
74
  type: {
64
75
  required: true,
@@ -107,6 +118,7 @@ export default {
107
118
  data: {},
108
119
  hasErrors: false
109
120
  },
121
+ fieldErrors: {},
110
122
  modal: {
111
123
  title: this.editLabel,
112
124
  active: false,
@@ -181,12 +193,16 @@ export default {
181
193
  },
182
194
  methods: {
183
195
  updateDocFields(value) {
184
- this.docFields = value;
196
+ this.docFields.data = {
197
+ ...this.docFields.data,
198
+ ...value.data
199
+ };
185
200
  this.evaluateConditions();
186
201
  },
187
202
  async save() {
188
203
  this.triggerValidation = true;
189
204
  this.$nextTick(async () => {
205
+ const widget = klona(this.docFields.data);
190
206
  if (this.docFields.hasErrors) {
191
207
  this.triggerValidation = false;
192
208
  return;
@@ -199,7 +215,6 @@ export default {
199
215
  });
200
216
  return;
201
217
  }
202
- const widget = this.docFields.data;
203
218
  if (!widget.type) {
204
219
  widget.type = this.type;
205
220
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "4.2.3",
3
+ "version": "4.3.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/utils.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const t = require('../test-lib/test.js');
2
2
  const assert = require('assert');
3
3
  const _ = require('lodash');
4
+ const { debounce, throttle } = require('../modules/@apostrophecms/ui/ui/apos/utils/index');
4
5
 
5
6
  describe('Utils', function() {
6
7
 
@@ -369,5 +370,143 @@ describe('Utils', function() {
369
370
  assert(data.shoes[0].size === 8);
370
371
  });
371
372
 
373
+ it('can debounce functions and should be be awaitable with promises', async function () {
374
+ const calledNormal = [];
375
+ const calledAsync = [];
376
+ const calledAsyncSlow = [];
377
+ let asyncErrCatched = false;
378
+
379
+ const debouncedNormal = debounce(normalFn, 50);
380
+ const debouncedAsync = debounce(asyncFn, 50);
381
+ const debouncedAsyncSlow = debounce(asyncSlowFn, 50);
382
+ const debouncedAsyncErr = debounce(AsyncErrFn, 50);
383
+
384
+ debouncedNormal(1);
385
+ debouncedNormal(2);
386
+ await debouncedNormal(3);
387
+
388
+ debouncedAsync(1);
389
+ await wait(100);
390
+ debouncedAsync(2);
391
+ await debouncedAsync(3);
392
+
393
+ debouncedAsyncSlow(1);
394
+ await wait(100);
395
+ debouncedAsyncSlow(2);
396
+ debouncedAsyncSlow(3);
397
+ await wait(60);
398
+ await debouncedAsyncSlow(4);
399
+
400
+ try {
401
+ await debouncedAsyncErr(1);
402
+ } catch (err) {
403
+ asyncErrCatched = true;
404
+ }
405
+
406
+ const actual = {
407
+ calledNormal,
408
+ calledAsync,
409
+ calledAsyncSlow,
410
+ asyncErrCatched
411
+ };
412
+
413
+ const expected = {
414
+ calledNormal: [ 3, 3 ],
415
+ calledAsync: [ 1, 1, 3, 3 ],
416
+ calledAsyncSlow: [ 1, 1, 3, 3, 4, 4 ],
417
+ asyncErrCatched: true
418
+ };
419
+
420
+ assert.deepEqual(actual, expected);
421
+
422
+ function normalFn(num) {
423
+ calledNormal.push(num);
424
+ calledNormal.push(num);
425
+ return 'test';
426
+ };
427
+
428
+ async function asyncFn(num) {
429
+ calledAsync.push(num);
430
+ await wait(50);
431
+ calledAsync.push(num);
432
+ return 'async';
433
+ }
434
+
435
+ async function asyncSlowFn(num) {
436
+ calledAsyncSlow.push(num);
437
+ await wait(75);
438
+ calledAsyncSlow.push(num);
439
+ return 'asyncSlow';
440
+ }
441
+
442
+ });
443
+
444
+ it('can throttle functions', async function () {
445
+ const calledNormal = [];
446
+ const calledAsync = [];
447
+ let asyncErrCatched = false;
448
+
449
+ const throttledNormal = throttle(normalFn, 50);
450
+ const throttledAsync = throttle(asyncFn, 50);
451
+ const throttledAsyncErr = throttle(AsyncErrFn, 100);
452
+
453
+ throttledNormal(1);
454
+ await wait(100);
455
+ throttledNormal(2);
456
+ throttledNormal(3);
457
+ throttledNormal(4);
458
+
459
+ await wait(100);
460
+ await throttledNormal(5);
461
+
462
+ throttledAsync(1);
463
+ throttledAsync(2);
464
+ await wait(100);
465
+ await throttledAsync(3);
466
+
467
+ try {
468
+ await throttledAsyncErr(1);
469
+ } catch (err) {
470
+ asyncErrCatched = true;
471
+ }
472
+
473
+ const actual = {
474
+ calledNormal,
475
+ calledAsync,
476
+ asyncErrCatched
477
+ };
478
+
479
+ const expected = {
480
+ calledNormal: [ 1, 2, 5 ],
481
+ calledAsync: [ 1, 3 ],
482
+ asyncErrCatched: true
483
+ };
484
+
485
+ assert.deepEqual(actual, expected);
486
+
487
+ function normalFn(num) {
488
+ calledNormal.push(num);
489
+ return 'test';
490
+ };
491
+
492
+ async function asyncFn(num, time = 50) {
493
+ await wait(time);
494
+ calledAsync.push(num);
495
+ return 'async';
496
+ }
497
+ });
372
498
  });
373
499
  });
500
+
501
+ function wait(delay) {
502
+ return new Promise((resolve) => {
503
+ setTimeout(() => {
504
+ resolve('done');
505
+ }, delay);
506
+ });
507
+ };
508
+
509
+ async function AsyncErrFn(num, time = 50) {
510
+ await wait();
511
+ throw new Error('async error');
512
+ }