Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add groups filter to the users list #8378

Merged
merged 9 commits into from
Feb 13, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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')"
JammingBen marked this conversation as resolved.
Show resolved Hide resolved
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
82 changes: 62 additions & 20 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"
AlexAndBear marked this conversation as resolved.
Show resolved Hide resolved
: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,11 +163,21 @@ export default defineComponent({
roles.value = applicationsResponse.data.value[0].appRoles
})

const loadResourcesTask = useTask(function* (signal) {
const usersResponse = yield unref(graphClient).users.listUsers('displayName')
const loadResourcesTask = useTask(function* (signal, loadGroups = true, groupIds = null) {
const groupFilter = groupIds
?.reduce((acc, id) => {
acc += `memberOf/any(m:m/id eq '${id}') and `
return acc
}, '')
.slice(0, -5)
JammingBen marked this conversation as resolved.
Show resolved Hide resolved

const usersResponse = yield unref(graphClient).users.listUsers('displayName', groupFilter)
users.value = usersResponse.data.value || []

yield loadGroupsTask.perform()
if (loadGroups) {
yield loadGroupsTask.perform()
}

yield loadAppRolesTask.perform()
})

Expand All @@ -156,6 +191,11 @@ export default defineComponent({
return data
})

const filterGroups = (groups) => {
const groupIds = groups.map((g) => g.id)
loadResourcesTask.perform(false, groupIds)
JammingBen marked this conversation as resolved.
Show resolved Hide resolved
}

watch(
selectedUsers,
async () => {
Expand Down Expand Up @@ -189,7 +229,8 @@ export default defineComponent({
})

onMounted(async () => {
await loadResourcesTask.perform()
const groupFilterIds = queryItemAsString(unref(groupFilterParam))?.split('+')
await loadResourcesTask.perform(true, groupFilterIds)
loadResourcesEventToken = eventBus.subscribe('app.admin-settings.list.load', () => {
loadResourcesTask.perform()
selectedUsers.value = []
Expand Down Expand Up @@ -225,7 +266,8 @@ export default defineComponent({
accessToken,
listHeaderPosition,
createUserModalOpen,
batchActions
batchActions,
filterGroups
}
},
computed: {
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