Merge pull request #109 from owncloud/feature/batch-actions

This commit is contained in:
Lukas Hirt
2020-09-07 18:09:28 +02:00
committed by GitHub
11 changed files with 972 additions and 37 deletions

View File

@@ -0,0 +1,6 @@
Enhancement: Add enable/disable capabilities to the WebUI
We've added batch actions into the accounts listing to provide options to enable and disable accounts.
https://github.com/owncloud/product/issues/118
https://github.com/owncloud/ocis-accounts/pull/109

23
go.mod
View File

@@ -5,40 +5,25 @@ go 1.13
require (
github.com/CiscoM31/godata v0.0.0-20191007193734-c2c4ebb1b415
github.com/blevesearch/bleve v1.0.9
github.com/cznic/b v0.0.0-20181122101859-a26611c4d92d // indirect
github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548 // indirect
github.com/cznic/strutil v0.0.0-20181122101858-275e90344537 // indirect
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect
github.com/go-chi/chi v4.1.2+incompatible
github.com/go-chi/render v1.0.1
github.com/go-test/deep v1.0.6 // indirect
github.com/gofrs/uuid v3.3.0+incompatible
github.com/gogo/protobuf v1.3.1 // indirect
github.com/golang/protobuf v1.4.2
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/imdario/mergo v0.3.11 // indirect
github.com/jmhodges/levigo v1.0.0 // indirect
github.com/mennanov/fieldmask-utils v0.3.2
github.com/micro/cli/v2 v2.1.2
github.com/micro/go-micro/v2 v2.9.1
github.com/oklog/run v1.1.0
github.com/olekukonko/tablewriter v0.0.4
github.com/onsi/ginkgo v1.10.1 // indirect
github.com/onsi/gomega v1.7.0 // indirect
github.com/owncloud/ocis v1.0.0-rc1 // indirect
github.com/owncloud/ocis-pkg/v2 v2.4.1-0.20200902134813-1e87c6173ada
github.com/owncloud/ocis-settings v0.3.2-0.20200828130413-0cc0f5bf26fe
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/owncloud/ocis-settings v0.3.2-0.20200903035407-ad5de8264f91
github.com/restic/calens v0.2.0
github.com/rs/zerolog v1.19.0
github.com/spf13/viper v1.7.0
github.com/stretchr/testify v1.6.1
github.com/tecbot/gorocksdb v0.0.0-20191217155057-f0fad39f321c // indirect
github.com/tredoe/osutil v1.0.5
golang.org/x/net v0.0.0-20200625001655-4c5254603344
google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece
golang.org/x/net v0.0.0-20200822124328-c89045814202
google.golang.org/genproto v0.0.0-20200624020401-64a14ca9d1ad
google.golang.org/protobuf v1.25.0
)

