apostrophe 4.28.0 → 4.29.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 (88) hide show
  1. package/CHANGELOG.md +33 -3
  2. package/README.md +142 -0
  3. package/defaults.js +1 -0
  4. package/lib/safe-json-script.js +27 -0
  5. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarLocale.vue +1 -1
  6. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +1 -0
  7. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +3 -5
  8. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +13 -1
  9. package/modules/@apostrophecms/asset/lib/globalIcons.js +3 -0
  10. package/modules/@apostrophecms/attachment/index.js +43 -1
  11. package/modules/@apostrophecms/color-field/index.js +7 -1
  12. package/modules/@apostrophecms/doc/index.js +11 -1
  13. package/modules/@apostrophecms/doc-type/index.js +165 -32
  14. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +1 -1
  15. package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +104 -59
  16. package/modules/@apostrophecms/file/index.js +109 -9
  17. package/modules/@apostrophecms/i18n/i18n/de.json +0 -2
  18. package/modules/@apostrophecms/i18n/i18n/en.json +40 -1
  19. package/modules/@apostrophecms/i18n/i18n/es.json +0 -1
  20. package/modules/@apostrophecms/i18n/i18n/fr.json +0 -1
  21. package/modules/@apostrophecms/i18n/i18n/it.json +0 -1
  22. package/modules/@apostrophecms/i18n/i18n/pt-BR.json +0 -1
  23. package/modules/@apostrophecms/i18n/i18n/sk.json +0 -1
  24. package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nBatchReporting.js +18 -1
  25. package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nLocalizeActions.js +50 -0
  26. package/modules/@apostrophecms/i18n/ui/apos/components/AposI18nLocalize.vue +56 -13
  27. package/modules/@apostrophecms/image/ui/apos/components/AposImageRelationshipEditor.vue +8 -2
  28. package/modules/@apostrophecms/layout-column-widget/index.js +156 -163
  29. package/modules/@apostrophecms/layout-widget/index.js +7 -2
  30. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +6 -11
  31. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +3 -5
  32. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +4 -4
  33. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -16
  34. package/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs +7 -27
  35. package/modules/@apostrophecms/layout-widget/views/column.html +7 -9
  36. package/modules/@apostrophecms/login/index.js +39 -40
  37. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +17 -2
  38. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +3 -2
  39. package/modules/@apostrophecms/notification/ui/apos/components/AposNotification.vue +1 -0
  40. package/modules/@apostrophecms/page/index.js +2 -0
  41. package/modules/@apostrophecms/piece-type/index.js +3 -1
  42. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +1 -0
  43. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +5 -0
  44. package/modules/@apostrophecms/recently-edited/index.js +831 -0
  45. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +54 -0
  46. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedCombo.vue +454 -0
  47. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilterTag.vue +75 -0
  48. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilters.vue +287 -0
  49. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +16 -0
  50. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedManager.vue +346 -0
  51. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedBatch.js +193 -0
  52. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedData.js +276 -0
  53. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFetch.js +199 -0
  54. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFilters.js +100 -0
  55. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js +8 -4
  56. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js +1 -1
  57. package/modules/@apostrophecms/styles/index.js +10 -0
  58. package/modules/@apostrophecms/styles/lib/apiRoutes.js +6 -0
  59. package/modules/@apostrophecms/styles/lib/handlers.js +5 -0
  60. package/modules/@apostrophecms/styles/lib/methods.js +9 -3
  61. package/modules/@apostrophecms/styles/lib/presets.js +119 -0
  62. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +3 -8
  63. package/modules/@apostrophecms/styles/ui/apos/composables/AposStyles.js +1 -3
  64. package/modules/@apostrophecms/styles/ui/apos/render-factory.js +29 -0
  65. package/modules/@apostrophecms/styles/ui/apos/universal/backgroundHelpers.mjs +140 -0
  66. package/modules/@apostrophecms/styles/ui/apos/universal/customRules.mjs +105 -0
  67. package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +195 -15
  68. package/modules/@apostrophecms/template/index.js +22 -6
  69. package/modules/@apostrophecms/ui/ui/apos/components/AposCellContextMenu.vue +2 -0
  70. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +18 -4
  71. package/modules/@apostrophecms/ui/ui/apos/composables/useInfiniteScroll.js +91 -0
  72. package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
  73. package/modules/@apostrophecms/ui/ui/apos/stores/modal.js +5 -2
  74. package/modules/@apostrophecms/ui/ui/apos/utils/index.js +9 -0
  75. package/modules/@apostrophecms/url/index.js +38 -4
  76. package/modules/@apostrophecms/widget-type/index.js +22 -6
  77. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +8 -4
  78. package/package.json +19 -19
  79. package/test/files.js +129 -0
  80. package/test/layout-widget-migration.js +719 -0
  81. package/test/login-requirements.js +1 -1
  82. package/test/pieces-public-api.js +80 -0
  83. package/test/pieces.js +25 -0
  84. package/test/recently-edited.js +2311 -0
  85. package/test/schemas.js +39 -3
  86. package/test/static-build.js +642 -0
  87. package/test/styles.js +2569 -0
  88. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposLayoutColControlDialog.vue +0 -171
