aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorChristopher Ng <chrng8@gmail.com>2023-10-04 17:54:42 -0700
committerChristopher Ng <chrng8@gmail.com>2023-10-05 16:49:24 -0700
commitd655f2a3b994cffdd9dbf5d08124ed70b507653a (patch)
treed68d6e0b9fd6938d5e136b8497506cd10b203204 /apps
parentb4fec29e8e403cdf6a589f5a0855b3b904c720e4 (diff)
downloadnextcloud-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.vue113
-rw-r--r--apps/settings/src/components/Users/UserListFooter.vue2
-rw-r--r--apps/settings/src/components/Users/UserListHeader.vue2
-rw-r--r--apps/settings/src/components/Users/UserRow.vue242
-rw-r--r--apps/settings/src/components/Users/VirtualList.vue199
-rw-r--r--apps/settings/src/components/Users/shared/styles.scss17
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);