626
go.sum

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,36 @@
<template>
<div>
<div class="uk-container uk-padding">
<h1>
Accounts
</h1>
<h1 v-text="$gettext('Accounts')" />
<oc-grid v-if="numberOfSelectedAccounts > 0" key="selected-accounts-info" gutter="small" class="uk-flex-middle">
<span v-text="selectionInfoText" />
<span>|</span>
<div>
<oc-button v-text="$gettext('Clear selection')" variation="raw" @click="RESET_ACCOUNTS_SELECTION" />
</div>
<div>
<oc-action-drop class="accounts-actions-dropdown">
<template v-slot:button>
<span class="uk-margin-xsmall-right" v-text="$gettext('Actions')" />
<oc-icon name="expand_more" />
</template>
<template v-slot:actions>
<oc-button
v-for="(action, index) in actions"
:key="action.label"
:id="action.id"
variation="raw"
role="menuitem"
:class="{ 'uk-margin-small-bottom': index + 1 !== actions.length }"
class="uk-width-1-1 uk-flex-left"
@click="action.handler"
>
{{ action.label }}
</oc-button>
</template>
</oc-action-drop>
</div>
</oc-grid>
<template v-if="isInitialized">
<accounts-list :accounts="accounts" />
</template>
@@ -13,22 +40,77 @@
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
import { mapGetters, mapActions, mapState, mapMutations } from 'vuex'
import AccountsList from './accounts/AccountsList.vue'
export default {
name: 'App',
components: { AccountsList },
computed: {
...mapGetters('Accounts', ['isInitialized', 'getAccountsSorted']),
...mapState('Accounts', ['selectedAccounts']),
accounts () {
return this.getAccountsSorted
},
numberOfSelectedAccounts () {
return this.selectedAccounts.length
},
selectionInfoText () {
const translated = this.$ngettext('%{ amount } selected user', '%{ amount } selected users', this.numberOfSelectedAccounts)
return this.$gettextInterpolate(translated, { amount: this.numberOfSelectedAccounts })
},
actions () {
const actions = []
const numberOfDisabledAccounts = this.selectedAccounts.filter(account => !account.accountEnabled).length
const isAnyAccountDisabled = numberOfDisabledAccounts > 0
const isAnyAccountEnabled = numberOfDisabledAccounts < this.numberOfSelectedAccounts
if (isAnyAccountDisabled) {
actions.push({
id: 'accounts-actions-dropdown-action-enable',
label: this.$gettext('Enable'),
handler: () => this.toggleAccountStatus(true)
})
}
if (isAnyAccountEnabled) {
actions.push({
id: 'accounts-actions-dropdown-action-disable',
label: this.$gettext('Disable'),
handler: () => this.toggleAccountStatus(false)
})
}
return actions
}
},
methods: {
...mapActions('Accounts', ['initialize'])
...mapActions('Accounts', ['initialize', 'toggleAccountStatus']),
...mapMutations('Accounts', ['RESET_ACCOUNTS_SELECTION'])
},
created () {
this.initialize()
},
beforeDestroy () {
this.RESET_ACCOUNTS_SELECTION()
}
}
</script>
<style>
/* TODO: After https://github.com/owncloud/owncloud-design-system/pull/418 gets merged
there won't be an extra span and this won't be needed anymore */
.accounts-selection-actions-btn > span {
display: flex;
align-items: center;
}
/* TODO: Adjust in ODS */
.oc-dropdown-menu {
width: 150px;
}
</style>

View File

@@ -3,6 +3,14 @@
<oc-table middle divider>
<oc-table-group>
<oc-table-row>
<oc-table-cell shrink type="head">
<oc-checkbox
:value="areAllAccountsSelected"
:label="$gettext('Select all users')"
hide-label
@change="toggleSelectionAll"
/>
</oc-table-cell>
<oc-table-cell shrink type="head" />
<oc-table-cell type="head" v-text="$gettext('Username')" />
<oc-table-cell type="head" v-text="$gettext('Display name')" />
@@ -25,6 +33,7 @@
</template>
<script>
import { mapActions, mapGetters } from 'vuex'
import AccountsListRow from './AccountsListRow.vue'
export default {
@@ -37,6 +46,12 @@ export default {
type: Array,
required: true
}
},
computed: {
...mapGetters('Accounts', ['areAllAccountsSelected'])
},
methods: {
...mapActions('Accounts', ['toggleSelectionAll'])
}
}
</script>

View File

