fix(settings): Migrate away from deprecated `NcPopoverMenu`

* Replace popover menu with `NcActions`
* Deduplicate user actions code between `UserRow` and `UserRowSimple`
* Fix user action to cover whole row heigh to prevent dropdown from shining through the actions
* Fix user action popover to be overlayed by current edited row actions

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
pull/39073/head
Ferdinand Thiessen 11 months ago committed by Christopher Ng
parent d76f39889a
commit 97683a5b66

@ -1386,7 +1386,7 @@ doesnotexist:-o-prefocus, .strengthify-wrapper {
// Scroll if too much groups
&:not(.row--editable) {
.groups,
.subadmins,
.subadmins,
.subAdminsGroups {
overflow: auto;
max-height: 100%;
@ -1395,7 +1395,7 @@ doesnotexist:-o-prefocus, .strengthify-wrapper {
.managers,
.groups,
.subadmins,
.subadmins,
.subAdminsGroups,
.quota {
min-width: $grid-col-min-width;
@ -1569,50 +1569,14 @@ doesnotexist:-o-prefocus, .strengthify-wrapper {
&.userActions {
display: flex;
align-items: center;
justify-content: flex-end;
#newsubmit {
width: 100%;
}
.toggleUserActions {
position: relative;
display: flex;
align-items: center;
background-color: var(--color-main-background);
.icon-more {
width: 44px;
height: 44px;
opacity: .5;
cursor: pointer;
&:focus,
&:hover,
&:active {
opacity: .7;
background-color: var(--color-background-dark)
}
}
}
.feedback {
display: flex;
align-items: center;
white-space: nowrap;
transition: opacity 200ms ease-in-out;
.icon-checkmark {
opacity: .5;
margin-right: 5px;
}
}
}
/* Fill the grid cell */
.v-select.select-vue {
min-width: 100%;
width: 100%;
// Make sure to cover whole row
height: 100%;
width: fit-content;
padding-inline: 12px;
background-color: var(--color-main-background);
}
}
}

@ -44,7 +44,6 @@
<!-- User full data -->
<UserRowSimple v-else-if="!editing"
:editing.sync="editing"
:feedback-message="feedbackMessage"
:groups="groups"
:languages="languages"
:loading="loading"
@ -222,59 +221,41 @@
</div>
<div class="userActions">
<div v-if="!loading.all"
class="toggleUserActions">
<NcActions>
<NcActionButton icon="icon-checkmark"
:title="t('settings', 'Done')"
:aria-label="t('settings', 'Done')"
@click="handleDoneButton" />
</NcActions>
<div v-click-outside="hideMenu" class="userPopoverMenuWrapper">
<button class="icon-more"
:aria-expanded="openedMenu"
:aria-label="t('settings', 'Toggle user actions menu')"
@click.prevent="toggleMenu" />
<div :class="{ 'open': openedMenu }" class="popovermenu">
<NcPopoverMenu :menu="userActions" />
</div>
</div>
</div>
<div :style="{opacity: feedbackMessage !== '' ? 1 : 0}"
class="feedback">
<div class="icon-checkmark" />
{{ feedbackMessage }}
</div>
<UserRowActions v-if="!loading.all"
:actions="userActions"
:edit="true"
@update:edit="toggleEdit" />
</div>
</div>
</template>
<script>
import ClickOutside from 'vue-click-outside'
import { showSuccess, showError } from '@nextcloud/dialogs'
import NcPopoverMenu from '@nextcloud/vue/dist/Components/NcPopoverMenu.js'
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import ClickOutside from 'vue-click-outside'
import UserRowActions from './UserRowActions.vue'
import UserRowSimple from './UserRowSimple.vue'
import UserRowMixin from '../../mixins/UserRowMixin.js'
import { showSuccess, showError } from '@nextcloud/dialogs'
export default {
name: 'UserRow',
components: {
UserRowSimple,
NcPopoverMenu,
NcActions,
NcActionButton,
NcSelect,
NcTextField,
UserRowActions,
UserRowSimple,
},
directives: {
ClickOutside,
},
mixins: [UserRowMixin],
props: {
users: {
type: Array,
@ -325,7 +306,6 @@ export default {
selectedQuota: false,
rand: parseInt(Math.random() * 1000),
openedMenu: false,
feedbackMessage: '',
possibleManagers: [],
currentManager: '',
editing: false,
@ -348,8 +328,8 @@ export default {
editedMail: this.user.email ?? '',
}
},
computed: {
computed: {
/* USER POPOVERMENU ACTIONS */
userActions() {
const actions = [
@ -400,8 +380,10 @@ export default {
return this.languages[0].languages.concat(this.languages[1].languages)
},
},
async beforeMount() {
await this.searchUserManager()
if (this.user.manager) {
await this.initManager(this.user.manager)
}
@ -432,13 +414,14 @@ export default {
this.loading.wipe = true
this.loading.all = true
this.$store.dispatch('wipeUserDevices', userid)
.then(() => {
.then(() => showSuccess(t('settings', 'Wiped {userid}\'s devices', { userid })), { timeout: 2000 })
.finally(() => {
this.loading.wipe = false
this.loading.all = false
})
}
},
true
true,
)
},
@ -500,7 +483,7 @@ export default {
})
}
},
true
true,
)
},
@ -778,19 +761,13 @@ export default {
sendWelcomeMail() {
this.loading.all = true
this.$store.dispatch('sendWelcomeMail', this.user.id)
.then(success => {
if (success) {
// Show feedback to indicate the success
this.feedbackMessage = t('setting', 'Welcome mail sent!')
setTimeout(() => {
this.feedbackMessage = ''
}, 2000)
}
.then(() => showSuccess(t('setting', 'Welcome mail sent!'), { timeout: 2000 }))
.finally(() => {
this.loading.all = false
})
},
handleDoneButton() {
toggleEdit() {
this.editing = false
if (this.editedDisplayName !== this.user.displayname) {
this.editedDisplayName = this.user.displayname
@ -807,7 +784,12 @@ export default {
z-index: 1 !important;
}
.row :deep() {
.row :deep() {
.v-select.select {
// reset min width to 100% instead of X px
min-width: 100%;
}
.mailAddress,
.password,
.displayName {

@ -0,0 +1,78 @@
<template>
<NcActions :aria-label="t('settings', 'Toggle user actions menu')"
:inline="1">
<NcActionButton @click="toggleEdit">
{{ edit ? t('settings', 'Done') : t('settings', 'Edit') }}
<template #icon>
<NcIconSvgWrapper :svg="editSvg" aria-hidden="true" />
</template>
</NcActionButton>
<NcActionButton v-for="(action, index) in actions"
:key="index"
:aria-label="action.text"
:icon="action.icon"
@click="action.action">
{{ action.text }}
</NcActionButton>
</NcActions>
</template>
<script lang="ts">
import { PropType, defineComponent } from 'vue'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import SvgCheck from '@mdi/svg/svg/check.svg?raw'
import SvgPencil from '@mdi/svg/svg/pencil.svg?raw'
interface UserAction {
action: (event: MouseEvent) => void,
icon: string,
text: string
}
export default defineComponent({
components: {
NcActionButton,
NcActions,
NcIconSvgWrapper,
},
props: {
/**
* Array of user actions
*/
actions: {
type: Array as PropType<readonly UserAction[]>,
required: true,
},
/**
* The state whether the row is currently edited
*/
edit: {
type: Boolean,
required: true,
},
},
computed: {
/**
* Current MDI logo to show for edit toggle
*/
editSvg() {
return this.edit ? SvgCheck : SvgPencil
},
},
methods: {
/**
* Toggle edit mode by emitting the update event
*/
toggleEdit() {
this.$emit('update:edit', !this.edit)
},
},
})
</script>

@ -59,45 +59,26 @@
{{ user.manager }}
</div>
<div class="userActions">
<div v-if="canEdit && !loading.all" class="toggleUserActions">
<NcActions>
<NcActionButton icon="icon-rename"
:title="t('settings', 'Edit User')"
:aria-label="t('settings', 'Edit User')"
@click="toggleEdit" />
</NcActions>
<div class="userPopoverMenuWrapper">
<button v-click-outside="hideMenu"
class="icon-more"
:aria-expanded="openedMenu"
:aria-label="t('settings', 'Toggle user actions menu')"
@click.prevent="toggleMenu" />
<div class="popovermenu" :class="{ 'open': openedMenu }">
<NcPopoverMenu :menu="userActions" />
</div>
</div>
</div>
<div class="feedback" :style="{opacity: feedbackMessage !== '' ? 1 : 0}">
<div class="icon-checkmark" />
{{ feedbackMessage }}
</div>
<UserRowActions v-if="canEdit && !loading.all"
:actions="userActions"
:edit="false"
@update:edit="toggleEdit" />
</div>
</div>
</template>
<script>
import NcPopoverMenu from '@nextcloud/vue/dist/Components/NcPopoverMenu.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import ClickOutside from 'vue-click-outside'
import { getCurrentUser } from '@nextcloud/auth'
import ClickOutside from 'vue-click-outside'
import UserRowActions from './UserRowActions.vue'
import UserRowMixin from '../../mixins/UserRowMixin.js'
export default {
name: 'UserRowSimple',
components: {
NcPopoverMenu,
NcActionButton,
NcActions,
UserRowActions,
},
directives: {
ClickOutside,
@ -124,10 +105,6 @@ export default {
type: Boolean,
required: true,
},
feedbackMessage: {
type: String,
required: true,
},
subAdminsGroups: {
type: Array,
required: true,

@ -25,9 +25,8 @@ import { User } from '@nextcloud/cypress'
const admin = new User('admin', 'admin')
const jdoe = new User('jdoe', 'jdoe')
describe('Setting: Users list', function() {
describe('Settings: Create and delete users', function() {
before(function() {
cy.createUser(jdoe)
cy.login(admin)
})
@ -35,48 +34,26 @@ describe('Setting: Users list', function() {
cy.deleteUser(jdoe)
})
it('Can change the password', function() {
it('Can delete a user', function() {
// ensure user exists
cy.createUser(jdoe).login(admin)
// open the User settings
cy.visit('/settings/users')
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).within(($row) => {
// see that the user is in the list
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).within(() => {
// see that the list of users contains the user jdoe
cy.contains(jdoe.userId).should('exist')
// toggle the edit mode for the user jdoe
cy.get('.userActions button .icon-rename').click()
// open the actions menu for the user
cy.get('.userActions button.action-item__menutoggle').click()
})
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).within(($row) => {
// see that the edit mode is on
cy.wrap($row).should('have.class', 'row--editable')
// see that the password of user0 is ""
cy.get('input[type="password"]').should('exist').and('have.value', '')
// set the password for user0 to 123456
cy.get('input[type="password"]').type('123456')
// When I set the password for user0 to 123456
cy.get('input[type="password"]').should('have.value', '123456')
cy.get('.password button').click()
// Ignore failure if modal is not shown
cy.once('fail', (error) => {
expect(error.name).to.equal('AssertionError')
expect(error).to.have.property('node', '.modal-container')
})
// Make sure no confirmation modal is shown
cy.root().closest('body').find('.modal-container').then(($modal) => {
if ($modal.length > 0) {
cy.wrap($modal).find('input[type="password"]').type(admin.password)
cy.wrap($modal).find('button').contains('Confirm').click()
}
})
// see that the password cell for user user0 is done loading
cy.get('.user-row-text-field.icon-loading-small').should('exist')
cy.waitUntil(() => cy.get('.user-row-text-field.icon-loading-small').should('not.exist'), { timeout: 10000 })
// password input is emptied on change
cy.get('input[type="password"]').should('have.value', '')
})
// Success message is shown
cy.get('.toastify.toast-success').contains(/Password.+successfully.+changed/i).should('exist')
// The "Delete user" action in the actions menu is shown and clicked
cy.get('.action-item__popper .action').contains('Delete user').should('exist').click()
// And confirmation dialog accepted
cy.get('.oc-dialog button').contains(`Delete ${jdoe.userId}`).click()
// deleted clicked the user is not shown anymore
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).should('not.exist')
})
})

@ -0,0 +1,89 @@
/**
* @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @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/>.
*
*/
import { User } from '@nextcloud/cypress'
const admin = new User('admin', 'admin')
const jdoe = new User('jdoe', 'jdoe')
describe('Settings: Disable and enable users', function() {
before(function() {
cy.createUser(jdoe)
cy.login(admin)
})
after(() => {
cy.deleteUser(jdoe)
})
it('Can disable the user', function() {
// ensure user is enabled
cy.enableUser(jdoe)
// open the User settings
cy.visit('/settings/users')
// see that the user is in the list of active users
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).within(() => {
// see that the list of users contains the user jdoe
cy.contains(jdoe.userId).should('exist')
// open the actions menu for the user
cy.get('.userActions button.action-item__menutoggle').click()
})
// The "Disable user" action in the actions menu is shown and clicked
cy.get('.action-item__popper .action').contains('Disable user').should('exist').click()
// When clicked the section is not shown anymore
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).should('not.exist')
// But the disabled user section now exists
cy.get('#disabled').should('exist')
// Open disabled users section
cy.get('#disabled a').click()
cy.url().should('match', /\/disabled/)
// The list of disabled users should now contain the user
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).should('exist')
})
it('Can enable the user', function() {
// ensure user is disabled
cy.enableUser(jdoe, false)
// open the User settings
cy.visit('/settings/users')
// Open disabled users section
cy.get('#disabled a').click()
cy.url().should('match', /\/disabled/)
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).within(() => {
// see that the list of disabled users contains the user jdoe
cy.contains(jdoe.userId).should('exist')
// open the actions menu for the user
cy.get('.userActions button.action-item__menutoggle').click()
})
// The "Enable user" action in the actions menu is shown and clicked
cy.get('.action-item__popper .action').contains('Enable user').should('exist').click()
// When clicked the section is not shown anymore
cy.get('#disabled').should('not.exist')
// Make sure it is still gone after the reload reload
cy.reload().login(admin)
cy.get('#disabled').should('not.exist')
})
})

@ -0,0 +1,82 @@
/**
* @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @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/>.
*
*/
import { User } from '@nextcloud/cypress'
const admin = new User('admin', 'admin')
const jdoe = new User('jdoe', 'jdoe')
describe('Settings: Change user properties', function() {
before(function() {
cy.createUser(jdoe)
cy.login(admin)
})
after(() => {
cy.deleteUser(jdoe)
})
it('Can change the password', function() {
// open the User settings
cy.visit('/settings/users')
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).within(($row) => {
// see that the list of users contains the user jdoe
cy.contains(jdoe.userId).should('exist')
// toggle the edit mode for the user jdoe
cy.get('.userActions .action-items > button:first-of-type').click()
})
cy.get(`.user-list-grid .row[data-id="${jdoe.userId}"]`).within(($row) => {
// see that the edit mode is on
cy.wrap($row).should('have.class', 'row--editable')
// see that the password of user0 is ""
cy.get('input[type="password"]').should('exist').and('have.value', '')
// set the password for user0 to 123456
cy.get('input[type="password"]').type('123456')
// When I set the password for user0 to 123456
cy.get('input[type="password"]').should('have.value', '123456')
cy.get('.password button').click()
// Ignore failure if modal is not shown
cy.once('fail', (error) => {
expect(error.name).to.equal('AssertionError')
expect(error).to.have.property('node', '.modal-container')
})
// Make sure no confirmation modal is shown
cy.root().closest('body').find('.modal-container').then(($modal) => {
if ($modal.length > 0) {
cy.wrap($modal).find('input[type="password"]').type(admin.password)
cy.wrap($modal).find('button').contains('Confirm').click()
}
})
// see that the password cell for user user0 is done loading
cy.get('.user-row-text-field.icon-loading-small').should('exist')
cy.waitUntil(() => cy.get('.user-row-text-field.icon-loading-small').should('not.exist'), { timeout: 10000 })
// password input is emptied on change
cy.get('input[type="password"]').should('have.value', '')
})
// Success message is shown
cy.get('.toastify.toast-success').contains(/Password.+successfully.+changed/i).should('exist')
})
})

@ -33,6 +33,11 @@ declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable<Subject = any> {
/**
* Enable or disable a given user
*/
enableUser(user: User, enable?: boolean): Cypress.Chainable<Cypress.Response<any>>,
/**
* Upload a file from the fixtures folder to a given user storage.
* **Warning**: Using this function will reset the previous session
@ -69,6 +74,33 @@ declare global {
const url = (Cypress.config('baseUrl') || '').replace(/\/index.php\/?$/g, '')
Cypress.env('baseUrl', url)
/**
* Enable or disable a user
* TODO: standardise in @nextcloud/cypress
*
* @param {User} user the user to dis- / enable
* @param {boolean} enable True if the user should be enable, false to disable
*/
Cypress.Commands.add('enableUser', (user: User, enable = true) => {
const url = `${Cypress.config('baseUrl')}/ocs/v2.php/cloud/users/${user.userId}/${enable ? 'enable' : 'disable'}`.replace('index.php/', '')
return cy.request({
method: 'PUT',
url,
form: true,
auth: {
user: 'admin',
password: 'admin',
},
headers: {
'OCS-ApiRequest': 'true',
'Content-Type': 'application/x-www-form-urlencoded',
},
}).then((response) => {
cy.log(`Enabled user ${user}`, response.status)
return cy.wrap(response)
})
})
/**
* cy.uploadedFile - uploads a file from the fixtures folder
* TODO: standardise in @nextcloud/cypress

@ -125,7 +125,7 @@ class UsersSettingsContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function actionsMenuOf($user) {
return Locator::forThe()->css(".icon-more")->
return Locator::forThe()->css(".userActions .action-item:not(.action-item--single)")->
descendantOf(self::rowForUser($user))->
describedAs("Actions menu for user $user in Users Settings");
}
@ -134,8 +134,7 @@ class UsersSettingsContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function theAction($action, $user) {
return Locator::forThe()->xpath("//button[normalize-space() = '$action']")->
descendantOf(self::rowForUser($user))->
return Locator::forThe()->xpath("//button[@aria-label = normalize-space('$action')]")->
describedAs("$action action for the user $user row in Users Settings");
}
@ -160,7 +159,7 @@ class UsersSettingsContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function editModeToggle($user) {
return Locator::forThe()->css(".toggleUserActions button")->
return Locator::forThe()->css(".userActions .action-items button:first-of-type")->
descendantOf(self::rowForUser($user))->
describedAs("The edit toggle button for the user $user in Users Settings");
}

@ -22,42 +22,42 @@ Feature: users
Then I see that the list of users contains the user "test"
# And I see that the display name for the user "test" is "Test display name"
Scenario: delete a user
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
And I open the actions menu for the user user0
And I see that the "Delete user" action in the user0 actions menu is shown
When I click the "Delete user" action in the user0 actions menu
And I click the "Delete user0's account" button of the confirmation dialog
Then I see that the list of users does not contains the user user0
# Scenario: delete a user
# 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
# And I open the actions menu for the user user0
# And I see that the "Delete user" action in the user0 actions menu is shown
# When I click the "Delete user" action in the user0 actions menu
# And I click the "Delete user0's account" button of the confirmation dialog
# Then I see that the list of users does not contains the user user0
Scenario: disable a user
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
And I open the actions menu for the user user0
And I see that the "Disable user" action in the user0 actions menu is shown
When I click the "Disable user" action in the user0 actions menu
Then I see that the list of users does not contains the user user0
When I open the "Disabled users" section
Then I see that the list of users contains the user user0
# Scenario: disable a user
# 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
# And I open the actions menu for the user user0
# And I see that the "Disable user" action in the user0 actions menu is shown
# When I click the "Disable user" action in the user0 actions menu
# Then I see that the list of users does not contains the user user0
# When I open the "Disabled users" section
# Then I see that the list of users contains the user user0
Scenario: users navigation without disabled users
Given I act as Jane
And I am logged in as the admin
And I open the User settings
And I open the "Disabled users" section
And I see that the list of users contains the user disabledUser
And I open the actions menu for the user disabledUser
And I see that the "Enable user" action in the disabledUser actions menu is shown
When I click the "Enable user" action in the disabledUser actions menu
Then I see that the section "Disabled users" is not shown
# check again after reloading the settings
When I open the User settings
Then I see that the section "Disabled users" is not shown
# Scenario: users navigation without disabled users
# Given I act as Jane
# And I am logged in as the admin
# And I open the User settings
# And I open the "Disabled users" section
# And I see that the list of users contains the user disabledUser
# And I open the actions menu for the user disabledUser
# And I see that the "Enable user" action in the disabledUser actions menu is shown
# When I click the "Enable user" action in the disabledUser actions menu
# Then I see that the section "Disabled users" is not shown
# # check again after reloading the settings
# When I open the User settings
# Then I see that the section "Disabled users" is not shown
Scenario: assign user to a group
Given I act as Jane

Loading…
Cancel
Save