Skip to content

Commit 4521b62

Browse files
author
Robin Angelé
committed
feat: add select-all checkbox, fix important icon and favorite/unfavorite logic
Closes: #4285, #7880 Refs: #6070, #7276, #11526 Add a 'Select all X messages' checkbox above the envelope list that allows selecting all visible messages at once (closes feature requests #4285, #7880). The checkbox uses NcCheckboxRadioSwitch from @nextcloud/vue and shows a count of selectable messages. Lift envelope selection state from individual EnvelopeList instances up to the Mailbox parent component. This enables: - Cross-group shift-click range selection via flat envelope indexing (#7276) - Consistent selection state across grouped envelope lists (Today, Yesterday, etc.) - Global select-all / unselect-all from the parent level - Groundwork for a single unified multiselect header (#11526) Fixes: - Add margin-top to the select-all bar so its upper border is clearly visible and not hidden behind the sticky search header - Fix missing ImportantIcon import and component registration in EnvelopeList (the 'Mark as important' icon would not render during bulk selection) - Fix favorite/unfavorite bulk action logic: the methods now use explicit favFlag values (true/false) instead of inverted computed checks that failed when all selected messages shared the same favorite state Signed-off-by: Robin Angelé <frontend@robin4consulting.com>
1 parent 9da007d commit 4521b62

2 files changed

Lines changed: 197 additions & 24 deletions

File tree

src/components/EnvelopeList.vue

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,15 @@
4343
v-if="isAtLeastOneSelectedFavorite"
4444
variant="tertiary"
4545
:title="n('mail', 'Unfavorite {number}', 'Unfavorite {number}', selection.length, { number: selection.length })"
46-
@click.prevent="favoriteAll">
46+
@click.prevent="unfavoriteAll">
4747
<IconUnFavorite :size="20" />
4848
</NcButton>
4949

5050
<NcButton
5151
v-if="isAtLeastOneSelectedUnFavorite"
5252
variant="tertiary"
5353
:title="n('mail', 'Favorite {number}', 'Favorite {number}', selection.length, { number: selection.length })"
54-
@click.prevent="unFavoriteAll">
54+
@click.prevent="favoriteAll">
5555
<IconFavorite :size="20" />
5656
</NcButton>
5757

