diff options
author | Christopher Ng <chrng8@gmail.com> | 2023-07-07 11:31:23 -0700 |
---|---|---|
committer | Christopher Ng <chrng8@gmail.com> | 2023-07-12 17:30:11 -0700 |
commit | cbfe0c67e9072f18bb40b795032d47f1639decb9 (patch) | |
tree | 8090c18f58dd0f4794f0265907c5edc218af44df /apps/settings/src/components/UserList.vue | |
parent | 97a93c73cec09a72cf035e9f70a62d4396b09e82 (diff) | |
download | nextcloud-server-cbfe0c67e9072f18bb40b795032d47f1639decb9.tar.gz nextcloud-server-cbfe0c67e9072f18bb40b795032d47f1639decb9.zip |
enh(a11y): Users table
Signed-off-by: Christopher Ng <chrng8@gmail.com>
Diffstat (limited to 'apps/settings/src/components/UserList.vue')
-rw-r--r-- | apps/settings/src/components/UserList.vue | 382 |
1 files changed, 228 insertions, 154 deletions
diff --git a/apps/settings/src/components/UserList.vue b/apps/settings/src/components/UserList.vue index 2f3da92ca02..3d061a2f0d0 100644 --- a/apps/settings/src/components/UserList.vue +++ b/apps/settings/src/components/UserList.vue @@ -21,120 +21,90 @@ --> <template> - <div id="app-content" - role="grid" - :aria-label="t('settings', 'User\'s table')" - class="user-list-grid" - @scroll.passive="onScroll"> + <Fragment> <NewUserModal v-if="showConfig.showNewUserForm" :loading="loading" :new-user="newUser" - :show-config="showConfig" - @reset="resetForm" - @close="showConfig.showNewUserForm = false" /> - <div id="grid-header" - :class="{'sticky': scrolled && !showConfig.showNewUserForm}" - class="row"> - <div id="headerAvatar" class="avatar" /> - <div id="headerName" class="name"> - <div class="subtitle"> - <strong> - {{ t('settings', 'Display name') }} - </strong> - </div> - {{ t('settings', 'Username') }} - </div> - <div id="headerPassword" class="password"> - {{ t('settings', 'Password') }} - </div> - <div id="headerAddress" class="mailAddress"> - {{ t('settings', 'Email') }} - </div> - <div id="headerGroups" class="groups"> - {{ t('settings', 'Groups') }} - </div> - <div v-if="subAdminsGroups.length>0 && settings.isAdmin" - id="headerSubAdmins" - class="subadmins"> - {{ t('settings', 'Group admin for') }} - </div> - <div id="headerQuota" class="quota"> - {{ t('settings', 'Quota') }} - </div> - <div v-if="showConfig.showLanguages" - id="headerLanguages" - class="languages"> - {{ t('settings', 'Language') }} - </div> - - <div v-if="showConfig.showUserBackend || showConfig.showStoragePath" - class="headerUserBackend userBackend"> - <div v-if="showConfig.showUserBackend" class="userBackend"> - {{ t('settings', 'User backend') }} - </div> - <div v-if="showConfig.showStoragePath" - class="subtitle storageLocation"> - {{ t('settings', 'Storage location') }} - </div> - </div> - <div v-if="showConfig.showLastLogin" - class="headerLastLogin lastLogin"> - {{ t('settings', 'Last login') }} - </div> - <div id="headerManager" class="manager"> - {{ t('settings', 'Manager') }} - </div> - <div class="userActions" /> - </div> - - <UserRow v-for="user in filteredUsers" - :key="user.id" - :external-actions="externalActions" - :groups="groups" - :languages="languages" :quota-options="quotaOptions" - :settings="settings" - :show-config="showConfig" - :sub-admins-groups="subAdminsGroups" - :user="user" - :users="users" - :is-dark-theme="isDarkTheme" /> - - <InfiniteLoading ref="infiniteLoading" @infinite="infiniteHandler"> - <div slot="spinner"> - <div class="users-icon-loading icon-loading" /> - </div> - <div slot="no-more"> - <div class="users-list-end" /> - </div> - <div slot="no-results"> - <div id="emptycontent"> - <div class="icon-contacts-dark" /> - <h2>{{ t('settings', 'No users in here') }}</h2> - </div> - </div> - </InfiniteLoading> - </div> + @reset="resetForm" + @close="closeModal" /> + + <NcEmptyContent v-if="filteredUsers.length === 0" + class="empty" + :title="isInitialLoad && loading.users ? null : t('settings', 'No users')"> + <template #icon> + <NcLoadingIcon v-if="isInitialLoad && loading.users" + :title="t('settings', 'Loading users …')" + :size="64" /> + <NcIconSvgWrapper v-else + :svg="usersSvg" /> + </template> + </NcEmptyContent> + + <RecycleScroller v-else + class="user-list" + :style="style" + ref="scroller" + :items="filteredUsers" + key-field="id" + role="table" + list-tag="tbody" + list-class="user-list__body" + item-tag="tr" + item-class="user-list__row" + :item-size="rowHeight" + @hook:mounted="handleMounted" + @scroll-end="handleScrollEnd"> + + <template #before> + <caption class="hidden-visually"> + {{ t('settings', 'List of users. This list is not fully rendered for performances reasons. The users will be rendered as you navigate through the list.') }} + </caption> + <UserListHeader :has-obfuscated="hasObfuscated" /> + </template> + + <template #default="{ item: user }"> + <UserRow :user="user" + :users="users" + :settings="settings" + :has-obfuscated="hasObfuscated" + :groups="groups" + :sub-admins-groups="subAdminsGroups" + :quota-options="quotaOptions" + :languages="languages" + :external-actions="externalActions" /> + </template> + + <template #after> + <UserListFooter :loading="loading.users" + :filtered-users="filteredUsers" /> + </template> + + </RecycleScroller> + </Fragment> </template> <script> import Vue from 'vue' -import InfiniteLoading from 'vue-infinite-loading' +import { Fragment } from 'vue-frag' +import { RecycleScroller } from 'vue-virtual-scroller' + +import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' +import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' import { subscribe, unsubscribe } from '@nextcloud/event-bus' +import { showError } from '@nextcloud/dialogs' -import UserRow from './Users/UserRow.vue' import NewUserModal from './Users/NewUserModal.vue' +import UserListFooter from './Users/UserListFooter.vue' +import UserListHeader from './Users/UserListHeader.vue' +import UserRow from './Users/UserRow.vue' -const unlimitedQuota = { - id: 'none', - label: t('settings', 'Unlimited'), -} +import { defaultQuota, isObfuscated, unlimitedQuota } from '../utils/userUtils.ts' +import logger from '../logger.js' -const defaultQuota = { - id: 'default', - label: t('settings', 'Default quota'), -} +import usersSvg from '../../img/users.svg?raw' const newUser = { id: '', @@ -155,20 +125,18 @@ export default { name: 'UserList', components: { - InfiniteLoading, + Fragment, + NcEmptyContent, + NcIconSvgWrapper, + NcLoadingIcon, NewUserModal, + RecycleScroller, + UserListFooter, + UserListHeader, UserRow, }, props: { - users: { - type: Array, - default: () => [], - }, - showConfig: { - type: Object, - required: true, - }, selectedGroup: { type: String, default: null, @@ -184,20 +152,39 @@ export default { loading: { all: false, groups: false, + users: false, }, - scrolled: false, + isInitialLoad: true, + rowHeight: 55, + usersSvg, searchQuery: '', newUser: Object.assign({}, newUser), } }, computed: { + showConfig() { + return this.$store.getters.getShowConfig + }, + settings() { return this.$store.getters.getServerData }, - selectedGroupDecoded() { - return decodeURIComponent(this.selectedGroup) + + style() { + return { + '--row-height': `${this.rowHeight}px`, + } + }, + + hasObfuscated() { + return this.filteredUsers.some(user => isObfuscated(user)) }, + + users() { + return this.$store.getters.getUsers + }, + filteredUsers() { if (this.selectedGroup === 'disabled') { return this.users.filter(user => user.enabled === false) @@ -208,16 +195,19 @@ export default { } return this.users.filter(user => user.enabled !== false) }, + groups() { // data provided php side + remove the disabled group return this.$store.getters.getGroups .filter(group => group.id !== 'disabled') .sort((a, b) => a.name.localeCompare(b.name)) }, + subAdminsGroups() { // data provided php side return this.$store.getters.getSubadminGroups }, + quotaOptions() { // convert the preset array into objects const quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({ @@ -231,12 +221,15 @@ export default { quotaPreset.unshift(defaultQuota) return quotaPreset }, + usersOffset() { return this.$store.getters.getUsersOffset }, + usersLimit() { return this.$store.getters.getUsersLimit }, + usersCount() { return this.users.length }, @@ -254,37 +247,29 @@ export default { }, ] }, - isDarkTheme() { - return window.getComputedStyle(this.$el) - .getPropertyValue('--background-invert-if-dark') === 'invert(100%)' - }, }, + watch: { // watch url change and group select - selectedGroup(val, old) { + async selectedGroup(val, old) { + this.isInitialLoad = true // if selected is the disabled group but it's empty - this.redirectIfDisabled() + await this.redirectIfDisabled() this.$store.commit('resetUsers') - this.$refs.infiniteLoading.stateChanger.reset() + await this.loadUsers() this.setNewUserDefaultGroup(val) }, - // make sure the infiniteLoading state is changed if we manually - // add/remove data from the store - usersCount(val, old) { - // deleting the last user, reset the list - if (val === 0 && old === 1) { - this.$refs.infiniteLoading.stateChanger.reset() - // adding the first user, warn the infiniteLoader that - // the list is not empty anymore (we don't fetch the newly - // added user as we already have all the info we need) - } else if (val === 1 && old === 0) { - this.$refs.infiniteLoading.stateChanger.loaded() - } + filteredUsers(filteredUsers) { + logger.debug(`${filteredUsers.length} filtered user(s)`) }, }, - mounted() { + async created() { + await this.loadUsers() + }, + + async mounted() { if (!this.settings.canChangePassword) { OC.Notification.showTemporary(t('settings', 'Password change is disabled because the master key is disabled')) } @@ -303,40 +288,58 @@ export default { /** * If disabled group but empty, redirect */ - this.redirectIfDisabled() + await this.redirectIfDisabled() }, + beforeDestroy() { unsubscribe('nextcloud:unified-search.search', this.search) unsubscribe('nextcloud:unified-search.reset', this.resetSearch) }, methods: { - onScroll(event) { - this.scrolled = event.target.scrollTo > 0 + async handleMounted() { + // Add proper semantics to the recycle scroller slots + const header = this.$refs.scroller.$refs.before + const footer = this.$refs.scroller.$refs.after + header.classList.add('user-list__header') + header.setAttribute('role', 'rowgroup') + footer.classList.add('user-list__footer') + footer.setAttribute('role', 'rowgroup') }, - infiniteHandler($state) { - this.$store.dispatch('getUsers', { - offset: this.usersOffset, - limit: this.usersLimit, - group: this.selectedGroup !== 'disabled' ? this.selectedGroup : '', - search: this.searchQuery, - }) - .then((usersCount) => { - if (usersCount > 0) { - $state.loaded() - } - if (usersCount < this.usersLimit) { - $state.complete() - } + async handleScrollEnd() { + await this.loadUsers() + }, + + async loadUsers() { + this.loading.users = true + try { + await this.$store.dispatch('getUsers', { + offset: this.usersOffset, + limit: this.usersLimit, + group: this.selectedGroup !== 'disabled' ? this.selectedGroup : '', + search: this.searchQuery, }) + logger.debug(`${this.users.length} total user(s) loaded`) + } catch (error) { + logger.error('Failed to load users', { error }) + showError('Failed to load users') + } + this.loading.users = false + this.isInitialLoad = false + }, + + closeModal() { + this.$store.commit('setShowConfig', { + key: 'showNewUserForm', + value: false, + }) }, - /* SEARCH */ - search({ query }) { + async search({ query }) { this.searchQuery = query this.$store.commit('resetUsers') - this.$refs.infiniteLoading.stateChanger.reset() + await this.loadUsers() }, resetSearch() { @@ -384,15 +387,86 @@ export default { * we only check for 0 because we don't have the count on ldap * and we therefore set the usercount to -1 in this specific case */ - redirectIfDisabled() { + async redirectIfDisabled() { const allGroups = this.$store.getters.getGroups if (this.selectedGroup === 'disabled' && allGroups.findIndex(group => group.id === 'disabled' && group.usercount === 0) > -1) { // disabled group is empty, redirection to all users this.$router.push({ name: 'users' }) - this.$refs.infiniteLoading.stateChanger.reset() + await this.loadUsers() } }, }, } </script> + +<style lang="scss" scoped> +@import './Users/shared/styles.scss'; + +.empty { + :deep { + .icon-vue { + width: 64px; + height: 64px; + + svg { + max-width: 64px; + max-height: 64px; + } + } + } +} + +.user-list { + --avatar-cell-width: 48px; + --cell-padding: 7px; + --cell-width: 200px; + --cell-min-width: calc(var(--cell-width) - (2 * var(--cell-padding))); + + display: block; + overflow: auto; + height: 100%; + + :deep { + .user-list { + &__body { + display: flex; + flex-direction: column; + width: 100%; + // Necessary for virtual scrolling absolute + position: relative; + margin-top: var(--row-height); + } + + &__row { + @include row; + border-bottom: 1px solid var(--color-border); + + &:hover { + background-color: var(--color-background-hover); + + .row__cell:not(.row__cell--actions) { + background-color: var(--color-background-hover); + } + } + } + } + + .vue-recycle-scroller__slot { + &.user-list__header, + &.user-list__footer { + position: sticky; + } + + &.user-list__header { + top: 0; + z-index: 10; + } + + &.user-list__footer { + left: 0; + } + } + } +} +</style> |