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 search to usersManagement #6779

Merged
merged 7 commits into from
Apr 13, 2022
Merged
Show file tree
Hide file tree
Changes from 6 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
3 changes: 2 additions & 1 deletion packages/web-app-user-management/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"description": "ownCloud user management",
"license": "AGPL-3.0",
"dependencies": {
"email-validator": "^2.0.4"
"email-validator": "^2.0.4",
"mark.js": "^8.11.1"
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<template>
<oc-table
ref="table"
:sort-by="sortBy"
:sort-dir="sortDir"
:fields="fields"
Expand Down Expand Up @@ -50,13 +51,10 @@
</template>

<script>
const orderBy = (list, prop, desc) => {
return [...list].sort((a, b) => {
a = a[prop] || ''
b = b[prop] || ''
return desc ? b.localeCompare(a) : a.localeCompare(b)
})
}
import { onBeforeUnmount, ref } from '@vue/composition-api'
import { Registry } from '../../services'
import Fuse from 'fuse.js'
import Mark from 'mark.js'

export default {
name: 'GroupsList',
Expand All @@ -70,10 +68,20 @@ export default {
required: true
}
},
setup() {
const searchTerm = ref('')
const token = Registry.search.subscribe('updateTerm', ({ term }) => (searchTerm.value = term))
onBeforeUnmount(() => Registry.search.unsubscribe('updateTerm', token))

return {
searchTerm
}
},
data() {
return {
sortBy: 'displayName',
sortDir: 'asc'
sortDir: 'asc',
markInstance: null
}
},
computed: {
Expand Down Expand Up @@ -114,13 +122,52 @@ export default {
return this.$gettextInterpolate(translated, { groupCount: this.groups.length })
},
data() {
return orderBy(this.groups, this.sortBy, this.sortDir === 'desc')
const orderedGroups = this.orderBy(this.groups, this.sortBy, this.sortDir === 'desc')
return this.filter(orderedGroups, this.searchTerm)
},
highlighted() {
return this.selectedGroups.map((group) => group.id)
}
},
watch: {
searchTerm() {
if (!this.markInstance) {
return
}
this.markInstance.unmark()
this.markInstance.mark(this.searchTerm, {
element: 'span',
className: 'highlight-mark',
exclude: ['th *', 'tfoot *']
})
}
},
mounted() {
this.$nextTick(() => {
this.markInstance = new Mark(this.$refs.table.$el)
})
},
methods: {
filter(groups, searchTerm) {
if (!(searchTerm || '').trim()) {
return groups
}
const groupsSearchEngine = new Fuse(groups, {
includeScore: true,
useExtendedSearch: true,
threshold: 0.3,
keys: ['displayName']
})

return groupsSearchEngine.search(searchTerm).map((r) => r.item)
},
orderBy(list, prop, desc) {
return [...list].sort((a, b) => {
a = a[prop] || ''
b = b[prop] || ''
return desc ? b.localeCompare(a) : a.localeCompare(b)
})
},
handleSort(event) {
this.sortBy = event.sortBy
this.sortDir = event.sortDir
Expand All @@ -132,3 +179,9 @@ export default {
}
}
</script>

<style lang="scss">
.highlight-mark {
background: yellow;
}
</style>
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<template>
<oc-table
ref="table"
:sort-by="sortBy"
:sort-dir="sortDir"
:fields="fields"
Expand Down Expand Up @@ -31,7 +32,7 @@
<template #avatar="{ item }">
<avatar-image :width="32" :userid="item.id" :user-name="item.displayName" />
</template>
<template #role="{ item }"> {{ getUserRole(item) }} </template>
<template #role="{ item }"> {{ getUserRole(item) }}</template>
<template #actions="{ item }">
<oc-button v-oc-tooltip="$gettext('Details')" @click="$emit('clickDetails', item)">
<oc-icon size="small" name="information" />
Expand All @@ -49,6 +50,11 @@
</template>

<script>
import { onBeforeUnmount, ref } from '@vue/composition-api'
import { Registry } from '../../services'
import Fuse from 'fuse.js'
import Mark from 'mark.js'

export default {
name: 'UsersList',
props: {
Expand All @@ -65,10 +71,20 @@ export default {
required: true
}
},
setup() {
const searchTerm = ref('')
const token = Registry.search.subscribe('updateTerm', ({ term }) => (searchTerm.value = term))
onBeforeUnmount(() => Registry.search.unsubscribe('updateTerm', token))

return {
searchTerm
}
},
data() {
return {
sortBy: 'onPremisesSamAccountName',
sortDir: 'asc'
sortDir: 'asc',
markInstance: null
}
},
computed: {
Expand Down Expand Up @@ -125,13 +141,45 @@ export default {
]
},
data() {
return this.orderBy(this.users, this.sortBy, this.sortDir === 'desc')
const orderedUsers = this.orderBy(this.users, this.sortBy, this.sortDir === 'desc')
return this.filter(orderedUsers, this.searchTerm)
},
highlighted() {
return this.selectedUsers.map((user) => user.id)
}
},
watch: {
searchTerm() {
if (!this.markInstance) {
return
}
this.markInstance.unmark()
this.markInstance.mark(this.searchTerm, {
element: 'span',
className: 'highlight-mark',
exclude: ['th *', 'tfoot *']
})
}
},
mounted() {
this.$nextTick(() => {
this.markInstance = new Mark(this.$refs.table.$el)
})
},
methods: {
filter(users, searchTerm) {
if (!(searchTerm || '').trim()) {
return users
}
const usersSearchEngine = new Fuse(users, {
includeScore: true,
useExtendedSearch: true,
threshold: 0.3,
keys: ['displayName', 'mail', 'onPremisesSamAccountName']
AlexAndBear marked this conversation as resolved.
Show resolved Hide resolved
})

return usersSearchEngine.search(searchTerm).map((r) => r.item)
},
orderBy(list, prop, desc) {
return [...list].sort((user1, user2) => {
if (prop === 'role') {
Expand Down Expand Up @@ -161,3 +209,9 @@ export default {
}
}
</script>

<style lang="scss">
.highlight-mark {
background: yellow;
}
</style>
10 changes: 8 additions & 2 deletions packages/web-app-user-management/src/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Registry } from './services'
import translations from '../l10n/translations'
import Users from './views/Users.vue'
import Groups from './views/Groups.vue'

import { FilterSearch } from './search'
import { bus } from 'web-pkg/src/instance'
// just a dummy function to trick gettext tools
function $gettext(msg) {
return msg
Expand Down Expand Up @@ -54,5 +56,9 @@ export default {
appInfo,
routes,
translations,
navItems
navItems,
ready({ router }) {
Registry.search = new FilterSearch(router)
bus.publish('app.search.register.provider', Registry.search)
}
}
55 changes: 55 additions & 0 deletions packages/web-app-user-management/src/search/filter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { SearchProvider } from 'search/src/types'
import { EventBus } from 'web-pkg/src/event'
import VueRouter, { Route } from 'vue-router'

function $gettext(msg) {
return msg
}

const kind = (currentRoute: Route): 'users' | 'groups' | undefined => {
switch (currentRoute.name) {
case 'user-management-users':
return 'users'
case 'user-management-groups':
return 'groups'
default:
return undefined
}
}

export default class Provider extends EventBus implements SearchProvider {
public readonly id: string
private readonly router: VueRouter

constructor(router: VueRouter) {
super()

this.id = 'usersManagement.filter'
this.router = router
}

public get label(): string {
switch (kind(this.router.currentRoute)) {
case 'users':
return $gettext('Search users ↵')
case 'groups':
return $gettext('Search groups ↵')
}
}

public activate(term: string): void {
/* noop */
}

public reset(): void {
/* noop */
}

public updateTerm(term: string): void {
this.publish('updateTerm', { kind: kind(this.router.currentRoute), term })
}

public get available(): boolean {
return !!kind(this.router.currentRoute)
}
}
1 change: 1 addition & 0 deletions packages/web-app-user-management/src/search/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as FilterSearch } from './filter'
1 change: 1 addition & 0 deletions packages/web-app-user-management/src/services/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as Registry } from './registry'
5 changes: 5 additions & 0 deletions packages/web-app-user-management/src/services/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { FilterSearch } from '../search'

export default class Registry {
static search: FilterSearch
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Vuex from 'vuex'
import { mount, createLocalVue } from '@vue/test-utils'
import UsersList from '../../../../src/components/Groups/GroupsList'

const localVue = createLocalVue()
localVue.use(Vuex)

afterEach(() => jest.clearAllMocks())

describe('GroupsList', () => {
describe('method "orderBy"', () => {
it('should return an ascending ordered list while desc is set to false', () => {
const wrapper = getMountedWrapper()

expect(
wrapper.vm.orderBy(
[{ displayName: 'users' }, { displayName: 'admins' }],
'displayName',
false
)
).toEqual([{ displayName: 'admins' }, { displayName: 'users' }])
})
it('should return an descending ordered list while desc is set to true', () => {
const wrapper = getMountedWrapper()

expect(
wrapper.vm.orderBy(
[{ displayName: 'admins' }, { displayName: 'users' }],
'displayName',
true
)
).toEqual([{ displayName: 'users' }, { displayName: 'admins' }])
})
})

describe('method "filter"', () => {
it('should return a list containing record admins if search term is "ad"', () => {
const wrapper = getMountedWrapper()

expect(
wrapper.vm.filter([{ displayName: 'users' }, { displayName: 'admins' }], 'ad')
).toEqual([{ displayName: 'admins' }])
})
it('should return an an empty list if search term does not match any entry', () => {
const wrapper = getMountedWrapper()

expect(
wrapper.vm.filter([{ displayName: 'admins' }, { displayName: 'users' }], 'ownClouders')
).toEqual([])
})
})
})

function getMountedWrapper({ propsData = {} } = {}) {
return mount(UsersList, {
localVue,
mocks: {
$gettext: jest.fn(),
$gettextInterpolate: jest.fn()
},
propsData: {
groups: [],
selectedGroups: [],
...propsData
},
stubs: {
'avatar-image': true,
'oc-checkbox': true,
'oc-button': true,
'oc-table': { template: '<div></div>' }
}
})
}
Loading