Merge pull request #40719 from nextcloud/enh/a11y/semantic-user-table

pull/40795/head
Pytal 8 months ago committed by GitHub
commit 649990ee8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -41,51 +41,44 @@
</template>
</NcEmptyContent>
<RecycleScroller v-else
ref="scroller"
class="user-list"
<VirtualList v-else
:data-component="UserRow"
:data-sources="filteredUsers"
data-key="id"
:item-height="rowHeight"
:style="style"
: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"
:extra-props="{
users,
settings,
hasObfuscated,
groups,
subAdminsGroups,
quotaOptions,
languages,
externalActions,
}"
@scroll-end="handleScrollEnd">
<template #before>
<caption class="hidden-visually">
{{ t('settings', 'List of users. This list is not fully rendered for performance 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 #header>
<UserListHeader :has-obfuscated="hasObfuscated" />
</template>
<template #after>
<template #footer>
<UserListFooter :loading="loading.users"
:filtered-users="filteredUsers" />
</template>
</RecycleScroller>
</VirtualList>
</Fragment>
</template>
<script>
import Vue from 'vue'
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'
@ -94,6 +87,7 @@ import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { showError } from '@nextcloud/dialogs'
import VirtualList from './Users/VirtualList.vue'
import NewUserModal from './Users/NewUserModal.vue'
import UserListFooter from './Users/UserListFooter.vue'
import UserListHeader from './Users/UserListHeader.vue'
@ -128,10 +122,9 @@ export default {
NcIconSvgWrapper,
NcLoadingIcon,
NewUserModal,
RecycleScroller,
UserListFooter,
UserListHeader,
UserRow,
VirtualList,
},
props: {
@ -147,6 +140,7 @@ export default {
data() {
return {
UserRow,
loading: {
all: false,
groups: false,
@ -295,16 +289,6 @@ export default {
},
methods: {
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')
},
async handleScrollEnd() {
await this.loadUsers()
},
@ -414,57 +398,4 @@ export default {
}
}
}
.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>

@ -112,6 +112,7 @@ export default Vue.extend({
&--loading {
left: 0;
min-width: var(--avatar-cell-width);
width: var(--avatar-cell-width);
align-items: center;
padding: 0;
@ -119,6 +120,7 @@ export default Vue.extend({
&--count {
left: var(--avatar-cell-width);
min-width: var(--cell-width);
width: var(--cell-width);
}
}

@ -80,7 +80,7 @@
scope="col">
<span>{{ t('settings', 'Last login') }}</span>
</th>
<th class="header__cell header__cell--large"
<th class="header__cell header__cell--large header__cell--fill"
scope="col">
<!-- TRANSLATORS This string describes a manager in the context of an organization -->
<span>{{ t('settings', 'Manager') }}</span>

@ -24,31 +24,30 @@
-->
<template>
<Fragment>
<tr class="user-list__row"
:data-test="user.id">
<td class="row__cell row__cell--avatar">
<NcLoadingIcon v-if="isLoadingUser"
:name="t('settings', 'Loading user …')"
:size="32" />
<NcAvatar v-else
:key="user.id"
<NcAvatar v-else-if="visible"
disable-menu
:show-user-status="false"
:user="user.id" />
</td>
<td class="row__cell row__cell--displayname"
:data-test="user.id">
<template v-if="idState.editing && user.backendCapabilities.setDisplayName">
<td class="row__cell row__cell--displayname">
<template v-if="editing && user.backendCapabilities.setDisplayName">
<NcTextField ref="displayNameField"
data-test="displayNameField"
class="user-row-text-field"
:trailing-button-label="t('settings', 'Submit')"
:class="{ 'icon-loading-small': idState.loading.displayName }"
:class="{ 'icon-loading-small': loading.displayName }"
:show-trailing-button="true"
:disabled="idState.loading.displayName || isLoadingField"
:disabled="loading.displayName || isLoadingField"
:label="t('settings', 'Change display name')"
trailing-button-icon="arrowRight"
:value.sync="idState.editedDisplayName"
:value.sync="editedDisplayName"
autocapitalize="off"
autocomplete="off"
autocorrect="off"
@ -66,17 +65,17 @@
<td class="row__cell"
:class="{ 'row__cell--obfuscated': hasObfuscated }">
<template v-if="idState.editing && settings.canChangePassword && user.backendCapabilities.setPassword">
<template v-if="editing && settings.canChangePassword && user.backendCapabilities.setPassword">
<NcTextField class="user-row-text-field"
:class="{'icon-loading-small': idState.loading.password}"
:trailing-button-label="t('settings', 'Submit')"
:class="{'icon-loading-small': loading.password}"
:show-trailing-button="true"
:disabled="idState.loading.password || isLoadingField"
:disabled="loading.password || isLoadingField"
:minlength="minPasswordLength"
maxlength="469"
:label="t('settings', 'Set new password')"
trailing-button-icon="arrowRight"
:value.sync="idState.editedPassword"
:value.sync="editedPassword"
autocapitalize="off"
autocomplete="new-password"
autocorrect="off"
@ -91,15 +90,15 @@
</td>
<td class="row__cell">
<template v-if="idState.editing">
<template v-if="editing">
<NcTextField class="user-row-text-field"
:class="{'icon-loading-small': idState.loading.mailAddress}"
:class="{'icon-loading-small': loading.mailAddress}"
:show-trailing-button="true"
:trailing-button-label="t('settings', 'Submit')"
:disabled="idState.loading.mailAddress || isLoadingField"
:label="t('settings', 'Set new email address')"
:disabled="loading.mailAddress || isLoadingField"
trailing-button-icon="arrowRight"
:value.sync="idState.editedMail"
:value.sync="editedMail"
autocapitalize="off"
autocomplete="new-password"
autocorrect="off"
@ -114,7 +113,7 @@
</td>
<td class="row__cell row__cell--large row__cell--multiline">
<template v-if="idState.editing">
<template v-if="editing">
<label class="hidden-visually"
:for="'groups' + uniqueId">
{{ t('settings', 'Add user to group') }}
@ -122,13 +121,12 @@
<NcSelect :input-id="'groups' + uniqueId"
:close-on-select="false"
:disabled="isLoadingField"
:loading="idState.loading.groups"
:loading="loading.groups"
:multiple="true"
:options="availableGroups"
:placeholder="t('settings', 'Add user to group')"
:taggable="settings.isAdmin"
:value="userGroups"
class="select-vue"
label="name"
:no-wrap="true"
:create-option="(value) => ({ name: value, isCreating: true })"
@ -144,7 +142,7 @@
<td v-if="subAdminsGroups.length > 0 && settings.isAdmin"
class="row__cell row__cell--large row__cell--multiline">
<template v-if="idState.editing && settings.isAdmin && subAdminsGroups.length > 0">
<template v-if="editing && settings.isAdmin && subAdminsGroups.length > 0">
<label class="hidden-visually"
:for="'subadmins' + uniqueId">
{{ t('settings', 'Set user as admin for') }}
@ -152,14 +150,13 @@
<NcSelect :id="'subadmins' + uniqueId"
:close-on-select="false"
:disabled="isLoadingField"
:loading="idState.loading.subadmins"
:loading="loading.subadmins"
label="name"
:multiple="true"
:no-wrap="true"
:options="subAdminsGroups"
:placeholder="t('settings', 'Set user as admin for')"
:value="userSubAdminsGroups"
class="select-vue"
@option:deselected="removeUserSubAdmin"
@option:selected="options => addUserSubAdmin(options.at(-1))" />
</template>
@ -170,7 +167,7 @@
</td>
<td class="row__cell">
<template v-if="idState.editing">
<template v-if="editing">
<label class="hidden-visually"
:for="'quota' + uniqueId">
{{ t('settings', 'Select user quota') }}
@ -179,10 +176,9 @@
:close-on-select="true"
:create-option="validateQuota"
:disabled="isLoadingField"
:loading="idState.loading.quota"
:loading="loading.quota"
:clearable="false"
:input-id="'quota' + uniqueId"
class="select-vue"
:options="quotaOptions"
:placeholder="t('settings', 'Select user quota')"
:taggable="true"
@ -202,7 +198,7 @@
<td v-if="showConfig.showLanguages"
class="row__cell row__cell--large"
data-test="language">
<template v-if="idState.editing">
<template v-if="editing">
<label class="hidden-visually"
:for="'language' + uniqueId">
{{ t('settings', 'Set the language') }}
@ -210,13 +206,12 @@
<NcSelect :id="'language' + uniqueId"
:allow-empty="false"
:disabled="isLoadingField"
:loading="idState.loading.languages"
:loading="loading.languages"
:clearable="false"
:options="availableLanguages"
:placeholder="t('settings', 'No language set')"
:value="userLanguage"
label="name"
class="select-vue"
@input="setUserLanguage" />
</template>
<span v-else-if="!isObfuscated">
@ -243,21 +238,21 @@
<span v-if="!isObfuscated">{{ userLastLogin }}</span>
</td>
<td class="row__cell row__cell--large">
<template v-if="idState.editing">
<td class="row__cell row__cell--large row__cell--fill">
<template v-if="editing">
<label class="hidden-visually"
:for="'manager' + uniqueId">
{{ managerLabel }}
</label>
<NcSelect v-model="idState.currentManager"
<NcSelect v-model="currentManager"
class="select--fill"
:input-id="'manager' + uniqueId"
:close-on-select="true"
:disabled="isLoadingField"
:loading="idState.loadingPossibleManagers || idState.loading.manager"
:loading="loadingPossibleManagers || loading.manager"
label="displayname"
:options="idState.possibleManagers"
:options="possibleManagers"
:placeholder="managerLabel"
class="select-vue"
@open="searchInitialUserManager"
@search="searchUserManager"
@option:selected="updateUserManager"
@ -269,18 +264,16 @@
</td>
<td class="row__cell row__cell--actions">
<UserRowActions v-if="!isObfuscated && canEdit && !idState.loading.all"
<UserRowActions v-if="visible && !isObfuscated && canEdit && !loading.all"
:actions="userActions"
:disabled="isLoadingField"
:edit="idState.editing"
:edit="editing"
@update:edit="toggleEdit" />
</td>
</Fragment>
</tr>
</template>
<script>
import { Fragment } from 'vue-frag'
import { IdState } from 'vue-virtual-scroller'
import { getCurrentUser } from '@nextcloud/auth'
import { showSuccess, showError } from '@nextcloud/dialogs'
@ -299,7 +292,6 @@ export default {
name: 'UserRow',
components: {
Fragment,
NcAvatar,
NcLoadingIcon,
NcProgressBar,
@ -309,14 +301,6 @@ export default {
},
mixins: [
/**
* Use scoped `idState` instead of `data` which is reused between rows
*
* See https://github.com/Akryum/vue-virtual-scroller/tree/v1/packages/vue-virtual-scroller#why-is-this-useful
*/
IdState({
idProp: vm => vm.user.id,
}),
UserRowMixin,
],
@ -325,6 +309,10 @@ export default {
type: Object,
required: true,
},
visible: {
type: Boolean,
required: true,
},
users: {
type: Array,
required: true,
@ -359,7 +347,7 @@ export default {
},
},
idState() {
data() {
return {
selectedQuota: false,
rand: Math.random().toString(36).substring(2),
@ -402,15 +390,15 @@ export default {
},
isLoadingUser() {
return this.idState.loading.delete || this.idState.loading.disable || this.idState.loading.wipe
return this.loading.delete || this.loading.disable || this.loading.wipe
},
isLoadingField() {
return this.idState.loading.delete || this.idState.loading.disable || this.idState.loading.all
return this.loading.delete || this.loading.disable || this.loading.all
},
uniqueId() {
return this.user.id + this.idState.rand
return this.user.id + this.rand
},
userGroupsLabels() {
@ -487,8 +475,8 @@ export default {
// mapping saved values to objects
editedUserQuota: {
get() {
if (this.idState.selectedQuota !== false) {
return this.idState.selectedQuota
if (this.selectedQuota !== false) {
return this.selectedQuota
}
if (this.settings.defaultQuota !== unlimitedQuota.id && OC.Util.computerFileSize(this.settings.defaultQuota) >= 0) {
// if value is valid, let's map the quotaOptions or return custom quota
@ -497,7 +485,7 @@ export default {
return unlimitedQuota // unlimited
},
set(quota) {
this.idState.selectedQuota = quota
this.selectedQuota = quota
},
},
@ -510,10 +498,6 @@ export default {
if (this.user.manager) {
await this.initManager(this.user.manager)
}
// Reset loading state before mounting the component.
// This is useful when we disable a user as the loading state cannot be properly reset upon promise resolution.
Object.keys(this.idState.loading).forEach(key => (this.idState.loading[key] = false))
},
methods: {
@ -530,13 +514,13 @@ export default {
},
(result) => {
if (result) {
this.idState.loading.wipe = true
this.idState.loading.all = true
this.loading.wipe = true
this.loading.all = true
this.$store.dispatch('wipeUserDevices', userid)
.then(() => showSuccess(t('settings', 'Wiped {userid}\'s devices', { userid })), { timeout: 2000 })
.finally(() => {
this.idState.loading.wipe = false
this.idState.loading.all = false
this.loading.wipe = false
this.loading.all = false
})
}
},
@ -550,42 +534,42 @@ export default {
async initManager(userId) {
await this.$store.dispatch('getUser', userId).then(response => {
this.idState.currentManager = response?.data.ocs.data
this.currentManager = response?.data.ocs.data
})
},
async searchInitialUserManager() {
this.idState.loadingPossibleManagers = true
this.loadingPossibleManagers = true
await this.searchUserManager()
this.idState.loadingPossibleManagers = false
this.loadingPossibleManagers = false
},
async searchUserManager(query) {
await this.$store.dispatch('searchUsers', { offset: 0, limit: 10, search: query }).then(response => {
const users = response?.data ? this.filterManagers(Object.values(response?.data.ocs.data.users)) : []
if (users.length > 0) {
this.idState.possibleManagers = users
this.possibleManagers = users
}
})
},
async updateUserManager(manager) {
if (manager === null) {
this.idState.currentManager = ''
this.currentManager = ''
}
this.idState.loading.manager = true
this.loading.manager = true
try {
await this.$store.dispatch('setUserData', {
userid: this.user.id,
key: 'manager',
value: this.idState.currentManager ? this.idState.currentManager.id : '',
value: this.currentManager ? this.currentManager.id : '',
})
} catch (error) {
// TRANSLATORS This string describes a manager in the context of an organization
showError(t('setting', 'Failed to update user manager'))
console.error(error)
} finally {
this.idState.loading.manager = false
this.loading.manager = false
}
},
@ -602,12 +586,12 @@ export default {
},
(result) => {
if (result) {
this.idState.loading.delete = true
this.idState.loading.all = true
this.loading.delete = true
this.loading.all = true
return this.$store.dispatch('deleteUser', userid)
.then(() => {
this.idState.loading.delete = false
this.idState.loading.all = false
this.loading.delete = false
this.loading.all = false
})
}
},
@ -616,8 +600,8 @@ export default {
},
enableDisableUser() {
this.idState.loading.delete = true
this.idState.loading.all = true
this.loading.delete = true
this.loading.all = true
const userid = this.user.id
const enabled = !this.user.enabled
return this.$store.dispatch('enableDisableUser', {
@ -625,8 +609,8 @@ export default {
enabled,
})
.then(() => {
this.idState.loading.delete = false
this.idState.loading.all = false
this.loading.delete = false
this.loading.all = false
})
},
@ -636,14 +620,14 @@ export default {
* @param {string} displayName The display name
*/
updateDisplayName() {
this.idState.loading.displayName = true
this.loading.displayName = true
this.$store.dispatch('setUserData', {
userid: this.user.id,
key: 'displayname',
value: this.idState.editedDisplayName,
value: this.editedDisplayName,
}).then(() => {
this.idState.loading.displayName = false
if (this.idState.editedDisplayName === this.user.displayname) {
this.loading.displayName = false
if (this.editedDisplayName === this.user.displayname) {
showSuccess(t('setting', 'Display name was successfully changed'))
}
})
@ -655,18 +639,18 @@ export default {
* @param {string} password The email address
*/
updatePassword() {
this.idState.loading.password = true
if (this.idState.editedPassword.length === 0) {
this.loading.password = true
if (this.editedPassword.length === 0) {
showError(t('setting', "Password can't be empty"))
this.idState.loading.password = false
this.loading.password = false
} else {
this.$store.dispatch('setUserData', {
userid: this.user.id,
key: 'password',
value: this.idState.editedPassword,
value: this.editedPassword,
}).then(() => {
this.idState.loading.password = false
this.idState.editedPassword = ''
this.loading.password = false
this.editedPassword = ''
showSuccess(t('setting', 'Password was successfully changed'))
})
}
@ -678,19 +662,19 @@ export default {
* @param {string} mailAddress The email address
*/
updateEmail() {
this.idState.loading.mailAddress = true
if (this.idState.editedMail === '') {
this.loading.mailAddress = true
if (this.editedMail === '') {
showError(t('setting', "Email can't be empty"))
this.idState.loading.mailAddress = false
this.idState.editedMail = this.user.email
this.loading.mailAddress = false
this.editedMail = this.user.email
} else {
this.$store.dispatch('setUserData', {
userid: this.user.id,
key: 'email',
value: this.idState.editedMail,
value: this.editedMail,
}).then(() => {
this.idState.loading.mailAddress = false
if (this.idState.editedMail === this.user.email) {
this.loading.mailAddress = false
if (this.editedMail === this.user.email) {
showSuccess(t('setting', 'Email was successfully changed'))
}
})
@ -703,7 +687,7 @@ export default {
* @param {string} gid Group id
*/
async createGroup({ name: gid }) {
this.idState.loading = { groups: true, subadmins: true }
this.loading = { groups: true, subadmins: true }
try {
await this.$store.dispatch('addGroup', gid)
const userid = this.user.id
@ -711,7 +695,7 @@ export default {
} catch (error) {
console.error(error)
} finally {
this.idState.loading = { groups: false, subadmins: false }
this.loading = { groups: false, subadmins: false }
}
return this.$store.getters.getGroups[this.groups.length]
},
@ -727,7 +711,7 @@ export default {
// Ignore
return
}
this.idState.loading.groups = true
this.loading.groups = true
const userid = this.user.id
const gid = group.id
if (group.canAdd === false) {
@ -738,7 +722,7 @@ export default {
} catch (error) {
console.error(error)
} finally {
this.idState.loading.groups = false
this.loading.groups = false
}
},
@ -751,7 +735,7 @@ export default {
if (group.canRemove === false) {
return false
}
this.idState.loading.groups = true
this.loading.groups = true
const userid = this.user.id
const gid = group.id
try {
@ -759,13 +743,13 @@ export default {
userid,
gid,
})
this.idState.loading.groups = false
this.loading.groups = false
// remove user from current list if current list is the removed group
if (this.$route.params.selectedGroup === gid) {
this.$store.commit('deleteUser', userid)
}
} catch {
this.idState.loading.groups = false
this.loading.groups = false
}
},
@ -775,7 +759,7 @@ export default {
* @param {object} group Group object
*/
async addUserSubAdmin(group) {
this.idState.loading.subadmins = true
this.loading.subadmins = true
const userid = this.user.id
const gid = group.id
try {
@ -783,7 +767,7 @@ export default {
userid,
gid,
})
this.idState.loading.subadmins = false
this.loading.subadmins = false
} catch (error) {
console.error(error)
}
@ -795,7 +779,7 @@ export default {
* @param {object} group Group object
*/
async removeUserSubAdmin(group) {
this.idState.loading.subadmins = true
this.loading.subadmins = true
const userid = this.user.id
const gid = group.id
@ -807,7 +791,7 @@ export default {
} catch (error) {
console.error(error)
} finally {
this.idState.loading.subadmins = false
this.loading.subadmins = false
}
},
@ -822,7 +806,7 @@ export default {
if (quota === 'none') {
quota = unlimitedQuota
}
this.idState.loading.quota = true
this.loading.quota = true
// ensure we only send the preset id
quota = quota.id ? quota.id : quota
@ -835,7 +819,7 @@ export default {
} catch (error) {
console.error(error)
} finally {
this.idState.loading.quota = false
this.loading.quota = false
}
return quota
},
@ -868,7 +852,7 @@ export default {
* @return {object}
*/
async setUserLanguage(lang) {
this.idState.loading.languages = true
this.loading.languages = true
// ensure we only send the preset id
try {
await this.$store.dispatch('setUserData', {
@ -876,7 +860,7 @@ export default {
key: 'language',
value: lang.code,
})
this.idState.loading.languages = false
this.loading.languages = false
} catch (error) {
console.error(error)
}
@ -887,24 +871,24 @@ export default {
* Dispatch new welcome mail request
*/
sendWelcomeMail() {
this.idState.loading.all = true
this.loading.all = true
this.$store.dispatch('sendWelcomeMail', this.user.id)
.then(() => showSuccess(t('setting', 'Welcome mail sent!'), { timeout: 2000 }))
.finally(() => {
this.idState.loading.all = false
this.loading.all = false
})
},
async toggleEdit() {
this.idState.editing = !this.idState.editing
if (this.idState.editing) {
this.editing = !this.editing
if (this.editing) {
await this.$nextTick()
this.$refs.displayNameField?.$refs?.inputField?.$refs?.input?.focus()
}
if (this.idState.editedDisplayName !== this.user.displayname) {
this.idState.editedDisplayName = this.user.displayname
} else if (this.idState.editedMail !== this.user.email) {
this.idState.editedMail = this.user.email ?? ''
if (this.editedDisplayName !== this.user.displayname) {
this.editedDisplayName = this.user.displayname
} else if (this.editedMail !== this.user.email) {
this.editedMail = this.user.email ?? ''
}
},
},
@ -914,6 +898,24 @@ export default {
<style lang="scss" scoped>
@import './shared/styles.scss';
.user-list__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);
}
}
// Limit width of select in fill cell
.select--fill {
max-width: calc(var(--cell-width-large) - (2 * var(--cell-padding)));
}
}
.row {
@include cell;

@ -0,0 +1,199 @@
<!--
- @copyright 2023 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<table class="user-list">
<slot name="before" />
<thead ref="thead"
role="rowgroup"
class="user-list__header">
<slot name="header" />
</thead>
<tbody :style="tbodyStyle"
class="user-list__body">
<component :is="dataComponent"
v-for="(item, i) in renderedItems"
:key="item[dataKey]"
:user="item"
:visible="(i >= bufferItems || index <= bufferItems) && (i < shownItems - bufferItems)"
v-bind="extraProps" />
</tbody>
<tfoot ref="tfoot"
v-element-visibility="handleFooterVisibility"
role="rowgroup"
class="user-list__footer">
<slot name="footer" />
</tfoot>
</table>
</template>
<script lang="ts">
import Vue from 'vue'
import { vElementVisibility } from '@vueuse/components'
import { debounce } from 'debounce'
import logger from '../../logger.js'
Vue.directive('elementVisibility', vElementVisibility)
// Items to render before and after the visible area
const bufferItems = 3
export default Vue.extend({
name: 'VirtualList',
props: {
dataComponent: {
type: [Object, Function],
required: true,
},
dataKey: {
type: String,
required: true,
},
dataSources: {
type: Array,
required: true,
},
itemHeight: {
type: Number,
required: true,
},
extraProps: {
type: Object,
default: () => ({}),
},
},
data() {
return {
bufferItems,
index: 0,
headerHeight: 0,
tableHeight: 0,
resizeObserver: null as ResizeObserver | null,
}
},
computed: {
startIndex() {
return Math.max(0, this.index - bufferItems)
},
shownItems() {
return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + bufferItems * 2
},
renderedItems() {
return this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems)
},
tbodyStyle() {
const isOverScrolled = this.startIndex + this.shownItems > this.dataSources.length
const lastIndex = this.dataSources.length - this.startIndex - this.shownItems
const hiddenAfterItems = Math.min(this.dataSources.length - this.startIndex, lastIndex)
return {
paddingTop: `${this.startIndex * this.itemHeight}px`,
paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`,
}
},
},
mounted() {
const root = this.$el as HTMLElement
const tfoot = this.$refs?.tfoot as HTMLElement
const thead = this.$refs?.thead as HTMLElement
this.resizeObserver = new ResizeObserver(debounce(() => {
this.headerHeight = thead?.clientHeight ?? 0
this.tableHeight = root?.clientHeight ?? 0
logger.debug('VirtualList resizeObserver updated')
this.onScroll()
}, 100, false))
this.resizeObserver.observe(root)
this.resizeObserver.observe(tfoot)
this.resizeObserver.observe(thead)
this.$el.addEventListener('scroll', this.onScroll)
},
beforeDestroy() {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
},
methods: {
handleFooterVisibility(visible: boolean) {
if (visible) {
this.$emit('scroll-end')
}
},
onScroll() {
// Max 0 to prevent negative index
this.index = Math.max(0, Math.round(this.$el.scrollTop / this.itemHeight))
},
},
})
</script>
<style lang="scss" scoped>
.user-list {
--avatar-cell-width: 48px;
--cell-padding: 7px;
--cell-width: 200px;
--cell-width-large: 300px;
--cell-min-width: calc(var(--cell-width) - (2 * var(--cell-padding)));
// Necessary for virtual scroll optimized rendering
display: block;
overflow: auto;
height: 100%;
&__header,
&__footer {
position: sticky;
// Fix sticky positioning in Firefox
display: block;
}
&__header {
top: 0;
z-index: 20;
}
&__footer {
left: 0;
}
&__body {
display: flex;
flex-direction: column;
width: 100%;
}
}
</style>

@ -21,8 +21,10 @@
*/
@mixin row {
position: absolute;
position: relative;
display: flex;
min-width: 100%;
width: fit-content;
height: var(--row-height);
background-color: var(--color-main-background);
}
@ -33,6 +35,7 @@
flex-direction: column;
justify-content: center;
padding: 0 var(--cell-padding);
min-width: var(--cell-width);
width: var(--cell-width);
color: var(--color-main-text);
@ -64,6 +67,7 @@
}
&--avatar {
min-width: var(--avatar-cell-width);
width: var(--avatar-cell-width);
align-items: center;
padding: 0;
@ -84,13 +88,21 @@
}
&--large {
width: 300px;
min-width: var(--cell-width-large);
width: var(--cell-width-large);
}
&--obfuscated {
min-width: 400px;
width: 400px;
}
// Fill remaining row space with cell
&--fill {
min-width: var(--cell-width-large);
width: 100%;
}
&--actions {
position: sticky;
right: 0;
@ -98,6 +110,7 @@
display: flex;
flex-direction: row;
align-items: center;
min-width: 110px;
width: 110px;
background-color: var(--color-main-background);
border-left: 1px solid var(--color-border);

@ -81,7 +81,7 @@ describe('Settings: Create and delete users', function() {
})
// see that the created user is in the list
cy.get(`tbody.user-list__body tr td[data-test="john"]`).parents('tr').within(() => {
cy.get('tbody.user-list__body tr[data-test="john"]').within(() => {
// see that the list of users contains the user john
cy.contains('john').should('exist')
})
@ -126,7 +126,7 @@ describe('Settings: Create and delete users', function() {
})
// see that the created user is in the list
cy.get(`tbody.user-list__body tr td[data-test="john"]`).parents('tr').within(() => {
cy.get('tbody.user-list__body tr[data-test="john"]').within(() => {
// see that the list of users contains the user john
cy.contains('john').should('exist')
})
@ -139,7 +139,7 @@ describe('Settings: Create and delete users', function() {
cy.reload().login(admin)
// see that the user is in the list
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').within(() => {
cy.get(`tbody.user-list__body tr[data-test="${jdoe.userId}"]`).within(() => {
// see that the list of users contains the user jdoe
cy.contains(jdoe.userId).should('exist')
// open the actions menu for the user
@ -165,6 +165,6 @@ describe('Settings: Create and delete users', function() {
})
// deleted clicked the user is not shown anymore
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').should('not.be.visible')
cy.get(`tbody.user-list__body tr[data-test="${jdoe.userId}"]`).should('not.exist')
})
})

@ -49,12 +49,12 @@ describe('Settings: Show and hide columns', function() {
it('Can show a column', function() {
// see that the language column is not in the header
cy.get(`.user-list__header tr`).within(() => {
cy.get('.user-list__header tr').within(() => {
cy.contains('Language').should('not.exist')
})
// see that the language column is not in all user rows
cy.get(`tbody.user-list__body tr`).each(($row) => {
cy.get('tbody.user-list__body tr').each(($row) => {
cy.wrap($row).get('[data-test="language"]').should('not.exist')
})
@ -72,24 +72,24 @@ describe('Settings: Show and hide columns', function() {
cy.waitUntil(() => cy.get('.modal-container').should(el => assertNotExistOrNotVisible(el)))
// see that the language column is in the header
cy.get(`.user-list__header tr`).within(() => {
cy.get('.user-list__header tr').within(() => {
cy.contains('Language').should('exist')
})
// see that the language column is in all user rows
cy.get(`tbody.user-list__body tr`).each(($row) => {
cy.get('tbody.user-list__body tr').each(($row) => {
cy.wrap($row).get('[data-test="language"]').should('exist')
})
})
it('Can hide a column', function() {
// see that the last login column is in the header
cy.get(`.user-list__header tr`).within(() => {
cy.get('.user-list__header tr').within(() => {
cy.contains('Last login').should('exist')
})
// see that the last login column is in all user rows
cy.get(`tbody.user-list__body tr`).each(($row) => {
cy.get('tbody.user-list__body tr').each(($row) => {
cy.wrap($row).get('[data-test="lastLogin"]').should('exist')
})
@ -107,12 +107,12 @@ describe('Settings: Show and hide columns', function() {
cy.waitUntil(() => cy.get('.modal-container').should(el => assertNotExistOrNotVisible(el)))
// see that the last login column is not in the header
cy.get(`.user-list__header tr`).within(() => {
cy.get('.user-list__header tr').within(() => {
cy.contains('Last login').should('not.exist')
})
// see that the last login column is not in all user rows
cy.get(`tbody.user-list__body tr`).each(($row) => {
cy.get('tbody.user-list__body tr').each(($row) => {
cy.wrap($row).get('[data-test="lastLogin"]').should('not.exist')
})
})

@ -42,24 +42,24 @@ describe('Settings: Disable and enable users', function() {
cy.enableUser(jdoe)
// see that the user is in the list of active users
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').within(() => {
cy.get(`tbody.user-list__body tr[data-test="${jdoe.userId}"]`).within(() => {
// see that the list of users contains the user jdoe
cy.contains(jdoe.userId).should('exist')
// open the actions menu for the user
cy.get('td.row__cell--actions button.action-item__menutoggle').click()
cy.get('td.row__cell--actions button.action-item__menutoggle').click({ scrollBehavior: 'center' })
})
// The "Disable user" action in the actions menu is shown and clicked
cy.get('.action-item__popper .action').contains('Disable user').should('exist').click()
// When clicked the section is not shown anymore
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').should('not.be.visible')
cy.get(`tbody.user-list__body tr[data-test="${jdoe.userId}"]`).should('not.exist')
// But the disabled user section now exists
cy.get('#disabled').should('exist')
// Open disabled users section
cy.get('#disabled a').click()
cy.url().should('match', /\/disabled/)
// The list of disabled users should now contain the user
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').should('exist')
cy.get(`tbody.user-list__body tr[data-test="${jdoe.userId}"]`).should('exist')
})
it('Can enable the user', function() {
@ -71,11 +71,11 @@ describe('Settings: Disable and enable users', function() {
cy.url().should('match', /\/disabled/)
// see that the user is in the list of active users
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').within(() => {
cy.get(`tbody.user-list__body tr[data-test="${jdoe.userId}"]`).within(() => {
// see that the list of disabled users contains the user jdoe
cy.contains(jdoe.userId).should('exist')
// open the actions menu for the user
cy.get('td.row__cell--actions button.action-item__menutoggle').click()
cy.get('td.row__cell--actions button.action-item__menutoggle').click({ scrollBehavior: 'center' })
})
// The "Enable user" action in the actions menu is shown and clicked

@ -34,7 +34,7 @@ describe('Settings: Change user properties', function() {
})
beforeEach(function() {
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').within(() => {
cy.get(`tbody.user-list__body tr[data-test="${jdoe.userId}"]`).within(() => {
// reset edit mode for the user jdoe
cy.get('td.row__cell--actions .action-items > button:first-of-type')
.invoke('attr', 'title')
@ -51,14 +51,14 @@ describe('Settings: Change user properties', function() {
})
it('Can change the display name', function() {
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').within(() => {
cy.get(`tbody.user-list__body tr[data-test="${jdoe.userId}"]`).within(() => {
// see that the list of users contains the user jdoe
cy.contains(jdoe.userId).should('exist')
// toggle the edit mode for the user jdoe
cy.get('td.row__cell--actions .action-items > button:first-of-type').click()
})
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').within(() => {
cy.get(`tbody.user-list__body tr[data-test="${jdoe.userId}"]`).within(() => {
// set the display name
cy.get('input[data-test="displayNameField"]').should('exist').and('have.value', 'jdoe')
cy.get('input[data-test="displayNameField"]').clear()
@ -88,14 +88,14 @@ describe('Settings: Change user properties', function() {
})
it('Can change the password', function() {
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').within(() => {
cy.get(`tbody.user-list__body tr[data-test="${jdoe.userId}"]`).within(() => {
// see that the list of users contains the user jdoe
cy.contains(jdoe.userId).should('exist')
// toggle the edit mode for the user jdoe
cy.get('td.row__cell--actions .action-items > button:first-of-type').click()
})
cy.get(`tbody.user-list__body tr td[data-test="${jdoe.userId}"]`).parents('tr').within(() => {
cy.get(`tbody.user-list__body tr[data-test="${jdoe.userId}"]`).within(() => {
// see that the password of user0 is ""
cy.get('input[type="password"]').should('exist').and('have.value', '')
// set the password for user0 to 123456

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

32
package-lock.json generated

@ -84,7 +84,6 @@
"vue-multiselect": "^2.1.6",
"vue-observe-visibility": "^1.0.0",
"vue-router": "^3.6.5",
"vue-virtual-scroller": "^1.1.2",
"vuedraggable": "^2.24.3",
"vuex": "^3.6.2",
"vuex-router-sync": "^5.0.0",
@ -21787,11 +21786,6 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true
},
"node_modules/scrollparent": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="
},
"node_modules/select": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz",
@ -25231,32 +25225,6 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true
},
"node_modules/vue-virtual-scroller": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-1.1.2.tgz",
"integrity": "sha512-SkUyc7QHCJFB5h1Fya7LxVizlVzOZZuFVipBGHYoTK8dwLs08bIz/tclvRApYhksaJIm/nn51inzO2UjpGJPMQ==",
"dependencies": {
"scrollparent": "^2.0.1",
"vue-observe-visibility": "^0.4.4",
"vue-resize": "^0.4.5"
},
"peerDependencies": {
"vue": "^2.6.11"
}
},
"node_modules/vue-virtual-scroller/node_modules/vue-observe-visibility": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/vue-observe-visibility/-/vue-observe-visibility-0.4.6.tgz",
"integrity": "sha512-xo0CEVdkjSjhJoDdLSvoZoQrw/H2BlzB5jrCBKGZNXN2zdZgMuZ9BKrxXDjNP2AxlcCoKc8OahI3F3r3JGLv2Q=="
},
"node_modules/vue-virtual-scroller/node_modules/vue-resize": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-0.4.5.tgz",
"integrity": "sha512-bhP7MlgJQ8TIkZJXAfDf78uJO+mEI3CaLABLjv0WNzr4CcGRGPIAItyWYnP6LsPA4Oq0WE+suidNs6dgpO4RHg==",
"peerDependencies": {
"vue": "^2.3.0"
}
},
"node_modules/vue2-datepicker": {
"version": "3.11.1",
"resolved": "https://registry.npmjs.org/vue2-datepicker/-/vue2-datepicker-3.11.1.tgz",

@ -111,7 +111,6 @@
"vue-multiselect": "^2.1.6",
"vue-observe-visibility": "^1.0.0",
"vue-router": "^3.6.5",
"vue-virtual-scroller": "^1.1.2",
"vuedraggable": "^2.24.3",
"vuex": "^3.6.2",
"vuex-router-sync": "^5.0.0",

Loading…
Cancel
Save