Skip to content

Commit 0aa3d50

Browse files
committed
feat(ContactsList): add multiaction for batch adding to group and changing addressbook
Signed-off-by: Grigory Vodyanov <[email protected]> # Conflicts: # src/components/ContactsList.vue # src/components/ContactsList/ContactsListItem.vue
1 parent 96b9484 commit 0aa3d50

File tree

3 files changed

+339
-11
lines changed

3 files changed

+339
-11
lines changed

src/components/ContactsList.vue

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,22 @@
3535
<Merging :contacts="multiSelectedContacts" @finished="finishContactMerging" />
3636
</NcModal>
3737

38+
<NcModal
39+
v-if="isGrouping"
40+
:name="t('contacts', 'Add contacts to group')"
41+
size="large"
42+
@close="isGrouping = false">
43+
<Batch :contacts="Array.from(multiSelectedContacts.values())" mode="grouping" @submit="isGrouping = false" />
44+
</NcModal>
45+
46+
<NcModal
47+
v-if="isMovingAddressbook"
48+
:name="t('contacts', 'Move contacts to addressbook')"
49+
size="large"
50+
@close="isMovingAddressbook = false">
51+
<Batch :contacts="Array.from(multiSelectedContacts.values())" mode="ab" @submit="isMovingAddressbook = false" />
52+
</NcModal>
53+
3854
<div class="contacts-list__header">
3955
<div class="search-contacts-field">
4056
<NcTextField
@@ -72,6 +88,24 @@
7288
<IconSetMerge :size="20" />
7389
</NcButton>
7490
<NcLoadingIcon v-else :size="20" />
91+
<NcButton
92+
variant="tertiary"
93+
:title="groupActionTitle"
94+
:disabled="!isAtLeastOneEditable"
95+
:close-after-click="true"
96+
@submit="finishBatch"
97+
@click.prevent="isGrouping = true">
98+
<IconAccountMultiple :size="20" />
99+
</NcButton>
100+
<NcButton
101+
variant="tertiary"
102+
:title="addressbookActionTitle"
103+
:disabled="!isAtLeastOneEditable"
104+
:close-after-click="true"
105+
@submit="finishBatch"
106+
@click.prevent="isMovingAddressbook = true">
107+
<IconBookAccount :size="20" />
108+
</NcButton>
75109
</div>
76110
</transition>
77111

