apostrophe 3.7.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.eslintrc +4 -0
  2. package/.scratch.md +2 -0
  3. package/CHANGELOG.md +34 -3
  4. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +5 -1
  5. package/modules/@apostrophecms/asset/index.js +77 -13
  6. package/modules/@apostrophecms/attachment/index.js +1 -0
  7. package/modules/@apostrophecms/db/index.js +5 -6
  8. package/modules/@apostrophecms/doc-type/index.js +23 -3
  9. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocContextMenu.vue +13 -1
  10. package/modules/@apostrophecms/i18n/i18n/en.json +15 -4
  11. package/modules/@apostrophecms/i18n/i18n/es.json +0 -1
  12. package/modules/@apostrophecms/i18n/i18n/pt-BR.json +0 -1
  13. package/modules/@apostrophecms/i18n/i18n/sk.json +3 -4
  14. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +6 -3
  15. package/modules/@apostrophecms/image-widget/index.js +2 -1
  16. package/modules/@apostrophecms/image-widget/views/widget.html +12 -2
  17. package/modules/@apostrophecms/job/index.js +164 -212
  18. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +151 -61
  19. package/modules/@apostrophecms/modal/ui/apos/components/AposModalConfirm.vue +8 -6
  20. package/modules/@apostrophecms/modal/ui/apos/mixins/AposDocsManagerMixin.js +12 -15
  21. package/modules/@apostrophecms/notification/index.js +116 -8
  22. package/modules/@apostrophecms/notification/ui/apos/components/AposNotification.vue +89 -11
  23. package/modules/@apostrophecms/notification/ui/apos/components/TheAposNotifications.vue +1 -1
  24. package/modules/@apostrophecms/page/index.js +37 -30
  25. package/modules/@apostrophecms/piece-type/index.js +178 -61
  26. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +179 -50
  27. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +0 -2
  28. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerSelectBox.vue +138 -0
  29. package/modules/@apostrophecms/schema/ui/apos/components/AposInputAttachment.vue +11 -160
  30. package/modules/@apostrophecms/task/index.js +2 -2
  31. package/modules/@apostrophecms/template/index.js +2 -0
  32. package/modules/@apostrophecms/ui/ui/apos/components/AposButton.vue +5 -0
  33. package/modules/@apostrophecms/ui/ui/apos/components/AposFile.vue +205 -0
  34. package/modules/@apostrophecms/util/ui/src/util.js +15 -0
  35. package/modules/@apostrophecms/widget-type/ui/apos/mixins/AposWidgetMixin.js +15 -7
  36. package/package.json +2 -2
  37. package/test/job.js +224 -0
  38. package/test/pieces.js +17 -0
  39. package/test-lib/util.js +32 -0
@@ -2,26 +2,46 @@
2
2
  <AposModalToolbar class-name="apos-manager-toolbar">
3
3
  <template #leftControls>
4
4
  <AposButton
5
- v-if="!options.hideSelectAll"
6
- label="apostrophe:select" :icon-only="true"
7
- :icon="checkboxIcon" type="outline"
5
+ v-if="displayedItems"
6
+ label="apostrophe:select"
7
+ type="outline"
8
+ text-color="var(--a-primary)"
9
+ :icon-only="true"
10
+ :icon="checkboxIcon"
8
11
  @click="$emit('select-click')"
9
12
  />
10
- <!-- TODO: Return this delete button when batch updates are added.
11
- When we do that though, we should do it like we handle the other
12
- batch operation events, not with extra event plumbing
13
- percolating everywhere. We can still achieve a custom button
14
- without that. -->
15
- <!-- <AposButton
16
- label="apostrophe:delete" @click="$emit('archive-click')"
17
- :icon-only="true" icon="delete-icon"
18
- type="outline"
19
- /> -->
20
- <!-- <AposContextMenu
21
- :button="more.button"
22
- :menu="more.menu"
23
- @item-clicked="managerAction"
24
- /> -->
13
+ <div
14
+ v-for="{
15
+ action,
16
+ label,
17
+ icon,
18
+ operations,
19
+ ...rest
20
+ } in activeOperations"
21
+ :key="action"
22
+ >
23
+ <AposButton
24
+ v-if="!operations"
25
+ :label="label"
26
+ :icon-only="true"
27
+ :icon="icon"
28
+ :disabled="!checkedCount"
29
+ type="outline"
30
+ @click="confirmOperation({ action, label, ...rest })"
31
+ />
32
+ <AposContextMenu
33
+ v-else
34
+ :button="{
35
+ label,
36
+ icon,
37
+ iconOnly: true,
38
+ type: 'outline'
39
+ }"
40
+ :disabled="!checkedCount"
41
+ :menu="operations"
42
+ @item-clicked="(a) => beginGroupedOperation(a, operations)"
43
+ />
44
+ </div>
25
45
  </template>
