Skip to content

Commit 648af36

Browse files
authored
Pro 8000 batch tagging reordering (#4998)
* tags are properly sorted when opening popover, or going back to list. Not when checking or unchecking. * when using `__apostGetWithQuery` in body, we keep existing `req.query` instead of erasing it (to keep things like mode and locale). * don't get images when requesting tags only, lighter request. * search empty states wins over the no tags yet state. * hides create button if user has no create permission on tags. * improves tags watcher, works if a tag has its title or slug updated.
1 parent 33409ea commit 648af36

4 files changed

Lines changed: 100 additions & 59 deletions

File tree

modules/@apostrophecms/express/index.js

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -423,17 +423,13 @@ module.exports = {
423423
convertPostToGetWithQuery(req, res, next) {
424424
if (req.method === 'POST' && req.body?.__aposGetWithQuery) {
425425
req.method = 'GET';
426-
req.query = req.body.__aposGetWithQuery;
426+
req.query = {
427+
...req.query,
428+
...req.body.__aposGetWithQuery
429+
};
427430
delete req.body;
428-
if (!req.url.includes('?')) {
429-
req.url = req.url + '?' + qs.stringify(req.query);
430-
} else {
431-
const [ url, queryString ] = req.url.split('?');
432-
const firstPart = queryString.endsWith('&')
433-
? queryString
434-
: (queryString ? `${queryString}&` : '');
435-
req.url = `${url}?${firstPart}${qs.stringify(req.query)}`;
436-
}
431+
const [ url ] = req.url.split('?');
432+
req.url = `${url}?${qs.stringify(req.query)}`;
437433
}
438434

439435
return next();

modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -389,15 +389,16 @@ export default {
389389
// We never filter the tag list because they are presented like
390390
// folders, and folders don't disappear when empty. So we need to make
391391
// a separate query for distinct tags if our first query was filtered
392-
const tagApiResponse = await apos.http.get(
393-
this.moduleOptions.action, {
394-
busy: true,
395-
qs: {
396-
choices: '_tags'
397-
},
398-
draft: true
399-
}
400-
);
392+
const tagApiResponse = await apos.http.get(this.moduleOptions.action, {
393+
busy: true,
394+
qs: {
395+
choices: '_tags',
396+
// Don't get useless data (minimimum per page is 1)
397+
perPage: 1,
398+
project: { title: 1 }
399+
},
400+
draft: true
401+
});
401402
result.tagList = tagApiResponse.choices._tags;
402403
} else {
403404
result.tagList = apiResponse.choices ? apiResponse.choices._tags : [];

modules/@apostrophecms/piece-type/index.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ module.exports = {
261261
count
262262
};
263263
}
264+
264265
result.pages = query.get('totalPages');
265266
result.currentPage = query.get('page') || 1;
266267
result.results = (await query.toArray())
@@ -272,11 +273,14 @@ module.exports = {
272273
inline
273274
});
274275
}
275-
if (query.get('choicesResults')) {
276-
result.choices = query.get('choicesResults');
276+
277+
const choicesResult = query.get('choicesResults');
278+
if (choicesResult) {
279+
result.choices = choicesResult;
277280
}
278-
if (query.get('countsResults')) {
279-
result.counts = query.get('countsResults');
281+
const countsResult = query.get('countsResults');
282+
if (countsResult) {
283+
result.counts = countsResult;
280284
}
281285

282286
if (

modules/@apostrophecms/ui/ui/apos/components/AposTagApply.vue

Lines changed: 76 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
:disabled="isDisabled"
77
class="apos-apply-tag-menu"
88
:class="{ 'apos-apply-tag-menu--create-ui': createUi }"
9-
@open="isOpen = $event"
10-
@close="clearSearch"
9+
@open="openPopover"
10+
@close="closePopover"
1111
>
1212
<div class="apos-apply-tag-menu__inner">
1313
<AposInputString
@@ -18,6 +18,7 @@
1818
/>
1919
<div class="apos-apply-tag-menu__create">
2020
<AposButton
21+
v-if="canCreate"
2122
class="apos-apply-tag-menu__create-tag-btn"
2223
:label="createBtnLabel"
2324
:disabled="!createUi && isTagFound"
@@ -58,6 +59,7 @@
5859
{{ noTagsTranslation }}
5960
</p>
6061
<AposButton
62+
v-if="canCreate"
6163
class="apos-apply-tag-menu__empty-create-btn"
6264
:label="noTagsCreateLabel"
6365
type="quiet"
@@ -75,7 +77,7 @@
7577
class="apos-apply-tag-menu__btn"
7678
type="secondary"
7779
label="apostrophe:cancel"
78-
@click.stop="closeCreateUi"
80+
@click.stop="toggleCreateUi"
7981
/>
8082
<AposButton
8183
class="apos-apply-tag-menu__btn"
@@ -91,7 +93,7 @@
9193

9294
<script setup>
9395
import {
94-
computed, inject, ref, useTemplateRef
96+
computed, inject, ref, useTemplateRef, watch
9597
} from 'vue';
9698
9799
const $t = inject('i18n');
@@ -138,6 +140,9 @@ const contextMenuEl = useTemplateRef('contextMenu');
138140
const isOpen = ref(false);
139141
const searchValue = ref({ data: '' });
140142
const createUi = ref(false);
143+
const sortedTags = ref([]);
144+
const unwatchTags = ref(null);
145+
const canCreate = apos.modules['@apostrophecms/image-tag'].canCreate;
141146
142147
const applyToIds = computed(() => {
143148
return Object.keys(props.applyTo);
@@ -154,32 +159,9 @@ const isTagFound = computed(() => {
154159
});
155160
156161
const noTagsTranslation = computed(() => {
157-
return props.tags.length
158-
? $t('apostrophe:tagNoResultFor', { tag: searchValue.value.data })
159-
: $t('apostrophe:tagNoTagsYet');
160-
});
161-
162-
// Sort checked first
163-
// then indeterminate
164-
// and finally unchecked alphabetically
165-
const sortedTags = computed(() => {
166-
if (!applyToIds.value.length) {
167-
return props.tags;
168-
}
169-
170-
const checked = props.tags.filter(tag =>
171-
checkboxes.value[tag.slug].model.value === true &&
172-
checkboxes.value[tag.slug].choice.indeterminate !== true
173-
);
174-
const indeterminate = props.tags.filter(tag =>
175-
checkboxes.value[tag.slug].model.value === true &&
176-
checkboxes.value[tag.slug].choice.indeterminate === true
177-
);
178-
const unchecked = props.tags.filter(tag =>
179-
checkboxes.value[tag.slug].model.value !== true
180-
);
181-
182-
return [].concat(checked, indeterminate, unchecked);
162+
return !props.tags.length && !searchValue.value.data
163+
? $t('apostrophe:tagNoTagsYet')
164+
: $t('apostrophe:tagNoResultFor', { tag: searchValue.value.data });
183165
});
184166
185167
// Unless we're in the middle of creating a new tag,
@@ -242,7 +224,55 @@ const checkboxes = computed(() => {
242224
return state;
243225
});
244226
245-
// methods
227+
function openPopover() {
228+
isOpen.value = true;
229+
sortTags();
230+
textInputEl.value.$el.querySelector('input').focus();
231+
232+
unwatchTags.value = watch(() => props.tags, (newVal, oldVal) => {
233+
if (triggerTagsWatcher(oldVal, newVal)) {
234+
sortTags();
235+
}
236+
});
237+
}
238+
239+
function triggerTagsWatcher(oldTags, newTags) {
240+
if (oldTags.length !== newTags.length) {
241+
return true;
242+
}
243+
244+
return newTags.some((tag, index) => {
245+
const oldTag = oldTags[index];
246+
return oldTag.title !== tag.title || oldTag.slug !== tag.slug;
247+
});
248+
}
249+
250+
function closePopover() {
251+
unwatchTags.value?.();
252+
clearSearch();
253+
}
254+
255+
// Sort checked first then indeterminate and finally unchecked alphabetically
256+
function sortTags() {
257+
if (!applyToIds.value.length) {
258+
return props.tags;
259+
}
260+
261+
const checked = props.tags.filter(tag =>
262+
checkboxes.value[tag.slug].model.value === true &&
263+
checkboxes.value[tag.slug].choice.indeterminate !== true
264+
);
265+
const indeterminate = props.tags.filter(tag =>
266+
checkboxes.value[tag.slug].model.value === true &&
267+
checkboxes.value[tag.slug].choice.indeterminate === true
268+
);
269+
const unchecked = props.tags.filter(tag =>
270+
checkboxes.value[tag.slug].model.value !== true
271+
);
272+
273+
sortedTags.value = [].concat(checked, indeterminate, unchecked);
274+
}
275+
246276
function clearSearch() {
247277
searchValue.value.data = '';
248278
closeCreateUi();
@@ -270,7 +300,11 @@ async function createOrManage() {
270300
});
271301
272302
emit('refresh-data');
273-
contextMenuEl.value.show();
303+
304+
if (contextMenuEl.value) {
305+
contextMenuEl.value.show();
306+
}
307+
274308
return;
275309
}
276310
@@ -287,14 +321,13 @@ function createOrSearch() {
287321
}
288322
289323
toggleCreateUi();
290-
textInputEl.value.$el.querySelector('input').focus();
291324
}
292325
293326
// Create a new tag, or set up the input with "New Tag" if empty.
294327
function create() {
295328
// The string input's `return` event still submits duplicates, so prevent
296329
// them here.
297-
if (isTagFound.value) {
330+
if (isTagFound.value || !canCreate) {
298331
return;
299332
}
300333
@@ -305,6 +338,10 @@ function create() {
305338
306339
function toggleCreateUi() {
307340
createUi.value = !createUi.value;
341+
if (!createUi.value) {
342+
sortTags();
343+
}
344+
textInputEl.value.$el.querySelector('input').focus();
308345
}
309346
310347
function closeCreateUi() {
@@ -442,14 +479,17 @@ function getCheckedState(tag) {
442479
443480
& {
444481
overflow: hidden;
445-
margin-top: 0;
446-
margin-bottom: 10px;
482+
margin: 0;
447483
font-size: var(--a-type-heading);
448484
text-align: center;
449485
text-overflow: ellipsis;
450486
max-width: 100%;
451487
text-wrap: nowrap;
452488
}
489+
490+
+ .apos-button__wrapper {
491+
margin-top: 10px;
492+
}
453493
}
454494
455495
.apos-apply-tag-menu__empty-create-btn {

0 commit comments

Comments
 (0)