@@ -1,5 +1,8 @@
<template>
<oc-table-row>
<oc-table-cell>
<oc-checkbox :value="isAccountSelected" @change="TOGGLE_SELECTION_ACCOUNT(account)" :label="selectAccountLabel" hide-label />
</oc-table-cell>
<oc-table-cell>
<avatar :user-name="account.displayName || account.onPremisesSamAccountName" :userid="account.id" :width="35" />
</oc-table-cell>
@@ -39,14 +42,28 @@
<oc-table-cell v-text="account.uidNumber || '-'" />
<oc-table-cell v-text="account.gidNumber || '-'" />
<oc-table-cell class="uk-text-center">
<oc-icon v-if="account.accountEnabled" name="ready" variation="success" :aria-label="$gettext('Account is enabled')" />
<oc-icon v-else name="deprecated" variation="danger" :aria-label="$gettext('Account is disabled')" />
<oc-icon
v-if="account.accountEnabled"
key="account-icon-enabled"
name="ready"
variation="success"
:aria-label="$gettext('Account is enabled')"
class="accounts-status-indicator-enabled"
/>
<oc-icon
v-else
name="deprecated"
key="account-icon-disabled"
variation="danger"
:aria-label="$gettext('Account is disabled')"
class="accounts-status-indicator-disabled"
/>
</oc-table-cell>
</oc-table-row>
</template>
<script>
import { mapGetters, mapState, mapActions } from 'vuex'
import { mapGetters, mapState, mapActions, mapMutations } from 'vuex'
import { isObjectEmpty } from '../../helpers/utils'
import { injectAuthToken } from '../../helpers/auth'
// eslint-disable-next-line camelcase
@@ -73,7 +90,17 @@ export default {
computed: {
...mapGetters(['user', 'configuration']),
...mapState('Accounts', ['roles'])
...mapState('Accounts', ['roles', 'selectedAccounts']),
isAccountSelected () {
return this.selectedAccounts.indexOf(this.account) > -1
},
selectAccountLabel () {
const translated = this.$gettext('Select %{ account }')
return this.$gettextInterpolate(translated, { account: this.account.displayName }, true)
}
},
created () {
@@ -82,6 +109,7 @@ export default {
methods: {
...mapActions(['showMessage']),
...mapMutations('Accounts', ['TOGGLE_SELECTION_ACCOUNT']),
async changeRole (roleId) {
injectAuthToken(this.user.token)

View File

@@ -1,5 +1,5 @@
/* eslint-disable camelcase */
import { AccountsService_ListAccounts } from '../client/accounts'
import { AccountsService_ListAccounts, AccountsService_UpdateAccount } from '../client/accounts'
import { RoleService_ListRoles } from '../client/settings'
/* eslint-enable camelcase */
import { injectAuthToken } from '../helpers/auth'
@@ -8,7 +8,8 @@ const state = {
config: null,
initialized: false,
accounts: {},
roles: null
roles: null,
selectedAccounts: []
}
const getters = {
@@ -21,7 +22,8 @@ const getters = {
}
return a1.onPremisesSamAccountName.localeCompare(a2.onPremisesSamAccountName)
})
}
},
areAllAccountsSelected: state => state.accounts.length === state.selectedAccounts.length
}
const mutations = {
@@ -36,6 +38,24 @@ const mutations = {
},
SET_ROLES (state, roles) {
state.roles = roles
},
TOGGLE_SELECTION_ACCOUNT (state, account) {
const accountIndex = state.selectedAccounts.indexOf(account)
accountIndex > -1 ? state.selectedAccounts.splice(accountIndex, 1) : state.selectedAccounts.push(account)
},
SET_SELECTED_ACCOUNTS (state, accounts) {
state.selectedAccounts = accounts
},
UPDATE_ACCOUNT (state, updatedAccount) {
const accountIndex = state.accounts.findIndex(account => account.id === updatedAccount.id)
state.accounts.splice(accountIndex, 1, updatedAccount)
},
RESET_ACCOUNTS_SELECTION (state) {
state.selectedAccounts = []
}
}
@@ -87,6 +107,63 @@ const actions = {
status: 'danger'
}, { root: true })
}
},
toggleSelectionAll ({ commit, getters, state }) {
getters.areAllAccountsSelected ? commit('RESET_ACCOUNTS_SELECTION') : commit('SET_SELECTED_ACCOUNTS', [...state.accounts])
},
async toggleAccountStatus ({ commit, dispatch, state, rootGetters }, status) {
const failedAccounts = []
injectAuthToken(rootGetters.user.token)
for (const account of state.selectedAccounts) {
if (account.accountEnabled === status) {
continue
}
const response = await AccountsService_UpdateAccount({
$domain: rootGetters.configuration.server,
body: {
account: {
id: account.id,
accountEnabled: status
},
update_mask: {
paths: ['AccountEnabled']
}
}
})
if (response.status === 201) {
commit('UPDATE_ACCOUNT', { ...account, accountEnabled: status })
} else {
failedAccounts.push({ account: account.diisplayName, statusText: response.statusText })
}
}
if (failedAccounts.length === 1) {
const failedMessageTitle = status ? 'Failed to enable account.' : 'Failed to disable account.'
dispatch('showMessage', {
title: failedMessageTitle,
desc: failedAccounts[0].statusText,
status: 'danger'
}, { root: true })
}
if (failedAccounts.length > 1) {
const failedMessageTitle = status ? 'Failed to enable accounts.' : 'Failed to disable accounts.'
const failedMessageDesc = status ? 'Could not enable multiple accounts.' : 'Could not disable multiple accounts.'
dispatch('showMessage', {
title: failedMessageTitle,
desc: failedMessageDesc,
status: 'danger'
}, { root: true })
}
commit('RESET_ACCOUNTS_SELECTION')
}
}