26
46
  <template #rightControls>
27
47
  <AposPager
@@ -30,7 +50,7 @@
30
50
  :total-pages="totalPages" :current-page="currentPage"
31
51
  />
32
52
  <AposFilterMenu
33
- v-if="filters.length > 0"
53
+ v-if="filters.length"
34
54
  :filters="filters"
35
55
  :choices="filterChoices"
36
56
  :values="filterValues"
@@ -57,27 +77,19 @@ export default {
57
77
  },
58
78
  applyTags: {
59
79
  type: Array,
60
- default () {
61
- return [];
62
- }
63
- },
64
- filters: {
65
- type: Array,
66
- default () {
67
- return [];
68
- }
80
+ default: () => []
69
81
  },
70
82
  filterChoices: {
71
83
  type: Object,
72
- default () {
73
- return {};
74
- }
84
+ default: () => ({})
75
85
  },
76
86
  filterValues: {
77
87
  type: Object,
78
- default () {
79
- return {};
80
- }
88
+ default: () => ({})
89
+ },
90
+ filters: {
91
+ type: Array,
92
+ default: () => []
81
93
  },
82
94
  totalPages: {
83
95
  type: Number,
@@ -87,42 +99,40 @@ export default {
87
99
  type: Number,
88
100
  default: 1
89
101
  },
102
+ isRelationship: {
103
+ type: Boolean,
104
+ default: false
105
+ },
90
106
  labels: {
91
107
  type: Object,
92
- default () {
93
- return {};
94
- }
108
+ default: () => ({})
95
109
  },
96
110
  options: {
97
111
  type: Object,
98
- default () {
99
- return {};
100
- }
112
+ default: () => ({})
113
+ },
114
+ batchOperations: {
115
+ type: Array,
116
+ default: () => []
117
+ },
118
+ displayedItems: {
119
+ type: Number,
120
+ required: true
121
+ },
122
+ checkedCount: {
123
+ type: Number,
124
+ required: true
101
125
  }
102
126
  },
103
127
  emits: [
104
128
  'select-click',
105
129
  'filter',
106
130
  'search',
107
- 'page-change'
131
+ 'page-change',
132
+ 'batch'
108
133
  ],
109
134
  data() {
110
135
  return {
111
- // TODO: Uncomment to return this when batch updates are added.
112
- // more: {
113
- // button: {
114
- // label: 'apostrophe:moreOperations',
115
- // iconOnly: true,
116
- // icon: 'dots-vertical-icon',
117
- // type: 'outline'
118
- // },
119
- // menu: [
120
- // {
121
- // label: 'Unpublish All',
122
- // action: 'unpublish-all'
123
- // }
124
- // ]
125
- // },
126
136
  searchField: {
127
137
  field: {
128
138
  name: 'search',
@@ -135,7 +145,8 @@ export default {
135
145
  },
136
146
  status: {},
137
147
  value: { data: '' }
138
- }
148
+ },
149
+ activeOperations: []
139
150
  };
140
151
  },
141
152
  computed: {
@@ -149,9 +160,52 @@ export default {
149
160
  }
150
161
  }
151
162
  },
