diff --git a/apps/settings/css/settings.scss b/apps/settings/css/settings.scss index 80c431008fc0e57832042ba8f30ac4b74f37eb80..47a035016add020b42941f00cb0c9da499ac5a97 100644 --- a/apps/settings/css/settings.scss +++ b/apps/settings/css/settings.scss @@ -524,7 +524,6 @@ td, th { visibility: hidden; } &.password, - &.displayName, &.mailAddress { min-width: 5em; max-width: 12em; @@ -705,6 +704,7 @@ span.version { #searchresults { display: none; } + } #apps-list.store { .section { @@ -1351,8 +1351,8 @@ doesnotexist:-o-prefocus, .strengthify-wrapper { /* USERS LIST -------------------------------------------------------------- */ #body-settings { - $grid-row-height: 46px; - $grid-col-min-width: 120px; + $grid-row-height: 60px; + $grid-col-min-width: 150px; #app-content.user-list-grid { display: grid; grid-auto-columns: 1fr; @@ -1376,7 +1376,6 @@ doesnotexist:-o-prefocus, .strengthify-wrapper { /* grid col width */ .name, - .displayName, .password, .mailAddress, .languages, @@ -1384,12 +1383,17 @@ doesnotexist:-o-prefocus, .strengthify-wrapper { .userBackend, .lastLogin { min-width: $grid-col-min-width; + display: flex; + color: var(--color-text-dark); + vertical-align: baseline; } .groups, .subadmins, .quota { .multiselect { min-width: $grid-col-min-width; + color: var(--color-text-dark); + vertical-align: baseline; } } .obfuscated { @@ -1399,6 +1403,10 @@ doesnotexist:-o-prefocus, .strengthify-wrapper { .userActions { min-width: 44px; } + .subtitle { + color: var(--color-text-maxcontrast); + vertical-align: baseline; + } /* various */ &#grid-header, @@ -1427,16 +1435,23 @@ doesnotexist:-o-prefocus, .strengthify-wrapper { &#grid-header { color: var(--color-text-maxcontrast); z-index: 60; /* above new-user */ + border-bottom-width: thin; #headerDisplayName, #headerPassword, #headerAddress, #headerGroups, #headerSubAdmins, + #theHeaderUserBackend, + #theHeaderLastLogin, #headerQuota, + #theHeaderStorageLocation, #headerLanguages { /* Line up header text with column content for when there’s inputs */ padding-left: 7px; + text-transform: none; + color: var(--color-text-maxcontrast); + vertical-align: baseline; } } &:hover { @@ -1451,8 +1466,7 @@ doesnotexist:-o-prefocus, .strengthify-wrapper { > form { grid-row: 1; display: inline-flex; - align-items: center; - color: var(--color-text); + color: var(--color-text-lighter); position: relative; > input:not(:focus):not(:active) { border-color: transparent; @@ -1478,7 +1492,7 @@ doesnotexist:-o-prefocus, .strengthify-wrapper { } } &.name, - &.storageLocation { + &.userBackend { /* better multi-line visual */ line-height: 1.3em; max-height: 100%; @@ -1492,16 +1506,14 @@ doesnotexist:-o-prefocus, .strengthify-wrapper { -webkit-box-orient: vertical; } &.quota { - .multiselect--active + progress { - display: none; - } + height: 44px; + display: flex; + align-items: center; + justify-content: center; progress { - position: absolute; - width: calc(100% - 4px); /* minus left and right */ - left: 2px; - bottom: 2px; + width: 100%; + margin: 0 10px; height: 3px; - z-index: 5; /* above multiselect */ } } .icon-confirm { @@ -1520,16 +1532,22 @@ doesnotexist:-o-prefocus, .strengthify-wrapper { } } &.userActions { + .action-item { + position: absolute; + } #newsubmit { width: 100%; } .toggleUserActions { position: relative; + display: block; + align-items: center; .icon-more { width: 44px; height: 44px; opacity: .5; cursor: pointer; + margin-left: 40px; &:hover { opacity: .7; } diff --git a/apps/settings/js/vue-1.js b/apps/settings/js/vue-1.js new file mode 100644 index 0000000000000000000000000000000000000000..0b135343ce877aa2cf3a7826c5cc67334729d685 Binary files /dev/null and b/apps/settings/js/vue-1.js differ diff --git a/apps/settings/js/vue-1.js.map b/apps/settings/js/vue-1.js.map new file mode 100644 index 0000000000000000000000000000000000000000..6b1d19d75f0c3fca35601271261ba195f54f1761 Binary files /dev/null and b/apps/settings/js/vue-1.js.map differ diff --git a/apps/settings/js/vue-2.js b/apps/settings/js/vue-2.js new file mode 100644 index 0000000000000000000000000000000000000000..4dce54646a6b10a67d417c2d5c2e0e436f081587 Binary files /dev/null and b/apps/settings/js/vue-2.js differ diff --git a/apps/settings/js/vue-2.js.map b/apps/settings/js/vue-2.js.map new file mode 100644 index 0000000000000000000000000000000000000000..28bf656ae49aff4c91617b8e6a231380bf9bb1f7 Binary files /dev/null and b/apps/settings/js/vue-2.js.map differ diff --git a/apps/settings/js/vue-3.js b/apps/settings/js/vue-3.js new file mode 100644 index 0000000000000000000000000000000000000000..e22c08ce9ceb3dd25021369f1d6e71e0a2ad8369 Binary files /dev/null and b/apps/settings/js/vue-3.js differ diff --git a/apps/settings/js/vue-3.js.map b/apps/settings/js/vue-3.js.map new file mode 100644 index 0000000000000000000000000000000000000000..ae47034de33a07d5b3ae70737620f7eb7446ac7e Binary files /dev/null and b/apps/settings/js/vue-3.js.map differ diff --git a/apps/settings/js/vue-4.js b/apps/settings/js/vue-4.js index be21d10b35e1c81cbd6ac945dcb729e6a53222e0..00d70b2c14c613bda95bed2a5c65cc3c769cccd9 100644 Binary files a/apps/settings/js/vue-4.js and b/apps/settings/js/vue-4.js differ diff --git a/apps/settings/js/vue-4.js.map b/apps/settings/js/vue-4.js.map index d78ea55117994640840113ac4e5f206d6defba58..0cea8d852da4e5e40b72da1575e17dfa3863446f 100644 Binary files a/apps/settings/js/vue-4.js.map and b/apps/settings/js/vue-4.js.map differ diff --git a/apps/settings/js/vue-6.js b/apps/settings/js/vue-6.js index 65699a0042d33f37eb2284e9bcbde9f061f29165..71d385dca0e56fdd6e3584ecf98b0f3f286f5f61 100644 Binary files a/apps/settings/js/vue-6.js and b/apps/settings/js/vue-6.js differ diff --git a/apps/settings/js/vue-6.js.map b/apps/settings/js/vue-6.js.map index 587c3cee5c90881b453f64b61b884364cd0e7dd3..6849a40965d2108dda64174399538fb87d259d87 100644 Binary files a/apps/settings/js/vue-6.js.map and b/apps/settings/js/vue-6.js.map differ diff --git a/apps/settings/js/vue-settings-admin-security.js b/apps/settings/js/vue-settings-admin-security.js index 9cbce132bec2ae54cd43f9f5ccad4466c42b814c..6aff2b05c171f2b15c3403b11bbf6aecba122c8d 100644 Binary files a/apps/settings/js/vue-settings-admin-security.js and b/apps/settings/js/vue-settings-admin-security.js differ diff --git a/apps/settings/js/vue-settings-admin-security.js.map b/apps/settings/js/vue-settings-admin-security.js.map index c0f90eeba04f70fc7d68b0820dd826d42d625bc4..fe38171929b12d0e827d8b4c6d49f225477e3f94 100644 Binary files a/apps/settings/js/vue-settings-admin-security.js.map and b/apps/settings/js/vue-settings-admin-security.js.map differ diff --git a/apps/settings/js/vue-settings-apps-users-management.js b/apps/settings/js/vue-settings-apps-users-management.js index 5b9b5beee627f3c98d599d12ecea191327c52951..8946490a3f9d730cb014578330015d55884f3d36 100644 Binary files a/apps/settings/js/vue-settings-apps-users-management.js and b/apps/settings/js/vue-settings-apps-users-management.js differ diff --git a/apps/settings/js/vue-settings-apps-users-management.js.map b/apps/settings/js/vue-settings-apps-users-management.js.map index bfaa3be2d04ab982c6dfa9451f2db0cf62ea42d5..e7a8b7f840e2a07a4c664d777ae411c6573515d9 100644 Binary files a/apps/settings/js/vue-settings-apps-users-management.js.map and b/apps/settings/js/vue-settings-apps-users-management.js.map differ diff --git a/apps/settings/js/vue-settings-personal-security.js b/apps/settings/js/vue-settings-personal-security.js index f56c5c3ce42e87e14cee1ce82b6165d3435bb609..7279cf340e8864ab243d116718c6968cbf732545 100644 Binary files a/apps/settings/js/vue-settings-personal-security.js and b/apps/settings/js/vue-settings-personal-security.js differ diff --git a/apps/settings/js/vue-settings-personal-security.js.map b/apps/settings/js/vue-settings-personal-security.js.map index ed9d9486f95e434f71b8b59edb713f1cf97d60d6..0d8b0f2daed2e3c6172c1ef84e0e3bceee28b974 100644 Binary files a/apps/settings/js/vue-settings-personal-security.js.map and b/apps/settings/js/vue-settings-personal-security.js.map differ diff --git a/apps/settings/src/components/AppList.vue b/apps/settings/src/components/AppList.vue index a406f6b8ff63e81d44551a6a86118d9598e5e576..3259011497aa99b4d54c421d5d99f1e2aab73340 100644 --- a/apps/settings/src/components/AppList.vue +++ b/apps/settings/src/components/AppList.vue @@ -29,7 +29,9 @@ <button v-if="showUpdateAll" id="app-list-update-all" class="primary" - @click="updateAll">{{t('settings', 'Update all')}}</button> + @click="updateAll"> + {{ t('settings', 'Update all') }} + </button> </div> <transition-group name="app-list" tag="div" class="apps-list-container"> <AppItem v-for="app in apps" diff --git a/apps/settings/src/components/UserList.vue b/apps/settings/src/components/UserList.vue index 57e40b9eb0be4b7acd3b09d4f2933be5754a604c..fc3ee47065a79c46fef116c5d2bc3c3001a5ba89 100644 --- a/apps/settings/src/components/UserList.vue +++ b/apps/settings/src/components/UserList.vue @@ -22,13 +22,16 @@ <template> <div id="app-content" class="user-list-grid" @scroll.passive="onScroll"> - <div id="grid-header" class="row" :class="{'sticky': scrolled && !showConfig.showNewUserForm}"> + <div id="grid-header" + :class="{'sticky': scrolled && !showConfig.showNewUserForm}" + class="row"> <div id="headerAvatar" class="avatar" /> <div id="headerName" class="name"> {{ t('settings', 'Username') }} - </div> - <div id="headerDisplayName" class="displayName"> - {{ t('settings', 'Display name') }} + + <div class="subtitle"> + {{ t('settings', 'Display name') }} + </div> </div> <div id="headerPassword" class="password"> {{ t('settings', 'Password') }} @@ -52,99 +55,103 @@ class="languages"> {{ t('settings', 'Language') }} </div> - <div v-if="showConfig.showStoragePath" - class="headerStorageLocation storageLocation"> - {{ t('settings', 'Storage location') }} - </div> - <div v-if="showConfig.showUserBackend" + + <div v-if="showConfig.showUserBackend || showConfig.showStoragePath" class="headerUserBackend userBackend"> - {{ t('settings', 'User backend') }} + <div v-if="showConfig.showUserBackend" class="userBackend"> + {{ t('settings', 'User backend') }} + </div> + <div v-if="showConfig.showStoragePath" + class="subtitle storageLocation"> + {{ t('settings', 'Storage location') }} + </div> </div> <div v-if="showConfig.showLastLogin" class="headerLastLogin lastLogin"> {{ t('settings', 'Last login') }} </div> + <div class="userActions" /> </div> <form v-show="showConfig.showNewUserForm" id="new-user" - class="row" - :disabled="loading.all" :class="{'sticky': scrolled && showConfig.showNewUserForm}" + :disabled="loading.all" + class="row" @submit.prevent="createUser"> <div :class="loading.all?'icon-loading-small':'icon-add'" /> <div class="name"> <input id="newusername" ref="newusername" v-model="newUser.id" - type="text" - required + :disabled="settings.newUserGenerateUserID" :placeholder="settings.newUserGenerateUserID ? t('settings', 'Will be autogenerated') : t('settings', 'Username')" - name="username" - autocomplete="off" autocapitalize="none" + autocomplete="off" autocorrect="off" + name="username" pattern="[a-zA-Z0-9 _\.@\-']+" - :disabled="settings.newUserGenerateUserID"> + required + type="text"> </div> <div class="displayName"> <input id="newdisplayname" v-model="newUser.displayName" - type="text" :placeholder="t('settings', 'Display name')" - name="displayname" - autocomplete="off" autocapitalize="none" - autocorrect="off"> + autocomplete="off" + autocorrect="off" + name="displayname" + type="text"> </div> <div class="password"> <input id="newuserpassword" ref="newuserpassword" v-model="newUser.password" - type="password" - :required="newUser.mailAddress===''" + :minlength="minPasswordLength" :placeholder="t('settings', 'Password')" - name="password" - autocomplete="new-password" + :required="newUser.mailAddress===''" autocapitalize="none" + autocomplete="new-password" autocorrect="off" - :minlength="minPasswordLength"> + name="password" + type="password"> </div> <div class="mailAddress"> <input id="newemail" v-model="newUser.mailAddress" - type="email" - :required="newUser.password==='' || settings.newUserRequireEmail" :placeholder="t('settings', 'Email')" - name="email" - autocomplete="off" + :required="newUser.password==='' || settings.newUserRequireEmail" autocapitalize="none" - autocorrect="off"> + autocomplete="off" + autocorrect="off" + name="email" + type="email"> </div> <div class="groups"> <!-- hidden input trick for vanilla html5 form validation --> <input v-if="!settings.isAdmin" id="newgroups" - type="text" + :class="{'icon-loading-small': loading.groups}" + :required="!settings.isAdmin" :value="newUser.groups" tabindex="-1" - :required="!settings.isAdmin" - :class="{'icon-loading-small': loading.groups}"> + type="text"> <Multiselect v-model="newUser.groups" - :options="canAddGroups" + :close-on-select="false" :disabled="loading.groups||loading.all" - tag-placeholder="create" + :multiple="true" + :options="canAddGroups" :placeholder="t('settings', 'Add user in group')" + :tag-width="60" + :taggable="true" + class="multiselect-vue" label="name" + tag-placeholder="create" track-by="id" - class="multiselect-vue" - :multiple="true" - :taggable="true" - :close-on-select="false" - :tag-width="60" @tag="createGroup"> <!-- If user is not admin, he is a subadmin. Subadmins can't create users outside their groups @@ -152,63 +159,64 @@ <span slot="noResult">{{ t('settings', 'No results') }}</span> </Multiselect> </div> - <div v-if="subAdminsGroups.length>0 && settings.isAdmin" class="subadmins"> + <div v-if="subAdminsGroups.length>0 && settings.isAdmin" + class="subadmins"> <Multiselect v-model="newUser.subAdminsGroups" + :close-on-select="false" + :multiple="true" :options="subAdminsGroups" :placeholder="t('settings', 'Set user as admin for')" - label="name" - track-by="id" + :tag-width="60" class="multiselect-vue" - :multiple="true" - :close-on-select="false" - :tag-width="60"> + label="name" + track-by="id"> <span slot="noResult">{{ t('settings', 'No results') }}</span> </Multiselect> </div> <div class="quota"> <Multiselect v-model="newUser.quota" + :allow-empty="false" :options="quotaOptions" :placeholder="t('settings', 'Select user quota')" + :taggable="true" + class="multiselect-vue" label="label" track-by="id" - class="multiselect-vue" - :allow-empty="false" - :taggable="true" @tag="validateQuota" /> </div> <div v-if="showConfig.showLanguages" class="languages"> <Multiselect v-model="newUser.language" + :allow-empty="false" :options="languages" :placeholder="t('settings', 'Default language')" - label="name" - track-by="code" class="multiselect-vue" - :allow-empty="false" + group-label="label" group-values="languages" - group-label="label" /> + label="name" + track-by="code" /> </div> <div v-if="showConfig.showStoragePath" class="storageLocation" /> <div v-if="showConfig.showUserBackend" class="userBackend" /> <div v-if="showConfig.showLastLogin" class="lastLogin" /> <div class="userActions"> <input id="newsubmit" - type="submit" + :title="t('settings', 'Add a new user')" class="button primary icon-checkmark-white has-tooltip" - value="" - :title="t('settings', 'Add a new user')"> + type="submit" + value=""> </div> </form> <user-row v-for="(user, key) in filteredUsers" :key="key" - :user="user" + :external-actions="externalActions" + :groups="groups" + :languages="languages" + :quota-options="quotaOptions" :settings="settings" :show-config="showConfig" - :groups="groups" :sub-admins-groups="subAdminsGroups" - :quota-options="quotaOptions" - :languages="languages" - :external-actions="externalActions" /> + :user="user" /> <InfiniteLoading ref="infiniteLoading" @infinite="infiniteHandler"> <div slot="spinner"> <div class="users-icon-loading icon-loading" /> @@ -328,7 +336,10 @@ export default { }, quotaOptions() { // convert the preset array into objects - let quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({ id: cur, label: cur }), []) + let quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({ + id: cur, + label: cur + }), []) // add default presets quotaPreset.unshift(this.unlimitedQuota) quotaPreset.unshift(this.defaultQuota) @@ -377,9 +388,9 @@ export default { // deleting the last user, reset the list if (val === 0 && old === 1) { this.$refs.infiniteLoading.stateChanger.reset() - // adding the first user, warn the infiniteLoader that - // the list is not empty anymore (we don't fetch the newly - // added user as we already have all the info we need) + // adding the first user, warn the infiniteLoader that + // the list is not empty anymore (we don't fetch the newly + // added user as we already have all the info we need) } else if (val === 1 && old === 0) { this.$refs.infiniteLoading.stateChanger.loaded() } @@ -437,7 +448,9 @@ export default { group: this.selectedGroup !== 'disabled' ? this.selectedGroup : '', search: this.searchQuery }) - .then((response) => { response ? $state.loaded() : $state.complete() }) + .then((response) => { + response ? $state.loaded() : $state.complete() + }) }, /* SEARCH */ @@ -492,10 +505,10 @@ export default { if (error.response && error.response.data && error.response.data.ocs && error.response.data.ocs.meta) { const statuscode = error.response.data.ocs.meta.statuscode if (statuscode === 102) { - // wrong username + // wrong username this.$refs.newusername.focus() } else if (statuscode === 107) { - // wrong password + // wrong password this.$refs.newuserpassword.focus() } } @@ -542,7 +555,7 @@ export default { redirectIfDisabled() { const allGroups = this.$store.getters.getGroups if (this.selectedGroup === 'disabled' - && allGroups.findIndex(group => group.id === 'disabled' && group.usercount === 0) > -1) { + && allGroups.findIndex(group => group.id === 'disabled' && group.usercount === 0) > -1) { // disabled group is empty, redirection to all users this.$router.push({ name: 'users' }) this.$refs.infiniteLoading.stateChanger.reset() diff --git a/apps/settings/src/components/UserList/UserRow.vue b/apps/settings/src/components/UserList/UserRow.vue index 435f1b8bb7c1d75d17cb4db23d281c1db77a7e1d..c818cc3e73382220ad47246735fe1b420a031f08 100644 --- a/apps/settings/src/components/UserList/UserRow.vue +++ b/apps/settings/src/components/UserList/UserRow.vue @@ -24,14 +24,15 @@ <template> <!-- Obfuscated user: Logged in user does not have permissions to see all of the data --> - <div v-if="Object.keys(user).length ===1" class="row" :data-id="user.id"> - <div class="avatar" :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}"> + <div v-if="Object.keys(user).length ===1" :data-id="user.id" class="row"> + <div :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}" + class="avatar"> <img v-if="!loading.delete && !loading.disable && !loading.wipe" + :src="generateAvatar(user.id, 32)" + :srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'" alt="" - width="32" height="32" - :src="generateAvatar(user.id, 32)" - :srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'"> + width="32"> </div> <div class="name"> {{ user.id }} @@ -42,163 +43,189 @@ </div> <!-- User full data --> + <UserRowSimple + v-else-if="!editing" + :editing.sync="editing" + :feedback-message="feedbackMessage" + :groups="groups" + :languages="languages" + :loading="loading" + :opened-menu="openedMenu" + :settings="settings" + :show-config="showConfig" + :sub-admins-groups="subAdminsGroups" + :user-actions="userActions" + :user="user" + @hideMenu="hideMenu" + @toggleMenu="toggleMenu" /> <div v-else - class="row" :class="{'disabled': loading.delete || loading.disable}" - :data-id="user.id"> - <div class="avatar" :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}"> + :data-id="user.id" + class="row row--editable"> + <div :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}" + class="avatar"> <img v-if="!loading.delete && !loading.disable && !loading.wipe" + :src="generateAvatar(user.id, 32)" + :srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'" alt="" - width="32" height="32" - :src="generateAvatar(user.id, 32)" - :srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'"> + width="32"> </div> <!-- dirty hack to ellipsis on two lines --> - <div class="name"> - {{ user.id }} + <div class="displayName"> + <form + :class="{'icon-loading-small': loading.displayName}" + class="displayName" + @submit.prevent="updateDisplayName"> + <template v-if="user.backendCapabilities.setDisplayName"> + <input v-if="user.backendCapabilities.setDisplayName" + :id="'displayName'+user.id+rand" + ref="displayName" + :disabled="loading.displayName||loading.all" + :value="user.displayname" + autocapitalize="off" + autocomplete="new-password" + autocorrect="off" + spellcheck="false" + type="text"> + <input v-if="user.backendCapabilities.setDisplayName" + class="icon-confirm" + type="submit" + value=""> + </template> + <div v-else + v-tooltip.auto="t('settings', 'The backend does not support changing the display name')" + class="name" /> + </form> </div> - <form class="displayName" :class="{'icon-loading-small': loading.displayName}" @submit.prevent="updateDisplayName"> - <template v-if="user.backendCapabilities.setDisplayName"> - <input v-if="user.backendCapabilities.setDisplayName" - :id="'displayName'+user.id+rand" - ref="displayName" - type="text" - :disabled="loading.displayName||loading.all" - :value="user.displayname" - autocomplete="new-password" - autocorrect="off" - autocapitalize="off" - spellcheck="false"> - <input v-if="user.backendCapabilities.setDisplayName" - type="submit" - class="icon-confirm" - value=""> - </template> - <div v-else v-tooltip.auto="t('settings', 'The backend does not support changing the display name')" class="name"> - {{ user.displayname }} - </div> - </form> <form v-if="settings.canChangePassword && user.backendCapabilities.setPassword" - class="password" :class="{'icon-loading-small': loading.password}" + class="password" @submit.prevent="updatePassword"> <input :id="'password'+user.id+rand" ref="password" - type="password" - required - :disabled="loading.password||loading.all" + :disabled="loading.password || loading.all" :minlength="minPasswordLength" - value="" - :placeholder="t('settings', 'New password')" + :placeholder="t('settings', 'Add new password')" + autocapitalize="off" autocomplete="new-password" autocorrect="off" - autocapitalize="off" - spellcheck="false"> - <input type="submit" class="icon-confirm" value=""> + required + spellcheck="false" + type="password" + value=""> + <input class="icon-confirm" type="submit" value=""> </form> <div v-else /> - <form class="mailAddress" :class="{'icon-loading-small': loading.mailAddress}" @submit.prevent="updateEmail"> + <form :class="{'icon-loading-small': loading.mailAddress}" + class="mailAddress" + @submit.prevent="updateEmail"> <input :id="'mailAddress'+user.id+rand" ref="mailAddress" - type="email" :disabled="loading.mailAddress||loading.all" + :placeholder="t('settings', 'Add new email address')" :value="user.email" + autocapitalize="off" autocomplete="new-password" autocorrect="off" - autocapitalize="off" - spellcheck="false"> - <input type="submit" class="icon-confirm" value=""> + spellcheck="false" + type="email"> + <input class="icon-confirm" type="submit" value=""> </form> - <div class="groups" :class="{'icon-loading-small': loading.groups}"> - <Multiselect :value="userGroups" - :options="availableGroups" + <div :class="{'icon-loading-small': loading.groups}" class="groups"> + <Multiselect :close-on-select="false" :disabled="loading.groups||loading.all" - tag-placeholder="create" - :placeholder="t('settings', 'Add user in group')" - label="name" - track-by="id" - class="multiselect-vue" :limit="2" :multiple="true" - :taggable="settings.isAdmin" - :close-on-select="false" + :options="availableGroups" + :placeholder="t('settings', 'Add user in group')" :tag-width="60" - @tag="createGroup" + :taggable="settings.isAdmin" + :value="userGroups" + class="multiselect-vue" + label="name" + tag-placeholder="create" + track-by="id" + @remove="removeUserGroup" @select="addUserGroup" - @remove="removeUserGroup"> - <span slot="limit" v-tooltip.auto="formatGroupsTitle(userGroups)" class="multiselect__limit">+{{ userGroups.length-2 }}</span> + @tag="createGroup"> <span slot="noResult">{{ t('settings', 'No results') }}</span> </Multiselect> </div> - <div v-if="subAdminsGroups.length>0 && settings.isAdmin" class="subadmins" :class="{'icon-loading-small': loading.subadmins}"> - <Multiselect :value="userSubAdminsGroups" - :options="subAdminsGroups" + <div v-if="subAdminsGroups.length>0 && settings.isAdmin" + :class="{'icon-loading-small': loading.subadmins}" + class="subadmins"> + <Multiselect :close-on-select="false" :disabled="loading.subadmins||loading.all" - :placeholder="t('settings', 'Set user as admin for')" - label="name" - track-by="id" - class="multiselect-vue" :limit="2" :multiple="true" - :close-on-select="false" + :options="subAdminsGroups" + :placeholder="t('settings', 'Set user as admin for')" :tag-width="60" - @select="addUserSubAdmin" - @remove="removeUserSubAdmin"> - <span slot="limit" v-tooltip.auto="formatGroupsTitle(userSubAdminsGroups)" class="multiselect__limit">+{{ userSubAdminsGroups.length-2 }}</span> + :value="userSubAdminsGroups" + class="multiselect-vue" + label="name" + track-by="id" + @remove="removeUserSubAdmin" + @select="addUserSubAdmin"> <span slot="noResult">{{ t('settings', 'No results') }}</span> </Multiselect> </div> - <div v-tooltip.auto="usedSpace" class="quota" :class="{'icon-loading-small': loading.quota}"> - <Multiselect :value="userQuota" - :options="quotaOptions" + <div v-tooltip.auto="usedSpace" + :class="{'icon-loading-small': loading.quota}" + class="quota"> + <Multiselect :allow-empty="false" :disabled="loading.quota||loading.all" - tag-placeholder="create" + :options="quotaOptions" :placeholder="t('settings', 'Select user quota')" + :taggable="true" + :value="userQuota" + class="multiselect-vue" label="label" + tag-placeholder="create" track-by="id" - class="multiselect-vue" - :allow-empty="false" - :taggable="true" - @tag="validateQuota" - @input="setUserQuota" /> - <progress class="quota-user-progress" - :class="{'warn':usedQuota>80}" - :value="usedQuota" - max="100" /> + @input="setUserQuota" + @tag="validateQuota" /> </div> <div v-if="showConfig.showLanguages" - class="languages" - :class="{'icon-loading-small': loading.languages}"> - <Multiselect :value="userLanguage" - :options="languages" + :class="{'icon-loading-small': loading.languages}" + class="languages"> + <Multiselect :allow-empty="false" :disabled="loading.languages||loading.all" + :options="languages" :placeholder="t('settings', 'No language set')" - label="name" - track-by="code" + :value="userLanguage" class="multiselect-vue" - :allow-empty="false" - group-values="languages" group-label="label" + group-values="languages" + label="name" + track-by="code" @input="setUserLanguage" /> </div> - <div v-if="showConfig.showStoragePath" class="storageLocation"> - {{ user.storageLocation }} - </div> - <div v-if="showConfig.showUserBackend" class="userBackend"> - {{ user.backend }} - </div> - <div v-if="showConfig.showLastLogin" v-tooltip.auto="user.lastLogin>0 ? OC.Util.formatDate(user.lastLogin) : ''" class="lastLogin"> - {{ user.lastLogin>0 ? OC.Util.relativeModifiedDate(user.lastLogin) : t('settings','Never') }} - </div> + + <!-- don't show this on edit mode --> + <div v-if="showConfig.showStoragePath || showConfig.showUserBackend" + class="storageLocation" /> + <div v-if="showConfig.showLastLogin" /> + <div class="userActions"> - <div v-if="OC.currentUser !== user.id && user.id !== 'admin' && !loading.all" class="toggleUserActions"> - <div v-click-outside="hideMenu" class="icon-more" @click="toggleMenu" /> - <div class="popovermenu" :class="{ 'open': openedMenu }"> + <div v-if="OC.currentUser !== user.id && user.id !== 'admin' && !loading.all" + class="toggleUserActions"> + <Actions> + <ActionButton icon="icon-checkmark" + @click="editing = false"> + {{ t('settings', 'Done') }} + </ActionButton> + </Actions> + <div v-click-outside="hideMenu" + class="icon-more" + @click="toggleMenu" /> + <div :class="{ 'open': openedMenu }" class="popovermenu"> <PopoverMenu :menu="userActions" /> </div> </div> - <div class="feedback" :style="{opacity: feedbackMessage !== '' ? 1 : 0}"> + <div :style="{opacity: feedbackMessage !== '' ? 1 : 0}" + class="feedback"> <div class="icon-checkmark" /> {{ feedbackMessage }} </div> @@ -210,19 +237,30 @@ import ClickOutside from 'vue-click-outside' import Vue from 'vue' import VTooltip from 'v-tooltip' -import { PopoverMenu, Multiselect } from 'nextcloud-vue' +import { + PopoverMenu, + Multiselect, + Actions, + ActionButton +} from 'nextcloud-vue' +import UserRowSimple from './UserRowSimple' +import UserRowMixin from '../../mixins/UserRowMixin' Vue.use(VTooltip) export default { name: 'UserRow', components: { + UserRowSimple, PopoverMenu, + Actions, + ActionButton, Multiselect }, directives: { ClickOutside }, + mixins: [UserRowMixin], props: { user: { type: Object, @@ -262,6 +300,7 @@ export default { rand: parseInt(Math.random() * 1000), openedMenu: false, feedbackMessage: '', + editing: false, loading: { all: false, displayName: false, @@ -305,92 +344,9 @@ export default { }) } return actions.concat(this.externalActions) - }, - - /* GROUPS MANAGEMENT */ - userGroups() { - let userGroups = this.groups.filter(group => this.user.groups.includes(group.id)) - return userGroups - }, - userSubAdminsGroups() { - let userSubAdminsGroups = this.subAdminsGroups.filter(group => this.user.subadmin.includes(group.id)) - return userSubAdminsGroups - }, - availableGroups() { - return this.groups.map((group) => { - // clone object because we don't want - // to edit the original groups - let groupClone = Object.assign({}, group) - - // two settings here: - // 1. user NOT in group but no permission to add - // 2. user is in group but no permission to remove - groupClone.$isDisabled - = (group.canAdd === false - && !this.user.groups.includes(group.id)) - || (group.canRemove === false - && this.user.groups.includes(group.id)) - return groupClone - }) - }, - - /* QUOTA MANAGEMENT */ - usedSpace() { - if (this.user.quota.used) { - return t('settings', '{size} used', { size: OC.Util.humanFileSize(this.user.quota.used) }) - } - return t('settings', '{size} used', { size: OC.Util.humanFileSize(0) }) - }, - usedQuota() { - let quota = this.user.quota.quota - if (quota > 0) { - quota = Math.min(100, Math.round(this.user.quota.used / quota * 100)) - } else { - var usedInGB = this.user.quota.used / (10 * Math.pow(2, 30)) - // asymptotic curve approaching 50% at 10GB to visualize used stace with infinite quota - quota = 95 * (1 - (1 / (usedInGB + 1))) - } - return isNaN(quota) ? 0 : quota - }, - // Mapping saved values to objects - userQuota() { - if (this.user.quota.quota >= 0) { - // if value is valid, let's map the quotaOptions or return custom quota - let humanQuota = OC.Util.humanFileSize(this.user.quota.quota) - let userQuota = this.quotaOptions.find(quota => quota.id === humanQuota) - return userQuota || { id: humanQuota, label: humanQuota } - } else if (this.user.quota.quota === 'default') { - // default quota is replaced by the proper value on load - return this.quotaOptions[0] - } - return this.quotaOptions[1] // unlimited - }, - - /* PASSWORD POLICY? */ - minPasswordLength() { - return this.$store.getters.getPasswordPolicyMinLength - }, - - /* LANGUAGE */ - userLanguage() { - let availableLanguages = this.languages[0].languages.concat(this.languages[1].languages) - let userLang = availableLanguages.find(lang => lang.code === this.user.language) - if (typeof userLang !== 'object' && this.user.language !== '') { - return { - code: this.user.language, - name: this.user.language - } - } else if (this.user.language === '') { - return false - } - return userLang } }, - mounted() { - // required if popup needs to stay opened after menu click - // since we only have disable/delete actions, let's close it directly - // this.popupItem = this.$el; - }, + methods: { /* MENU HANDLING */ toggleMenu() { @@ -400,35 +356,6 @@ export default { this.openedMenu = false }, - /** - * Generate avatar url - * - * @param {string} user The user name - * @param {int} size Size integer, default 32 - * @returns {string} - */ - generateAvatar(user, size = 32) { - return OC.generateUrl( - '/avatar/{user}/{size}?v={version}', - { - user: user, - size: size, - version: oc_userconfig.avatar.version - } - ) - }, - - /** - * Format array of groups objects to a string for the popup - * - * @param {array} groups The groups - * @returns {string} - */ - formatGroupsTitle(groups) { - let names = groups.map(group => group.name) - return names.slice(2).join(', ') - }, - wipeUserDevices() { let userid = this.user.id OC.dialogs.confirmDestructive( @@ -486,7 +413,10 @@ export default { this.loading.all = true let userid = this.user.id let enabled = !this.user.enabled - return this.$store.dispatch('enableDisableUser', { userid, enabled }) + return this.$store.dispatch('enableDisableUser', { + userid, + enabled + }) .then(() => { this.loading.delete = false this.loading.all = false @@ -494,10 +424,10 @@ export default { }, /** - * Set user displayName - * - * @param {string} displayName The display name - */ + * Set user displayName + * + * @param {string} displayName The display name + */ updateDisplayName() { let displayName = this.$refs.displayName.value this.loading.displayName = true @@ -512,10 +442,10 @@ export default { }, /** - * Set user password - * - * @param {string} password The email adress - */ + * Set user password + * + * @param {string} password The email adress + */ updatePassword() { let password = this.$refs.password.value this.loading.password = true @@ -530,10 +460,10 @@ export default { }, /** - * Set user mailAddress - * - * @param {string} mailAddress The email adress - */ + * Set user mailAddress + * + * @param {string} mailAddress The email adress + */ updateEmail() { let mailAddress = this.$refs.mailAddress.value this.loading.mailAddress = true @@ -548,10 +478,10 @@ export default { }, /** - * Create a new group and add user to it - * - * @param {string} gid Group id - */ + * Create a new group and add user to it + * + * @param {string} gid Group id + */ async createGroup(gid) { this.loading = { groups: true, subadmins: true } try { @@ -567,10 +497,10 @@ export default { }, /** - * Add user to group - * - * @param {object} group Group object - */ + * Add user to group + * + * @param {object} group Group object + */ async addUserGroup(group) { if (group.canAdd === false) { return false @@ -588,10 +518,10 @@ export default { }, /** - * Remove user from group - * - * @param {object} group Group object - */ + * Remove user from group + * + * @param {object} group Group object + */ async removeUserGroup(group) { if (group.canRemove === false) { return false @@ -602,7 +532,10 @@ export default { let gid = group.id try { - await this.$store.dispatch('removeUserGroup', { userid, gid }) + await this.$store.dispatch('removeUserGroup', { + userid, + gid + }) this.loading.groups = false // remove user from current list if current list is the removed group if (this.$route.params.selectedGroup === gid) { @@ -614,17 +547,20 @@ export default { }, /** - * Add user to group - * - * @param {object} group Group object - */ + * Add user to group + * + * @param {object} group Group object + */ async addUserSubAdmin(group) { this.loading.subadmins = true let userid = this.user.id let gid = group.id try { - await this.$store.dispatch('addUserSubAdmin', { userid, gid }) + await this.$store.dispatch('addUserSubAdmin', { + userid, + gid + }) this.loading.subadmins = false } catch (error) { console.error(error) @@ -632,17 +568,20 @@ export default { }, /** - * Remove user from group - * - * @param {object} group Group object - */ + * Remove user from group + * + * @param {object} group Group object + */ async removeUserSubAdmin(group) { this.loading.subadmins = true let userid = this.user.id let gid = group.id try { - await this.$store.dispatch('removeUserSubAdmin', { userid, gid }) + await this.$store.dispatch('removeUserSubAdmin', { + userid, + gid + }) } catch (error) { console.error(error) } finally { @@ -651,11 +590,11 @@ export default { }, /** - * Dispatch quota set request - * - * @param {string|Object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'} - * @returns {string} - */ + * Dispatch quota set request + * + * @param {string|Object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'} + * @returns {string} + */ async setUserQuota(quota = 'none') { this.loading.quota = true // ensure we only send the preset id @@ -676,11 +615,11 @@ export default { }, /** - * Validate quota string to make sure it's a valid human file size - * - * @param {string} quota Quota in readable format '5 GB' - * @returns {Promise|boolean} - */ + * Validate quota string to make sure it's a valid human file size + * + * @param {string} quota Quota in readable format '5 GB' + * @returns {Promise|boolean} + */ validateQuota(quota) { // only used for new presets sent through @Tag let validQuota = OC.Util.computerFileSize(quota) @@ -693,11 +632,11 @@ export default { }, /** - * Dispatch language set request - * - * @param {Object} lang language object {code:'en', name:'English'} - * @returns {Object} - */ + * Dispatch language set request + * + * @param {Object} lang language object {code:'en', name:'English'} + * @returns {Object} + */ async setUserLanguage(lang) { this.loading.languages = true // ensure we only send the preset id @@ -716,8 +655,8 @@ export default { }, /** - * Dispatch new welcome mail request - */ + * Dispatch new welcome mail request + */ sendWelcomeMail() { this.loading.all = true this.$store.dispatch('sendWelcomeMail', this.user.id) diff --git a/apps/settings/src/components/UserList/UserRowSimple.vue b/apps/settings/src/components/UserList/UserRowSimple.vue new file mode 100644 index 0000000000000000000000000000000000000000..247bfb063cebdf0188a063bb13f8570491a9f348 --- /dev/null +++ b/apps/settings/src/components/UserList/UserRowSimple.vue @@ -0,0 +1,159 @@ +<template> + <div + class="row" + :class="{'disabled': loading.delete || loading.disable}" + :data-id="user.id"> + <div class="avatar" :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}"> + <img v-if="!loading.delete && !loading.disable && !loading.wipe" + alt="" + width="32" + height="32" + :src="generateAvatar(user.id, 32)" + :srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'"> + </div> + <!-- dirty hack to ellipsis on two lines --> + <div class="name"> + {{ user.id }} + <div class="displayName subtitle"> + {{ user.displayname }} + </div> + </div> + <div /> + <div class="mailAddress"> + {{ user.email }} + </div> + <div class="groups"> + {{ userGroupsLabels }} + </div> + <div v-if="subAdminsGroups.length > 0 && settings.isAdmin" class="subAdminsGroups"> + {{ userSubAdminsGroupsLabels }} + </div> + <div v-tooltip.auto="usedSpace" class="quota"> + <progress + class="quota-user-progress" + :class="{'warn': usedQuota > 80}" + :value="usedQuota" + max="100" /> + </div> + <div v-if="showConfig.showLanguages" class="languages"> + {{ userLanguage.name }} + </div> + <div v-if="showConfig.showUserBackend || showConfig.showStoragePath" class="userBackend"> + <div v-if="showConfig.showUserBackend" class="userBackend"> + {{ user.backend }} + </div> + <div v-if="showConfig.showStoragePath" class="storageLocation subtitle"> + {{ user.storageLocation }} + </div> + </div> + <div v-if="showConfig.showLastLogin" v-tooltip.auto="userLastLoginTooltip" class="lastLogin"> + {{ userLastLogin }} + </div> + + <div class="userActions"> + <div v-if="canEdit && !loading.all" class="toggleUserActions"> + <Actions> + <ActionButton icon="icon-rename" @click="toggleEdit"> + {{ t('settings', 'Edit User') }} + </ActionButton> + </Actions> + <div v-click-outside="hideMenu" class="icon-more" @click="$emit('toggleMenu')" /> + <div class="popovermenu" :class="{ 'open': openedMenu }"> + <PopoverMenu :menu="userActions" /> + </div> + </div> + <div class="feedback" :style="{opacity: feedbackMessage !== '' ? 1 : 0}"> + <div class="icon-checkmark" /> + {{ feedbackMessage }} + </div> + </div> + </div> +</template> + +<script> +import { PopoverMenu, Actions, ActionButton } from 'nextcloud-vue' +import ClickOutside from 'vue-click-outside' +import { getCurrentUser } from '@nextcloud/auth' + +import UserRowMixin from '../../mixins/UserRowMixin' +export default { + name: 'UserRowSimple', + components: { + PopoverMenu, + ActionButton, + Actions + }, + directives: { + ClickOutside + }, + mixins: [UserRowMixin], + props: { + user: { + type: Object, + required: true + }, + loading: { + type: Object, + required: true + }, + showConfig: { + type: Object, + required: true + }, + userActions: { + type: Array, + required: true + }, + openedMenu: { + type: Boolean, + required: true + }, + feedbackMessage: { + type: String, + required: true + }, + subAdminsGroups: { + type: Array, + required: true + }, + settings: { + type: Object, + required: true + } + }, + computed: { + userGroupsLabels() { + return this.userGroups + .map(group => group.name) + .join(', ') + }, + userSubAdminsGroupsLabels() { + return this.userSubAdminsGroups + .map(group => group.name) + .join(', ') + }, + usedSpace() { + if (this.user.quota.used) { + return t('settings', '{size} used', { size: OC.Util.humanFileSize(this.user.quota.used) }) + } + return t('settings', '{size} used', { size: OC.Util.humanFileSize(0) }) + }, + canEdit() { + return getCurrentUser().uid !== this.user.id && this.user.id !== 'admin' + } + + }, + methods: { + hideMenu() { + this.$emit('hideMenu') + }, + toggleEdit() { + this.$emit('update:editing', true) + } + } +} +</script> + +<style scoped> + +</style> diff --git a/apps/settings/src/mixins/UserRowMixin.js b/apps/settings/src/mixins/UserRowMixin.js new file mode 100644 index 0000000000000000000000000000000000000000..ff1276fdd1599adc4ae53def8c38a284b0b82585 --- /dev/null +++ b/apps/settings/src/mixins/UserRowMixin.js @@ -0,0 +1,171 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license GNU AGPL version 3 or any later version + * + * 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/>. + * + */ + +export default { + props: { + user: { + type: Object, + required: true + }, + settings: { + type: Object, + default: () => ({}) + }, + groups: { + type: Array, + default: () => [] + }, + subAdminsGroups: { + type: Array, + default: () => [] + }, + quotaOptions: { + type: Array, + default: () => [] + }, + showConfig: { + type: Object, + default: () => ({}) + }, + languages: { + type: Array, + required: true + }, + externalActions: { + type: Array, + default: () => [] + } + }, + computed: { + /* GROUPS MANAGEMENT */ + userGroups() { + const userGroups = this.groups.filter(group => this.user.groups.includes(group.id)) + return userGroups + }, + userSubAdminsGroups() { + const userSubAdminsGroups = this.subAdminsGroups.filter(group => this.user.subadmin.includes(group.id)) + return userSubAdminsGroups + }, + availableGroups() { + return this.groups.map((group) => { + // clone object because we don't want + // to edit the original groups + let groupClone = Object.assign({}, group) + + // two settings here: + // 1. user NOT in group but no permission to add + // 2. user is in group but no permission to remove + groupClone.$isDisabled + = (group.canAdd === false + && !this.user.groups.includes(group.id)) + || (group.canRemove === false + && this.user.groups.includes(group.id)) + return groupClone + }) + }, + + /* QUOTA MANAGEMENT */ + usedSpace() { + if (this.user.quota.used) { + return t('settings', '{size} used', { size: OC.Util.humanFileSize(this.user.quota.used) }) + } + return t('settings', '{size} used', { size: OC.Util.humanFileSize(0) }) + }, + usedQuota() { + let quota = this.user.quota.quota + if (quota > 0) { + quota = Math.min(100, Math.round(this.user.quota.used / quota * 100)) + } else { + var usedInGB = this.user.quota.used / (10 * Math.pow(2, 30)) + // asymptotic curve approaching 50% at 10GB to visualize used stace with infinite quota + quota = 95 * (1 - (1 / (usedInGB + 1))) + } + return isNaN(quota) ? 0 : quota + }, + // Mapping saved values to objects + userQuota() { + if (this.user.quota.quota >= 0) { + // if value is valid, let's map the quotaOptions or return custom quota + let humanQuota = OC.Util.humanFileSize(this.user.quota.quota) + let userQuota = this.quotaOptions.find(quota => quota.id === humanQuota) + return userQuota || { id: humanQuota, label: humanQuota } + } else if (this.user.quota.quota === 'default') { + // default quota is replaced by the proper value on load + return this.quotaOptions[0] + } + return this.quotaOptions[1] // unlimited + }, + + /* PASSWORD POLICY? */ + minPasswordLength() { + return this.$store.getters.getPasswordPolicyMinLength + }, + + /* LANGUAGE */ + userLanguage() { + let availableLanguages = this.languages[0].languages.concat(this.languages[1].languages) + let userLang = availableLanguages.find(lang => lang.code === this.user.language) + if (typeof userLang !== 'object' && this.user.language !== '') { + return { + code: this.user.language, + name: this.user.language + } + } else if (this.user.language === '') { + return false + } + return userLang + }, + + /* LAST LOGIN */ + userLastLoginTooltip() { + if (this.user.lastLogin > 0) { + return OC.Util.formatDate(this.user.lastLogin) + } + return '' + }, + userLastLogin() { + if (this.user.lastLogin > 0) { + return OC.Util.relativeModifiedDate(this.user.lastLogin) + } + return t('settings', 'Never') + } + }, + methods: { + /** + * Generate avatar url + * + * @param {string} user The user name + * @param {int} size Size integer, default 32 + * @returns {string} + */ + generateAvatar(user, size = 32) { + return OC.generateUrl( + '/avatar/{user}/{size}?v={version}', + { + user: user, + size: size, + version: oc_userconfig.avatar.version + } + ) + } + } +} diff --git a/tests/acceptance/features/bootstrap/UsersSettingsContext.php b/tests/acceptance/features/bootstrap/UsersSettingsContext.php index 56dce82235986fa74bb21f410d819c48bb16ee72..d42b49cbf2dde425ace779ac12a47aaa131c0f72 100644 --- a/tests/acceptance/features/bootstrap/UsersSettingsContext.php +++ b/tests/acceptance/features/bootstrap/UsersSettingsContext.php @@ -1,9 +1,10 @@ <?php /** - * + * * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) * @copyright Copyright (c) 2018, John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> + * @copyright Copyright (c) 2019, Greta Doci <gretadoci@gmail.com> * * @license GNU AGPL version 3 or any later version * @@ -33,7 +34,7 @@ class UsersSettingsContext implements Context, ActorAwareInterface { */ public static function newUserForm() { return Locator::forThe()->id("new-user")-> - describedAs("New user form in Users Settings"); + describedAs("New user form in Users Settings"); } /** @@ -41,7 +42,7 @@ class UsersSettingsContext implements Context, ActorAwareInterface { */ public static function userNameFieldForNewUser() { return Locator::forThe()->field("newusername")-> - describedAs("User name field for new user in Users Settings"); + describedAs("User name field for new user in Users Settings"); } /** @@ -49,7 +50,7 @@ class UsersSettingsContext implements Context, ActorAwareInterface { */ public static function displayNameFieldForNewUser() { return Locator::forThe()->field("newdisplayname")-> - describedAs("Display name field for new user in Users Settings"); + describedAs("Display name field for new user in Users Settings"); } /** @@ -57,7 +58,7 @@ class UsersSettingsContext implements Context, ActorAwareInterface { */ public static function passwordFieldForNewUser() { return Locator::forThe()->field("newuserpassword")-> - describedAs("Password field for new user in Users Settings"); + describedAs("Password field for new user in Users Settings"); } /** @@ -65,7 +66,7 @@ class UsersSettingsContext implements Context, ActorAwareInterface { */ public static function newUserButton() { return Locator::forThe()->id("new-user-button")-> - describedAs("New user button in Users Settings"); + describedAs("New user button in Users Settings"); } /** @@ -73,26 +74,26 @@ class UsersSettingsContext implements Context, ActorAwareInterface { */ public static function createNewUserButton() { return Locator::forThe()->xpath("//form[@id = 'new-user']//input[@type = 'submit']")-> - describedAs("Create user button in Users Settings"); + describedAs("Create user button in Users Settings"); } /** * @return Locator */ public static function rowForUser($user) { - return Locator::forThe()->xpath("//div[@id='app-content']/div/div[normalize-space() = '$user']/..")-> - describedAs("Row for user $user in Users Settings"); + return Locator::forThe()->css("div.user-list-grid div.row[data-id=$user]")-> + describedAs("Row for user $user in Users Settings"); } /** * Warning: you need to watch out for the proper classes order - * + * * @return Locator */ public static function classCellForUser($class, $user) { return Locator::forThe()->xpath("//*[contains(concat(' ', normalize-space(@class), ' '), ' $class ')]")-> - descendantOf(self::rowForUser($user))-> - describedAs("$class cell for user $user in Users Settings"); + descendantOf(self::rowForUser($user))-> + describedAs("$class cell for user $user in Users Settings"); } /** @@ -100,8 +101,8 @@ class UsersSettingsContext implements Context, ActorAwareInterface { */ public static function inputForUserInCell($cell, $user) { return Locator::forThe()->css("input")-> - descendantOf(self::classCellForUser($cell, $user))-> - describedAs("$cell input for user $user in Users Settings"); + descendantOf(self::classCellForUser($cell, $user))-> + describedAs("$cell input for user $user in Users Settings"); } /** @@ -116,8 +117,8 @@ class UsersSettingsContext implements Context, ActorAwareInterface { */ public static function optionInInputForUser($cell, $user) { return Locator::forThe()->css(".multiselect__option--highlight")-> - descendantOf(self::classCellForUser($cell, $user))-> - describedAs("Selected $cell option in $cell input for user $user in Users Settings"); + descendantOf(self::classCellForUser($cell, $user))-> + describedAs("Selected $cell option in $cell input for user $user in Users Settings"); } /** @@ -125,8 +126,8 @@ class UsersSettingsContext implements Context, ActorAwareInterface { */ public static function actionsMenuOf($user) { return Locator::forThe()->css(".icon-more")-> - descendantOf(self::rowForUser($user))-> - describedAs("Actions menu for user $user in Users Settings"); + descendantOf(self::rowForUser($user))-> + describedAs("Actions menu for user $user in Users Settings"); } /** @@ -134,8 +135,8 @@ class UsersSettingsContext implements Context, ActorAwareInterface { */ public static function theAction($action, $user) { return Locator::forThe()->xpath("//button[normalize-space() = '$action']")-> - descendantOf(self::rowForUser($user))-> - describedAs("$action action for the user $user row in Users Settings"); + descendantOf(self::rowForUser($user))-> + describedAs("$action action for the user $user row in Users Settings"); } /** @@ -143,7 +144,7 @@ class UsersSettingsContext implements Context, ActorAwareInterface { */ public static function theColumn($column) { return Locator::forThe()->xpath("//div[@class='user-list-grid']//div[normalize-space() = '$column']")-> - describedAs("The $column column in Users Settings"); + describedAs("The $column column in Users Settings"); } /** @@ -151,8 +152,25 @@ class UsersSettingsContext implements Context, ActorAwareInterface { */ public static function selectedSelectOption($cell, $user) { return Locator::forThe()->css(".multiselect__single")-> - descendantOf(self::classCellForUser($cell, $user))-> - describedAs("The selected option of the $cell select for the user $user in Users Settings"); + descendantOf(self::classCellForUser($cell, $user))-> + describedAs("The selected option of the $cell select for the user $user in Users Settings"); + } + + /** + * @return Locator + */ + public static function editModeToggle($user) { + return Locator::forThe()->css(".toggleUserActions button.icon-rename")-> + descendantOf(self::rowForUser($user))-> + describedAs("The edit toggle button for the user $user in Users Settings"); + } + + /** + * @return Locator + */ + public static function editModeOn($user) { + return Locator::forThe()->css("div.user-list-grid div.row.row--editable[data-id=$user]")-> + describedAs("I see the edit mode is on for the user $user in Users Settings"); } /** @@ -204,6 +222,13 @@ class UsersSettingsContext implements Context, ActorAwareInterface { $this->actor->find(self::createNewUserButton(), 10)->click(); } + /** + * @When I toggle the edit mode for the user :user + */ + public function iToggleTheEditModeForUser($user) { + $this->actor->find(self::editModeToggle($user), 10)->click(); + } + /** * @When I create user :user with password :password */ @@ -258,7 +283,7 @@ class UsersSettingsContext implements Context, ActorAwareInterface { */ public function iSeeThatTheNewUserFormIsShown() { PHPUnit_Framework_Assert::assertTrue( - $this->actor->find(self::newUserForm(), 10)->isVisible()); + $this->actor->find(self::newUserForm(), 10)->isVisible()); } /** @@ -266,7 +291,7 @@ class UsersSettingsContext implements Context, ActorAwareInterface { */ public function iSeeTheAction($action, $user) { PHPUnit_Framework_Assert::assertTrue( - $this->actor->find(self::theAction($action, $user), 10)->isVisible()); + $this->actor->find(self::theAction($action, $user), 10)->isVisible()); } /** @@ -274,7 +299,7 @@ class UsersSettingsContext implements Context, ActorAwareInterface { */ public function iSeeThatTheColumnIsShown($column) { PHPUnit_Framework_Assert::assertTrue( - $this->actor->find(self::theColumn($column), 10)->isVisible()); + $this->actor->find(self::theColumn($column), 10)->isVisible()); } /** @@ -289,15 +314,16 @@ class UsersSettingsContext implements Context, ActorAwareInterface { * @Then I see that the display name for the user :user is :displayName */ public function iSeeThatTheDisplayNameForTheUserIs($user, $displayName) { - PHPUnit_Framework_Assert::assertEquals($displayName, $this->actor->find(self::displayNameCellForUser($user), 10)->getValue()); + PHPUnit_Framework_Assert::assertEquals( + $displayName, $this->actor->find(self::displayNameCellForUser($user), 10)->getValue()); } /** * @Then I see that the :cell cell for user :user is done loading */ public function iSeeThatTheCellForUserIsDoneLoading($cell, $user) { - WaitFor::elementToBeEventuallyShown($this->actor, self::classCellForUser($cell.' icon-loading-small', $user)); - WaitFor::elementToBeEventuallyNotShown($this->actor, self::classCellForUser($cell.' icon-loading-small', $user)); + WaitFor::elementToBeEventuallyShown($this->actor, self::classCellForUser($cell . ' icon-loading-small', $user)); + WaitFor::elementToBeEventuallyNotShown($this->actor, self::classCellForUser($cell . ' icon-loading-small', $user)); } /** @@ -307,6 +333,11 @@ class UsersSettingsContext implements Context, ActorAwareInterface { PHPUnit_Framework_Assert::assertEquals( $this->actor->find(self::selectedSelectOption('quota', $user), 2)->getText(), $quota); } - + /** + * @Then I see that the edit mode is on for user :user + */ + public function iSeeThatTheEditModeIsOn($user) { + WaitFor::elementToBeEventuallyShown($this->actor, self::editModeOn($user)); + } } diff --git a/tests/acceptance/features/users.feature b/tests/acceptance/features/users.feature index 263e9fddfc04ccf839407f176d7795b9645a6553..c4cfa3b69bf851666e5234d0083ef02b257c2b5e 100644 --- a/tests/acceptance/features/users.feature +++ b/tests/acceptance/features/users.feature @@ -63,18 +63,20 @@ Feature: users And I am logged in as the admin And I open the User settings And I see that the list of users contains the user user0 - # disabled because we need the TAB patch: + When I toggle the edit mode for the user user0 + Then I see that the edit mode is on for user user0 + # disabled because we need the TAB patch: # https://github.com/minkphp/MinkSelenium2Driver/pull/244 # When I assign the user user0 to the group admin # Then I see that the section Admins is shown # And I see that the section Admins has a count of 2 - + Scenario: create and delete a group Given I act as Jane And I am logged in as the admin And I open the User settings And I see that the list of users contains the user user0 - # disabled because we need the TAB patch: + # disabled because we need the TAB patch: # https://github.com/minkphp/MinkSelenium2Driver/pull/244 # And I assign the user user0 to the group Group1 # And I see that the section Group1 is shown @@ -112,7 +114,7 @@ Feature: users Then I see that the "Storage location" column is shown When I toggle the showUserBackend checkbox in the settings Then I see that the "User backend" column is shown - + # Scenario: change display name # Given I act as Jane # And I am logged in as the admin @@ -128,6 +130,8 @@ Feature: users And I am logged in as the admin And I open the User settings And I see that the list of users contains the user user0 + When I toggle the edit mode for the user user0 + Then I see that the edit mode is on for user user0 And I see that the password of user0 is "" When I set the password for user0 to 123456 And I see that the password cell for user user0 is done loading @@ -149,8 +153,10 @@ Feature: users And I am logged in as the admin And I open the User settings And I see that the list of users contains the user user0 + When I toggle the edit mode for the user user0 + Then I see that the edit mode is on for user user0 And I see that the user quota of user0 is Unlimited - # disabled because we need the TAB patch: + # disabled because we need the TAB patch: # https://github.com/minkphp/MinkSelenium2Driver/pull/244 # When I set the user user0 quota to 1GB # And I see that the quota cell for user user0 is done loading @@ -163,4 +169,4 @@ Feature: users # Then I see that the user quota of user0 is "0 B" # When I set the user user0 quota to Default # And I see that the quota cell for user user0 is done loading - # Then I see that the user quota of user0 is "Default quota" \ No newline at end of file + # Then I see that the user quota of user0 is "Default quota"