aboutsummaryrefslogtreecommitdiffstats
path: root/apps/settings/src/components/UserList.vue
diff options
context:
space:
mode:
authorChristopher Ng <chrng8@gmail.com>2023-07-07 11:31:23 -0700
committerChristopher Ng <chrng8@gmail.com>2023-07-12 17:30:11 -0700
commitcbfe0c67e9072f18bb40b795032d47f1639decb9 (patch)
tree8090c18f58dd0f4794f0265907c5edc218af44df /apps/settings/src/components/UserList.vue
parent97a93c73cec09a72cf035e9f70a62d4396b09e82 (diff)
downloadnextcloud-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.vue382
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>