Merge pull request #51336 from nextcloud/perf/paginate-filter-groups
fix(settings): Fix infinitely loading account management page with pagination of groupspull/51784/head
commit
4a9bd9bb6d
@ -0,0 +1,200 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<Fragment>
|
||||
<NcAppNavigationCaption :name="t('settings', 'Groups')"
|
||||
:disabled="loadingAddGroup"
|
||||
:aria-label="loadingAddGroup ? t('settings', 'Creating group…') : t('settings', 'Create group')"
|
||||
force-menu
|
||||
is-heading
|
||||
:open.sync="isAddGroupOpen">
|
||||
<template v-if="isAdminOrDelegatedAdmin" #actionsTriggerIcon>
|
||||
<NcLoadingIcon v-if="loadingAddGroup" />
|
||||
<NcIconSvgWrapper v-else :path="mdiPlus" />
|
||||
</template>
|
||||
<template v-if="isAdminOrDelegatedAdmin" #actions>
|
||||
<NcActionText>
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiAccountGroup" />
|
||||
</template>
|
||||
{{ t('settings', 'Create group') }}
|
||||
</NcActionText>
|
||||
<NcActionInput :label="t('settings', 'Group name')"
|
||||
data-cy-users-settings-new-group-name
|
||||
:label-outside="false"
|
||||
:disabled="loadingAddGroup"
|
||||
:value.sync="newGroupName"
|
||||
:error="hasAddGroupError"
|
||||
:helper-text="hasAddGroupError ? t('settings', 'Please enter a valid group name') : ''"
|
||||
@submit="createGroup" />
|
||||
</template>
|
||||
</NcAppNavigationCaption>
|
||||
|
||||
<NcAppNavigationSearch v-model="groupsSearchQuery"
|
||||
:label="t('settings', 'Search groups…')" />
|
||||
|
||||
<p id="group-list-desc" class="hidden-visually">
|
||||
{{ t('settings', 'List of groups. This list is not fully populated for performance reasons. The groups will be loaded as you navigate or search through the list.') }}
|
||||
</p>
|
||||
<NcAppNavigationList class="account-management__group-list"
|
||||
aria-describedby="group-list-desc"
|
||||
data-cy-users-settings-navigation-groups="custom">
|
||||
<GroupListItem v-for="group in userGroups"
|
||||
:id="group.id"
|
||||
ref="groupListItems"
|
||||
:key="group.id"
|
||||
:active="selectedGroupDecoded === group.id"
|
||||
:name="group.title"
|
||||
:count="group.count" />
|
||||
<div v-if="loadingGroups" role="note">
|
||||
<NcLoadingIcon :name="t('settings', 'Loading groups…')" />
|
||||
</div>
|
||||
</NcAppNavigationList>
|
||||
</Fragment>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onBeforeMount } from 'vue'
|
||||
import { Fragment } from 'vue-frag'
|
||||
import { useRoute, useRouter } from 'vue-router/composables'
|
||||
import { useElementVisibility } from '@vueuse/core'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { mdiAccountGroup, mdiPlus } from '@mdi/js'
|
||||
|
||||
import NcActionInput from '@nextcloud/vue/components/NcActionInput'
|
||||
import NcActionText from '@nextcloud/vue/components/NcActionText'
|
||||
import NcAppNavigationCaption from '@nextcloud/vue/components/NcAppNavigationCaption'
|
||||
import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList'
|
||||
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
|
||||
import GroupListItem from './GroupListItem.vue'
|
||||
|
||||
import { useFormatGroups } from '../composables/useGroupsNavigation.ts'
|
||||
import { useStore } from '../store'
|
||||
import { searchGroups } from '../service/groups.ts'
|
||||
import logger from '../logger.ts'
|
||||
|
||||
const store = useStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await loadGroups()
|
||||
})
|
||||
|
||||
/** Current active group in the view - this is URL encoded */
|
||||
const selectedGroup = computed(() => route.params?.selectedGroup)
|
||||
/** Current active group - URL decoded */
|
||||
const selectedGroupDecoded = computed(() => selectedGroup.value ? decodeURIComponent(selectedGroup.value) : null)
|
||||
/** All available groups */
|
||||
const groups = computed(() => store.getters.getSortedGroups)
|
||||
/** User groups */
|
||||
const { userGroups } = useFormatGroups(groups)
|
||||
/** Server settings for current user */
|
||||
const settings = computed(() => store.getters.getServerData)
|
||||
/** True if the current user is a (delegated) admin */
|
||||
const isAdminOrDelegatedAdmin = computed(() => settings.value.isAdmin || settings.value.isDelegatedAdmin)
|
||||
|
||||
/** True if the 'add-group' dialog is open - needed to be able to close it when the group is created */
|
||||
const isAddGroupOpen = ref(false)
|
||||
/** True if the group creation is in progress to show loading spinner and disable adding another one */
|
||||
const loadingAddGroup = ref(false)
|
||||
/** Error state for creating a new group */
|
||||
const hasAddGroupError = ref(false)
|
||||
/** Name of the group to create (used in the group creation dialog) */
|
||||
const newGroupName = ref('')
|
||||
|
||||
/** True if groups are loading */
|
||||
const loadingGroups = ref(false)
|
||||
/** Search offset */
|
||||
const offset = ref(0)
|
||||
/** Search query for groups */
|
||||
const groupsSearchQuery = ref('')
|
||||
|
||||
const groupListItems = ref([])
|
||||
const lastGroupListItem = computed(() => {
|
||||
return groupListItems.value
|
||||
.findLast(component => component?.$vnode?.key === userGroups.value?.at(-1)?.id) // Order of refs is not guaranteed to match source array order
|
||||
?.$refs?.listItem?.$el
|
||||
})
|
||||
const isLastGroupVisible = useElementVisibility(lastGroupListItem)
|
||||
watch(isLastGroupVisible, async () => {
|
||||
if (!isLastGroupVisible.value) {
|
||||
return
|
||||
}
|
||||
await loadGroups()
|
||||
})
|
||||
|
||||
watch(groupsSearchQuery, async () => {
|
||||
store.commit('resetGroups')
|
||||
offset.value = 0
|
||||
await loadGroups()
|
||||
})
|
||||
|
||||
/** Cancelable promise for search groups request */
|
||||
const promise = ref(null)
|
||||
|
||||
/**
|
||||
* Load groups
|
||||
*/
|
||||
async function loadGroups() {
|
||||
if (promise.value) {
|
||||
promise.value.cancel()
|
||||
}
|
||||
loadingGroups.value = true
|
||||
try {
|
||||
promise.value = searchGroups({
|
||||
search: groupsSearchQuery.value,
|
||||
offset: offset.value,
|
||||
limit: 25,
|
||||
})
|
||||
const groups = await promise.value
|
||||
if (groups.length > 0) {
|
||||
offset.value += 25
|
||||
}
|
||||
for (const group of groups) {
|
||||
store.commit('addGroup', group)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(t('settings', 'Failed to load groups'), { error })
|
||||
}
|
||||
promise.value = null
|
||||
loadingGroups.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new group
|
||||
*/
|
||||
async function createGroup() {
|
||||
hasAddGroupError.value = false
|
||||
const groupId = newGroupName.value.trim()
|
||||
if (groupId === '') {
|
||||
hasAddGroupError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isAddGroupOpen.value = false
|
||||
loadingAddGroup.value = true
|
||||
|
||||
try {
|
||||
await store.dispatch('addGroup', groupId)
|
||||
await router.push({
|
||||
name: 'group',
|
||||
params: {
|
||||
selectedGroup: encodeURIComponent(groupId),
|
||||
},
|
||||
})
|
||||
const newGroupListItem = groupListItems.value.findLast(component => component?.$vnode?.key === groupId)
|
||||
newGroupListItem?.$refs?.listItem?.$el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
newGroupName.value = ''
|
||||
} catch {
|
||||
showError(t('settings', 'Failed to create group'))
|
||||
}
|
||||
loadingAddGroup.value = false
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,83 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IGroup } from '../views/user-types.d.ts'
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import { CancelablePromise } from 'cancelable-promise'
|
||||
|
||||
interface Group {
|
||||
id: string
|
||||
displayname: string
|
||||
usercount: number
|
||||
disabled: number
|
||||
canAdd: boolean
|
||||
canRemove: boolean
|
||||
}
|
||||
|
||||
const formatGroup = (group: Group): Required<IGroup> => ({
|
||||
id: group.id,
|
||||
name: group.displayname,
|
||||
usercount: group.usercount,
|
||||
disabled: group.disabled,
|
||||
canAdd: group.canAdd,
|
||||
canRemove: group.canRemove,
|
||||
})
|
||||
|
||||
/**
|
||||
* Search groups
|
||||
*
|
||||
* @param {object} options Options
|
||||
* @param {string} options.search Search query
|
||||
* @param {number} options.offset Offset
|
||||
* @param {number} options.limit Limit
|
||||
*/
|
||||
export const searchGroups = ({ search, offset, limit }): CancelablePromise<Required<IGroup>[]> => {
|
||||
const controller = new AbortController()
|
||||
return new CancelablePromise(async (resolve, reject, onCancel) => {
|
||||
onCancel(() => controller.abort())
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
generateOcsUrl('/cloud/groups/details?search={search}&offset={offset}&limit={limit}', { search, offset, limit }), {
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
const groups: Group[] = data.ocs?.data?.groups ?? []
|
||||
const formattedGroups = groups.map(formatGroup)
|
||||
resolve(formattedGroups)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user groups
|
||||
*
|
||||
* @param {object} options Options
|
||||
* @param {string} options.userId User id
|
||||
*/
|
||||
export const loadUserGroups = async ({ userId }): Promise<Required<IGroup>[]> => {
|
||||
const url = generateOcsUrl('/cloud/users/{userId}/groups/details', { userId })
|
||||
const { data } = await axios.get(url)
|
||||
const groups: Group[] = data.ocs?.data?.groups ?? []
|
||||
const formattedGroups = groups.map(formatGroup)
|
||||
return formattedGroups
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user subadmin groups
|
||||
*
|
||||
* @param {object} options Options
|
||||
* @param {string} options.userId User id
|
||||
*/
|
||||
export const loadUserSubAdminGroups = async ({ userId }): Promise<Required<IGroup>[]> => {
|
||||
const url = generateOcsUrl('/cloud/users/{userId}/subadmins/details', { userId })
|
||||
const { data } = await axios.get(url)
|
||||
const groups: Group[] = data.ocs?.data?.groups ?? []
|
||||
const formattedGroups = groups.map(formatGroup)
|
||||
return formattedGroups
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
|
||||
|
||||
export const naturalCollator = Intl.Collator(
|
||||
[getLanguage(), getCanonicalLocale()],
|
||||
{
|
||||
numeric: true,
|
||||
usage: 'sort',
|
||||
},
|
||||
)
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue