Skip to content

Commit 72fc93e

Browse files
committed
feat: optimize member list
Signed-off-by: Kostiantyn Miakshyn <molodchick@gmail.com>
1 parent 81a9b75 commit 72fc93e

7 files changed

Lines changed: 216 additions & 74 deletions

File tree

src/components/AppContent/CircleContent.vue

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,6 @@ export default {
5858
},
5959
},
6060
61-
data() {
62-
return {
63-
loadingList: false,
64-
}
65-
},
66-
6761
computed: {
6862
// store variables
6963
circles() {
@@ -82,25 +76,10 @@ export default {
8276
return Object.values(this.circle?.members || [])
8377
},
8478
85-
/**
86-
* Is the current circle empty
87-
*
88-
* @return {boolean}
89-
*/
90-
isEmptyCircle() {
91-
return this.members.length === 0
92-
},
93-
9479
...mapStores(useUserGroupStore),
9580
},
9681
9782
watch: {
98-
circle(newCircle) {
99-
if (newCircle?.id) {
100-
this.fetchCircleMembers(newCircle.id)
101-
}
102-
},
103-
10483
userGroup(newUserGroup) {
10584
if (newUserGroup?.id) {
10685
this.fetchUserGroupMembers(newUserGroup.id)
@@ -109,40 +88,18 @@ export default {
10988
},
11089
11190
beforeMount() {
112-
if (this.circle?.id) {
113-
this.fetchCircleMembers(this.circle.id)
114-
}
115-
11691
if (this.userGroup?.id) {
11792
this.fetchUserGroupMembers(this.userGroup.id)
11893
}
11994
},
12095
12196
methods: {
122-
async fetchCircleMembers(circleId) {
123-
this.loadingList = true
124-
this.logger.debug('Fetching members for', { circleId })
125-
126-
try {
127-
await this.$store.dispatch('getCircleMembers', circleId)
128-
} catch (error) {
129-
console.error(error)
130-
showError(t('contacts', 'There was an error fetching the member list'))
131-
} finally {
132-
this.loadingList = false
133-
}
134-
},
135-
13697
async fetchUserGroupMembers(userGroupId) {
137-
this.loadingList = true
138-
13998
try {
14099
await this.userGroupStore.getUserGroupMembers(userGroupId)
141100
} catch (error) {
142101
console.error(error)
143102
showError(t('contacts', 'There was an error fetching the member list'))
144-
} finally {
145-
this.loadingList = false
146103
}
147104
},
148105
},

src/components/MemberList/MemberList.vue

Lines changed: 156 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55

66
<template>
77
<section class="member-list">
8-
<NcEmptyContent v-if="loading" class="empty-content" :name="t('contacts', 'Loading members list …')">
8+
<NcEmptyContent v-if="loading || loadingList" class="empty-content" :name="t('contacts', 'Loading members list …')">
99
<template #icon>
10-
<IconLoading :size="20" />
10+
<NcLoadingIcon :size="20" />
1111
</template>
1212
</NcEmptyContent>
1313

@@ -20,26 +20,50 @@
2020
</template>
2121
</NcEmptyContent>
2222

23-
<NcEmptyContent
24-
v-else-if="!hasMembers"
25-
class="empty-content"
26-
:name="t('contacts', 'You currently have no access to the member list')">
27-
<template #icon>
28-
<IconContact :size="20" />
23+
<template v-else>
24+
<div style="display: flex; margin-bottom: 2rem;">
25+
<NcTextField
26+
v-model="searchQuery"
27+
:label="t('contacts', 'Search among current members')"
28+
trailing-button-icon="close"
29+
:show-trailing-button="searchQuery !== ''"
30+
@trailing-button-click="clearSearchField">
31+
<IconSearch :size="20" />
32+
</NcTextField>
33+
34+
<NcSelect
35+
v-model="searchRole"
36+
:options="roles"
37+
:multiple="false"
38+
style="margin-top: 6px;min-width: 200px;margin-left: 1rem;" />
39+
</div>
40+
41+
<NcEmptyContent
42+
v-if="!hasMembers"
43+
class="empty-content"
44+
:name="hasActiveFilters ? t('contacts', 'No members found matching your search') : t('contacts', 'You currently have no access to the member list')">
45+
<template #icon>
46+
<IconContact :size="20" />
47+
</template>
48+
</NcEmptyContent>
49+
50+
<template v-else>
51+
<VList
52+
v-slot="{ item }"
53+
class="member-list__virtual"
54+
:style="virtualListStyle"
55+
:data="flatList">
56+
<MemberGridItem
57+
:key="`member-grid-item-${item.id}`"
58+
:member="item"
59+
:is-team="!item.isUser" />
60+
</VList>
61+
62+
<div v-if="isMembersLisTooLarge" class="member-list__too-large">
63+
{{ membershipTooLargeMessage }}
64+
</div>
2965
</template>
30-
</NcEmptyContent>
31-
32-
<VList
33-
v-else
34-
v-slot="{ item }"
35-
class="member-list__virtual"
36-
:style="virtualListStyle"
37-
:data="flatList">
38-
<MemberGridItem
39-
:key="`member-grid-item-${item.id}`"
40-
:member="item"
41-
:is-team="!item.isUser" />
42-
</VList>
66+
</template>
4367

4468
<!-- member picker -->
4569
<EntityPicker
@@ -62,26 +86,38 @@
6286
import { showError, showWarning } from '@nextcloud/dialogs'
6387
import { subscribe } from '@nextcloud/event-bus'
6488
import { t } from '@nextcloud/l10n'
65-
import { NcEmptyContent } from '@nextcloud/vue'
89+
import { NcEmptyContent, NcLoadingIcon, NcSelect, NcTextField } from '@nextcloud/vue'
90+
import { refDebounced } from '@vueuse/core'
6691
import { VList } from 'virtua/vue'
67-
import { defineComponent } from 'vue'
92+
import { defineComponent, ref } from 'vue'
6893
import IconContact from 'vue-material-design-icons/AccountMultipleOutline.vue'
94+
import IconSearch from 'vue-material-design-icons/Magnify.vue'
6995
import EntityPicker from '../EntityPicker/EntityPicker.vue'
7096
import MemberGridItem from './MemberGridItem.vue'
7197
import IsMobileMixin from '../../mixins/IsMobileMixin.ts'
7298
import RouterMixin from '../../mixins/RouterMixin.js'
73-
import { CIRCLES_MEMBER_GROUPING, SHARES_TYPES_MEMBER_MAP } from '../../models/constants.ts'
99+
import {
100+
CIRCLES_MEMBER_GROUPING,
101+
CIRCLES_MEMBER_LEVELS,
102+
MAX_MEMBERS_TO_RENDER,
103+
MemberLevels,
104+
SHARES_TYPES_MEMBER_MAP,
105+
} from '../../models/constants.ts'
74106
import { getRecommendations, getSuggestions } from '../../services/collaborationAutocompletion.js'
75107
76108
export default defineComponent({
77109
name: 'MemberList',
78110
79111
components: {
112+
IconSearch,
113+
NcTextField,
114+
NcSelect,
80115
EntityPicker,
81116
IconContact,
82117
MemberGridItem,
83118
NcEmptyContent,
84119
VList,
120+
NcLoadingIcon,
85121
},
86122
87123
mixins: [IsMobileMixin, RouterMixin],
@@ -98,8 +134,35 @@ export default defineComponent({
98134
},
99135
},
100136
137+
setup() {
138+
const searchQuery = ref('')
139+
const clearSearchField = () => {
140+
searchQuery.value = ''
141+
}
142+
const searchQueryDebounced = refDebounced(searchQuery, 500)
143+
144+
const searchRole = ref(null)
145+
const roles = Object.entries(CIRCLES_MEMBER_LEVELS).map(([id, label]) => ({
146+
id: Number(id),
147+
label,
148+
}))
149+
roles.unshift({
150+
id: Number(MemberLevels.NONE),
151+
label: t('contacts', 'Pending'),
152+
})
153+
154+
return {
155+
searchQuery,
156+
searchQueryDebounced,
157+
clearSearchField,
158+
searchRole,
159+
roles,
160+
}
161+
},
162+
101163
data() {
102164
return {
165+
loadingList: false,
103166
pickerLoading: false,
104167
showPicker: false,
105168
showPickerIntro: true,
@@ -118,12 +181,30 @@ export default defineComponent({
118181
/**
119182
* Return the current circle
120183
*
121-
* @return {object}
184+
* @return {Circle}
122185
*/
123186
circle() {
124187
return this.$store.getters.getCircle(this.selectedCircle)
125188
},
126189
190+
members() {
191+
return Object.values(this.$store.getters.getCircle(this.circle.id)?.members || [])
192+
},
193+
194+
membershipTooLargeMessage() {
195+
if (this.searchQueryDebounced || this.searchRole?.id) {
196+
const searchQuery = this.searchQueryDebounced || '-'
197+
const searchRole = this.searchRole?.label || 'any'
198+
return `Search results (query: ${searchQuery}, role: ${searchRole}) contains too many entries.`
199+
}
200+
201+
return 'Users list too large'
202+
},
203+
204+
isMembersLisTooLarge() {
205+
return this.flatList.length > MAX_MEMBERS_TO_RENDER
206+
},
207+
127208
// Decode HTML entities in the circle display name so apostrophes (') and other
128209
// HTML-encoded chars (e.g. &#39;) are shown correctly in the picker labels.
129210
decodedTeamName(): string {
@@ -157,6 +238,10 @@ export default defineComponent({
157238
return this.flatList.length > 0
158239
},
159240
241+
hasActiveFilters() {
242+
return this.searchQuery !== '' || this.searchRole !== null
243+
},
244+
160245
virtualListStyle() {
161246
const gridBaseline = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--default-grid-baseline')) || 4
162247
const headerHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 50
@@ -168,6 +253,24 @@ export default defineComponent({
168253
},
169254
},
170255
256+
watch: {
257+
searchQueryDebounced(value) {
258+
this.fetchCircleMembers()
259+
},
260+
261+
searchRole(value) {
262+
this.fetchCircleMembers()
263+
},
264+
265+
'circle.id': {
266+
handler() {
267+
this.fetchCircleMembers()
268+
},
269+
270+
immediate: true,
271+
},
272+
},
273+
171274
mounted() {
172275
subscribe('contacts:circles:append', this.onShowPicker)
173276
subscribe('guests:user:created', this.onGuestCreated)
@@ -179,6 +282,8 @@ export default defineComponent({
179282
},
180283
181284
methods: {
285+
t,
286+
182287
/**
183288
* Measure the circle details header height from the DOM
184289
* and keep it updated via ResizeObserver.
@@ -296,6 +401,26 @@ export default defineComponent({
296401
const results = await getSuggestions(guest.username, this.circle)
297402
this.$refs.entityPicker.onClick(results[0])
298403
},
404+
405+
async fetchCircleMembers() {
406+
if (!this.circle?.canManageMembers) {
407+
return
408+
}
409+
410+
this.loadingList = true
411+
const payload = { circleId: this.circle.id, search: this.searchQuery || null, role: this.searchRole?.id }
412+
this.logger.debug('Fetching members for', payload)
413+
414+
try {
415+
await this.$store.dispatch('getCircleMembers', payload)
416+
console.log('debug: getCircleMembers', this.list)
417+
} catch (error) {
418+
console.error(error)
419+
showError(t('contacts', 'There was an error fetching the member list'))
420+
} finally {
421+
this.loadingList = false
422+
}
423+
},
299424
},
300425
})
301426
</script>
@@ -312,4 +437,10 @@ export default defineComponent({
312437
.empty-content {
313438
height: 100%;
314439
}
440+
441+
.member-list__too-large {
442+
padding: var(--default-grid-baseline) 0;
443+
text-align: center;
444+
color: var(--color-text-maxcontrast);
445+
}
315446
</style>

src/components/MemberList/MemberListGroup.vue

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
<script setup lang="ts">
77
import type Member from '../../models/member.ts'
88
9+
import { t } from '@nextcloud/l10n'
10+
import { NcEmptyContent } from '@nextcloud/vue'
11+
import IconSearch from 'vue-material-design-icons/Magnify.vue'
912
import MemberListItem from './MemberListItem.vue'
1013
1114
defineProps<{
@@ -22,6 +25,17 @@ defineProps<{
2225
class="member-list-group__heading">
2326
{{ label }}
2427
</h4>
28+
29+
<template v-if="!members.length">
30+
<div style="margin-top: 2rem;">
31+
<NcEmptyContent :name="t('contacts', 'No results found')">
32+
<template #icon>
33+
<IconSearch :size="20" />
34+
</template>
35+
</NcEmptyContent>
36+
</div>
37+
</template>
38+
2539
<ul :aria-labelledby="`member-list-group-${type}`" class="member-list-group__list">
2640
<MemberListItem
2741
v-for="member in members"
@@ -32,6 +46,13 @@ defineProps<{
3246
</template>
3347

3448
<style scoped lang="scss">
49+
#member-list-group-1 {
50+
margin-top: 0;
51+
padding-top: 0;
52+
}
53+
.member-list-group__list-extended {
54+
max-height: 200px;
55+
}
3556
.member-list-group {
3657
&__heading {
3758
display: flex;

0 commit comments

Comments
 (0)