163
+ mounted () {
164
+ this.computeActiveOperations();
165
+ },
152
166
  methods: {
167
+ computeActiveOperations () {
168
+ if (this.isRelationship) {
169
+ this.activeOperations = [];
170
+ return;
171
+ }
172
+
173
+ this.activeOperations = this.batchOperations
174
+ .map(({ operations, ...rest }) => {
175
+ if (!operations) {
176
+ return {
177
+ ...rest,
178
+ operations
179
+ };
180
+ }
181
+
182
+ return {
183
+ operations: operations.filter((op) => this.isOperationActive(op)),
184
+ ...rest
185
+ };
186
+ }).filter((operation) => {
187
+ if (operation.operations && !operation.operations.length) {
188
+ return false;
189
+ }
190
+
191
+ return this.isOperationActive(operation);
192
+ });
193
+ },
194
+ isOperationActive (operation) {
195
+ return Object.entries(operation.if || {})
196
+ .every(([ filter, val ]) => {
197
+ if (Array.isArray(val)) {
198
+ return val.includes(this.filterValues[filter]);
199
+ }
200
+
201
+ return this.filterValues[filter] === val;
202
+ });
203
+ },
153
204
  filter(filter, value) {
154
205
  this.$emit('filter', filter, value.data);
206
+ if (this.filterValues[filter] !== value) {
207
+ this.computeActiveOperations();
208
+ }
155
209
  },
156
210
  search(value, force) {
157
211
  if ((force && !value) || value.data === '') {
@@ -165,12 +219,48 @@ export default {
165
219
 
166
220
  this.$emit('search', value.data);
167
221
  },
168
- managerAction(action) {
169
- // TODO: flesh this out.
170
- console.info('ACTION: ', action);
171
- },
172
222
  registerPageChange(pageNum) {
173
223
  this.$emit('page-change', pageNum);
224
+ },
225
+ async beginGroupedOperation(action, operations) {
226
+ const operation = operations.find(o => o.action === action);
227
+
228
+ await this.confirmOperation(operation);
229
+ },
230
+ async confirmOperation ({
231
+ modalOptions = {}, action, operations, label, ...rest
232
+ }) {
233
+ const {
234
+ title = label,
235
+ description = '',
236
+ confirmationButton = 'apostrophe:affirmativeLabel',
237
+ form
238
+ } = action && operations
239
+ ? (operations.find((op) => op.action === action)).modalOptions
240
+ : modalOptions;
241
+
242
+ const interpolations = {
243
+ count: this.checkedCount,
244
+ type: this.checkedCount === 1
245
+ ? this.$t(this.labels.singular)
246
+ : this.$t(this.labels.plural)
247
+ };
248
+
249
+ const confirmed = await apos.confirm({
250
+ heading: this.$t(title, interpolations),
251
+ description: this.$t(description, interpolations),
252
+ affirmativeLabel: confirmationButton,
253
+ localize: false,
254
+ ...form && form
255
+ });
256
+
257
+ if (confirmed) {
258
+ this.$emit('batch', {
259
+ label,
260
+ action,
261
+ ...rest
262
+ });
263
+ }
174
264
  }
175
265
  }
176
266
  };
@@ -27,7 +27,11 @@
27
27
  <p v-if="content.description" class="apos-confirm__description">
28
28
  {{ localize(content.description) }}
29
29
  </p>
30
- <Component v-if="content.body" :is="content.body.component" v-bind="content.body.props" />
30
+ <Component
31
+ v-if="content.body"
32
+ :is="content.body.component"
33
+ v-bind="content.body.props"
34
+ />
31
35
  <div v-if="content.form" class="apos-confirm__schema">
32
36
  <AposSchema
33
37
  v-if="formValues"
@@ -143,11 +147,9 @@ export default {
143
147
  this.$emit('modal-result', false);
144
148
  },
145
149
  localize(s) {
146
- if (this.options.localize === false) {
147
- return s;
148
- } else {
149
- return this.$t(s, this.options.interpolate || {});
150
- }
150
+ return this.options.localize === false
151
+ ? s
152
+ : this.$t(s, this.options.interpolate || {});
151
153
  }
152
154
  }
153
155
  };
