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,193 @@
1
+ import { computed, ref } from 'vue';
2
+
3
+ /**
4
+ * Manages batch selection and operations for the recently-edited
5
+ * document manager.
6
+ *
7
+ * @param {object} options
8
+ * @param {import('vue').Ref<object>} options.filterState - reactive filter state
9
+ * @param {import('vue').ComputedRef<object>} options.moduleOptions - module config
10
+ * @param {import('vue').Ref<Array>} options.items - current page items
11
+ * @param {Function} options.fetchPage - fetches a page of results
12
+ * @param {import('vue').ComputedRef<object>} options.moduleLabels - singular/plural
13
+ * labels
14
+ * @param {Function} options.reload - triggers a page-1 reload
15
+ */
16
+ export function useRecentlyEditedBatch({
17
+ filterState, items, fetchPage, moduleLabels, reload
18
+ }) {
19
+ function getSelectedSingleValue(filterValue) {
20
+ if (Array.isArray(filterValue)) {
21
+ return filterValue.length === 1 ? filterValue[0] : null;
22
+ }
23
+ return filterValue || null;
24
+ }
25
+
26
+ function getSelectedTypeName() {
27
+ return getSelectedSingleValue(filterState.value._docType);
28
+ }
29
+
30
+ function getSelectedLocaleName() {
31
+ return getSelectedSingleValue(filterState.value._locale);
32
+ }
33
+
34
+ function getBatchModule(typeName) {
35
+ const pageModule = apos.modules['@apostrophecms/page'];
36
+ if (
37
+ typeName === '@apostrophecms/any-page-type' ||
38
+ pageModule?.validPageTypes?.includes(typeName)
39
+ ) {
40
+ return pageModule;
41
+ }
42
+ return apos.modules[typeName];
43
+ }
44
+
45
+ // Batch operations are available only when a single concrete type
46
+ // and locale are selected, making the list equivalent to a
47
+ // standard single-type manager.
48
+ const batchOperations = computed(() => {
49
+ const typeName = getSelectedTypeName();
50
+ const localeName = getSelectedLocaleName();
51
+ if (!typeName || !localeName) {
52
+ return [];
53
+ }
54
+ return getBatchModule(typeName)?.batchOperations || [];
55
+ });
56
+
57
+ // Map recently-edited filter state to the format batch operation
58
+ // `if` conditions expect (e.g. `{ archived: false }`).
59
+ const batchFilterValues = computed(() => ({
60
+ archived: filterState.value._status === 'archived'
61
+ }));
62
+
63
+ const checked = ref([]);
64
+ const allPiecesSelection = ref({
65
+ isSelected: false,
66
+ total: 0
67
+ });
68
+
69
+ const selectAllChoice = computed(() => {
70
+ const checkCount = checked.value.length;
71
+ const pageNotFullyChecked = items.value
72
+ .some(item => !checked.value.includes(item._id));
73
+ return {
74
+ value: 'checked',
75
+ indeterminate: checkCount > 0 && pageNotFullyChecked
76
+ };
77
+ });
78
+
79
+ const selectAllState = computed(() => {
80
+ if (checked.value.length && !selectAllChoice.value.indeterminate) {
81
+ return 'checked';
82
+ }
83
+ if (checked.value.length && selectAllChoice.value.indeterminate) {
84
+ return 'indeterminate';
85
+ }
86
+ return 'empty';
87
+ });
88
+
89
+ function selectAll() {
90
+ if (!checked.value.length) {
91
+ checked.value = items.value.map(item => item._id);
92
+ // With infinite scroll all items may already be loaded,
93
+ // so mark the full set as selected to show the banner.
94
+ if (items.value.length >= allPiecesSelection.value.total) {
95
+ allPiecesSelection.value.isSelected = true;
96
+ }
97
+ return;
98
+ }
99
+ allPiecesSelection.value.isSelected = false;
100
+ checked.value = [];
101
+ }
102
+
103
+ async function selectAllPieces() {
104
+ const response = await fetchPage(1, {
105
+ perPage: allPiecesSelection.value.total,
106
+ project: { _id: 1 },
107
+ lean: 1
108
+ });
109
+ const allIds = (response.results || []).map(item => item._id);
110
+ checked.value = allIds;
111
+ allPiecesSelection.value.isSelected = true;
112
+ }
113
+
114
+ function setAllPiecesSelection({
115
+ isSelected, total, docs
116
+ }) {
117
+ if (typeof isSelected === 'boolean') {
118
+ allPiecesSelection.value.isSelected = isSelected;
119
+ }
120
+ if (typeof total === 'number') {
121
+ allPiecesSelection.value.total = total;
122
+ }
123
+ if (docs) {
124
+ checked.value = docs.map(d => d._id || d);
125
+ }
126
+ }
127
+
128
+ async function handleBatchAction({
129
+ label, action, requestOptions = {}, messages
130
+ }) {
131
+ const typeName = getSelectedTypeName();
132
+ const batchModule = typeName && getBatchModule(typeName);
133
+ if (!action || !batchModule?.action) {
134
+ return;
135
+ }
136
+ try {
137
+ await apos.http.post(`${batchModule.action}/${action}`, {
138
+ body: {
139
+ ...requestOptions,
140
+ _ids: checked.value,
141
+ messages,
142
+ type: checked.value.length === 1
143
+ ? moduleLabels.value.singular
144
+ : moduleLabels.value.plural
145
+ }
146
+ });
147
+ } catch (error) {
148
+ apos.notify('apostrophe:errorBatchOperationNoti', {
149
+ interpolate: { operation: label },
150
+ type: 'danger'
151
+ });
152
+ }
153
+ allPiecesSelection.value.isSelected = false;
154
+ checked.value = [];
155
+ reload({ immediate: true });
156
+ }
157
+
158
+ async function fetchTotalCount() {
159
+ const { count } = await fetchPage(1, { count: 1 });
160
+ return count || 0;
161
+ }
162
+
163
+ // Refresh the select-all total count. Called by the orchestrator
164
+ // after page-1 results are applied when batch operations are available.
165
+ async function refreshTotalCount() {
166
+ if (!batchOperations.value.length) {
167
+ return;
168
+ }
169
+ try {
170
+ const total = await fetchTotalCount();
171
+ setAllPiecesSelection({
172
+ isSelected: false,
173
+ total
174
+ });
175
+ } catch {
176
+ // count fetch may fail if filters changed mid-flight.
177
+ }
178
+ }
179
+
180
+ return {
181
+ batchOperations,
182
+ batchFilterValues,
183
+ checked,
184
+ allPiecesSelection,
185
+ selectAllChoice,
186
+ selectAllState,
187
+ selectAll,
188
+ selectAllPieces,
189
+ setAllPiecesSelection,
190
+ handleBatchAction,
191
+ refreshTotalCount
192
+ };
193
+ }
@@ -0,0 +1,276 @@
1
+ import {
2
+ computed, inject, ref, watch
3
+ } from 'vue';
4
+ import { useModalStore } from 'Modules/@apostrophecms/ui/stores/modal';
5
+ import { useRecentlyEditedFilters } from './useRecentlyEditedFilters.js';
6
+ import { useRecentlyEditedFetch } from './useRecentlyEditedFetch.js';
7
+ import { useRecentlyEditedBatch } from './useRecentlyEditedBatch.js';
8
+
9
+ /**
10
+ * Orchestrates filter state, data fetching, batch operations and
11
+ * cross-composable wiring for the recently-edited document manager.
12
+ *
13
+ * @param {string} moduleName
14
+ * @param {Object} [initialFilters={}] - Optional initial filter values.
15
+ * When provided (non-empty), fully replaces the default filter state.
16
+ * Values should be arrays; coerced to scalar/array based on filter inputType.
17
+ */
18
+ export function useRecentlyEditedData(moduleName, initialFilters = {}) {
19
+ const $t = inject('i18n');
20
+ const modalStore = useModalStore();
21
+
22
+ // --- Module-level setup ---
23
+
24
+ const moduleOptions = computed(() => apos.modules[moduleName]);
25
+ const managerFilters = computed(() => moduleOptions.value.filters || []);
26
+ const choiceFilterNames = computed(() => managerFilters.value.map(f => f.name));
27
+ const moduleLabels = computed(() => ({
28
+ singular: moduleOptions.value.label,
29
+ plural: moduleOptions.value.pluralLabel
30
+ }));
31
+ const searchQuery = ref('');
32
+
33
+ // --- Compose sub-composables ---
34
+
35
+ const {
36
+ filterState,
37
+ hasActiveFilters,
38
+ updateFilters,
39
+ clearFilter,
40
+ clearAllFilters
41
+ } = useRecentlyEditedFilters(managerFilters, initialFilters);
42
+
43
+ const {
44
+ items,
45
+ currentPage,
46
+ totalPages,
47
+ isLoading,
48
+ isLoadingMore,
49
+ filterChoices,
50
+ reload,
51
+ loadMore,
52
+ fetchPage,
53
+ cancel: fetchCancel
54
+ } = useRecentlyEditedFetch({
55
+ moduleOptions,
56
+ filterState,
57
+ searchQuery,
58
+ choiceFilterNames
59
+ });
60
+
61
+ const {
62
+ batchOperations,
63
+ batchFilterValues,
64
+ checked,
65
+ allPiecesSelection,
66
+ selectAllState,
67
+ selectAll,
68
+ selectAllPieces,
69
+ setAllPiecesSelection,
70
+ handleBatchAction,
71
+ refreshTotalCount
72
+ } = useRecentlyEditedBatch({
73
+ filterState,
74
+ moduleOptions,
75
+ items,
76
+ fetchPage,
77
+ moduleLabels,
78
+ reload
79
+ });
80
+
81
+ // --- Cross-composable wiring ---
82
+
83
+ // Reload on filter changes. Determine which multiselect filters
84
+ // changed so their choices are excluded from the API request.
85
+ watch(filterState, (next, prev) => {
86
+ const excludeChoices = managerFilters.value
87
+ .filter(f => f.inputType === 'checkbox')
88
+ .filter(f => !arraysEqual(next[f.name], prev?.[f.name]))
89
+ .map(f => f.name);
90
+ checked.value = [];
91
+ reload({ excludeChoices });
92
+ }, { deep: true });
93
+
94
+ // Sync locale filter → modal store locale, so apos.http auto-appends
95
+ // the correct aposLocale to all requests (including child modals).
96
+ watch(() => filterState.value._locale, (newLocale) => {
97
+ const id = modalStore.activeModal?.id;
98
+ if (!id) {
99
+ return;
100
+ }
101
+ const multiLocale = Object.keys(apos.i18n.locales || {}).length > 1;
102
+ const effectiveLocale = Array.isArray(newLocale)
103
+ ? (newLocale.length === 1 ? newLocale[0] : null)
104
+ : newLocale;
105
+ modalStore.updateModalData(id, {
106
+ locale: effectiveLocale || apos.i18n.locale,
107
+ crossLocale: multiLocale && !effectiveLocale
108
+ });
109
+ }, { immediate: true });
110
+
111
+ // Refresh batch total count after each page-1 reload completes.
112
+ watch(isLoading, (loading) => {
113
+ if (!loading) {
114
+ refreshTotalCount();
115
+ }
116
+ });
117
+
118
+ // --- Computed properties ---
119
+
120
+ const crossLocale = computed(() => {
121
+ const locale = filterState.value._locale;
122
+ if (Object.keys(apos.i18n.locales || {}).length <= 1) {
123
+ return false;
124
+ }
125
+ if (Array.isArray(locale)) {
126
+ return locale.length !== 1;
127
+ }
128
+ return !locale;
129
+ });
130
+
131
+ const emptyDisplay = computed(() => {
132
+ const hasSearch = !!searchQuery.value;
133
+ if (hasActiveFilters.value || hasSearch) {
134
+ return {
135
+ icon: 'magnify-icon',
136
+ title: hasSearch
137
+ ? 'apostrophe:recentlyEditedEmptySearched'
138
+ : 'apostrophe:recentlyEditedEmptyFiltered',
139
+ message: hasSearch
140
+ ? 'apostrophe:recentlyEditedEmptySearchedHint'
141
+ : 'apostrophe:recentlyEditedEmptyFilteredHint'
142
+ };
143
+ }
144
+ return {
145
+ icon: 'file-document-multiple-outline-icon',
146
+ title: {
147
+ key: 'apostrophe:recentlyEditedEmpty',
148
+ count: moduleOptions.value.rollingWindowDays || 30
149
+ }
150
+ };
151
+ });
152
+
153
+ const activeFilterTags = computed(() => {
154
+ const tags = [];
155
+ for (const [ name, value ] of Object.entries(filterState.value)) {
156
+ if (value === null || value === undefined || value === '') {
157
+ continue;
158
+ }
159
+ const filter = managerFilters.value.find(f => f.name === name);
160
+ const filterLabel = filter?.label || name;
161
+ const choices = filterChoices.value[name];
162
+
163
+ // Array values (checkbox/combo): one tag per selected item.
164
+ if (Array.isArray(value)) {
165
+ if (!value.length) {
166
+ continue;
167
+ }
168
+ for (const v of value) {
169
+ const choice = choices?.find(c => c.value === v);
170
+ tags.push({
171
+ name,
172
+ value: v,
173
+ label: filterLabel,
174
+ valueLabel: translateLabel(choice?.label ?? v)
175
+ });
176
+ }
177
+ continue;
178
+ }
179
+
180
+ // Scalar values (select/radio).
181
+ const choice = choices?.find(c => c.value === value);
182
+ tags.push({
183
+ name,
184
+ label: filterLabel,
185
+ valueLabel: translateLabel(choice?.label ?? value)
186
+ });
187
+ }
188
+ return tags;
189
+ });
190
+
191
+ // --- Actions ---
192
+
193
+ function onContentChanged() {
194
+ reload({ immediate: true });
195
+ }
196
+
197
+ function onSearch(query) {
198
+ searchQuery.value = query;
199
+ checked.value = [];
200
+ reload();
201
+ }
202
+
203
+ const hasActiveSearch = computed(() => !!searchQuery.value);
204
+
205
+ function clearSearch() {
206
+ searchQuery.value = '';
207
+ checked.value = [];
208
+ reload();
209
+ }
210
+
211
+ function cancel() {
212
+ fetchCancel();
213
+ }
214
+
215
+ // --- Helpers ---
216
+
217
+ function translateLabel(label) {
218
+ if (label && typeof label === 'object') {
219
+ return $t(label);
220
+ }
221
+ if (typeof label === 'string' && label.length) {
222
+ return $t(label);
223
+ }
224
+ return label;
225
+ }
226
+
227
+ return {
228
+ moduleOptions,
229
+ managerFilters,
230
+ moduleLabels,
231
+ emptyDisplay,
232
+ items,
233
+ currentPage,
234
+ totalPages,
235
+ isLoading,
236
+ isLoadingMore,
237
+ filterChoices,
238
+ searchQuery,
239
+ filterState,
240
+ activeFilterTags,
241
+ crossLocale,
242
+ reload,
243
+ loadMore,
244
+ updateFilters,
245
+ clearFilter,
246
+ clearAllFilters,
247
+ hasActiveFilters,
248
+ hasActiveSearch,
249
+ clearSearch,
250
+ onContentChanged,
251
+ onSearch,
252
+ cancel,
253
+ batchOperations,
254
+ batchFilterValues,
255
+ checked,
256
+ allPiecesSelection,
257
+ selectAllState,
258
+ selectAll,
259
+ selectAllPieces,
260
+ setAllPiecesSelection,
261
+ handleBatchAction
262
+ };
263
+ }
264
+
265
+ // Shallow array comparison (order-insensitive, value-based).
266
+ function arraysEqual(a, b) {
267
+ if (!Array.isArray(a) || !Array.isArray(b)) {
268
+ return a === b;
269
+ }
270
+ if (a.length !== b.length) {
271
+ return false;
272
+ }
273
+ const sortedA = [ ...a ].sort();
274
+ const sortedB = [ ...b ].sort();
275
+ return sortedA.every((v, i) => v === sortedB[i]);
276
+ }
@@ -0,0 +1,199 @@
1
+ import {
2
+ computed, inject, ref
3
+ } from 'vue';
4
+ import { debounceAsync, asyncTaskQueue } from 'Modules/@apostrophecms/ui/utils';
5
+
6
+ const DEBOUNCE_TIMEOUT = 300;
7
+
8
+ /**
9
+ * Manages data fetching and pagination for the recently-edited document manager.
10
+ *
11
+ * Uses two separate concurrency mechanisms:
12
+ * - `debounceAsync` coalesces page-1 reloads (used by `reload()`)
13
+ * - `asyncTaskQueue` serializes loadMore requests only
14
+ *
15
+ * @param {object} options
16
+ * @param {import('vue').ComputedRef<object>} options.moduleOptions
17
+ * @param {import('vue').Ref<object>} options.filterState
18
+ * @param {import('vue').Ref<string>} options.searchQuery
19
+ * @param {import('vue').ComputedRef<string[]>} options.choiceFilterNames
20
+ */
21
+ export function useRecentlyEditedFetch({
22
+ moduleOptions, filterState, searchQuery, choiceFilterNames
23
+ }) {
24
+ const $t = inject('i18n');
25
+
26
+ const items = ref([]);
27
+ const currentPage = ref(1);
28
+ const totalPages = ref(1);
29
+ const isLoading = ref(false);
30
+ const loadMorePending = ref(0);
31
+ const isLoadingMore = computed(() => loadMorePending.value > 0);
32
+ const filterChoices = ref({});
33
+
34
+ // Generation counter: incremented on every reload so that
35
+ // in-flight loadMore() calls from a stale dataset are discarded.
36
+ let generation = 0;
37
+ // Multiselect filter names to exclude from the choices request
38
+ // parameter during the next page-1 fetch.
39
+ let pendingExcludeChoices = [];
40
+
41
+ // Serial queue for loadMore calls, ensuring they are processed
42
+ // one at a time in order.
43
+ const queue = asyncTaskQueue();
44
+
45
+ function formatLocale(aposLocale) {
46
+ const code = aposLocale?.split(':')[0];
47
+ const label = apos.i18n.locales[code]?.label;
48
+ return label ? `${label} (${code})` : code;
49
+ }
50
+
51
+ function enrichItem(item) {
52
+ const typeConfig = moduleOptions.value.managedTypes
53
+ ?.find(type => type.name === item.type);
54
+ return {
55
+ ...item,
56
+ title: item.title || $t(typeConfig?.label || item.type),
57
+ _lastEditor: item.updatedBy?.title || item.updatedBy?.username || '',
58
+ _localeLabel: formatLocale(item.aposLocale)
59
+ };
60
+ }
61
+
62
+ async function fetchPage(page = 1, overrides = {}) {
63
+ const qs = {
64
+ page,
65
+ ...overrides
66
+ };
67
+ for (const [ key, val ] of Object.entries(filterState.value)) {
68
+ if (val != null && val !== '' && !(Array.isArray(val) && !val.length)) {
69
+ qs[key] = val;
70
+ }
71
+ }
72
+ if (searchQuery.value) {
73
+ qs.autocomplete = searchQuery.value;
74
+ }
75
+ if (!overrides.perPage && page === 1 && choiceFilterNames.value.length) {
76
+ const requestChoices = choiceFilterNames.value
77
+ .filter(n => !pendingExcludeChoices.includes(n));
78
+ if (requestChoices.length) {
79
+ qs.choices = requestChoices;
80
+ }
81
+ }
82
+ return apos.http.get(moduleOptions.value.action, {
83
+ qs,
84
+ draft: true
85
+ });
86
+ }
87
+
88
+ function applyPage1Results(response) {
89
+ items.value = (response.results || []).map(enrichItem);
90
+ currentPage.value = response.currentPage;
91
+ totalPages.value = response.pages;
92
+ // Merge only keys present in the response — excluded multiselect
93
+ // filters retain their previous choices.
94
+ const incoming = response.choices || {};
95
+ const prev = filterChoices.value;
96
+ const merged = { ...prev };
97
+ for (const [ key, val ] of Object.entries(incoming)) {
98
+ if (choicesChanged(val, merged[key])) {
99
+ merged[key] = preserveSelectedChoices(key, val, prev[key]);
100
+ }
101
+ }
102
+ filterChoices.value = merged;
103
+ isLoading.value = false;
104
+ }
105
+
106
+ // When the server returns narrowed choices that no longer include a
107
+ // currently-selected value, keep that choice entry (with its label)
108
+ // so that dropdowns, combo pills, and filter tags remain functional.
109
+ function preserveSelectedChoices(filterName, incoming, previous) {
110
+ const selected = filterState.value[filterName];
111
+ if (selected == null || !Array.isArray(previous)) {
112
+ return incoming;
113
+ }
114
+ const selectedValues = Array.isArray(selected) ? selected : [ selected ];
115
+ if (!selectedValues.length) {
116
+ return incoming;
117
+ }
118
+ const incomingValues = new Set((incoming || []).map(c => c.value));
119
+ const missing = selectedValues
120
+ .filter(v => !incomingValues.has(v))
121
+ .map(v => previous.find(c => c.value === v))
122
+ .filter(Boolean);
123
+ if (!missing.length) {
124
+ return incoming;
125
+ }
126
+ return [ ...incoming, ...missing ];
127
+ }
128
+
129
+ const debouncedFetch = debounceAsync(
130
+ () => fetchPage(1),
131
+ DEBOUNCE_TIMEOUT,
132
+ { onSuccess: applyPage1Results }
133
+ );
134
+
135
+ function reload({ immediate = false, excludeChoices = [] } = {}) {
136
+ generation++;
137
+ queue.clear();
138
+ pendingExcludeChoices = excludeChoices;
139
+ isLoading.value = true;
140
+ return immediate ? debouncedFetch.skipDelay() : debouncedFetch();
141
+ }
142
+
143
+ function loadMore() {
144
+ loadMorePending.value++;
145
+ const gen = generation;
146
+ // Queue will reject pending request if another loadMore is in progress,
147
+ // so we catch and ignore errors.
148
+ queue.add(async () => {
149
+ if (gen !== generation) {
150
+ return;
151
+ }
152
+ if (currentPage.value >= totalPages.value) {
153
+ return;
154
+ }
155
+ const response = await fetchPage(currentPage.value + 1);
156
+ if (gen !== generation) {
157
+ return;
158
+ }
159
+ items.value = [
160
+ ...items.value,
161
+ ...(response.results || []).map(enrichItem)
162
+ ];
163
+ currentPage.value = response.currentPage;
164
+ totalPages.value = response.pages;
165
+ })
166
+ .catch(() => {})
167
+ .finally(() => {
168
+ loadMorePending.value--;
169
+ });
170
+ }
171
+
172
+ function cancel() {
173
+ debouncedFetch.cancel();
174
+ queue.clear();
175
+ }
176
+
177
+ return {
178
+ items,
179
+ currentPage,
180
+ totalPages,
181
+ isLoading,
182
+ isLoadingMore,
183
+ filterChoices,
184
+ reload,
185
+ loadMore,
186
+ fetchPage,
187
+ cancel
188
+ };
189
+ }
190
+
191
+ function choicesChanged(a, b) {
192
+ if (!Array.isArray(a) || !Array.isArray(b)) {
193
+ return a !== b;
194
+ }
195
+ if (a.length !== b.length) {
196
+ return true;
197
+ }
198
+ return a.some((choice, i) => choice.value !== b[i]?.value);
199
+ }