View File

@@ -32,3 +32,30 @@ Feature: Accounts
And user "Einstein" logs in using the webUI
And the user browses to the accounts page
Then the user should not be able to see the accounts list on the WebUI
# We want to separate this into own scenarios but because we do not have clean env for each scenario yet
# we are resetting it manually by combining them into one
Scenario: disable/enable account
Given user "Moss" has logged in using the webUI
When the user browses to the accounts page
Then user "einstein" should be displayed in the accounts list on the WebUI
When the user disables user "einstein" using the WebUI
Then the status indicator of user "einstein" should be "disabled" on the WebUI
# And user "einstein" should not be able to log in
When the user enables user "einstein" using the WebUI
Then the status indicator of user "einstein" should be "enabled" on the WebUI
# And user "einstein" should be able to log in
Scenario: disable/enable multiple accounts
Given user "Moss" has logged in using the webUI
When the user browses to the accounts page
Then user "einstein" should be displayed in the accounts list on the WebUI
And user "marie" should be displayed in the accounts list on the WebUI
When the user disables users "einstein,marie" using the WebUI
Then the status indicator of users "einstein,marie" should be "disabled" on the WebUI
# And user "einstein" should not be able to log in
# And user "marie" should not be able to log in
When the user enables users "einstein,marie" using the WebUI
Then the status indicator of user "einstein,marie" should be "enabled" on the WebUI
# And user "einstein" should be able to log in
# And user "marie" should be able to log in

View File

@@ -40,6 +40,42 @@ module.exports = {
util.format(this.elements.currentRole.selector, role)
return this.useXpath().expect.element(roleSelector).to.be.visible
},
toggleUserStatus: function (usernames, status) {
usernames = usernames.split(',')
const actionSelector = status === 'enabled' ? this.elements.enableAction : this.elements.disableAction
// Select users
for (const username of usernames) {
const checkboxSelector =
util.format(this.elements.rowByUsername.selector, username) +
this.elements.rowCheckbox.selector
this.useXpath().click(checkboxSelector)
}
return this
.waitForElementVisible('@actionsDropdownTrigger')
.click('@actionsDropdownTrigger')
.useCss()
.waitForElementVisible(actionSelector)
.click(actionSelector)
.useXpath()
},
checkUsersStatus: function (usernames, status) {
usernames = usernames.split(',')
for (const username of usernames) {
const indicatorSelector =
util.format(this.elements.rowByUsername.selector, username) +
util.format(this.elements.statusIndicator.selector, status)
this.useXpath().waitForElementVisible(indicatorSelector)
}
return this
}
},
@@ -75,6 +111,24 @@ module.exports = {
loadingAccountsList: {
selector: '//div[contains(@class, "oc-loader")]',
locateStrategy: 'xpath'
},
rowCheckbox: {
selector: '//input[@class="oc-checkbox"]',
locateStrategy: 'xpath'
},
actionsDropdownTrigger: {
selector: '//div[contains(@class, "accounts-actions-dropdown")]//button[normalize-space()="Actions"]',
locateStrategy: 'xpath'
},
disableAction: {
selector: '#accounts-actions-dropdown-action-disable'
},
enableAction: {
selector: '#accounts-actions-dropdown-action-enable'
},
statusIndicator: {
selector: '//span[contains(@class, "accounts-status-indicator-%s")]',
locateStrategy: 'xpath'
}
}
}

View File

@@ -30,3 +30,15 @@ Then('the user should not be able to see the accounts list on the WebUI', async
.waitForElementVisible('@loadingAccountsList')
.waitForElementNotPresent('@accountsListTable')
})
When('the user disables user/users {string} using the WebUI', function (usernames) {
return client.page.accountsPage().toggleUserStatus(usernames, 'disabled')
})
When('the user enables user/users {string} using the WebUI', function (usernames) {
return client.page.accountsPage().toggleUserStatus(usernames, 'enabled')
})
Then('the status indicator of user/users {string} should be {string} on the WebUI', function (usernames, status) {
return client.page.accountsPage().checkUsersStatus(usernames, status)
})