apostrophe 4.2.3 → 4.3.1
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 +31 -6
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +6 -5
- package/modules/@apostrophecms/asset/lib/webpack/apos/webpack.config.js +17 -2
- package/modules/@apostrophecms/doc/lib/migrations.js +1 -1
- package/modules/@apostrophecms/doc/ui/apos/apps/AposDoc.js +10 -1
- package/modules/@apostrophecms/i18n/i18n/en.json +2 -0
- package/modules/@apostrophecms/image/index.js +185 -20
- package/modules/@apostrophecms/image/ui/apos/components/AposImageCropper.vue +1 -0
- package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +13 -12
- package/modules/@apostrophecms/modal/ui/apos/components/AposWidgetModalTabs.vue +282 -0
- package/modules/@apostrophecms/page/index.js +1 -1
- package/modules/@apostrophecms/page-type/index.js +2 -1
- package/modules/@apostrophecms/schema/ui/apos/mixins/AposInputMixin.js +6 -1
- package/modules/@apostrophecms/ui/ui/apos/utils/index.js +52 -8
- package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +23 -8
- package/package.json +1 -1
- package/test/images.js +140 -2
- package/test/utils.js +139 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,14 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 4.
|
|
3
|
+
## 4.3.1 (2024-05-17)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
### Fixes
|
|
6
|
+
|
|
7
|
+
* Databases containing documents that no longer correspond to any module no longer cause the migration that adds missing mode properties
|
|
8
|
+
to fail (an issue introduced in version 4.2.0). Databases with no such "orphaned" documents were not affected.
|
|
6
9
|
|
|
7
|
-
## 4.
|
|
10
|
+
## 4.3.0 (2024-05-15)
|
|
8
11
|
|
|
9
12
|
### Adds
|
|
10
13
|
|
|
11
|
-
*
|
|
14
|
+
* Allows to disable page refresh on content changed for page types.
|
|
15
|
+
* Widget editor can now have tabs.
|
|
16
|
+
* Adds prop to `AposInputMixin` to disable blur emit.
|
|
17
|
+
* Adds `throttle` function in ui module utils.
|
|
18
|
+
* 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
|
|
19
|
+
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).
|
|
20
|
+
Thanks to Michelin for contributing this feature.
|
|
21
|
+
|
|
22
|
+
### Fixes
|
|
23
|
+
|
|
24
|
+
* Do not show widget editor tabs when the developer hasn't created any groups.
|
|
25
|
+
* `npm link` now works again for Apostrophe modules that are dependencies of a project.
|
|
26
|
+
* Re-crop image attachments found in image widgets, etc. when replacing an image in the Media Manager.
|
|
27
|
+
* Fixes visual transitions between modals, as well as slider transition on overlay opacity.
|
|
28
|
+
* Changing the aspect ratio multiple times in the image cropper modal no longer makes the stencil smaller and smaller.
|
|
29
|
+
|
|
30
|
+
### Changes
|
|
31
|
+
|
|
32
|
+
* Improves `debounce` function to handle async properly (waiting for previous async call to finish before triggering a new one).
|
|
33
|
+
* Adds the `copyOfId` property to be passed to the `apos.doc.edit()` method, while still allowing the entire `copyOf` object for backwards compatibility.
|
|
34
|
+
|
|
35
|
+
### Fixes
|
|
36
|
+
|
|
12
37
|
|
|
13
38
|
## 4.2.1 (2024-04-29)
|
|
14
39
|
|
|
@@ -63,7 +88,7 @@ watching behavior by Vue 3.
|
|
|
63
88
|
|
|
64
89
|
* Don't crash if a document of a type no longer corresponding to any module is present
|
|
65
90
|
together with the advanced permission module.
|
|
66
|
-
* AposLoginForm.js now pulls its schema from the user module rather than hardcoding it. Includes the
|
|
91
|
+
* AposLoginForm.js now pulls its schema from the user module rather than hardcoding it. Includes the
|
|
67
92
|
addition of `enterUsername` and `enterPassword` i18n fields for front end customization and localization.
|
|
68
93
|
* Simulated Express requests returned by `apos.task.getReq` now include a `req.headers` property, for
|
|
69
94
|
greater accuracy and to prevent unexpected bugs in other code.
|
|
@@ -72,7 +97,7 @@ actually exists before calling `attachment.url` still lies with the developer.
|
|
|
72
97
|
|
|
73
98
|
### Adds
|
|
74
99
|
|
|
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.
|
|
100
|
+
* 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
101
|
* Add highlight class in UI when comparing documents.
|
|
77
102
|
|
|
78
103
|
## 4.0.0 (2024-03-12)
|
|
@@ -493,11 +493,12 @@ export default {
|
|
|
493
493
|
});
|
|
494
494
|
}
|
|
495
495
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
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$:
|
|
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
|
+
}
|
|
@@ -27,7 +27,7 @@ module.exports = (self) => {
|
|
|
27
27
|
self.apos.migration.add('set-document-modes', async () => {
|
|
28
28
|
return self.apos.migration.eachDoc({}, 5, async (doc) => {
|
|
29
29
|
const manager = self.getManager(doc.type);
|
|
30
|
-
if (!manager
|
|
30
|
+
if (!manager?.isLocalized()) {
|
|
31
31
|
return;
|
|
32
32
|
}
|
|
33
33
|
const idMode = doc._id.split(':').pop(); ;
|
|
@@ -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
|
-
|
|
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
|
|
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
|
};
|
|
@@ -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
|
-
|
|
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
|
-
&.
|
|
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]
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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 =
|
|
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
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(
|
|
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
|
+
}
|