@@ -56,25 +56,20 @@ export default {
56
56
  sort(action) {
57
57
  this.$emit('sort', action);
58
58
  },
59
- selectAllValue() {
60
- return this.checked.length > 0 ? { data: [ 'checked' ] } : { data: [] };
61
- },
62
59
  selectAllChoice() {
63
60
  const checkCount = this.checked.length;
64
61
  const itemCount = (this.items && this.items.length) || 0;
65
62
 
66
- return checkCount > 0 && checkCount !== itemCount ? {
63
+ return {
67
64
  value: 'checked',
68
- indeterminate: true
69
- } : {
70
- value: 'checked'
65
+ indeterminate: checkCount > 0 && checkCount !== itemCount
71
66
  };
72
67
  },
73
68
  selectAllState() {
74
- if (this.selectAllValue.data.length && !this.selectAllChoice.indeterminate) {
69
+ if (this.checked.length && !this.selectAllChoice.indeterminate) {
75
70
  return 'checked';
76
71
  }
77
- if (this.selectAllValue.data.length && this.selectAllChoice.indeterminate) {
72
+ if (this.checked.length && this.selectAllChoice.indeterminate) {
78
73
  return 'indeterminate';
79
74
  }
80
75
  return 'empty';
@@ -122,22 +117,24 @@ export default {
122
117
  selectAll() {
123
118
  if (!this.checked.length) {
124
119
  this.items.forEach((item) => {
125
- const relationshipsMaxed = this.relationshipField && this.maxReached();
120
+ const relationshipsMaxedOrUnpublished = this.relationshipField &&
121
+ (this.maxReached() || !item.lastPublishedAt);
126
122
 
127
- if (relationshipsMaxed || !item.lastPublishedAt) {
123
+ if (relationshipsMaxedOrUnpublished) {
128
124
  return;
129
125
  }
130
126
 
131
127
  this.checked.push(item._id);
132
128
  });
129
+
133
130
  return;
134
131
  }
135
132
 
136
- if (this.checked.length <= this.items.length) {
137
- this.checked.forEach((id) => {
138
- this.checked = this.checked.filter(item => item !== id);
139
- });
133
+ if (this.allPiecesSelection) {
134
+ this.allPiecesSelection.isSelected = false;
140
135
  }
136
+
137
+ this.checked = [];
141
138
  },
142
139
  iconSize(header) {
143
140
  if (header.icon) {
@@ -160,6 +160,40 @@ module.exports = {
160
160
  return self.db.deleteMany({ _id });
161
161
  }
162
162
  }),
163
+ apiRoutes(self) {
164
+ return {
165
+ post: {
166
+ // Clear a registered event on a notification to prevent it from
167
+ // emitting twice. Returns `true` if the event was found and cleared.
168
+ // Returns `false` if not found (because it was already cleared).
169
+ ':_id/clear-event': async function (req) {
170
+ const lockId = `clear-event-${req.params._id}`;
171
+
172
+ let response;
173
+ try {
174
+ await self.apos.lock.lock(lockId);
175
+
176
+ response = await self.db.updateOne({
177
+ _id: req.params._id,
178
+ event: {
179
+ $ne: null
180
+ }
181
+ }, {
182
+ $set: {
183
+ event: null
184
+ }
185
+ });
186
+ } catch (error) {
187
+ throw self.apos.error('notfound');
188
+ } finally {
189
+ await self.apos.lock.unlock(lockId);
190
+ }
191
+
192
+ return response.result.nModified > 0;
193
+ }
194
+ }
195
+ };
196
+ },
163
197
  methods(self) {
164
198
  return {
165
199
  getBrowserData(req) {
@@ -189,6 +223,19 @@ module.exports = {
189
223
  // apos bus event of the given `name` with the provided `data` object. Currently
190
224
  // `'event'` is the only supported value for `type`.
191
225
  //
226
+ // `options.return` will return the notification object. This is not
227
+ // done otherwise to minimize risk of leaking MongoDB metadata to the
228
+ // browser.
229
+ //
230
+ // `options.icon`, set to an active Vue Materials Icons icon name, will
231
+ // set an icon on the notification.
232
+ //
233
+ // `options.job` can be set to an object with properties related to an
234
+ // Apostrophe Job (from the @apostrophecms/job module) for the
235
+ // notification to track the job's progress. These can include the job
236
+ // `_id` and, for the 'completed' stage, the job `action`,
237
+ // e.g., 'archive'.
238
+ //
192
239
  // Throws an error if there is no `req.user`.
193
240
  //
194
241
  // `interpolate` may contain an object with properties to be
@@ -203,6 +250,8 @@ module.exports = {
203
250
  // the application, as in a command line task.
204
251
 
205
252
  async trigger(req, message, options = {}, interpolate = {}) {
253
+ const { return: returnId, ...copiedOptions } = options;
254
+
206
255
  if (typeof req === 'string') {
207
256
  // String was passed, assume it is a user _id
208
257
  req = { user: { _id: req } };
@@ -219,19 +268,23 @@ module.exports = {
219
268
  createdAt: new Date(),
220
269
  userId: req.user._id,
221
270
  message,
271
+ icon: options.icon,
222
272
  interpolate: interpolate || options.interpolate || {},
223
273
  // Defaults to true, otherwise launder as boolean
224
- localize: has(req.body, 'localize') ? self.apos.launder.boolean(req.body.localize) : true
274
+ localize: has(req.body, 'localize')
275
+ ? self.apos.launder.boolean(req.body.localize) : true,
276
+ job: options.job || null,
277
+ event: options.event
225
278
  };
226
279
 
227
- if (options.dismiss === true) {
228
- options.dismiss = 5;
280
+ if (copiedOptions.dismiss === true) {
281
+ copiedOptions.dismiss = 5;
229
282
  }
230
283
 
231
- Object.assign(notification, options);
284
+ Object.assign(notification, copiedOptions);
232
285
 
233
- // We await here rather than returning because we
234
- // expressly do not want to leak mongodb metadata to the browser
286
+ // We await here rather than returning because we expressly do not
287
+ // want to leak mongodb metadata to the browser
235
288
  await self.db.updateOne(
236
289
  notification,
237
290
  {
@@ -243,8 +296,55 @@ module.exports = {
243
296
  upsert: true
244
297
  }
245
298
  );
299
+
300
+ if (returnId) {
301
+ return {
302
+ noteId: notification._id
303
+ };
304
+ }
246
305
  },
247
306
 
307
+ // The dismiss method accepts the following arguments:
308
+ // - req: A valid req.
309
+ // - noteId: The _id of an active notification.
310
+ // - delay: An optional integer of milliseconds to pause before the
311
+ // notification actually dismisses.
312
+ async dismiss (req, noteId, delay) {
313
+ if (!req.user) {
314
+ throw self.apos.error('forbidden');
315
+ }
316
+
317
+ await pause(delay);
318
+
319
+ try {
320
+ await self.db.updateOne(
321
+ {
322
+ _id: noteId
323
+ },
324
+ {
325
+ $set: {
326
+ dismissed: true
327
+ },
328
+ $currentDate: {
329
+ updatedAt: true
330
+ }
331
+ }, {
332
+ upsert: true
333
+ }
334
+ );
335
+ } catch (error) {
336
+ // Most likely the ID did not belong to an actual notification.
337
+ throw self.apos.error('invalid');
338
+ }
339
+
340
+ async function pause (delay) {
341
+ if (!delay) {
342
+ return;
343
+ }
344
+
345
+ return new Promise((resolve) => setTimeout(resolve, delay));
346
+ }
347
+ },
248
348
  // Resolves with an object with `notifications` and `dismissed`
249
349
  // properties.
250
350
  //
@@ -256,8 +356,16 @@ module.exports = {
256
356
  try {
257
357
  const results = await self.db.find({
258
358
  userId: req.user._id,
259
- ...(options.modifiedOnOrSince && { updatedAt: { $gte: new Date(options.modifiedOnOrSince) } }),
260
- ...(options.seenIds && { _id: { $nin: options.seenIds } })
359
+ ...(options.modifiedOnOrSince && {
360
+ updatedAt: {
361
+ $gte: new Date(options.modifiedOnOrSince)
362
+ }
363
+ }),
364
+ ...(options.seenIds && {
365
+ _id: {
366
+ $nin: options.seenIds
367
+ }
368
+ })
261
369
  }).sort({ createdAt: 1 }).toArray();
262
370
 
263
371
  const notifications = results.filter(result => !result.dismissed);