apostrophe 4.28.1 → 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.
- package/CHANGELOG.md +29 -4
- package/README.md +2 -2
- package/defaults.js +1 -0
- package/lib/safe-json-script.js +27 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarLocale.vue +1 -1
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +1 -0
- package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +3 -5
- package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +13 -1
- package/modules/@apostrophecms/asset/lib/globalIcons.js +3 -0
- package/modules/@apostrophecms/attachment/index.js +43 -1
- package/modules/@apostrophecms/color-field/index.js +7 -1
- package/modules/@apostrophecms/doc/index.js +11 -1
- package/modules/@apostrophecms/doc-type/index.js +165 -32
- package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +1 -1
- package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +104 -59
- package/modules/@apostrophecms/file/index.js +109 -8
- package/modules/@apostrophecms/i18n/i18n/de.json +0 -2
- package/modules/@apostrophecms/i18n/i18n/en.json +40 -1
- package/modules/@apostrophecms/i18n/i18n/es.json +0 -1
- package/modules/@apostrophecms/i18n/i18n/fr.json +0 -1
- package/modules/@apostrophecms/i18n/i18n/it.json +0 -1
- package/modules/@apostrophecms/i18n/i18n/pt-BR.json +0 -1
- package/modules/@apostrophecms/i18n/i18n/sk.json +0 -1
- package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nBatchReporting.js +18 -1
- package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nLocalizeActions.js +50 -0
- package/modules/@apostrophecms/i18n/ui/apos/components/AposI18nLocalize.vue +56 -13
- package/modules/@apostrophecms/image/ui/apos/components/AposImageRelationshipEditor.vue +8 -2
- package/modules/@apostrophecms/layout-column-widget/index.js +156 -163
- package/modules/@apostrophecms/layout-widget/index.js +7 -2
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +6 -11
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +3 -5
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +4 -4
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -16
- package/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs +7 -27
- package/modules/@apostrophecms/layout-widget/views/column.html +7 -9
- package/modules/@apostrophecms/login/index.js +39 -40
- package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +17 -2
- package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +3 -2
- package/modules/@apostrophecms/notification/ui/apos/components/AposNotification.vue +1 -0
- package/modules/@apostrophecms/page/index.js +2 -0
- package/modules/@apostrophecms/piece-type/index.js +3 -1
- package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +1 -0
- package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +5 -0
- package/modules/@apostrophecms/recently-edited/index.js +831 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +54 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedCombo.vue +454 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilterTag.vue +75 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilters.vue +287 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +16 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedManager.vue +346 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedBatch.js +193 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedData.js +276 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFetch.js +199 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFilters.js +100 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js +8 -4
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js +1 -1
- package/modules/@apostrophecms/styles/index.js +10 -0
- package/modules/@apostrophecms/styles/lib/apiRoutes.js +6 -0
- package/modules/@apostrophecms/styles/lib/handlers.js +5 -0
- package/modules/@apostrophecms/styles/lib/methods.js +9 -3
- package/modules/@apostrophecms/styles/lib/presets.js +119 -0
- package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +3 -8
- package/modules/@apostrophecms/styles/ui/apos/composables/AposStyles.js +1 -3
- package/modules/@apostrophecms/styles/ui/apos/render-factory.js +29 -0
- package/modules/@apostrophecms/styles/ui/apos/universal/backgroundHelpers.mjs +140 -0
- package/modules/@apostrophecms/styles/ui/apos/universal/customRules.mjs +105 -0
- package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +195 -15
- package/modules/@apostrophecms/template/index.js +22 -6
- package/modules/@apostrophecms/ui/ui/apos/components/AposCellContextMenu.vue +2 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +18 -4
- package/modules/@apostrophecms/ui/ui/apos/composables/useInfiniteScroll.js +91 -0
- package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
- package/modules/@apostrophecms/ui/ui/apos/stores/modal.js +5 -2
- package/modules/@apostrophecms/ui/ui/apos/utils/index.js +9 -0
- package/modules/@apostrophecms/url/index.js +38 -4
- package/modules/@apostrophecms/widget-type/index.js +22 -6
- package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +8 -4
- package/package.json +17 -17
- package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +2 -2
- package/test/layout-widget-migration.js +719 -0
- package/test/login-requirements.js +1 -1
- package/test/pieces-public-api.js +80 -0
- package/test/pieces.js +25 -0
- package/test/recently-edited.js +2311 -0
- package/test/schemas.js +39 -3
- package/test/static-build.js +642 -0
- package/test/styles.js +2569 -0
- package/.claude/settings.local.json +0 -15
- 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
|
+
}
|