From 8cba413b4d5e06a54fd65c42bf2f306724fc7d75 Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Sun, 17 May 2026 19:59:05 +0200 Subject: [PATCH 1/2] Perf: Optimize member list on the landing page for collective with large number of members Signed-off-by: Kostiantyn Miakshyn --- cypress/e2e/collective-members.spec.js | 6 --- src/components/Member/MemberItem.vue | 8 +--- src/components/Member/MemberPicker.vue | 7 +++- src/components/Nav/CollectiveMembersModal.vue | 8 +++- .../Page/LandingPageWidgets/MembersWidget.vue | 4 +- src/components/Page/TextEditor.vue | 10 ++++- src/constants.js | 3 ++ src/stores/circles.js | 41 +++++++++++++++++-- src/views/CollectiveView.vue | 5 ++- 9 files changed, 68 insertions(+), 24 deletions(-) diff --git a/cypress/e2e/collective-members.spec.js b/cypress/e2e/collective-members.spec.js index 3f6ca1d1fe..16b7abcc0a 100644 --- a/cypress/e2e/collective-members.spec.js +++ b/cypress/e2e/collective-members.spec.js @@ -35,12 +35,10 @@ describe('Collective members', function() { .find('.member-row__actions') .click() cy.intercept('PUT', '**/circles/circles/*/members/*/level').as('updateCircleMemberLevel') - cy.intercept('GET', '**/circles/circles/*/members?**').as('getCircleMembers') cy.get('button.action-button') .contains('Promote to admin') .click() cy.wait('@updateCircleMemberLevel') - cy.wait('@getCircleMembers') cy.get('.current-members').contains('.member-row', member) .should('contain', '(admin)') @@ -49,12 +47,10 @@ describe('Collective members', function() { .find('.member-row__actions') .click() cy.intercept('PUT', '**/circles/circles/*/members/*/level').as('updateCircleMemberLevel') - cy.intercept('GET', '**/circles/circles/*/members?**').as('getCircleMembers') cy.get('button.action-button') .contains('Demote to moderator') .click() cy.wait('@updateCircleMemberLevel') - cy.wait('@getCircleMembers') cy.get('.current-members').contains('.member-row', member) .should('contain', '(moderator)') }) @@ -66,12 +62,10 @@ describe('Collective members', function() { .find('.member-row__actions') .click() cy.intercept('DELETE', '**/circles/circles/*/members/*').as('removeCircleMember') - cy.intercept('GET', '**/circles/circles/*/members?**').as('getCircleMembers') cy.get('button.action-button') .contains('Remove') .click() cy.wait('@removeCircleMember') - cy.wait('@getCircleMembers') cy.get('.current-members .member-row').should('not.contain', member) }) diff --git a/src/components/Member/MemberItem.vue b/src/components/Member/MemberItem.vue index 26ebf340dc..ce3fe42f1f 100644 --- a/src/components/Member/MemberItem.vue +++ b/src/components/Member/MemberItem.vue @@ -258,7 +258,6 @@ export default { t, ...mapActions(useCirclesStore, [ - 'getCircleMembers', 'changeCircleMemberLevel', 'removeMemberFromCircle', ]), @@ -267,9 +266,8 @@ export default { if (this.circleId) { this.isLoadingLevel = true await this.changeCircleMemberLevel({ circleId: this.circleId, memberId: this.memberId, level }) - .then(async () => { + .then(() => { showSuccess(t('collectives', 'Member level changed')) - await this.getCircleMembers(this.circleId) }).catch((error) => { showError(t('collectives', 'Could not change member level')) throw error @@ -283,16 +281,14 @@ export default { if (this.circleId) { this.isLoadingLevel = true await this.removeMemberFromCircle({ circleId: this.circleId, memberId: this.memberId }) - .then(async () => { + .then(() => { showSuccess(t('collectives', 'Member removed')) - await this.getCircleMembers(this.circleId) }).catch((error) => { showError(t('collectives', 'Could not remove member')) throw error }).finally(() => { this.isLoadingLevel = false }) - this.isLoadingLevel = false } }, diff --git a/src/components/Member/MemberPicker.vue b/src/components/Member/MemberPicker.vue index 0dac07ab97..1fad2f8a2d 100644 --- a/src/components/Member/MemberPicker.vue +++ b/src/components/Member/MemberPicker.vue @@ -87,6 +87,11 @@ export default { }, props: { + isLoading: { + type: Boolean, + default: false, + }, + searchWithoutQuery: { type: Boolean, default: false, @@ -169,7 +174,7 @@ export default { }, showCurrentSkeleton() { - return this.showCurrent && this.currentMembers.length === 0 + return this.showCurrent && (this.currentMembers.length === 0 || this.isLoading) }, }, diff --git a/src/components/Nav/CollectiveMembersModal.vue b/src/components/Nav/CollectiveMembersModal.vue index fe7a7d0077..9784a08cab 100644 --- a/src/components/Nav/CollectiveMembersModal.vue +++ b/src/components/Nav/CollectiveMembersModal.vue @@ -25,6 +25,7 @@ :circleId="collective.circleId" :currentUserIsAdmin :currentMembers="circleMembersSorted(collective.circleId)" + :isLoading="isLoadingMembers" :onClickSearched /> @@ -63,7 +64,7 @@ export default { ], computed: { - ...mapState(useCirclesStore, ['circleMembersSorted']), + ...mapState(useCirclesStore, ['circleMembersSorted', 'currentCircleMembersFullyLoaded']), ...mapState(useCollectivesStore, ['isCollectiveAdmin']), ...mapState(useRootStore, ['isPublic']), @@ -82,6 +83,10 @@ export default { showTeamLink() { return this.hasContactsApp && !this.isPublic }, + + isLoadingMembers() { + return !this.currentCircleMembersFullyLoaded + }, }, beforeMount() { @@ -107,7 +112,6 @@ export default { userId: member.id, type: circlesMemberTypes[autocompleteSourcesToCircleMemberTypes[member.source]], }) - await this.getCircleMembers(this.collective.circleId) }, }, } diff --git a/src/components/Page/LandingPageWidgets/MembersWidget.vue b/src/components/Page/LandingPageWidgets/MembersWidget.vue index d409768549..8985a6957b 100644 --- a/src/components/Page/LandingPageWidgets/MembersWidget.vue +++ b/src/components/Page/LandingPageWidgets/MembersWidget.vue @@ -63,7 +63,7 @@ import AccountMultiplePlusIcon from 'vue-material-design-icons/AccountMultiplePl import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue' import SkeletonLoading from '../../SkeletonLoading.vue' import WidgetHeading from './WidgetHeading.vue' -import { circlesMemberTypes } from '../../../constants.js' +import { CIRCLE_MEMBERS_PARTIAL_LIMIT, circlesMemberTypes } from '../../../constants.js' import { useCirclesStore } from '../../../stores/circles.js' import { useCollectivesStore } from '../../../stores/collectives.js' import { usePagesStore } from '../../../stores/pages.js' @@ -208,7 +208,7 @@ export default { const avatarHeight = defaultClickableArea + 12 if (membersWidth) { const maxMembers = Math.floor(membersWidth / avatarHeight) - 1 - this.showMembersCount = Math.min(this.sortedMembers.length, maxMembers) + this.showMembersCount = Math.min(this.sortedMembers.length, maxMembers, CIRCLE_MEMBERS_PARTIAL_LIMIT) } }, diff --git a/src/components/Page/TextEditor.vue b/src/components/Page/TextEditor.vue index 977c2cde60..0968777b3c 100644 --- a/src/components/Page/TextEditor.vue +++ b/src/components/Page/TextEditor.vue @@ -37,6 +37,7 @@ import SkeletonLoading from '../SkeletonLoading.vue' import { useEditor } from '../../composables/useEditor.ts' import { useReader } from '../../composables/useReader.ts' import pageContentMixin from '../../mixins/pageContentMixin.js' +import { useCirclesStore } from '../../stores/circles.js' import { useCollectivesStore } from '../../stores/collectives.js' import { usePagesStore } from '../../stores/pages.js' import { useRootStore } from '../../stores/root.js' @@ -121,9 +122,15 @@ export default { this.initEditMode() }) - this.textEditWatcher = this.$watch('isTextEdit', (val) => { + this.textEditWatcher = this.$watch('isTextEdit', async (val) => { if (val === false) { this.stopEdit() + } else if (val === true) { + // Load full circle members for autocomplete when entering edit mode + const circlesStore = useCirclesStore() + if (!circlesStore.currentCircleMembersFullyLoaded && !this.isPublic) { + await this.getCircleMembers(this.currentCollective.circleId) + } } }) subscribe('collectives:attachment:insert', this.insertAttachment) @@ -144,6 +151,7 @@ export default { ...mapActions(useRootStore, ['load', 'done']), ...mapActions(useVersionsStore, ['getVersions']), ...mapActions(usePagesStore, ['setTextEdit', 'setTextPreview', 'touchPage']), + ...mapActions(useCirclesStore, ['getCircleMembers']), insertAttachment({ name }) { // inspired by the fixedEncodeURIComponent function suggested in diff --git a/src/constants.js b/src/constants.js index b775560670..f3f43b4aea 100644 --- a/src/constants.js +++ b/src/constants.js @@ -45,6 +45,9 @@ export const pageModes = { MODE_EDIT: 1, } +// Circle members +export const CIRCLE_MEMBERS_PARTIAL_LIMIT = 15 + export const editorApiReaderFileId = 'READER_FILE_ID' export const editorApiUpdateReadonlyBarProps = 'UPDATE_READONLY_BAR_PROPS' export const editorApiAttachments = 'ATTACHMENTS' diff --git a/src/stores/circles.js b/src/stores/circles.js index 4ccad42ef5..351c4ff184 100644 --- a/src/stores/circles.js +++ b/src/stores/circles.js @@ -17,7 +17,9 @@ const STORE_PREFIX = 'collectives/pinia/circles/' export const useCirclesStore = defineStore('circles', { state: () => ({ circles: useLocalStorage(STORE_PREFIX + 'circles', []), - circlesMembers: useLocalStorage(STORE_PREFIX + 'circlesMembers', {}), + circlesMembers: {}, + circlesMembersFullyLoaded: {}, + circlesMembersPending: {}, }), getters: { @@ -59,6 +61,12 @@ export const useCirclesStore = defineStore('circles', { } return users }, + + currentCircleMembersFullyLoaded: (state) => { + const collectivesStore = useCollectivesStore() + const currentCircleId = collectivesStore.currentCollective?.circleId + return state.circlesMembersFullyLoaded[currentCircleId] || false + }, }, actions: { @@ -108,10 +116,23 @@ export const useCirclesStore = defineStore('circles', { * Get members of a team * * @param {string} circleId ID of the team + * @param {number} limit Limit of members to fetch, 0 for all members */ - async getCircleMembers(circleId) { - const response = await axios.get(generateOcsUrl(`apps/circles/circles/${circleId}/members?fullDetails=true`)) - this.circlesMembers[circleId] = response.data.ocs.data + async getCircleMembers(circleId, limit = 0) { + // Skip if already fully loaded or request is pending + if (this.circlesMembersFullyLoaded[circleId] || this.circlesMembersPending[circleId]) { + return + } + this.circlesMembersPending[circleId] = true + try { + const response = await axios.get(generateOcsUrl(`apps/circles/circles/${circleId}/members?fullDetails=true&limit=${limit}`)) + this.circlesMembers[circleId] = response.data.ocs.data + if (limit === 0) { + this.circlesMembersFullyLoaded[circleId] = true + } + } finally { + this.circlesMembersPending[circleId] = false + } }, /** @@ -127,6 +148,9 @@ export const useCirclesStore = defineStore('circles', { generateOcsUrl('apps/circles/circles/' + circleId + '/members'), { userId, type }, ) + if (this.circlesMembers[circleId]) { + this.circlesMembers[circleId].push(response.data.ocs.data) + } return response.data.ocs.data }, @@ -154,6 +178,9 @@ export const useCirclesStore = defineStore('circles', { */ async removeMemberFromCircle({ circleId, memberId }) { const response = await axios.delete(generateOcsUrl('apps/circles/circles/' + circleId + '/members/' + memberId)) + if (this.circlesMembers[circleId]) { + this.circlesMembers[circleId] = this.circlesMembers[circleId].filter((m) => m.id !== memberId) + } return response.data.ocs.data }, @@ -170,6 +197,12 @@ export const useCirclesStore = defineStore('circles', { generateOcsUrl('apps/circles/circles/' + circleId + '/members/' + memberId + '/level'), { level }, ) + if (this.circlesMembers[circleId]) { + const member = this.circlesMembers[circleId].find((m) => m.id === memberId) + if (member) { + member.level = level + } + } return response.data.ocs.data }, diff --git a/src/views/CollectiveView.vue b/src/views/CollectiveView.vue index 955d3bd445..0f3b82922b 100644 --- a/src/views/CollectiveView.vue +++ b/src/views/CollectiveView.vue @@ -33,7 +33,7 @@ import CollectiveContainer from '../components/CollectiveContainer.vue' import CollectiveNotFound from '../components/CollectiveNotFound.vue' import PageList from '../components/PageList.vue' import { useNetworkState } from '../composables/useNetworkState.ts' -import { sessionUpdateInterval } from '../constants.js' +import { CIRCLE_MEMBERS_PARTIAL_LIMIT, pageModes, sessionUpdateInterval } from '../constants.js' import { useCirclesStore } from '../stores/circles.js' import { useCollectivesStore } from '../stores/collectives.js' import { usePagesStore } from '../stores/pages.js' @@ -221,7 +221,8 @@ export default { promises.push(this.getTemplates(setLoading)) } if (!this.isPublic) { - promises.push(this.getCircleMembers(this.currentCollective.circleId)) + const limit = this.currentCollective.pageMode === pageModes.MODE_EDIT ? 0 : CIRCLE_MEMBERS_PARTIAL_LIMIT + promises.push(this.getCircleMembers(this.currentCollective.circleId, limit)) } } From a834e30b896a6daf950d35adfddcf324ccd21484 Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Sun, 28 Jun 2026 16:32:41 +0200 Subject: [PATCH 2/2] Perf: Optimize member list on the landing page for collective with large number of members (try to fix tests) Signed-off-by: Kostiantyn Miakshyn --- cypress/e2e/collective-members.spec.js | 4 +++- src/components/Nav/CollectiveMembersModal.vue | 4 ++-- src/stores/circles.js | 2 ++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/cypress/e2e/collective-members.spec.js b/cypress/e2e/collective-members.spec.js index 16b7abcc0a..7ff421350b 100644 --- a/cypress/e2e/collective-members.spec.js +++ b/cypress/e2e/collective-members.spec.js @@ -12,8 +12,10 @@ describe('Collective members', function() { beforeEach(function() { cy.loginAs('bob') cy.visit('apps/collectives') + cy.intercept('GET', '**/circles/circles/*/members?**').as('getCircleMembers') cy.openCollectiveMenu('Members Collective') cy.clickMenuButton('Manage members') + cy.wait('@getCircleMembers') }) it('Allows to add members', function() { @@ -67,6 +69,6 @@ describe('Collective members', function() { .click() cy.wait('@removeCircleMember') - cy.get('.current-members .member-row').should('not.contain', member) + cy.get('.current-members .member-row:visible').should('not.contain', member) }) }) diff --git a/src/components/Nav/CollectiveMembersModal.vue b/src/components/Nav/CollectiveMembersModal.vue index 9784a08cab..244008f730 100644 --- a/src/components/Nav/CollectiveMembersModal.vue +++ b/src/components/Nav/CollectiveMembersModal.vue @@ -64,7 +64,7 @@ export default { ], computed: { - ...mapState(useCirclesStore, ['circleMembersSorted', 'currentCircleMembersFullyLoaded']), + ...mapState(useCirclesStore, ['circleMembersSorted', 'circleMembersFullyLoaded']), ...mapState(useCollectivesStore, ['isCollectiveAdmin']), ...mapState(useRootStore, ['isPublic']), @@ -85,7 +85,7 @@ export default { }, isLoadingMembers() { - return !this.currentCircleMembersFullyLoaded + return !this.circleMembersFullyLoaded(this.collective.circleId) }, }, diff --git a/src/stores/circles.js b/src/stores/circles.js index 351c4ff184..f3e2bfe8be 100644 --- a/src/stores/circles.js +++ b/src/stores/circles.js @@ -62,6 +62,8 @@ export const useCirclesStore = defineStore('circles', { return users }, + circleMembersFullyLoaded: (state) => (circleId) => state.circlesMembersFullyLoaded[circleId] || false, + currentCircleMembersFullyLoaded: (state) => { const collectivesStore = useCollectivesStore() const currentCircleId = collectivesStore.currentCollective?.circleId