@@ -172,6 +172,7 @@ import AlertOctagonIcon from 'vue-material-design-icons/AlertOctagonOutline.vue'
172172
import IconSelect from 'vue-material-design-icons/CloseThick.vue'
173173
import EmailRead from 'vue-material-design-icons/EmailOpenOutline.vue'
174174
import EmailUnread from 'vue-material-design-icons/EmailOutline.vue'
175+
import ImportantIcon from 'vue-material-design-icons/LabelVariant.vue'
175176
import ImportantOutlineIcon from 'vue-material-design-icons/LabelVariantOutline.vue'
176177
import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue'
177178
import AddIcon from 'vue-material-design-icons/Plus.vue'
@@ -204,6 +205,7 @@ export default {
204205
ActionButton,
205206
Envelope,
206207
IconDelete,
208+
ImportantIcon,
207209
ImportantOutlineIcon,
208210
IconFavorite,
209211
IconSelect,
@@ -263,11 +265,20 @@ export default {
263265
type: Boolean,
264266
default: false,
265267
},
268+
269+
selection: {
270+
type: Array,
271+
default: () => [],
272+
},
273+
274+
flatIndex: {
275+
type: Number,
276+
default: 0,
277+
},
266278
},
267279
268280
data() {
269281
return {
270-
selection: [],
271282
showMoveModal: false,
272283
showTagModal: false,
273284
lastToggledIndex: undefined,
@@ -359,10 +370,27 @@ export default {
359370
},
360371
361372
watch: {
373+
selection: {
374+
handler(newSelection) {
375+
// Sync flags.selected with the global selection prop.
376+
// This ensures checkboxes stay correct when another
377+
// EnvelopeList instance changes the selection (e.g. shift-click
378+
// across groups, or Select All / Unselect All).
379+
const selectionSet = new Set(newSelection)
380+
this.sortedEnvelops.forEach((env) => {
381+
env.flags.selected = selectionSet.has(env.databaseId)
382+
})
383+
},
384+
immediate: true,
385+
},
386+
362387
sortedEnvelops(newVal, oldVal) {
363-
// Unselect vanished envelopes
364-
const newIds = newVal.map((env) => env.databaseId)
365-
this.selection = this.selection.filter((id) => newIds.includes(id))
388+
// Unselect vanished envelopes by emitting cleaned selection
389+
const newIds = new Set(newVal.map((env) => env.databaseId))
390+
const cleanedSelection = this.selection.filter((id) => newIds.has(id))
391+
if (cleanedSelection.length !== this.selection.length) {
392+
this.$emit('update:selection', cleanedSelection, this.envelopes)
393+
}
366394
differenceWith((a, b) => a.databaseId === b.databaseId, oldVal, newVal)
367395
.forEach((env) => {
368396
env.flags.selected = false
@@ -451,23 +479,21 @@ export default {
451479
this.unselectAll()
452480
},
453481
454-
favoriteAll() {
455-
const favFlag = !this.isAtLeastOneSelectedUnFavorite
482+
unfavoriteAll() {
456483
this.selectedEnvelopes.forEach((envelope) => {
457484
this.mainStore.markEnvelopeFavoriteOrUnfavorite({
458485
envelope,
459-
favFlag,
486+
favFlag: false,
460487
})
461488
})
462489
this.unselectAll()
463490
},
464491
465-
unFavoriteAll() {
466-
const favFlag = !this.isAtLeastOneSelectedFavorite
492+
favoriteAll() {
467493
this.selectedEnvelopes.forEach((envelope) => {
468494
this.mainStore.markEnvelopeFavoriteOrUnfavorite({
469495
envelope,
470-
favFlag,
496+
favFlag: true,
471497
})
472498
})
473499
this.unselectAll()
@@ -537,16 +563,22 @@ export default {
537563
const alreadySelected = this.selection.includes(envelope.databaseId)
538564
if (selected && !alreadySelected) {
539565
envelope.flags.selected = true
540-
this.selection.push(envelope.databaseId)
541566
} else if (!selected && alreadySelected) {
542567
envelope.flags.selected = false
543-
this.selection.splice(this.selection.indexOf(envelope.databaseId), 1)
544568
}
545569
},
546570
571+
emitLocalSelection() {
572+
const localIds = this.sortedEnvelops
573+
.filter((env) => env.flags.selected)
574+
.map((env) => env.databaseId)
575+
this.$emit('update:selection', localIds, this.envelopes)
576+
},
577+
547578
onEnvelopeSelectToggle(envelope, index, selected) {
548579
this.lastToggledIndex = index
549580
this.setEnvelopeSelected(envelope, selected)
581+
this.emitLocalSelection()
550582
},
551583
552584
onEnvelopeSelectMultiple(envelope, index) {
@@ -557,20 +589,20 @@ export default {
557589
return
558590
}
559591
560-
const start = Math.min(lastToggledIndex, index)
561-
const end = Math.max(lastToggledIndex, index)
562-
const selected = this.selection.includes(envelope.databaseId)
563-
for (let i = start; i <= end; i++) {
564-
this.setEnvelopeSelected(this.sortedEnvelops[i], !selected)
565-
}
592+
// Convert to global flat indices and delegate to the parent
593+
const globalFrom = this.flatIndex + lastToggledIndex
594+
const globalTo = this.flatIndex + index
595+
// If the clicked envelope is already selected, deselect the range
596+
const deselect = this.selection.includes(envelope.databaseId)
597+
this.$emit('select-range', globalFrom, globalTo, deselect)
566598
this.lastToggledIndex = index
567599
},
568600
569601
unselectAll() {
570602
this.sortedEnvelops.forEach((env) => {
571603
env.flags.selected = false
572604
})
573-
this.selection = []
605+
this.$emit('update:selection', [], this.envelopes)
574606
},
575607
576608
onOpenMoveModal() {

src/components/Mailbox.vue

Lines changed: 144 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,16 @@
1717
:slow-hint="t('mail', 'Indexing your messages. This can take a bit longer for larger folders.')" />
1818
<EmptyMailboxSection v-else-if="isPriorityInbox && !hasMessages" key="empty" />
1919
<EmptyMailbox v-else-if="!hasMessages" key="empty" />
20-
<template v-else-if="hasGroupedEnvelopes && !isPriorityInbox">
20+
<div v-else>
21+
<div v-if="!selectMode" class="select-all-bar">
22+
<NcCheckboxRadioSwitch
23+
:model-value="false"
24+
type="checkbox"
25+
@update:checked="selectAll">
26+
{{ n('mail', 'Select {count} message', 'Select all {count} messages', flatEnvelopeList.length, { count: flatEnvelopeList.length }) }}
27+
</NcCheckboxRadioSwitch>
28+
</div>
29+
<template v-if="hasGroupedEnvelopes && !isPriorityInbox">
2130
<div v-for="[label, group] in groupEnvelopes" :key="label">
2231
<SectionTitle class="section-title" :name="getLabelForGroup(label)" />
2332
<EnvelopeList
@@ -28,7 +37,11 @@
2837
:loading-more="false"
2938
:load-more-button="false"
3039
:skip-transition="skipListTransition"
31-
@delete="onDelete" />
40+
:selection="selection"
41+
:flat-index="getGroupFlatIndex(label)"
42+
@delete="onDelete"
43+
@update:selection="onUpdateSelection"
44+
@select-range="onSelectRange" />
3245
</div>
3346
</template>
3447
<EnvelopeList
@@ -41,8 +54,13 @@
4154
:loading-more="loadingMore"
4255
:load-more-button="showLoadMore"
4356
:skip-transition="skipListTransition"
57+
:selection="selection"
58+
:flat-index="0"
4459
@delete="onDelete"
45-
@load-more="loadMore" />
60+
@load-more="loadMore"
61+
@update:selection="onUpdateSelection"
62+
@select-range="onSelectRange" />
63+
</div>
4664
</div>
4765
</template>
4866

@@ -56,6 +74,7 @@ import EnvelopeList from './EnvelopeList.vue'
5674
import Error from './Error.vue'
5775
import Loading from './Loading.vue'
5876
import LoadingSkeleton from './LoadingSkeleton.vue'
77+
import { NcCheckboxRadioSwitch } from '@nextcloud/vue'
5978
import SectionTitle from './SectionTitle.vue'
6079
import MailboxLockedError from '../errors/MailboxLockedError.js'
6180
import MailboxNotCachedError from '../errors/MailboxNotCachedError.js'
@@ -76,6 +95,7 @@ export default {
7695
Error,
7796
Loading,
7897
LoadingSkeleton,
98+
NcCheckboxRadioSwitch,
7999
SectionTitle,
80100
},
81101
@@ -141,6 +161,7 @@ export default {
141161
endReached: false,
142162
syncedMailboxes: new Set(),
143163
skipListTransition: false,
164+
selection: [],
144165
}
145166
},
146167
@@ -175,10 +196,37 @@ export default {
175196
showLoadMore() {
176197
return !this.endReached && this.paginate === 'manual'
177198
},
199+
200+
/**
201+
* Flat list of all visible envelopes, regardless of grouping.
202+
* Used for shift-click range selection and Select All.
203+
*/
204+
flatEnvelopeList() {
205+
if (this.hasGroupedEnvelopes) {
206+
return this.groupEnvelopes.flatMap(([, group]) => group)
207+
}
208+
return this.envelopesToShow
209+
},
210+
211+
/**
212+
* Whether selection mode is active (at least one envelope selected).
213+
*/
214+
selectMode() {
215+
return this.selection.length > 0
216+
},
217+
218+
/**
219+
* Whether all visible envelopes are currently selected.
220+
*/
221+
allSelected() {
222+
return this.flatEnvelopeList.length > 0
223+
&& this.selection.length === this.flatEnvelopeList.length
224+
},
178225
},
179226
180227
watch: {
181228
mailbox() {
229+
this.selection = []
182230
this.loadEnvelopes()
183231
.then(() => {
184232
logger.debug(`syncing mailbox ${this.mailbox.databaseId} (${this.query}) after folder change`)
@@ -187,10 +235,12 @@ export default {
187235
},
188236
189237
searchQuery() {
238+
this.selection = []
190239
this.loadEnvelopes()
191240
},
192241
193242
sortOrder() {
243+
this.selection = []
194244
this.loadEnvelopes()
195245
},
196246
},
@@ -542,6 +592,9 @@ export default {
542592
// onDelete(id): Load more message and navigate to other message if needed
543593
// id: The id of the message being delete
544594
onDelete(id) {
595+
// Remove from selection if selected
596+
this.selection = this.selection.filter((selectedId) => selectedId !== id)
597+
545598
// Get a new message
546599
this.mainStore.fetchNextEnvelopes({
547600
mailboxId: this.mailbox.databaseId,
@@ -603,6 +656,82 @@ export default {
603656
this.loadMailboxInterval = undefined
604657
},
605658
659+
/**
660+
* Compute the flat index offset for a group label.
661+
* Used by grouped EnvelopeList children to emit
662+
* correct global indices for shift-click range selection.
663+
*
664+
* @param {string} label The group label key
665+
* @return {number} Flat index of the first envelope in this group
666+
*/
667+
getGroupFlatIndex(label) {
668+
let offset = 0
669+
for (const [groupLabel, group] of this.groupEnvelopes) {
670+
if (groupLabel === label) {
671+
return offset
672+
}
673+
offset += group.length
674+
}
675+
return offset
676+
},
677+
678+
/**
679+
* Handle a child EnvelopeList updating its selection.
680+
* The child emits its full new selection array (local IDs),
681+
* and we merge it with the global selection, replacing
682+
* any IDs from this child's visible envelope set.
683+
*
684+
* @param {number[]} childSelection Array of selected envelope databaseIds
685+
* @param {object[]} childEnvelopes Array of envelopes visible in this child
686+
*/
687+
onUpdateSelection(childSelection, childEnvelopes) {
688+
const childIds = new Set(childEnvelopes.map((e) => e.databaseId))
689+
// Remove all IDs from this child's scope, then add the new selection
690+
this.selection = this.selection.filter((id) => !childIds.has(id))
691+
this.selection.push(...childSelection)
692+
},
693+
694+
/**
695+
* Handle shift-click range selection across the flat envelope list.
696+
* Called by a child EnvelopeList with global flat indices.
697+
*
698+
* @param {number} fromIndex Start of the range (global flat index)
699+
* @param {number} toIndex End of the range (global flat index)
700+
* @param {boolean} deselect If true, remove the range from selection
701+
*/
702+
onSelectRange(fromIndex, toIndex, deselect = false) {
703+
const start = Math.min(fromIndex, toIndex)
704+
const end = Math.max(fromIndex, toIndex)
705+
const idsInRange = new Set(
706+
this.flatEnvelopeList
707+
.slice(start, end + 1)
708+
.map((e) => e.databaseId),
709+
)
710+
if (deselect) {
711+
this.selection = this.selection.filter((id) => !idsInRange.has(id))
712+
} else {
713+
const newSelection = new Set(this.selection)
714+
for (const id of idsInRange) {
715+
newSelection.add(id)
716+
}
717+
this.selection = [...newSelection]
718+
}
719+
},
720+
721+
/**
722+
* Select all visible envelopes.
723+
*/
724+
selectAll() {
725+
this.selection = this.flatEnvelopeList.map((e) => e.databaseId)
726+
},
727+
728+
/**
729+
* Clear the current selection.
730+
*/
731+
unselectAll() {
732+
this.selection = []
733+
},
734+
606735
getLabelForGroup(group) {
607736
switch (group) {
608737
case 'lastHour':
@@ -636,4 +765,16 @@ export default {
636765
display: flex;
637766
justify-content: center;
638767
}
768+
769+
.select-all-bar {
770+
display: flex;
771+
align-items: center;
772+
margin-top: 8px;
773+
padding: 4px 8px;
774+
cursor: pointer;
775+
border-bottom: 1px solid var(--color-border);
776+
&:hover {
777+
background-color: var(--color-background-hover);
778+
}
779+
}
639780
</style>

0 commit comments

Comments
 (0)