@@ -0,0 +1,831 @@
1
+ // A virtual "piece type" that provides a cross-type manager
2
+ // for recently edited documents. It doesn't own documents but
3
+ // queries across all managed doc types. Write operations are
4
+ // delegated to each document's actual type manager.
5
+
6
+ module.exports = {
7
+ extend: '@apostrophecms/piece-type',
8
+ options: {
9
+ alias: 'recentlyEdited',
10
+ label: 'apostrophe:recentlyEdited',
11
+ pluralLabel: 'apostrophe:recentlyEditedDocuments',
12
+ quickCreate: false,
13
+ showCreate: false,
14
+ showPermissions: false,
15
+ // 30-day window
16
+ recentDays: 30,
17
+ // Developer-configurable type exclusion
18
+ excludeTypes: [],
19
+ perPage: 50,
20
+ managerApiProjection: {
21
+ updatedAt: 1,
22
+ updatedBy: 1,
23
+ archived: 1,
24
+ modified: 1,
25
+ parked: 1,
26
+ lastPublishedAt: 1,
27
+ submitted: 1,
28
+ aposPermissions: 1
29
+ }
30
+ },
31
+ commands(self) {
32
+ return {
33
+ add: {
34
+ [`${self.__meta.name}:manager`]: {
35
+ type: 'item',
36
+ label: self.options.label,
37
+ action: {
38
+ type: 'admin-menu-click',
39
+ payload: {
40
+ itemName: `${self.__meta.name}:manager`
41
+ }
42
+ },
43
+ shortcut: 'T,R'
44
+ },
45
+ [`${self.__meta.name}:search`]: {
46
+ type: 'item',
47
+ label: 'apostrophe:commandMenuSearch',
48
+ action: {
49
+ type: 'command-menu-manager-focus-search'
50
+ },
51
+ shortcut: 'Ctrl+F Meta+F'
52
+ },
53
+ [`${self.__meta.name}:select-all`]: {
54
+ type: 'item',
55
+ label: 'apostrophe:commandMenuSelectAll',
56
+ action: {
57
+ type: 'command-menu-manager-select-all'
58
+ },
59
+ shortcut: 'Ctrl+Shift+A Meta+Shift+A'
60
+ },
61
+ [`${self.__meta.name}:exit-manager`]: {
62
+ type: 'item',
63
+ label: 'apostrophe:commandMenuExitManager',
64
+ action: {
65
+ type: 'command-menu-manager-close'
66
+ },
67
+ shortcut: 'Q'
68
+ }
69
+ },
70
+ remove: [
71
+ `${self.__meta.name}:create-new`,
72
+ `${self.__meta.name}:archive-selected`
73
+ ],
74
+ modal: {
75
+ default: {
76
+ '@apostrophecms/command-menu:taskbar': {
77
+ label: 'apostrophe:commandMenuTaskbar',
78
+ commands: [
79
+ `${self.__meta.name}:manager`
80
+ ]
81
+ }
82
+ },
83
+ [`${self.__meta.name}:manager`]: {
84
+ '@apostrophecms/command-menu:manager': {
85
+ label: 'apostrophe:commandMenuManager',
86
+ commands: [
87
+ `${self.__meta.name}:search`,
88
+ `${self.__meta.name}:select-all`,
89
+ `${self.__meta.name}:exit-manager`
90
+ ]
91
+ },
92
+ '@apostrophecms/command-menu:general': {
93
+ label: 'apostrophe:commandMenuGeneral',
94
+ commands: [
95
+ '@apostrophecms/command-menu:show-shortcut-list'
96
+ ]
97
+ }
98
+ }
99
+ }
100
+ };
101
+ },
102
+ columns: {
103
+ add: {
104
+ title: {
105
+ label: 'apostrophe:title',
106
+ component: 'AposCellTitle'
107
+ },
108
+ type: {
109
+ label: 'apostrophe:type',
110
+ component: 'AposCellType'
111
+ },
112
+ _localeLabel: {
113
+ label: 'apostrophe:locale',
114
+ component: 'AposCellBasic'
115
+ },
116
+ _lastEditor: {
117
+ label: 'apostrophe:lastEditor',
118
+ component: 'AposCellBasic'
119
+ },
120
+ updatedAt: {
121
+ label: 'apostrophe:lastEdited',
122
+ component: 'AposCellLastEdited'
123
+ }
124
+ },
125
+ remove: [ 'labels' ],
126
+ order: [ 'title', 'type', '_localeLabel', '_lastEditor', 'updatedAt' ]
127
+ },
128
+ filters: {
129
+ add: {
130
+ _editedBy: {
131
+ label: 'apostrophe:recentlyEditedEditedBy'
132
+ },
133
+ _docType: {
134
+ label: 'apostrophe:type',
135
+ inputType: 'checkbox'
136
+ },
137
+ _action: {
138
+ label: 'apostrophe:recentlyEditedAction'
139
+ },
140
+ _locale: {
141
+ label: 'apostrophe:locale',
142
+ inputType: 'checkbox'
143
+ },
144
+ _status: {
145
+ label: 'apostrophe:recentlyEditedStatus'
146
+ }
147
+ },
148
+ remove: [ 'visibility', 'archived' ]
149
+ },
150
+ async init(self) {
151
+ self.filterChoiceRegistry = {
152
+ action: {},
153
+ status: {}
154
+ };
155
+ self.managedTypes = [];
156
+ self.managedTypeNames = [];
157
+ self.managedPageTypeNames = [];
158
+ self.managedPieceTypeNames = [];
159
+
160
+ self.addOwnFilterChoices();
161
+ await self.createIndexes();
162
+ },
163
+ handlers(self) {
164
+ return {
165
+ 'apostrophe:modulesRegistered': {
166
+ detectManagedTypes() {
167
+ const internalExcludeTypes = [
168
+ self.__meta.name,
169
+ '@apostrophecms/submitted-draft',
170
+ '@apostrophecms/archive-page'
171
+ ];
172
+
173
+ const userExcludeTypes = self.options.excludeTypes || [];
174
+ const excludeSet = new Set([
175
+ ...internalExcludeTypes,
176
+ ...userExcludeTypes
177
+ ]);
178
+
179
+ const managers = Object.values(self.apos.doc.managers);
180
+ self.managedTypes = managers
181
+ .filter(manager => {
182
+ if (!manager.__meta) {
183
+ return false;
184
+ }
185
+ const name = manager.__meta.name;
186
+ if (excludeSet.has(name)) {
187
+ return false;
188
+ }
189
+ if (!manager.isLocalized?.()) {
190
+ return false;
191
+ }
192
+ // Only concrete types: piece types and page types.
193
+ // Excludes abstract bases like any-doc-type,
194
+ // any-page-type, polymorphic-type.
195
+ const isPiece = self.apos.instanceOf(
196
+ manager, '@apostrophecms/piece-type'
197
+ );
198
+ const isPage = self.apos.instanceOf(
199
+ manager, '@apostrophecms/page-type'
200
+ );
201
+ if (!isPiece && !isPage) {
202
+ return false;
203
+ }
204
+ return true;
205
+ })
206
+ .map(manager => ({
207
+ name: manager.__meta.name,
208
+ label: manager.options.label || manager.__meta.name,
209
+ pluralLabel: manager.options.pluralLabel ||
210
+ manager.options.label ||
211
+ manager.__meta.name
212
+ }));
213
+ self.managedTypeNames = self.managedTypes.map(t => t.name);
214
+
215
+ // Cache page and piece type names for virtual group filters.
216
+ const managedManagersByName = Object.fromEntries(
217
+ managers
218
+ .filter(m => m.__meta && self.managedTypeNames.includes(m.__meta.name))
219
+ .map(m => [ m.__meta.name, m ])
220
+ );
221
+ self.managedPageTypeNames = self.managedTypeNames.filter(
222
+ name => self.apos.instanceOf(
223
+ managedManagersByName[name], '@apostrophecms/page-type'
224
+ )
225
+ );
226
+ self.managedPieceTypeNames = self.managedTypeNames.filter(
227
+ name => self.apos.instanceOf(
228
+ managedManagersByName[name], '@apostrophecms/piece-type'
229
+ )
230
+ );
231
+ }
232
+ }
233
+ };
234
+ },
235
+ methods(self) {
236
+ return {
237
+ // Register a new choice for a filter dropdown (Action or Status).
238
+ // External modules can call this in their `modulesRegistered` handler.
239
+ //
240
+ // `type` - 'action' or 'status'
241
+ // `name` - unique choice identifier (e.g. 'imported', 'translated')
242
+ // `label` - i18n key for the dropdown choice label
243
+ // `criteria` - a standard MongoDB criteria object (e.g.
244
+ // `{ lastPublishedAt: { $ne: null } }`), or a function
245
+ // receiving `{ cutoffDate }` and returning one when the
246
+ // criteria must be computed at query time (e.g. rolling date
247
+ // windows). `cutoffDate` is the `Date` marking the start of
248
+ // the configured rolling window (`options.recentDays`).
249
+ // Multi-field objects work as implicit `$and`, and any valid
250
+ // MongoDB operator is allowed.
251
+ // `archived` - optional boolean. When `true` the choice matches
252
+ // archived documents (overrides the default exclusion of
253
+ // archived docs). Otherwise it is ignored.
254
+ // `project` - optional projection object (e.g. `{ translatedAt: 1 }`)
255
+ // merged into `managerApiProjection` so the field is available
256
+ // to context operations in the recently-edited manager
257
+ //
258
+ // Examples:
259
+ //
260
+ // // Static criteria (object) — no date dependency
261
+ // addFilterChoice({
262
+ // type: 'status',
263
+ // name: 'translated',
264
+ // label: 'myModule:translated',
265
+ // criteria: { 'translationMeta.state': 'translated' }
266
+ // });
267
+ //
268
+ // // Dynamic criteria (function) — uses the rolling window
269
+ // addFilterChoice({
270
+ // type: 'action',
271
+ // name: 'imported',
272
+ // label: 'myModule:imported',
273
+ // criteria({ cutoffDate }) {
274
+ // return { importedAt: { $gte: cutoffDate } };
275
+ // }
276
+ // });
277
+ addFilterChoice({
278
+ type, name, label, criteria, archived, project
279
+ }) {
280
+ if (type !== 'action' && type !== 'status') {
281
+ throw new Error(`addFilterChoice: type must be "action" or "status", got "${type}"`);
282
+ }
283
+ self.filterChoiceRegistry[type][name] = {
284
+ label,
285
+ criteria,
286
+ archived: archived || false
287
+ };
288
+ if (project) {
289
+ Object.assign(self.options.managerApiProjection, project);
290
+ }
291
+ },
292
+ // Calculate the cutoff date for recently edited documents based on the
293
+ // rolling window setting. Can be used by external modules to provide
294
+ // their own "recently X" filters that align with the same window.
295
+ getCutoffDate() {
296
+ const cutoff = new Date();
297
+ cutoff.setDate(
298
+ cutoff.getDate() - (self.options.recentDays || 30)
299
+ );
300
+ return cutoff;
301
+ },
302
+ addToAdminBar() {
303
+ self.apos.adminBar.add(
304
+ `${self.__meta.name}:manager`,
305
+ self.pluralLabel,
306
+ false,
307
+ {
308
+ component: 'AposRecentlyEditedIcon',
309
+ contextUtility: true,
310
+ tooltip: 'apostrophe:recentlyEditedDocuments'
311
+ }
312
+ );
313
+ },
314
+ addManagerModal() {
315
+ self.apos.modal.add(
316
+ `${self.__meta.name}:manager`,
317
+ 'AposRecentlyEditedManager',
318
+ { moduleName: self.__meta.name }
319
+ );
320
+ },
321
+ insert(req, piece, options) {
322
+ throw new Error('Virtual piece type, should never be inserted');
323
+ },
324
+ update(req, piece, options) {
325
+ const manager = self.apos.doc.getManager(piece.type);
326
+ return manager.update(req, piece, options);
327
+ },
328
+ publish(req, piece, options) {
329
+ const manager = self.apos.doc.getManager(piece.type);
330
+ return manager.publish(req, piece, options);
331
+ },
332
+ delete(req, piece, options) {
333
+ const manager = self.apos.doc.getManager(piece.type);
334
+ return manager.delete(req, piece, options);
335
+ },
336
+ revertDraftToPublished(req, piece, options) {
337
+ const manager = self.apos.doc.getManager(piece.type);
338
+ return manager.revertDraftToPublished(req, piece, options);
339
+ },
340
+ async distinctFromQuery(query, property, options = {}) {
341
+ const subquery = query.clone();
342
+ subquery.skip(undefined);
343
+ subquery.limit(undefined);
344
+ subquery.page(undefined);
345
+ subquery.perPage(undefined);
346
+ if (subquery.choices) {
347
+ subquery.choices(false);
348
+ }
349
+ if (subquery.counts) {
350
+ subquery.counts(false);
351
+ }
352
+ if (options.permission) {
353
+ subquery.and(
354
+ self.apos.permission.criteria(query.req, options.permission)
355
+ );
356
+ }
357
+ return subquery.toDistinct(property);
358
+ },
359
+ // Resolve the MongoDB criteria for a registered filter choice.
360
+ // Returns the criteria object — calls it if it's a function,
361
+ // passing `{ cutoffDate }` for dynamic date-based choices.
362
+ // The `cutoffDate` argument should come from `query.get('cutoffDate')`
363
+ // to ensure a single consistent date across all builders per query.
364
+ getFilterCriteria(entry, cutoffDate) {
365
+ return typeof entry.criteria === 'function'
366
+ ? entry.criteria({ cutoffDate })
367
+ : entry.criteria;
368
+ },
369
+ async createIndexes() {
370
+ await self.apos.doc.db.createIndex(
371
+ {
372
+ updatedAt: -1,
373
+ _id: 1,
374
+ type: 1,
375
+ aposLocale: 1
376
+ },
377
+ { name: 'recentlyEditedLookup' }
378
+ );
379
+ },
380
+ addOwnFilterChoices() {
381
+ self.addFilterChoice({
382
+ type: 'action',
383
+ name: 'created',
384
+ label: 'apostrophe:recentlyEditedActionCreated',
385
+ criteria({ cutoffDate }) {
386
+ return { createdAt: { $gte: cutoffDate } };
387
+ }
388
+ });
389
+ self.addFilterChoice({
390
+ type: 'action',
391
+ name: 'published',
392
+ label: 'apostrophe:recentlyEditedActionPublished',
393
+ criteria({ cutoffDate }) {
394
+ return { lastPublishedAt: { $gte: cutoffDate } };
395
+ }
396
+ });
397
+ self.addFilterChoice({
398
+ type: 'action',
399
+ name: 'submitted',
400
+ label: 'apostrophe:recentlyEditedActionSubmitted',
401
+ criteria({ cutoffDate }) {
402
+ return { 'submitted.at': { $gte: cutoffDate } };
403
+ }
404
+ });
405
+ self.addFilterChoice({
406
+ type: 'action',
407
+ name: 'localized',
408
+ label: 'apostrophe:recentlyEditedActionLocalized',
409
+ criteria({ cutoffDate }) {
410
+ return { localizedAt: { $gte: cutoffDate } };
411
+ }
412
+ });
413
+ self.addFilterChoice({
414
+ type: 'status',
415
+ name: 'live',
416
+ label: 'apostrophe:live',
417
+ criteria: { lastPublishedAt: { $ne: null } }
418
+ });
419
+ self.addFilterChoice({
420
+ type: 'status',
421
+ name: 'draft',
422
+ label: 'apostrophe:draft',
423
+ criteria: { lastPublishedAt: null }
424
+ });
425
+ self.addFilterChoice({
426
+ type: 'status',
427
+ name: 'modified',
428
+ label: 'apostrophe:pendingUpdates',
429
+ criteria: {
430
+ lastPublishedAt: { $ne: null },
431
+ $expr: {
432
+ $gt: [ '$updatedAt', '$lastPublishedAt' ]
433
+ }
434
+ }
435
+ });
436
+ self.addFilterChoice({
437
+ type: 'status',
438
+ name: 'submitted',
439
+ label: 'apostrophe:recentlyEditedStatusSubmitted',
440
+ criteria: { 'submitted.at': { $exists: true } }
441
+ });
442
+ self.addFilterChoice({
443
+ type: 'status',
444
+ name: 'archived',
445
+ label: 'apostrophe:archived',
446
+ archived: true
447
+ });
448
+ }
449
+ };
450
+ },
451
+ extendMethods(self) {
452
+ return {
453
+ find(_super, req, criteria, options) {
454
+ const cutoffDate = self.getCutoffDate();
455
+ return _super(req, criteria, options)
456
+ .type(null)
457
+ .locale(null)
458
+ .attachments(false)
459
+ .areas(false)
460
+ .relationships(false)
461
+ .cutoffDate(cutoffDate)
462
+ .and({
463
+ type: { $in: self.managedTypeNames },
464
+ aposMode: 'draft',
465
+ updatedAt: { $gte: cutoffDate }
466
+ })
467
+ .sort({
468
+ updatedAt: -1,
469
+ _id: 1
470
+ });
471
+ },
472
+ // Remove UI-computed column fields (_localeLabel, _lastEditor)
473
+ // from the projection to mute warnings - they don't exist in the DB and are
474
+ // explicitly supported by the custom manager component.
475
+ getManagerApiProjection(_super, req) {
476
+ const projection = _super(req);
477
+ for (const key of Object.keys(projection)) {
478
+ if ([ '_localeLabel', '_lastEditor' ].includes(key)) {
479
+ delete projection[key];
480
+ }
481
+ }
482
+ return projection;
483
+ },
484
+ // Inject the manager API projection into req.query before the
485
+ // parent processes it via applyBuildersSafely. If the client
486
+ // already sends a projection (e.g. { _id: 1 } for select-all),
487
+ // it takes precedence. This avoids unnecessarily data round-tripping.
488
+ //
489
+ // When `lean` is set, also disable URL resolution — the caller
490
+ // wants lightweight results close to raw DB data.
491
+ getRestQuery(_super, req) {
492
+ if (!req.query.project) {
493
+ const projection = self.getManagerApiProjection(req);
494
+ if (projection) {
495
+ req.query.project = projection;
496
+ }
497
+ }
498
+ const query = _super(req);
499
+ if (self.apos.launder.boolean(req.query.lean)) {
500
+ query.addUrls(false);
501
+ }
502
+ return query;
503
+ },
504
+ getBrowserData(_super, req) {
505
+ const data = _super(req);
506
+ return {
507
+ ...data,
508
+ managedTypes: self.managedTypes,
509
+ batchOperations: [],
510
+ perPage: self.options.perPage,
511
+ rollingWindowDays: self.options.recentDays,
512
+ components: {
513
+ managerModal: 'AposRecentlyEditedManager'
514
+ }
515
+ };
516
+ }
517
+ };
518
+ },
519
+ queries(self, query) {
520
+ return {
521
+ builders: {
522
+ cutoffDate: {},
523
+ _docType: {
524
+ def: null,
525
+ launder(value) {
526
+ const allowed = new Set([
527
+ ...self.managedTypeNames,
528
+ '@apostrophecms/any-page-type',
529
+ '@apostrophecms/piece-type'
530
+ ]);
531
+ const raw = Array.isArray(value)
532
+ ? self.apos.launder.strings(value)
533
+ : [ self.apos.launder.string(value) ].filter(Boolean);
534
+ return raw.filter(v => allowed.has(v));
535
+ },
536
+ finalize() {
537
+ const value = query.get('_docType');
538
+ if (!value || !value.length) {
539
+ return;
540
+ }
541
+ const resolved = [];
542
+ for (const v of value) {
543
+ if (v === '@apostrophecms/any-page-type') {
544
+ resolved.push(...self.managedPageTypeNames);
545
+ } else if (v === '@apostrophecms/piece-type') {
546
+ resolved.push(...self.managedPieceTypeNames);
547
+ } else {
548
+ resolved.push(v);
549
+ }
550
+ }
551
+ if (resolved.length) {
552
+ query.and({ type: { $in: [ ...new Set(resolved) ] } });
553
+ }
554
+ },
555
+ async choices() {
556
+ const distinctTypes = await self.distinctFromQuery(query, 'type');
557
+ const managedByName = Object.fromEntries(
558
+ self.managedTypes.map(type => [ type.name, type ])
559
+ );
560
+
561
+ const typeChoices = distinctTypes
562
+ .filter(type => managedByName[type])
563
+ .map(type => ({
564
+ value: type,
565
+ label: managedByName[type].label
566
+ }));
567
+
568
+ // Add virtual group entries when more than one type
569
+ // of that category has results — the group provides useful
570
+ // narrowing. A single-type category needs no group shortcut.
571
+ const pageCount = typeChoices.filter(
572
+ c => self.managedPageTypeNames.includes(c.value)
573
+ ).length;
574
+ const pieceCount = typeChoices.filter(
575
+ c => self.managedPieceTypeNames.includes(c.value)
576
+ ).length;
577
+ if (pageCount > 1) {
578
+ typeChoices.push({
579
+ value: '@apostrophecms/any-page-type',
580
+ label: 'apostrophe:pages'
581
+ });
582
+ }
583
+ if (pieceCount > 1) {
584
+ typeChoices.push({
585
+ value: '@apostrophecms/piece-type',
586
+ label: 'apostrophe:pieces'
587
+ });
588
+ }
589
+
590
+ return typeChoices;
591
+ }
592
+ },
593
+ _editedBy: {
594
+ def: null,
595
+ launder(value) {
596
+ if (Array.isArray(value)) {
597
+ return self.apos.launder.strings(value).filter(Boolean);
598
+ }
599
+ return self.apos.launder.string(value) || null;
600
+ },
601
+ finalize() {
602
+ const value = query.get('_editedBy');
603
+ if (!value) {
604
+ return;
605
+ }
606
+ const values = Array.isArray(value) ? value : [ value ];
607
+ if (!values.length) {
608
+ return;
609
+ }
610
+ const hasSystem = values.includes('__system');
611
+ const userIds = values.filter(v => v !== '__system');
612
+ const conditions = [];
613
+ if (userIds.length) {
614
+ conditions.push({ 'updatedBy._id': { $in: userIds } });
615
+ }
616
+ if (hasSystem) {
617
+ conditions.push({ 'updatedBy._id': null });
618
+ }
619
+ if (conditions.length === 1) {
620
+ query.and(conditions[0]);
621
+ } else {
622
+ query.and({ $or: conditions });
623
+ }
624
+ },
625
+ async choices() {
626
+ const users = await self.distinctFromQuery(query, 'updatedBy');
627
+ const choices = [];
628
+ for (const user of users) {
629
+ if (!user) {
630
+ continue;
631
+ }
632
+ if (user._id) {
633
+ choices.push({
634
+ value: user._id,
635
+ label: user.title || user.username || user._id
636
+ });
637
+ } else if (user.title) {
638
+ choices.push({
639
+ value: '__system',
640
+ label: user.title
641
+ });
642
+ }
643
+ }
644
+ return choices;
645
+ }
646
+ },
647
+ _locale: {
648
+ def: null,
649
+ launder(value) {
650
+ if (Array.isArray(value)) {
651
+ return self.apos.launder.strings(value)
652
+ .filter(v => v && self.apos.i18n.locales[v]);
653
+ }
654
+ const laundered = self.apos.launder.string(value);
655
+ if (laundered && self.apos.i18n.locales[laundered]) {
656
+ return laundered;
657
+ }
658
+ return null;
659
+ },
660
+ finalize() {
661
+ const value = query.get('_locale');
662
+ if (!value) {
663
+ return;
664
+ }
665
+ if (Array.isArray(value)) {
666
+ if (value.length) {
667
+ query.and({
668
+ aposLocale: { $in: value.map(v => `${v}:draft`) }
669
+ });
670
+ }
671
+ } else {
672
+ query.and({ aposLocale: `${value}:draft` });
673
+ }
674
+ },
675
+ async choices() {
676
+ // Pass permission: 'edit' so distinctFromQuery applies
677
+ // core permission criteria. Modules that extend
678
+ // permission.criteria() (e.g. advanced-permission)
679
+ // automatically filter by allowed locales.
680
+ const distinctLocales = await self.distinctFromQuery(
681
+ query, 'aposLocale', { permission: 'edit' }
682
+ );
683
+ const localesConfig = self.apos.i18n?.locales || {};
684
+ const seen = new Set();
685
+
686
+ return distinctLocales
687
+ .filter(Boolean)
688
+ .map(locale => locale.split(':')[0])
689
+ .filter(locale => {
690
+ if (!locale || seen.has(locale)) {
691
+ return false;
692
+ }
693
+ seen.add(locale);
694
+ return true;
695
+ })
696
+ .map(locale => ({
697
+ value: locale,
698
+ label: localesConfig[locale]?.label
699
+ ? `${localesConfig[locale].label} (${locale})`
700
+ : locale
701
+ }))
702
+ .sort((a, b) => a.label.localeCompare(b.label));
703
+ }
704
+ },
705
+ _status: {
706
+ def: null,
707
+ launder(value) {
708
+ if (Array.isArray(value)) {
709
+ return self.apos.launder.strings(value)
710
+ .filter(v => self.filterChoiceRegistry.status[v]);
711
+ }
712
+ const laundered = self.apos.launder.string(value);
713
+ return self.filterChoiceRegistry.status[laundered] ? laundered : null;
714
+ },
715
+ prefinalize() {
716
+ const value = query.get('_status');
717
+ if (!value) {
718
+ return;
719
+ }
720
+ const cutoffDate = query.get('cutoffDate');
721
+ if (Array.isArray(value)) {
722
+ if (!value.length) {
723
+ return;
724
+ }
725
+ const orClauses = [];
726
+ let needsArchivedNull = false;
727
+ for (const v of value) {
728
+ const entry = self.filterChoiceRegistry.status[v];
729
+ if (!entry) {
730
+ continue;
731
+ }
732
+ if (entry.archived) {
733
+ needsArchivedNull = true;
734
+ const criteria = self.getFilterCriteria(entry, cutoffDate);
735
+ if (criteria) {
736
+ orClauses.push({ $and: [ { archived: true }, criteria ] });
737
+ } else {
738
+ orClauses.push({ archived: true });
739
+ }
740
+ } else {
741
+ const criteria = self.getFilterCriteria(entry, cutoffDate);
742
+ if (criteria) {
743
+ orClauses.push(criteria);
744
+ }
745
+ }
746
+ }
747
+ if (needsArchivedNull) {
748
+ query.archived(null);
749
+ }
750
+ if (orClauses.length) {
751
+ query.and({ $or: orClauses });
752
+ }
753
+ } else {
754
+ const entry = self.filterChoiceRegistry.status[value];
755
+ if (entry) {
756
+ if (entry.archived) {
757
+ query.archived(true);
758
+ }
759
+ const criteria = self.getFilterCriteria(entry, cutoffDate);
760
+ if (criteria) {
761
+ query.and(criteria);
762
+ }
763
+ }
764
+ }
765
+ },
766
+ choices() {
767
+ return Object.entries(
768
+ self.filterChoiceRegistry.status
769
+ ).map(([ value, config ]) => ({
770
+ value,
771
+ label: config.label
772
+ }));
773
+ }
774
+ },
775
+ _action: {
776
+ def: null,
777
+ launder(value) {
778
+ if (Array.isArray(value)) {
779
+ return self.apos.launder.strings(value)
780
+ .filter(v => self.filterChoiceRegistry.action[v]);
781
+ }
782
+ const laundered = self.apos.launder.string(value);
783
+ return self.filterChoiceRegistry.action[laundered] ? laundered : null;
784
+ },
785
+ prefinalize() {
786
+ const value = query.get('_action');
787
+ if (!value) {
788
+ return;
789
+ }
790
+ const cutoffDate = query.get('cutoffDate');
791
+ if (Array.isArray(value)) {
792
+ if (!value.length) {
793
+ return;
794
+ }
795
+ const orClauses = [];
796
+ for (const v of value) {
797
+ const entry = self.filterChoiceRegistry.action[v];
798
+ if (!entry) {
799
+ continue;
800
+ }
801
+ const criteria = self.getFilterCriteria(entry, cutoffDate);
802
+ if (criteria) {
803
+ orClauses.push(criteria);
804
+ }
805
+ }
806
+ if (orClauses.length) {
807
+ query.and({ $or: orClauses });
808
+ }
809
+ } else {
810
+ const entry = self.filterChoiceRegistry.action[value];
811
+ if (entry) {
812
+ const criteria = self.getFilterCriteria(entry, cutoffDate);
813
+ if (criteria) {
814
+ query.and(criteria);
815
+ }
816
+ }
817
+ }
818
+ },
819
+ choices() {
820
+ return Object.entries(
821
+ self.filterChoiceRegistry.action
822
+ ).map(([ value, config ]) => ({
823
+ value,
824
+ label: config.label
825
+ }));
826
+ }
827
+ }
828
+ }
829
+ };
830
+ }
831
+ };