@@ -103,9 +137,12 @@ import {
103137
NcTextField,
104138
} from '@nextcloud/vue'
105139
import { VList } from 'virtua/vue'
140+
import IconAccountMultiple from 'vue-material-design-icons/AccountMultipleOutline.vue'
141+
import IconBookAccount from 'vue-material-design-icons/BookAccountOutline.vue'
106142
import IconSelect from 'vue-material-design-icons/CloseThick.vue'
107143
import IconSetMerge from 'vue-material-design-icons/SetMerge.vue'
108144
import IconDelete from 'vue-material-design-icons/TrashCanOutline.vue'
145+
import Batch from './ContactsList/Batch.vue'
109146
import ContactsListItem from './ContactsList/ContactsListItem.vue'
110147
import Merging from './ContactsList/Merging.vue'
111148
import RouterMixin from '../mixins/RouterMixin.js'
@@ -121,11 +158,14 @@ export default {
121158
IconSelect,
122159
IconDelete,
123160
IconSetMerge,
161+
IconAccountMultiple,
162+
IconBookAccount,
124163
NcDialog,
125164
NcModal,
126165
Merging,
127166
NcLoadingIcon,
128167
ContactsListItem,
168+
Batch,
129169
NcTextField,
130170
},
131171
@@ -178,6 +218,8 @@ export default {
178218
lastToggledIndex: undefined,
179219
isMerging: false,
180220
isMergingLoading: false,
221+
isGrouping: false,
222+
isMovingAddressbook: false,
181223
}
182224
},
183225
@@ -233,6 +275,18 @@ export default {
233275
? t('contacts', 'Merge contacts')
234276
: t('contacts', 'Please select two editable contacts to merge')
235277
},
278+
279+
groupActionTitle() {
280+
return this.isAtLeastOneEditable
281+
? n('contacts', 'Add {number} contact to group', 'Add {number} contacts to group', this.multiSelectedContacts.size, { number: this.multiSelectedContacts.size })
282+
: t('contacts', 'Please select at least one editable contact to add to a group')
283+
},
284+
285+
addressbookActionTitle() {
286+
return this.isAtLeastOneEditable
287+
? n('contacts', 'Move {number} contact to addressbook', 'Move {number} contacts to addressbook', this.multiSelectedContacts.size, { number: this.multiSelectedContacts.size })
288+
: t('contacts', 'Please select at least one editable contact to move to an addressbook')
289+
},
236290
},
237291
238292
watch: {
@@ -403,6 +457,17 @@ export default {
403457
name: 'root',
404458
})
405459
},
460+
461+
async finishBatch() {
462+
this.isGrouping = false
463+
this.isMovingAddressbook = false
464+
465+
for (const contact of this.multiSelectedContacts.values()) {
466+
await this.$store.dispatch('fetchFullContact', { contact, forceReFetch: true })
467+
}
468+
469+
this.unselectAllMultiSelected()
470+
},
406471
},
407472
}
408473
</script>
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<template>
7+
<div class="batch">
8+
<div class="batch__title">
9+
<h3 v-if="mode === 'grouping'">
10+
{{ t('contacts', 'Add contacts to groups') }}
11+
</h3>
12+
<h3 v-if="mode === 'ab'">
13+
{{ t('contacts', 'Move contacts to addressbook') }}
14+
</h3>
15+
</div>
16+
17+
<NcSelect v-if="mode === 'grouping'"
18+
v-model="selectedGroups"
19+
:input-label="t('contacts', 'Select groups')"
20+
:multiple="true"
21+
:options="groupOptions" />
22+
23+
<!-- Addressbook selector for move mode -->
24+
<NcSelect v-if="mode === 'ab'"
25+
v-model="selectedAddressesBook"
26+
:input-label="t('contacts', 'Select addressbook')"
27+
:options="addressbookOptions" />
28+
29+
<h6>{{ t('contacts', 'Selected contacts') }}</h6>
30+
<NcNoteCard v-if="amountOfReadOnlyContacts > 0" type="info">
31+
{{ t('contacts', 'Please note that {count} contact{p} readonly and will not be modified.', { count: amountOfReadOnlyContacts, p: amountOfReadOnlyContacts === 1 ? ' is' : 's are' }) }}
32+
</NcNoteCard>
33+
34+
<div class="contacts-list">
35+
<div v-for="(contact, index) in contactsLimited" :key="contact.key" class="contact-item">
36+
<ContactsListItem :key="contact.key"
37+
:class="{ disabled: !contact.addressbook.canModifyCard }"
38+
:index="index"
39+
:source="contact"
40+
:reload-bus="reloadBus"
41+
:title="contact.addressbook.canModifyCard ? '' : t('contacts', 'This contact is read-only and cannot be modified.')"
42+
:is-static="true" />
43+
</div>
44+
</div>
45+
46+
<NcButton v-if="contacts.length > 9"
47+
variant="secondary"
48+
@click="showAllContacts = !showAllContacts">
49+
<template #icon>
50+
<IconPlus :size="20" />
51+
</template>
52+
{{ t('contacts', showAllContacts ? 'Show less' : 'Show all') }}
53+
</NcButton>
54+
55+
<div class="batch__footer">
56+
<NcButton v-if="mode === 'grouping'"
57+
variant="primary"
58+
:disabled="selectedGroups.length === 0"
59+
@click="submit">
60+
<template #icon>
61+
<IconAccountPlus :size="20" />
62+
</template>
63+
{{ t('contacts', 'Add') }}
64+
</NcButton>
65+
<NcButton v-if="mode === 'ab'"
66+
variant="primary"
67+
:disabled="!selectedAddressesBook"
68+
@click="submit">
69+
<template #icon>
70+
<IconBookArrow :size="20" />
71+
</template>
72+
{{ t('contacts', 'Move') }}
73+
</NcButton>
74+
</div>
75+
</div>
76+
</template>
77+
78+
<script>
79+
import ContactsListItem from './ContactsListItem.vue'
80+
import { NcButton, NcSelect, NcNoteCard } from '@nextcloud/vue'
81+
import IconPlus from 'vue-material-design-icons/Plus.vue'
82+
import IconAccountPlus from 'vue-material-design-icons/AccountMultiplePlusOutline.vue'
83+
import IconBookArrow from 'vue-material-design-icons/BookArrowRightOutline.vue'
84+
import appendContactToGroup from '../../services/appendContactToGroup.js'
85+
86+
export default {
87+
name: 'Batch',
88+
89+
components: {
90+
ContactsListItem,
91+
NcButton,
92+
NcSelect,
93+
IconPlus,
94+
IconAccountPlus,
95+
IconBookArrow,
96+
NcNoteCard,
97+
},
98+
99+
props: {
100+
contacts: {
101+
type: Array,
102+
required: true,
103+
},
104+
mode: {
105+
type: String,
106+
required: false,
107+
default: 'grouping',
108+
},
109+
},
110+
111+
emits: ['submit'],
112+
113+
data() {
114+
return {
115+
reloadBus: null,
116+
showAllContacts: false,
117+
selectedGroups: [],
118+
selectedAddressesBook: null,
119+
}
120+
},
121+
122+
computed: {
123+
contactsLimited() {
124+
if (this.showAllContacts) {
125+
return this.contacts
126+
}
127+
return this.contacts.slice(0, 9)
128+
},
129+
groupOptions() {
130+
return this.$store.getters.getGroups.map(group => ({
131+
label: group.name,
132+
value: group.name,
133+
}))
134+
},
135+
amountOfReadOnlyContacts() {
136+
return this.contacts.filter(contact => !contact.addressbook.canModifyCard).length
137+
},
138+
addressbookOptions() {
139+
// Provide only enabled, writable addressbooks to move to
140+
return this.$store.getters.getAddressbooks
141+
.filter(ab => !ab.readOnly && ab.enabled)
142+
.map(ab => ({ label: ab.displayName || ab.label || ab.addressbook, value: ab.id || ab.addressbook }))
143+
},
144+
},
145+
146+
methods: {
147+
submit() {
148+
if (this.mode === 'grouping') {
149+
this.group()
150+
}
151+
152+
if (this.mode === 'ab') {
153+
this.moveToAddressbook()
154+
}
155+
},
156+
157+
async group() {
158+
const allGroups = this.$store.getters.getGroups
159+
160+
// Add to groups
161+
this.selectedGroups.forEach(selectedGroup => {
162+
const group = allGroups.find(g => g.name === selectedGroup.value)
163+
if (!group) {
164+
console.error('Cannot add contact to an undefined group', selectedGroup)
165+
return
166+
}
167+
this.contacts.forEach(contact => {
168+
if (!contact.addressbook.canModifyCard) return // skip read-only for groups
169+
if (contact.groups && contact.groups.includes(group.name)) return
170+
appendContactToGroup(contact, group.name)
171+
.then(() => {
172+
this.$store.dispatch('addContactToGroup', { contact, groupName: group.name })
173+
})
174+
.catch((error) => {
175+
console.error(error)
176+
})
177+
})
178+
})
179+
180+
this.$emit('submit')
181+
},
182+
183+
async moveToAddressbook() {
184+
if (!this.selectedAddressesBook) return
185+
const addressbook = this.$store.getters.getAddressbooks.find(ab => ab.id === this.selectedAddressesBook.value)
186+
if (!addressbook) {
187+
console.error('Selected addressbook not found', this.selectedAddressesBook)
188+
return
189+
}
190+
191+
const movePromises = this.contacts.map(async (contact) => {
192+
if (!contact.addressbook.canModifyCard || contact.addressbook.id === addressbook.id) {
193+
return null
194+
}
195+
try {
196+
await this.$store.dispatch('moveContactToAddressbook', { contact, addressbook })
197+
return contact
198+
} catch (error) {
199+
console.error('Failed to move contact', contact, error)
200+
return null
201+
}
202+
})
203+
204+
await Promise.all(movePromises)
205+
this.$emit('submit')
206+
},
207+
},
208+
}
209+
</script>
210+
211+
<style lang="scss" scoped>
212+
213+
.batch {
214+
margin: calc(var(--default-grid-baseline) * 8);
215+
216+
&__title {
217+
margin-bottom: var(--default-grid-baseline);
218+
}
219+
220+
&__footer {
221+
width: 100%;
222+
display: flex;
223+
justify-content: flex-end;
224+
}
225+
226+
.contacts-list {
227+
display: grid;
228+
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
229+
gap: var(--default-grid-baseline);
230+
margin: calc(var(--default-grid-baseline) * 2) 0;
231+
232+
.disabled {
233+
opacity: 0.5;
234+
}
235+
}
236+
}
237+
</style>

0 commit comments

Comments
 (0)