diff options
author | Christopher Ng <chrng8@gmail.com> | 2023-10-04 17:54:42 -0700 |
---|---|---|
committer | Christopher Ng <chrng8@gmail.com> | 2023-10-05 16:49:24 -0700 |
commit | d655f2a3b994cffdd9dbf5d08124ed70b507653a (patch) | |
tree | d68d6e0b9fd6938d5e136b8497506cd10b203204 /apps | |
parent | b4fec29e8e403cdf6a589f5a0855b3b904c720e4 (diff) | |
download | nextcloud-server-d655f2a3b994cffdd9dbf5d08124ed70b507653a.tar.gz nextcloud-server-d655f2a3b994cffdd9dbf5d08124ed70b507653a.zip |
enh(settings): Semantic user table markup
Signed-off-by: Christopher Ng <chrng8@gmail.com>
Diffstat (limited to 'apps')
-rw-r--r-- | apps/settings/src/components/UserList.vue | 113 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserListFooter.vue | 2 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserListHeader.vue | 2 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserRow.vue | 242 | ||||
-rw-r--r-- | apps/settings/src/components/Users/VirtualList.vue | 199 | ||||
-rw-r--r-- | apps/settings/src/components/Users/shared/styles.scss | 17 |
6 files changed, 361 insertions, 214 deletions
diff --git a/apps/settings/src/components/UserList.vue b/apps/settings/src/components/UserList.vue index b208a95d0c5..5d3993f040a 100644 --- a/apps/settings/src/components/UserList.vue +++ b/apps/settings/src/components/UserList.vue @@ -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> diff --git a/apps/settings/src/components/Users/UserListFooter.vue b/apps/settings/src/components/Users/UserListFooter.vue index c8713369203..490add4d920 100644 --- a/apps/settings/src/components/Users/UserListFooter.vue +++ b/apps/settings/src/components/Users/UserListFooter.vue @@ -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); } } diff --git a/apps/settings/src/components/Users/UserListHeader.vue b/apps/settings/src/components/Users/UserListHeader.vue index e600862a873..4ee0637d16b 100644 --- a/apps/settings/src/components/Users/UserListHeader.vue +++ b/apps/settings/src/components/Users/UserListHeader.vue @@ -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> diff --git a/apps/settings/src/components/Users/UserRow.vue b/apps/settings/src/components/Users/UserRow.vue index 35cd15a0367..ead4eca1552 100644 --- a/apps/settings/src/components/Users/UserRow.vue +++ b/apps/settings/src/components/Users/UserRow.vue @@ -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; diff --git a/apps/settings/src/components/Users/VirtualList.vue b/apps/settings/src/components/Users/VirtualList.vue new file mode 100644 index 00000000000..e642882f23d --- /dev/null +++ b/apps/settings/src/components/Users/VirtualList.vue @@ -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> diff --git a/apps/settings/src/components/Users/shared/styles.scss b/apps/settings/src/components/Users/shared/styles.scss index 12525347738..402a6a99fc2 100644 --- a/apps/settings/src/components/Users/shared/styles.scss +++ b/apps/settings/src/components/Users/shared/styles.scss @@ -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); |