Commit 366fdbfc authored by feld's avatar feld

Merge branch 'feature/select-multiple-users' into 'master'

Apply user actions to multiple users

Closes #18

See merge request pleroma/admin-fe!17
parents e5fbf93f ea938958
const users = [
export const users = [
{ active: true, deactivated: false, id: '2', nickname: 'allis', local: true, external: false, roles: { admin: true, moderator: false }, tags: [] },
{ active: true, deactivated: false, id: '10', nickname: 'bob', local: false, external: true, roles: { admin: false, moderator: true }, tags: ['sandbox'] },
{ active: true, deactivated: false, id: '10', nickname: 'bob', local: false, external: true, roles: { admin: false, moderator: false }, tags: ['sandbox'] },
{ active: false, deactivated: true, id: 'abc', nickname: 'john', local: true, external: false, roles: { admin: false, moderator: false }, tags: ['strip_media'] }
]
......
......@@ -188,7 +188,9 @@ export default {
forceUnlisted: 'Force posts to be unlisted',
sandbox: 'Force posts to be followers-only',
disableRemoteSubscription: 'Disallow following user from remote instances',
disableAnySubscription: 'Disallow following user at all'
disableAnySubscription: 'Disallow following user at all',
selectUsers: 'Select users to apply actions to multiple users',
moderateUsers: 'Moderate multiple users'
},
usersFilter: {
inputPlaceholder: 'Select filter',
......
......@@ -21,14 +21,9 @@ const users = {
SET_LOADING: (state, status) => {
state.loading = status
},
SWAP_USER: (state, user) => {
const usersWithoutSwapped = state.fetchedUsers.filter(u => {
return u.id !== user.id
})
state.fetchedUsers = [...usersWithoutSwapped, user].sort((a, b) =>
a.nickname.localeCompare(b.nickname)
)
SWAP_USER: (state, updatedUser) => {
const updated = state.fetchedUsers.map(user => user.id === updatedUser.id ? updatedUser : user)
state.fetchedUsers = updated.sort((a, b) => a.nickname.localeCompare(b.nickname))
},
SWAP_USERS: (state, users) => {
const usersWithoutSwapped = users.reduce((acc, user) => {
......
<template>
<el-dropdown size="small" trigger="click" placement="bottom-start">
<el-button v-if="isDesktop" class="actions-button">
<span class="actions-button-container">
<span>
<i class="el-icon-edit" />
{{ $t('users.moderateUsers') }}
</span>
<i class="el-icon-arrow-down el-icon--right"/>
</span>
</el-button>
<el-dropdown-menu v-if="showDropdownForMultipleUsers" slot="dropdown">
<el-dropdown-item
@click.native="grantRightToMultipleUsers('admin')">
{{ $t('users.grantAdmin') }}
</el-dropdown-item>
<el-dropdown-item
@click.native="revokeRightFromMultipleUsers('admin')">
{{ $t('users.revokeAdmin') }}
</el-dropdown-item>
<el-dropdown-item
@click.native="grantRightToMultipleUsers('moderator')">
{{ $t('users.grantModerator') }}
</el-dropdown-item>
<el-dropdown-item
@click.native="revokeRightFromMultipleUsers('moderator')">
{{ $t('users.revokeModerator') }}
</el-dropdown-item>
<el-dropdown-item
divided
@click.native="activateMultipleUsers">
{{ $t('users.activateAccount') }}
</el-dropdown-item>
<el-dropdown-item
@click.native="deactivateMultipleUsers">
{{ $t('users.deactivateAccount') }}
</el-dropdown-item>
<el-dropdown-item
@click.native="deleteMultipleUsers">
{{ $t('users.deleteAccount') }}
</el-dropdown-item>
<el-dropdown-item divided class="no-hover">
<div class="tag-container">
<span class="tag-text">{{ $t('users.forceNsfw') }}</span>
<el-button-group class="tag-button-group">
<el-button size="mini" @click.native="addTagForMultipleUsers('force_nsfw')">apply</el-button>
<el-button size="mini" @click.native="removeTagFromMultipleUsers('force_nsfw')">remove</el-button>
</el-button-group>
</div>
</el-dropdown-item>
<el-dropdown-item class="no-hover">
<div class="tag-container">
<span class="tag-text">{{ $t('users.stripMedia') }}</span>
<el-button-group class="tag-button-group">
<el-button size="mini" @click.native="addTagForMultipleUsers('strip_media')">apply</el-button>
<el-button size="mini" @click.native="removeTagFromMultipleUsers('strip_media')">remove</el-button>
</el-button-group>
</div>
</el-dropdown-item>
<el-dropdown-item class="no-hover">
<div class="tag-container">
<span class="tag-text">{{ $t('users.forceUnlisted') }}</span>
<el-button-group class="tag-button-group">
<el-button size="mini" @click.native="addTagForMultipleUsers('force_unlisted')">apply</el-button>
<el-button size="mini" @click.native="removeTagFromMultipleUsers('force_unlisted')">remove</el-button>
</el-button-group>
</div>
</el-dropdown-item>
<el-dropdown-item class="no-hover">
<div class="tag-container">
<span class="tag-text">{{ $t('users.sandbox') }}</span>
<el-button-group class="tag-button-group">
<el-button size="mini" @click.native="addTagForMultipleUsers('sandbox')">apply</el-button>
<el-button size="mini" @click.native="removeTagFromMultipleUsers('sandbox')">remove</el-button>
</el-button-group>
</div>
</el-dropdown-item>
<el-dropdown-item class="no-hover">
<div class="tag-container">
<span class="tag-text">{{ $t('users.disableRemoteSubscription') }}</span>
<el-button-group class="tag-button-group">
<el-button size="mini" @click.native="addTagForMultipleUsers('disable_remote_subscription')">apply</el-button>
<el-button size="mini" @click.native="removeTagFromMultipleUsers('disable_remote_subscription')">remove</el-button>
</el-button-group>
</div>
</el-dropdown-item>
<el-dropdown-item class="no-hover">
<div class="tag-container">
<span class="tag-text">{{ $t('users.disableAnySubscription') }}</span>
<el-button-group class="tag-button-group">
<el-button size="mini" @click.native="addTagForMultipleUsers('disable_any_subscription')">apply</el-button>
<el-button size="mini" @click.native="removeTagFromMultipleUsers('disable_any_subscription')">remove</el-button>
</el-button-group>
</div>
</el-dropdown-item>
</el-dropdown-menu>
<el-dropdown-menu v-else slot="dropdown">
<el-dropdown-item>
{{ $t('users.selectUsers') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
<script>
export default {
props: {
selectedUsers: {
type: Array,
default: function() {
return []
}
}
},
computed: {
showDropdownForMultipleUsers() {
return this.$props.selectedUsers.length > 0
},
isDesktop() {
return this.$store.state.app.device === 'desktop'
}
},
methods: {
mappers() {
return {
grantRight: (right) => () => this.selectedUsers
.filter(user => user.local && !user.roles[right] && this.$store.state.user.id !== user.id)
.map(user => this.$store.dispatch('ToggleRight', { user, right })),
revokeRight: (right) => () => this.selectedUsers
.filter(user => user.local && user.roles[right] && this.$store.state.user.id !== user.id)
.map(user => this.$store.dispatch('ToggleRight', { user, right })),
activate: () => this.selectedUsers
.filter(user => user.deactivated && this.$store.state.user.id !== user.id)
.map(user => this.$store.dispatch('ToggleUserActivation', user.nickname)),
deactivate: () => this.selectedUsers
.filter(user => !user.deactivated && this.$store.state.user.id !== user.id)
.map(user => this.$store.dispatch('ToggleUserActivation', user.nickname)),
remove: () => this.selectedUsers
.filter(user => this.$store.state.user.id !== user.id)
.map(user => this.$store.dispatch('DeleteUser', user)),
addTag: (tag) => () => {
const users = this.selectedUsers
.filter(user => tag === 'disable_remote_subscription' || tag === 'disable_any_subscription'
? user.local && !user.tags.includes(tag)
: !user.tags.includes(tag))
this.$store.dispatch('AddTag', { users, tag })
},
removeTag: (tag) => () => {
const users = this.selectedUsers
.filter(user => tag === 'disable_remote_subscription' || tag === 'disable_any_subscription'
? user.local && user.tags.includes(tag)
: user.tags.includes(tag))
this.$store.dispatch('RemoveTag', { users, tag })
}
}
},
grantRightToMultipleUsers(right) {
const { grantRight } = this.mappers()
this.confirmMessage(
`Are you sure you want to grant ${right} rights to all selected users?`,
grantRight(right)
)
},
revokeRightFromMultipleUsers(right) {
const { revokeRight } = this.mappers()
this.confirmMessage(
`Are you sure you want to revoke ${right} rights from all selected users?`,
revokeRight(right)
)
},
activateMultipleUsers() {
const { activate } = this.mappers()
this.confirmMessage(
'Are you sure you want to activate accounts of all selected users?',
activate
)
},
deactivateMultipleUsers() {
const { deactivate } = this.mappers()
this.confirmMessage(
'Are you sure you want to deactivate accounts of all selected users?',
deactivate
)
},
deleteMultipleUsers() {
const { remove } = this.mappers()
this.confirmMessage(
'Are you sure you want to delete accounts of all selected users?',
remove
)
},
addTagForMultipleUsers(tag) {
const { addTag } = this.mappers()
this.confirmMessage(
'Are you sure you want to apply tag to all selected users?',
addTag(tag)
)
},
removeTagFromMultipleUsers(tag) {
const { removeTag } = this.mappers()
this.confirmMessage(
'Are you sure you want to remove tag from all selected users?',
removeTag(tag)
)
},
confirmMessage(message, applyAction) {
this.$confirm(message, {
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning'
}).then(() => {
applyAction()
this.$emit('apply-action')
this.$message({
type: 'success',
message: 'Completed'
})
}).catch(() => {
this.$message({
type: 'info',
message: 'Canceled'
})
})
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss' scoped>
.actions-button {
text-align: left;
margin: 0 15px 10px 0;
width: 350px;
padding: 10px 15px;
}
.actions-button-container {
display: flex;
justify-content: space-between;
}
.el-dropdown {
float: right;
}
.el-dropdown-menu {
margin-right: 15px;
}
.tag-container {
display: flex;
justify-content: space-between;
align-items: center;
}
.tag-text {
padding-right: 20px;
}
.no-hover:hover {
color: #606266;
background-color: white;
cursor: auto;
}
</style>
<template>
<div class="users-container">
<h1>{{ $t('users.users') }}</h1>
<h1>
{{ $t('users.users') }}
<span class="user-count">({{ normalizedUsersCount }})</span>
</h1>
<div class="search-container">
<users-filter/>
<el-input :placeholder="$t('users.search')" v-model="search" class="search" @input="handleDebounceSearchInput"/>
</div>
<el-table v-loading="loading" :data="users" style="width: 100%">
<dropdown-actions-menu
:selected-users="selectedUsers"
@apply-action="clearSelection"/>
<el-table
v-loading="loading"
ref="usersTable"
:data="users"
row-key="id"
style="width: 100%"
@selection-change="handleSelectionChange">
<el-table-column
v-if="isDesktop"
type="selection"
reserve-selection
width="44"
align="center"/>
<el-table-column :min-width="width" :label="$t('users.id')" prop="id" />
<el-table-column :label="$t('users.name')" prop="nickname">
<template slot-scope="scope">
......@@ -31,7 +49,7 @@
</el-table-column>
<el-table-column :label="$t('users.actions')" fixed="right">
<template slot-scope="scope">
<el-dropdown size="small">
<el-dropdown size="small" trigger="click">
<span class="el-dropdown-link">
{{ $t('users.moderation') }}
<i v-if="isDesktop" class="el-icon-arrow-down el-icon--right"/>
......@@ -117,12 +135,20 @@
<script>
import debounce from 'lodash.debounce'
import numeral from 'numeral'
import UsersFilter from './components/UsersFilter'
import DropdownActionsMenu from './components/DropdownActionsMenu'
export default {
name: 'Users',
components: {
UsersFilter
UsersFilter,
DropdownActionsMenu
},
data: function() {
return {
selectedUsers: []
}
},
data() {
return {
......@@ -133,6 +159,9 @@ export default {
loading() {
return this.$store.state.users.loading
},
normalizedUsersCount() {
return numeral(this.$store.state.users.totalUsersCount).format('0a')
},
users() {
return this.$store.state.users.fetchedUsers
},
......@@ -167,6 +196,9 @@ export default {
activationIcon(status) {
return status ? 'el-icon-error' : 'el-icon-success'
},
clearSelection() {
this.$refs.usersTable.clearSelection()
},
getFirstLetter(str) {
return str.charAt(0).toUpperCase()
},
......@@ -184,6 +216,9 @@ export default {
this.$store.dispatch('SearchUsers', { query: searchQuery, page })
}
},
handleSelectionChange(value) {
this.$data.selectedUsers = value
},
showAdminAction({ local, id }) {
return local && this.showDeactivatedButton(id)
},
......@@ -212,6 +247,10 @@ export default {
margin: 7px 0 0 15px;
}
}
.el-dropdown-link:hover {
cursor: pointer;
color: #409EFF;
}
.users-container {
h1 {
margin: 22px 0 0 15px;
......@@ -231,7 +270,11 @@ export default {
height: 36px;
justify-content: space-between;
align-items: center;
margin: 22px 15px 22px 15px
margin: 22px 15px 15px 15px
}
.user-count {
color: gray;
font-size: 28px;
}
}
@media
......@@ -241,10 +284,6 @@ only screen and (max-width: 760px),
h1 {
margin: 7px 10px 7px 10px;
}
.el-dropdown-link {
cursor: pointer;
color: #409EFF;
}
.el-icon-arrow-down {
font-size: 12px;
}
......
This diff is collapsed.
import Vuex from 'vuex'
import { mount, createLocalVue, config } from '@vue/test-utils'
import flushPromises from 'flush-promises'
import Element from 'element-ui'
import Users from '@/views/users/index'
import storeConfig from './store.conf'
......@@ -8,7 +9,6 @@ import { cloneDeep } from 'lodash'
config.mocks["$t"] = () => {}
config.stubs['users-filter'] = '<div />'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Element)
......@@ -25,10 +25,11 @@ describe('Search and filter users', () => {
it('fetches initial list of users', async (done) => {
const wrapper = mount(Users, {
store,
localVue
localVue,
sync: false
})
await wrapper.vm.$nextTick()
await flushPromises()
expect(wrapper.vm.usersCount).toEqual(3)
done()
})
......@@ -36,24 +37,25 @@ describe('Search and filter users', () => {
it('starts a search on input change', async (done) => {
const wrapper = mount(Users, {
store,
localVue
localVue,
sync: false
})
wrapper.vm.handleDebounceSearchInput = (query) => {
store.dispatch('SearchUsers', { query, page: 1 })
}
await wrapper.vm.$nextTick()
await flushPromises()
expect(wrapper.vm.usersCount).toEqual(3)
const input = wrapper.find('.search input.el-input__inner')
input.element.value = 'bob'
input.trigger('input')
await wrapper.vm.$nextTick()
await flushPromises()
expect(wrapper.vm.usersCount).toEqual(1)
input.element.value = ''
input.trigger('input')
await wrapper.vm.$nextTick()
await flushPromises()
expect(wrapper.vm.usersCount).toEqual(3)
done()
......@@ -72,18 +74,19 @@ describe('Users actions', () => {
it('grants admin and moderator rights to a local user', async (done) => {
const wrapper = mount(Users, {
store,
localVue
localVue,
sync: false
})
await wrapper.vm.$nextTick()
await flushPromises()
const user = store.state.users.fetchedUsers[2]
expect(user.roles.admin).toBe(false)
expect(user.roles.moderator).toBe(false)
wrapper.find(htmlElement(3, 1)).trigger('click')
await wrapper.vm.$nextTick()
await flushPromises()
wrapper.find(htmlElement(3, 2)).trigger('click')
await wrapper.vm.$nextTick()
await flushPromises()
const updatedUser = store.state.users.fetchedUsers[2]
expect(updatedUser.roles.admin).toBe(true)
......@@ -94,9 +97,10 @@ describe('Users actions', () => {
it('does not show actions that grant admin and moderator rights to external users', async (done) => {
const wrapper = mount(Users, {
store,
localVue
localVue,
sync: false
})
await wrapper.vm.$nextTick()
await flushPromises()
const dropdownMenuItems = wrapper.findAll(
`.el-table__fixed-body-wrapper table tr:nth-child(2) ul.el-dropdown-menu li`
......@@ -108,15 +112,16 @@ describe('Users actions', () => {
it('toggles activation status', async (done) => {
const wrapper = mount(Users, {
store,
localVue
localVue,
sync: false
})
await wrapper.vm.$nextTick()
await flushPromises()
const user = store.state.users.fetchedUsers[1]
expect(user.deactivated).toBe(false)
wrapper.find(htmlElement(2, 1)).trigger('click')
await wrapper.vm.$nextTick()
await flushPromises()
const updatedUser = store.state.users.fetchedUsers[1]
expect(updatedUser.deactivated).toBe(true)
......@@ -126,15 +131,16 @@ describe('Users actions', () => {
it('deactivates user when Delete action is called', async (done) => {
const wrapper = mount(Users, {
store,
localVue
localVue,
sync: false
})
await wrapper.vm.$nextTick()
await flushPromises()
const user = store.state.users.fetchedUsers[1]
expect(user.deactivated).toBe(false)
wrapper.find(htmlElement(2, 2)).trigger('click')
await wrapper.vm.$nextTick()
await flushPromises()
const updatedUser = store.state.users.fetchedUsers[1]
expect(updatedUser.deactivated).toBe(true)
......@@ -144,9 +150,10 @@ describe('Users actions', () => {
it('adds tags', async (done) => {
const wrapper = mount(Users, {
store,
localVue
localVue,
sync: false
})
await wrapper.vm.$nextTick()
await flushPromises()
const user1 = store.state.users.fetchedUsers[0]
const user2 = store.state.users.fetchedUsers[1]
......@@ -154,9 +161,9 @@ describe('Users actions', () => {
expect(user2.tags.length).toBe(1)
wrapper.find(htmlElement(1, 5)).trigger('click')
await wrapper.vm.$nextTick()
await flushPromises()
wrapper.find(htmlElement(2, 5)).trigger('click')
await wrapper.vm.$nextTick()
await flushPromises()
const updatedUser1 = store.state.users.fetchedUsers[0]
const updatedUser2 = store.state.users.fetchedUsers[1]
......@@ -168,15 +175,16 @@ describe('Users actions', () => {
it('deletes tags', async (done) => {
const wrapper = mount(Users, {
store,
localVue
localVue,
sync: false
})
await wrapper.vm.$nextTick()
await flushPromises()
const user = store.state.users.fetchedUsers[1]
expect(user.tags.length).toBe(1)
wrapper.find(htmlElement(2, 6)).trigger('click')
await wrapper.vm.$nextTick()
await flushPromises()
const updatedUser = store.state.users.fetchedUsers[1]
expect(updatedUser.tags.length).toBe(0)
......@@ -186,14 +194,15 @@ describe('Users actions', () => {
it('shows check icon when tag is added', async (done) => {
const wrapper = mount(Users, {
store,
localVue
localVue,
sync: false
})
await wrapper.vm.$nextTick()
await flushPromises()
expect(wrapper.find(`${htmlElement(1, 5)} i`).exists()).toBe(false)
wrapper.find(htmlElement(1, 5)).trigger('click')
await wrapper.vm.$nextTick()
await flushPromises()
expect(wrapper.find(`${htmlElement(1, 5)} i`).exists()).toBe(true)
done()
......@@ -202,9 +211,10 @@ describe('Users actions', () => {
it('does not change user index in array when tag is added', async (done) => {
const wrapper = mount(Users, {
store,
localVue
localVue,
sync: false
})
await wrapper.vm.$nextTick()
await flushPromises()
const firstUserNickname = store.state.users.fetchedUsers[0].nickname
const secondUserNickname = store.state.users.fetchedUsers[1].nickname
......@@ -212,7 +222,7 @@ describe('Users actions', () => {
expect(secondUserNickname).toBe('bob')
wrapper.find(htmlElement(2, 5)).trigger('click')
await wrapper.vm.$nextTick()
await flushPromises()
const firstUserNicknameAfterToggle = store.state.users.fetchedUsers[0].nickname
const secondUserNicknameAfterToggle = store.state.users.fetchedUsers[1].nickname
......
......@@ -7024,6 +7024,11 @@ number-is-nan@^1.0.0:
resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
numeral@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/numeral/-/numeral-2.0.6.tgz#4ad080936d443c2561aed9f2197efffe25f4e506"
integrity sha1-StCAk21EPCVhrtnyGX7//iX05QY=
nwsapi@^2.0.7:
version "2.1.1"
resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.1.1.tgz#08d6d75e69fd791bdea31507ffafe8c843b67e9c"
......