mirror of
https://github.com/nextcloud/server.git
synced 2026-06-11 01:30:50 -04:00
enh(settings): Semantic user table markup
Signed-off-by: Christopher Ng <chrng8@gmail.com>
This commit is contained in:
parent
b4fec29e8e
commit
d655f2a3b9
8 changed files with 362 additions and 248 deletions
|
|
@ -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>
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<UserListHeader :has-obfuscated="hasObfuscated" />
|
||||
</template>
|
||||
|
||||
<template #default="{ item: user }">
|
||||
<UserRow :user="user"
|
||||
:users="users"
|
||||
:settings="settings"
|
||||
:has-obfuscated="hasObfuscated"
|
||||
:groups="groups"
|
||||
:sub-admins-groups="subAdminsGroups"
|
||||
:quota-options="quotaOptions"
|
||||
:languages="languages"
|
||||
:external-actions="externalActions" />
|
||||
</template>
|
||||
|
||||
<template #after>
|
||||
<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;
|
||||
|
||||
|
|
|
|||
199
apps/settings/src/components/Users/VirtualList.vue
Normal file
199
apps/settings/src/components/Users/VirtualList.vue
Normal file
|
|
@ -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);
|
||||
|
|
|
|||
32
package-lock.json
generated
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…
Reference in a new issue