Merge branch 'feature/add-filters' into 'master'

Add more users filters

Closes #9

See merge request pleroma/admin-fe!10
This commit is contained in:
feld 2019-03-29 14:25:53 +00:00
commit 75182336c7
8 changed files with 291 additions and 117 deletions

View file

@ -1,11 +1,27 @@
const users = [
{ deactivated: false, id: '2', nickname: 'allis', local: true, roles: { admin: true, moderator: false }, tags: [] },
{ deactivated: false, id: '10', nickname: 'bob', local: false, roles: { admin: false, moderator: true }, tags: ['sandbox'] },
{ deactivated: true, id: 'abc', nickname: 'john', local: true, roles: { admin: false, moderator: false }, tags: ['strip_media'] }
{ 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: false, deactivated: true, id: 'abc', nickname: 'john', local: true, external: false, roles: { admin: false, moderator: false }, tags: ['strip_media'] }
]
export async function fetchUsers(showLocalUsersOnly, authHost, token, page = 1) {
const filteredUsers = showLocalUsersOnly ? users.filter(user => user.local) : users
const filterUsers = (str) => {
const filters = str.split(',').filter(item => item.length > 0)
if (filters.length === 0) {
return users
}
const applyFilters = (acc, filters, users) => {
if (filters.length === 0) {
return acc
}
const filteredUsers = users.filter(user => user[filters[0]])
const newAcc = [...filteredUsers]
return applyFilters(newAcc, filters.slice(1), filteredUsers)
}
return applyFilters([], filters, users)
}
export async function fetchUsers(filters, authHost, token, page = 1) {
const filteredUsers = filterUsers(filters)
return Promise.resolve({ data: {
users: filteredUsers,
count: filteredUsers.length,
@ -18,8 +34,8 @@ export async function toggleUserActivation(nickname, authHost, token) {
return Promise.resolve({ data: { ...response, deactivated: !response.deactivated }})
}
export async function searchUsers(query, showLocalUsersOnly, authHost, token, page = 1) {
const filteredUsers = showLocalUsersOnly ? users.filter(user => user.local) : users
export async function searchUsers(query, filters, authHost, token, page = 1) {
const filteredUsers = filterUsers(filters)
const response = filteredUsers.filter(user => user.nickname === query)
return Promise.resolve({ data: {
users: response,

View file

@ -2,10 +2,10 @@ import request from '@/utils/request'
import { getToken } from '@/utils/auth'
import { baseName } from './utils'
export async function fetchUsers(showLocalUsersOnly, authHost, token, page = 1) {
export async function fetchUsers(filters, authHost, token, page = 1) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/users?page=${page}&local_only=${showLocalUsersOnly}`,
url: `/api/pleroma/admin/users?page=${page}&filters=${filters}`,
method: 'get',
headers: authHeaders(token)
})
@ -20,10 +20,10 @@ export async function toggleUserActivation(nickname, authHost, token) {
})
}
export async function searchUsers(query, showLocalUsersOnly, authHost, token, page = 1) {
export async function searchUsers(query, filters, authHost, token, page = 1) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/users?query=${query}&page=${page}&local_only=${showLocalUsersOnly}`,
url: `/api/pleroma/admin/users?query=${query}&page=${page}&filters=${filters}`,
method: 'get',
headers: authHeaders(token)
})

View file

@ -189,5 +189,14 @@ export default {
sandbox: 'Force posts to be followers-only',
disableRemoteSubscription: 'Disallow following user from remote instances',
disableAnySubscription: 'Disallow following user at all'
},
usersFilter: {
inputPlaceholder: 'Select filter',
byUserType: 'By user type',
local: 'Local',
external: 'External',
byStatus: 'By status',
active: 'Active',
deactivated: 'Deactivated'
}
}

View file

@ -7,7 +7,12 @@ const users = {
searchQuery: '',
totalUsersCount: 0,
currentPage: 1,
showLocalUsersOnly: false
filters: {
local: false,
external: false,
active: false,
deactivated: false
}
},
mutations: {
SET_USERS: (state, users) => {
@ -37,13 +42,14 @@ const users = {
SET_SEARCH_QUERY: (state, query) => {
state.searchQuery = query
},
SET_LOCAL_USERS_FILTER: (state, value) => {
state.showLocalUsersOnly = value
SET_USERS_FILTERS: (state, filters) => {
state.filters = filters
}
},
actions: {
async FetchUsers({ commit, state, getters }, { page }) {
const response = await fetchUsers(state.showLocalUsersOnly, getters.authHost, getters.token, page)
const filters = Object.keys(state.filters).filter(filter => state.filters[filter]).join()
const response = await fetchUsers(filters, getters.authHost, getters.token, page)
commit('SET_LOADING', true)
@ -62,13 +68,25 @@ const users = {
commit('SET_LOADING', true)
commit('SET_SEARCH_QUERY', query)
const response = await searchUsers(query, state.showLocalUsersOnly, getters.authHost, getters.token, page)
const filters = Object.keys(state.filters).filter(filter => state.filters[filter]).join()
const response = await searchUsers(query, filters, getters.authHost, getters.token, page)
loadUsers(commit, page, response.data)
}
},
async ToggleLocalUsersFilter({ commit, dispatch, state }, value) {
commit('SET_LOCAL_USERS_FILTER', value)
async ToggleUsersFilter({ commit, dispatch, state }, filters) {
const defaultFilters = {
local: false,
external: false,
active: false,
deactivated: false
}
const currentFilters = { ...defaultFilters, ...filters }
commit('SET_USERS_FILTERS', currentFilters)
dispatch('SearchUsers', { query: state.searchQuery, page: 1 })
},
async ClearFilters({ commit, dispatch, state }) {
commit('CLEAR_USERS_FILTERS')
dispatch('SearchUsers', { query: state.searchQuery, page: 1 })
},
async ToggleRight({ commit, getters }, { user, right }) {

View file

@ -0,0 +1,72 @@
<template>
<el-select
v-model="value"
:clearable="isDesktop"
:placeholder="$t('usersFilter.inputPlaceholder')"
multiple
class="select-field"
@change="toggleFilters">
<el-option-group :label="$t('usersFilter.byUserType')">
<el-option value="local">{{ $t('usersFilter.local') }}</el-option>
<el-option value="external">{{ $t('usersFilter.external') }}</el-option>
</el-option-group>
<el-option-group :label="$t('usersFilter.byStatus')">
<el-option value="active">{{ $t('usersFilter.active') }}</el-option>
<el-option value="deactivated">{{ $t('usersFilter.deactivated') }}</el-option>
</el-option-group>
</el-select>
</template>
<script>
export default {
data() {
return {
value: []
}
},
computed: {
isDesktop() {
return this.$store.state.app.device === 'desktop'
}
},
methods: {
removeOppositeFilters() {
const filtersQuantity = Object.keys(this.$store.state.users.filters).length
const currentFilters = this.$data.value.slice()
const indexOfLocal = currentFilters.indexOf('local')
const indexOfExternal = currentFilters.indexOf('external')
const indexOfActive = currentFilters.indexOf('active')
const indexOfDeactivated = currentFilters.indexOf('deactivated')
if (currentFilters.length === filtersQuantity) {
return []
} else if (indexOfLocal > -1 && indexOfExternal > -1) {
const filterToRemove = indexOfLocal > indexOfExternal ? indexOfExternal : indexOfLocal
currentFilters.splice(filterToRemove, 1)
} else if (indexOfActive > -1 && indexOfDeactivated > -1) {
const filterToRemove = indexOfActive > indexOfDeactivated ? indexOfDeactivated : indexOfActive
currentFilters.splice(filterToRemove, 1)
}
return currentFilters
},
toggleFilters() {
this.$data.value = this.removeOppositeFilters()
const currentFilters = this.$data.value.reduce((acc, filter) => ({ ...acc, [filter]: true }), {})
this.$store.dispatch('ToggleUsersFilter', currentFilters)
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss' scoped>
.select-field {
width: 350px;
}
@media
only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: 1024px) {
.select-field {
width: 100%;
margin-bottom: 5px;
}
}
</style>

View file

@ -2,7 +2,7 @@
<div class="users-container">
<h1>{{ $t('users.users') }}</h1>
<div class="search-container">
<el-checkbox :value="showLocalUsersOnly" @change="handleLocalUsersCheckbox">{{ $t('users.localUsersOnly') }}</el-checkbox>
<users-filter/>
<el-input :placeholder="$t('users.search')" class="search" @input="handleDebounceSearchInput"/>
</div>
<el-table v-loading="loading" :data="users" style="width: 100%">
@ -37,41 +37,65 @@
<i v-if="isDesktop" class="el-icon-arrow-down el-icon--right"/>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item v-if="showAdminAction(scope.row)" @click.native="toggleUserRight(scope.row, 'admin')">
<el-dropdown-item
v-if="showAdminAction(scope.row)"
@click.native="toggleUserRight(scope.row, 'admin')">
{{ scope.row.roles.admin ? $t('users.revokeAdmin') : $t('users.grantAdmin') }}
</el-dropdown-item>
<el-dropdown-item v-if="showAdminAction(scope.row)" @click.native="toggleUserRight(scope.row, 'moderator')">
<el-dropdown-item
v-if="showAdminAction(scope.row)"
@click.native="toggleUserRight(scope.row, 'moderator')">
{{ scope.row.roles.moderator ? $t('users.revokeModerator') : $t('users.grantModerator') }}
</el-dropdown-item>
<el-dropdown-item v-if="showDeactivatedButton(scope.row.id)" :divided="showAdminAction(scope.row)" @click.native="handleDeactivation(scope.row)">
<el-dropdown-item
v-if="showDeactivatedButton(scope.row.id)"
:divided="showAdminAction(scope.row)"
@click.native="handleDeactivation(scope.row)">
{{ scope.row.deactivated ? $t('users.activateAccount') : $t('users.deactivateAccount') }}
</el-dropdown-item>
<el-dropdown-item v-if="showDeactivatedButton(scope.row.id)" @click.native="handleDeletion(scope.row)">
<el-dropdown-item
v-if="showDeactivatedButton(scope.row.id)"
@click.native="handleDeletion(scope.row)">
{{ $t('users.deleteAccount') }}
</el-dropdown-item>
<el-dropdown-item :divided="showAdminAction(scope.row)" @click.native="toggleTag(scope.row, 'force_nsfw')">
<el-dropdown-item
:divided="showAdminAction(scope.row)"
:class="{ 'active-tag': scope.row.tags.includes('force_nsfw') }"
@click.native="toggleTag(scope.row, 'force_nsfw')">
{{ $t('users.forceNsfw') }}
<i v-if="scope.row.tags.includes('force_nsfw')" class="el-icon-circle-check"/>
<i v-if="scope.row.tags.includes('force_nsfw')" class="el-icon-check"/>
</el-dropdown-item>
<el-dropdown-item @click.native="toggleTag(scope.row, 'strip_media')">
<el-dropdown-item
:class="{ 'active-tag': scope.row.tags.includes('strip_media') }"
@click.native="toggleTag(scope.row, 'strip_media')">
{{ $t('users.stripMedia') }}
<i v-if="scope.row.tags.includes('strip_media')" class="el-icon-circle-check"/>
<i v-if="scope.row.tags.includes('strip_media')" class="el-icon-check"/>
</el-dropdown-item>
<el-dropdown-item @click.native="toggleTag(scope.row, 'force_unlisted')">
<el-dropdown-item
:class="{ 'active-tag': scope.row.tags.includes('force_unlisted') }"
@click.native="toggleTag(scope.row, 'force_unlisted')">
{{ $t('users.forceUnlisted') }}
<i v-if="scope.row.tags.includes('force_unlisted')" class="el-icon-circle-check"/>
<i v-if="scope.row.tags.includes('force_unlisted')" class="el-icon-check"/>
</el-dropdown-item>
<el-dropdown-item @click.native="toggleTag(scope.row, 'sandbox')">
<el-dropdown-item
:class="{ 'active-tag': scope.row.tags.includes('sandbox') }"
@click.native="toggleTag(scope.row, 'sandbox')">
{{ $t('users.sandbox') }}
<i v-if="scope.row.tags.includes('sandbox')" class="el-icon-circle-check"/>
<i v-if="scope.row.tags.includes('sandbox')" class="el-icon-check"/>
</el-dropdown-item>
<el-dropdown-item v-if="scope.row.local" @click.native="toggleTag(scope.row, 'disable_remote_subscription')">
<el-dropdown-item
v-if="scope.row.local"
:class="{ 'active-tag': scope.row.tags.includes('disable_remote_subscription') }"
@click.native="toggleTag(scope.row, 'disable_remote_subscription')">
{{ $t('users.disableRemoteSubscription') }}
<i v-if="scope.row.tags.includes('disable_remote_subscription')" class="el-icon-circle-check"/>
<i v-if="scope.row.tags.includes('disable_remote_subscription')" class="el-icon-check"/>
</el-dropdown-item>
<el-dropdown-item v-if="scope.row.local" @click.native="toggleTag(scope.row, 'disable_any_subscription')">
<el-dropdown-item
v-if="scope.row.local"
:class="{ 'active-tag': scope.row.tags.includes('disable_any_subscription') }"
@click.native="toggleTag(scope.row, 'disable_any_subscription')">
{{ $t('users.disableAnySubscription') }}
<i v-if="scope.row.tags.includes('disable_any_subscription')" class="el-icon-circle-check"/>
<i v-if="scope.row.tags.includes('disable_any_subscription')" class="el-icon-check"/>
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
@ -93,9 +117,13 @@
<script>
import debounce from 'lodash.debounce'
import UsersFilter from './components/UsersFilter'
export default {
name: 'Users',
components: {
UsersFilter
},
computed: {
loading() {
return this.$store.state.users.loading
@ -112,9 +140,6 @@ export default {
currentPage() {
return this.$store.state.users.currentPage
},
showLocalUsersOnly() {
return this.$store.state.users.showLocalUsersOnly
},
isDesktop() {
return this.$store.state.app.device === 'desktop'
},
@ -151,9 +176,6 @@ export default {
showAdminAction({ local, id }) {
return local && this.showDeactivatedButton(id)
},
handleLocalUsersCheckbox(e) {
this.$store.dispatch('ToggleLocalUsersFilter', e)
},
activationIcon(status) {
return status ? 'el-icon-error' : 'el-icon-success'
},
@ -174,9 +196,18 @@ export default {
</script>
<style rel='stylesheet/scss' lang='scss' scoped>
.active-tag {
color: #409EFF;
font-weight: 700;
.el-icon-check {
color: #409EFF;
float: right;
margin: 7px 0 0 15px;
}
}
.users-container {
h1 {
margin-left: 15px;
margin: 22px 0 0 15px;
}
.pagination {
@ -185,16 +216,15 @@ export default {
}
.search {
width: 300px;
margin-bottom: 21.5px;
margin-right: 15px;
width: 350px;
float: right;
}
.search-container {
display: flex;
height: 36px;
justify-content: space-between;
align-items: baseline;
margin-left: 15px;
align-items: center;
margin: 22px 15px 22px 15px
}
}
@media
@ -202,7 +232,7 @@ only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: 1024px) {
.users-container {
h1 {
margin-left: 7px;
margin: 7px 10px 7px 10px;
}
.el-dropdown-link {
cursor: pointer;
@ -212,16 +242,13 @@ only screen and (max-width: 760px),
font-size: 12px;
}
.search {
width: 50%;
margin-bottom: 21.5px;
margin-right: 7px;
float: right;
width: 100%;
}
.search-container {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-left: 7px;
height: 82px;
flex-direction: column;
margin: 0 10px 7px 10px
}
.el-tag {
width: 30px;

View file

@ -6,6 +6,8 @@ import storeConfig from './store.conf'
import { cloneDeep } from 'lodash'
config.mocks["$t"] = () => {}
config.stubs['users-filter'] = '<div />'
const localVue = createLocalVue()
localVue.use(Vuex)
@ -43,8 +45,7 @@ describe('Search and filter users', () => {
await wrapper.vm.$nextTick()
expect(wrapper.vm.usersCount).toEqual(3)
const input = wrapper.find('input.el-input__inner')
const input = wrapper.find('.search input.el-input__inner')
input.element.value = 'bob'
input.trigger('input')
await wrapper.vm.$nextTick()
@ -57,66 +58,6 @@ describe('Search and filter users', () => {
done()
})
it('shows local users on checkbox click', async (done) => {
const wrapper = mount(Users, {
store,
localVue
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.usersCount).toEqual(3)
const input = wrapper.find('input.el-checkbox__original')
input.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.vm.usersCount).toEqual(2)
input.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.vm.usersCount).toEqual(3)
done()
})
it('shows local users with search query', async (done) => {
const wrapper = mount(Users, {
store,
localVue
})
wrapper.vm.handleDebounceSearchInput = (query) => {
store.dispatch('SearchUsers', { query, page: 1 })
}
await wrapper.vm.$nextTick()
expect(wrapper.vm.usersCount).toEqual(3)
const checkboxInput = wrapper.find('input.el-checkbox__original')
checkboxInput.trigger('click')
await wrapper.vm.$nextTick()
const searchInput = wrapper.find('input.el-input__inner')
searchInput.element.value = 'bob'
searchInput.trigger('input')
await wrapper.vm.$nextTick()
expect(wrapper.vm.usersCount).toEqual(0)
searchInput.element.value = 'allis'
searchInput.trigger('input')
await wrapper.vm.$nextTick()
expect(wrapper.vm.usersCount).toEqual(1)
searchInput.element.value = ''
searchInput.trigger('input')
await wrapper.vm.$nextTick()
expect(wrapper.vm.usersCount).toEqual(2)
checkboxInput.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.vm.usersCount).toEqual(3)
done()
})
})
describe('Users actions', () => {

View file

@ -0,0 +1,91 @@
import Vuex from 'vuex'
import { mount, createLocalVue, config } from '@vue/test-utils'
import Element from 'element-ui'
import Filters from '@/views/users/components/UsersFilter'
import storeConfig from './store.conf'
import { cloneDeep } from 'lodash'
import flushPromises from 'flush-promises'
config.mocks["$t"] = () => {}
config.stubs.transition = false
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Element)
jest.mock('@/api/users')
describe('Filters users', () => {
let store
beforeEach(async() => {
store = new Vuex.Store(cloneDeep(storeConfig))
store.dispatch('FetchUsers', { page: 1 })
await flushPromises()
})
it('shows local users when "Local" filter is applied', async (done) => {
const wrapper = mount(Filters, {
store,
localVue
})
expect(store.state.users.totalUsersCount).toEqual(3)
const filter = wrapper.find(`li.el-select-dropdown__item:nth-child(${1})`)
filter.trigger('click')
await flushPromises()
expect(store.state.users.totalUsersCount).toEqual(2)
done()
})
it('shows users with applied filter and search query', async (done) => {
expect(store.state.users.totalUsersCount).toEqual(3)
store.dispatch('ToggleUsersFilter', { active: true })
await flushPromises()
store.dispatch('SearchUsers', { query: 'john', page: 1 })
await flushPromises()
expect(store.state.users.totalUsersCount).toEqual(0)
store.dispatch('SearchUsers', { query: 'allis', page: 1 })
await flushPromises()
expect(store.state.users.totalUsersCount).toEqual(1)
store.dispatch('SearchUsers', { query: '', page: 1 })
await flushPromises()
expect(store.state.users.totalUsersCount).toEqual(2)
done()
})
it('applies two filters', async (done) => {
expect(store.state.users.totalUsersCount).toEqual(3)
store.dispatch('ToggleUsersFilter', { active: true, local: true })
await flushPromises()
expect(store.state.users.totalUsersCount).toEqual(1)
expect(store.state.users.fetchedUsers[0].nickname).toEqual('allis')
store.dispatch('ToggleUsersFilter', { deactivated: true, external: true })
await flushPromises()
expect(store.state.users.totalUsersCount).toEqual(0)
done()
})
it('shows all users after removing filters', async (done) => {
expect(store.state.users.totalUsersCount).toEqual(3)
store.dispatch('ToggleUsersFilter', { deactivated: true })
await flushPromises()
expect(store.state.users.totalUsersCount).toEqual(1)
store.dispatch('ToggleUsersFilter', {})
await flushPromises()
expect(store.state.users.totalUsersCount).toEqual(3)
done()
})
})