Skip to content

Commit

Permalink
Add groups filter to the users list (#8378)
Browse files Browse the repository at this point in the history
* Add groups filter to the users list

* wip: filter

* wip: filter

* Add support for filtering for multiple groups

* Remove unnecessary stuff

* Add changelog item

* Update snapshots

* Rename other filters to 'Search'

* Introduce separate loadUsersTask for loading users
  • Loading branch information
JammingBen authored Feb 13, 2023
1 parent a5c84a0 commit fb91aef
Show file tree
Hide file tree
Showing 10 changed files with 135 additions and 46 deletions.
6 changes: 6 additions & 0 deletions changelog/unreleased/enhancement-user-group-filter
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Enhancement: User group filter

Users in the users list can now be filtered by their group assignments.

https://github.com/owncloud/web/issues/8377
https://github.com/owncloud/web/pull/8378
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
id="groups-filter"
v-model="filterTerm"
class="oc-ml-m oc-my-s"
:label="$gettext('Filter groups')"
:label="$gettext('Search')"
autocomplete="off"
/>
<oc-table
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
id="spaces-filter"
v-model="filterTerm"
class="oc-ml-m oc-my-s"
:label="$gettext('Filter spaces')"
:label="$gettext('Search')"
autocomplete="off"
/>
<oc-table
Expand Down
33 changes: 18 additions & 15 deletions packages/web-app-admin-settings/src/components/Users/UsersList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@
id="users-filter"
v-model="filterTerm"
class="oc-ml-m oc-my-s"
:label="$gettext('Filter users')"
:label="$gettext('Search')"
autocomplete="off"
/>
<slot name="filter" />
<no-content-message v-if="!users.length" icon="user">
<template #message>
<span v-text="$gettext('No users in here')" />
</template>
</no-content-message>
<oc-table
v-else
ref="tableRef"
class="users-table"
:sort-by="sortBy"
Expand Down Expand Up @@ -95,10 +102,11 @@ import { displayPositionedDropdown, eventBus } from 'web-pkg'
import { SideBarEventTopics } from 'web-pkg/src/composables/sideBar'
import { User } from 'web-client/src/generated'
import ContextMenuQuickAction from 'web-pkg/src/components/ContextActions/ContextMenuQuickAction.vue'
import NoContentMessage from 'web-pkg/src/components/NoContentMessage.vue'
export default defineComponent({
name: 'UsersList',
components: { ContextMenuQuickAction },
components: { ContextMenuQuickAction, NoContentMessage },
props: {
users: {
type: Array as PropType<User[]>,
Expand Down Expand Up @@ -253,22 +261,17 @@ export default defineComponent({
},
watch: {
filterTerm() {
if (!this.markInstance) {
return
if (this.$refs.tableRef) {
this.markInstance = new Mark(this.$refs.tableRef.$el)
this.markInstance.unmark()
this.markInstance.mark(this.filterTerm, {
element: 'span',
className: 'highlight-mark',
exclude: ['th *', 'tfoot *']
})
}
this.markInstance.unmark()
this.markInstance.mark(this.filterTerm, {
element: 'span',
className: 'highlight-mark',
exclude: ['th *', 'tfoot *']
})
}
},
mounted() {
this.$nextTick(() => {
this.markInstance = new Mark(this.$refs.tableRef.$el)
})
},
methods: {
filter(users, filterTerm) {
if (!(filterTerm || '').trim()) {
Expand Down
74 changes: 55 additions & 19 deletions packages/web-app-admin-settings/src/views/Users.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
<div>
<app-template
ref="template"
:loading="loadResourcesTask.isRunning || !loadResourcesTask.last"
:breadcrumbs="breadcrumbs"
:side-bar-active-panel="sideBarActivePanel"
:side-bar-available-panels="sideBarAvailablePanels"
Expand Down Expand Up @@ -34,16 +33,7 @@
</div>
</template>
<template #mainContent>
<no-content-message
v-if="!users.length"
id="admin-settings-users-empty"
class="files-empty"
icon="user"
>
<template #message>
<span v-translate>No users in here</span>
</template>
</no-content-message>
<app-loading-spinner v-if="loadResourcesTask.isRunning || !loadResourcesTask.last" />
<div v-else>
<UsersList
:users="users"
Expand All @@ -58,6 +48,31 @@
<template #contextMenu>
<context-actions :items="selectedUsers" />
</template>
<template #filter>
<div class="oc-flex oc-flex-middle oc-ml-m oc-mb-m oc-mt-m">
<div class="oc-mr-m oc-flex oc-flex-middle">
<oc-icon name="filter-2" class="oc-mr-xs" />
<span v-text="$gettext('Filter:')" />
</div>
<item-filter
filter-name="groups"
:filter-label="$gettext('Groups')"
:items="groups"
:show-filter="true"
:allow-multiple="true"
display-name-attribute="displayName"
:filterable-attributes="['displayName']"
@selection-change="filterGroups"
>
<template #image="{ item }">
<avatar-image :width="32" :userid="item.id" :user-name="item.displayName" />
</template>
<template #item="{ item }">
<div v-text="item.displayName" />
</template>
</item-filter>
</div>
</template>
</UsersList>
</div>
</template>
Expand All @@ -81,8 +96,13 @@ import DetailsPanel from '../components/Users/SideBar/DetailsPanel.vue'
import EditPanel from '../components/Users/SideBar/EditPanel.vue'
import BatchActions from 'web-pkg/src/components/BatchActions.vue'
import Delete from '../mixins/users/delete'
import NoContentMessage from 'web-pkg/src/components/NoContentMessage.vue'
import { useAccessToken, useGraphClient, useStore } from 'web-pkg/src/composables'
import {
queryItemAsString,
useAccessToken,
useGraphClient,
useRouteQuery,
useStore
} from 'web-pkg/src/composables'
import {
computed,
defineComponent,
Expand All @@ -98,16 +118,19 @@ import { eventBus } from 'web-pkg/src/services/eventBus'
import { mapActions, mapGetters, mapMutations, mapState } from 'vuex'
import AppTemplate from '../components/AppTemplate.vue'
import { useSideBar } from 'web-pkg/src/composables/sideBar'
import ItemFilter from 'web-pkg/src/components/ItemFilter.vue'
import AppLoadingSpinner from 'web-pkg/src/components/AppLoadingSpinner.vue'
export default defineComponent({
name: 'UsersView',
components: {
AppLoadingSpinner,
AppTemplate,
UsersList,
NoContentMessage,
CreateUserModal,
BatchActions,
ContextActions
ContextActions,
ItemFilter
},
mixins: [Delete],
setup() {
Expand All @@ -128,6 +151,8 @@ export default defineComponent({
let loadResourcesEventToken
let userUpdatedEventToken
const groupFilterParam = useRouteQuery('q_groups')
const loadGroupsTask = useTask(function* (signal) {
const groupsResponse = yield unref(graphClient).groups.listGroups()
groups.value = groupsResponse.data.value
Expand All @@ -138,10 +163,14 @@ export default defineComponent({
roles.value = applicationsResponse.data.value[0].appRoles
})
const loadResourcesTask = useTask(function* (signal) {
const usersResponse = yield unref(graphClient).users.listUsers('displayName')
const loadUsersTask = useTask(function* (signal, groupIds) {
const groupFilter = groupIds?.map((id) => `memberOf/any(m:m/id eq '${id}')`).join(' and ')
const usersResponse = yield unref(graphClient).users.listUsers('displayName', groupFilter)
users.value = usersResponse.data.value || []
})
const loadResourcesTask = useTask(function* (signal, groupIds = null) {
yield loadUsersTask.perform(groupIds)
yield loadGroupsTask.perform()
yield loadAppRolesTask.perform()
})
Expand All @@ -156,6 +185,11 @@ export default defineComponent({
return data
})
const filterGroups = (groups) => {
const groupIds = groups.map((g) => g.id)
loadUsersTask.perform(groupIds)
}
watch(
selectedUsers,
async () => {
Expand Down Expand Up @@ -189,7 +223,8 @@ export default defineComponent({
})
onMounted(async () => {
await loadResourcesTask.perform()
const groupFilterIds = queryItemAsString(unref(groupFilterParam))?.split('+')
await loadResourcesTask.perform(groupFilterIds)
loadResourcesEventToken = eventBus.subscribe('app.admin-settings.list.load', () => {
loadResourcesTask.perform()
selectedUsers.value = []
Expand Down Expand Up @@ -225,7 +260,8 @@ export default defineComponent({
accessToken,
listHeaderPosition,
createUserModalOpen,
batchActions
batchActions,
filterGroups
}
},
computed: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
exports[`SpacesList should render all spaces in a table 1`] = `
<div>
<div class="oc-ml-m oc-my-s">
<label class="oc-label" for="spaces-filter">Filter spaces</label>
<label class="oc-label" for="spaces-filter">Search</label>
<div class="oc-position-relative">
<input aria-invalid="false" autocomplete="off" class="oc-text-input oc-input oc-rounded" id="spaces-filter" type="text">
<!--v-if-->
Expand Down
50 changes: 47 additions & 3 deletions packages/web-app-admin-settings/tests/unit/views/Users.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
shallowMount
} from 'web-test-helpers'
import { AxiosResponse } from 'axios'
import { queryItemAsString } from 'web-pkg'

jest.mock('web-pkg/src/composables/appDefaults')

Expand Down Expand Up @@ -82,6 +83,10 @@ const getDefaultGraphMock = () => {
return graph
}

const selectors = {
itemFilterGroupsStub: 'item-filter-stub[filtername="groups"]'
}

describe('Users view', () => {
describe('method "createUser"', () => {
it('should hide the modal and show message on success', async () => {
Expand Down Expand Up @@ -325,9 +330,48 @@ describe('Users view', () => {
expect(wrapper.find('batch-actions-stub').exists()).toBeTruthy()
})
})

describe('filter', () => {
it('does filter users by groups when the "selectionChange"-event is triggered', async () => {
const graphMock = getDefaultGraphMock()
const { wrapper } = getMountedWrapper({ mountType: mount, graph: graphMock })
await wrapper.vm.loadResourcesTask.last
expect(graphMock.users.listUsers).toHaveBeenCalledTimes(1)
;(wrapper.findComponent<any>(selectors.itemFilterGroupsStub).vm as any).$emit(
'selectionChange',
[{ id: '1' }]
)
await wrapper.vm.$nextTick()
expect(graphMock.users.listUsers).toHaveBeenCalledTimes(2)
expect(graphMock.users.listUsers).toHaveBeenNthCalledWith(
2,
'displayName',
"memberOf/any(m:m/id eq '1')"
)
})
it('does filter initially if group ids are given via query param', async () => {
const groupIdsQueryParam = '1+2'
const graphMock = getDefaultGraphMock()
const { wrapper } = getMountedWrapper({
mountType: mount,
graph: graphMock,
queryItem: groupIdsQueryParam
})
await wrapper.vm.loadResourcesTask.last
expect(graphMock.users.listUsers).toHaveBeenCalledWith(
'displayName',
"memberOf/any(m:m/id eq '1') and memberOf/any(m:m/id eq '2')"
)
})
})
})

function getMountedWrapper({ mountType = shallowMount, graph = getDefaultGraphMock() } = {}) {
function getMountedWrapper({
mountType = shallowMount,
graph = getDefaultGraphMock(),
queryItem = null
} = {}) {
jest.mocked(queryItemAsString).mockImplementation(() => queryItem)
const mocks = {
...defaultComponentMocks()
}
Expand All @@ -351,9 +395,9 @@ function getMountedWrapper({ mountType = shallowMount, graph = getDefaultGraphMo
stubs: {
CreateUserModal: true,
AppLoadingSpinner: true,
NoContentMessage: true,
UsersList: true,
OcBreadcrumb: true,
OcTable: true,
ItemFilter: true,
BatchActions: true
}
}
Expand Down
6 changes: 3 additions & 3 deletions packages/web-client/src/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export interface Graph {
changeOwnPassword: (currentPassword: string, newPassword: string) => AxiosPromise<void>
editUser: (userId: string, user: User) => AxiosPromise<User>
deleteUser: (userId: string) => AxiosPromise<void>
listUsers: (orderBy?: string) => AxiosPromise<CollectionOfUser>
listUsers: (orderBy?: string, filter?: string) => AxiosPromise<CollectionOfUser>
createUserAppRoleAssignment: (
userId: string,
appRoleAssignment: AppRoleAssignment
Expand Down Expand Up @@ -131,12 +131,12 @@ export const graph = (baseURI: string, axiosClient: AxiosInstance): Graph => {
meChangepasswordApiFactory.changeOwnPassword({ currentPassword, newPassword }),
editUser: (userId: string, user: User) => userApiFactory.updateUser(userId, user),
deleteUser: (userId: string) => userApiFactory.deleteUser(userId),
listUsers: (orderBy?: any) =>
listUsers: (orderBy?: any, filter?: string) =>
usersApiFactory.listUsers(
0,
0,
'',
'',
filter,
false,
new Set<any>([orderBy]),
new Set<any>([]),
Expand Down
2 changes: 1 addition & 1 deletion packages/web-pkg/src/components/ItemFilter.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div class="item-filter oc-flex">
<div class="item-filter oc-flex" :class="`item-filter-${filterName}`">
<oc-filter-chip
:filter-label="filterLabel"
:selected-item-names="selectedItems.map((i) => i[displayNameAttribute])"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`ItemFilter can use a custom attribute as display name 1`] = `
<div class="item-filter oc-flex">
<div class="item-filter oc-flex item-filter-users">
<div class="oc-filter-chip oc-flex">
<button class="oc-button oc-rounded oc-button-m oc-button-justify-content-center oc-button-gap-m oc-button-passive oc-button-passive-raw oc-filter-chip-button" id="oc-filter-chip-5" type="button">
<!-- @slot Content of the button -->
Expand Down Expand Up @@ -47,7 +47,7 @@ exports[`ItemFilter can use a custom attribute as display name 1`] = `
`;

exports[`ItemFilter renders all items 1`] = `
<div class="item-filter oc-flex">
<div class="item-filter oc-flex item-filter-users">
<div class="oc-filter-chip oc-flex">
<button class="oc-button oc-rounded oc-button-m oc-button-justify-content-center oc-button-gap-m oc-button-passive oc-button-passive-raw oc-filter-chip-button" id="oc-filter-chip-1" type="button">
<!-- @slot Content of the button -->
Expand Down

0 comments on commit fb91aef

Please sign in to comment.