Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Enhancement: Use API groups search in admin settings

We have changed the search behaviour in the admin settings. Instead of filtering the groups in the client, we now use the search parameter of the list groups API endpoint.

https://github.com/owncloud/web/pull/13235
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
<template>
<div id="group-list">
<div class="group-filters oc-flex oc-flex-right oc-flex-wrap oc-flex-bottom oc-mx-m oc-mb-m">
<oc-text-input
id="groups-filter"
v-model="filterTerm"
:label="$gettext('Search')"
autocomplete="off"
/>
<slot name="filter" />
</div>

<slot v-if="!paginatedItems.length" name="noResults" />
<oc-table
v-else
:sort-by="sortBy"
:sort-dir="sortDir"
:fields="fields"
Expand Down Expand Up @@ -100,7 +98,6 @@
<pagination :pages="totalPages" :current-page="currentPage" />
<div class="oc-text-center oc-width-1-1 oc-my-s">
<p class="oc-text-muted">{{ footerTextTotal }}</p>
<p v-if="filterTerm" class="oc-text-muted">{{ footerTextFilter }}</p>
</div>
</template>
</oc-table>
Expand All @@ -115,7 +112,6 @@ import {
ref,
unref,
watch,
onMounted,
nextTick
} from 'vue'
import Fuse from 'fuse.js'
Expand All @@ -124,11 +120,10 @@ import {
ContextMenuBtnClickEventData,
displayPositionedDropdown,
eventBus,
queryItemAsString,
SortDir,
useIsTopBarSticky,
useKeyboardActions,
useRoute,
useRouter
useKeyboardActions
} from '@ownclouders/web-pkg'
import { SideBarEventTopics } from '@ownclouders/web-pkg'
import { Group } from '@ownclouders/web-client/graph/generated'
Expand All @@ -145,6 +140,7 @@ import {
import { useGroupSettingsStore } from '../../composables'
import { storeToRefs } from 'pinia'
import { findIndex } from 'lodash-es'
import { useRoute } from 'vue-router'

export default defineComponent({
name: 'GroupsList',
Expand All @@ -155,8 +151,6 @@ export default defineComponent({
const contextMenuButtonRef = ref(undefined)
const sortBy = ref<keyof Group>('displayName')
const sortDir = ref<SortDir>(SortDir.Asc)
const filterTerm = ref('')
const router = useRouter()
const route = useRoute()
const { isSticky } = useIsTopBarSticky()

Expand Down Expand Up @@ -278,11 +272,7 @@ export default defineComponent({
}

const items = computed(() => {
return orderBy(
filter(unref(groups), unref(filterTerm)),
unref(sortBy),
unref(sortDir) === SortDir.Desc
)
return orderBy(unref(groups), unref(sortBy), unref(sortDir) === SortDir.Desc)
})

const {
Expand Down Expand Up @@ -311,24 +301,30 @@ export default defineComponent({
unselectAllGroups()
})

watch(filterTerm, async () => {
await unref(router).push({ ...unref(route), query: { ...unref(route).query, page: '1' } })
})

const markInstance = ref<Mark>(null)
onMounted(async () => {
await nextTick()
markInstance.value = new Mark('.mark-element')
})
watch([filterTerm, paginatedItems], () => {
unref(markInstance)?.unmark()
if (unref(filterTerm)) {
unref(markInstance)?.mark(unref(filterTerm), {
element: 'span',
className: 'mark-highlight'
watch(
[() => route.query, paginatedItems],
() => {
nextTick(() => {
if (!unref(markInstance)) {
markInstance.value = new Mark('.mark-element')
}

unref(markInstance).unmark()

const filterTerm = queryItemAsString(unref(route).query.q_displayName)
if (!unref(filterTerm)) {
return
}

unref(markInstance).mark(unref(filterTerm), {
Comment on lines +316 to +320
Copy link

Copilot AI Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unref() call on line 316 is incorrect. filterTerm is already a string from queryItemAsString() on line 315, not a ref. Remove the unref() wrapper.

Suggested change
if (!unref(filterTerm)) {
return
}
unref(markInstance).mark(unref(filterTerm), {
if (!filterTerm) {
return
}
unref(markInstance).mark(filterTerm, {

Copilot uses AI. Check for mistakes.
element: 'span',
className: 'mark-highlight'
})
})
}
})
},
{ immediate: true }
)

return {
showDetails,
Expand All @@ -340,7 +336,6 @@ export default defineComponent({
contextMenuButtonRef,
showEditPanel,
readOnlyLabel,
filterTerm,
sortBy,
sortDir,
items,
Expand Down Expand Up @@ -403,11 +398,6 @@ export default defineComponent({
groupCount: this.groups.length.toString()
})
},
footerTextFilter() {
return this.$gettext('%{groupCount} matching groups', {
groupCount: this.items.length.toString()
})
},
highlighted() {
return this.selectedGroups.map((group) => group.id)
}
Expand Down
89 changes: 70 additions & 19 deletions packages/web-app-admin-settings/src/views/Groups.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,25 +30,49 @@
</template>
<template #mainContent>
<app-loading-spinner v-if="isLoading" />
<template v-else>
<no-content-message
v-if="!groups.length"
id="admin-settings-groups-empty"
class="files-empty"
icon="user"
>
<template #message>
<span v-translate>No groups in here</span>
<div v-else>
<groups-list>
<template #contextMenu>
<context-actions :action-options="{ resources: selectedGroups }" />
</template>
<template #filter>
<div class="oc-flex oc-flex-middle">
<oc-text-input
id="groups-filter"
v-model.trim="filterTerm"
Copy link

Copilot AI Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The v-model.trim modifier removes whitespace from the input value. However, when the search is triggered, the function uses unref(filterTerm) which will include the already-trimmed value. Consider whether .trim is necessary here or if validation should occur in the filterGroups function.

Copilot uses AI. Check for mistakes.
:label="$gettext('Search')"
autocomplete="off"
@enterKeyDown="filterGroups"
/>
<oc-button
id="groups-filter-confirm"
class="oc-ml-xs"
appearance="raw"
@click="filterGroups"
>
<oc-icon name="search" fill-type="line" />
</oc-button>
</div>
</template>
</no-content-message>
<div v-else>
<groups-list>
<template #contextMenu>
<context-actions :action-options="{ resources: selectedGroups }" />
</template>
</groups-list>
</div>
</template>
<template #noResults>
<no-content-message
v-if="!groups.length"
id="admin-settings-groups-empty"
class="files-empty"
icon="user"
>
<template #message>
{{
$pgettext(
'A message displayed when no groups are found in the groups list in admin settings when there is no filter applied.',
'No groups in here'
)
}}
</template>
</no-content-message>
</template>
</groups-list>
</div>
</template>
</app-template>
</div>
Expand All @@ -66,6 +90,7 @@ import { useGroupActionsCreateGroup, useGroupActionsDelete } from '../composable
import {
AppLoadingSpinner,
NoContentMessage,
queryItemAsString,
SideBarPanel,
SideBarPanelContext,
useClientService,
Expand All @@ -77,16 +102,22 @@ import { useTask } from 'vue-concurrency'
import { useGettext } from 'vue3-gettext'
import { storeToRefs } from 'pinia'
import { call } from '@ownclouders/web-client'
import { useRoute, useRouter } from 'vue-router'
import { omit } from 'lodash-es'

const template = ref()
const groupSettingsStore = useGroupSettingsStore()
const { selectedGroups, groups } = storeToRefs(groupSettingsStore)
const clientService = useClientService()
const { $gettext } = useGettext()
const { sideBarActivePanel, isSideBarOpen } = useSideBar()
const router = useRouter()
const route = useRoute()

provide('group', selectedGroups[0])

const filterTerm = ref(queryItemAsString(unref(route).query.q_displayName))

const { actions: createGroupActions } = useGroupActionsCreateGroup()
const createGroupAction = computed(() => unref(createGroupActions)[0])

Expand All @@ -95,7 +126,8 @@ const loadResourcesTask = useTask(function* (signal) {
clientService.graphAuthenticated.groups.listGroups(
{
orderBy: ['displayName'],
expand: ['members']
expand: ['members'],
search: queryItemAsString(unref(route).query.q_displayName)
},
{ signal }
)
Expand Down Expand Up @@ -154,6 +186,19 @@ const sideBarAvailablePanels = [
}
] satisfies SideBarPanel<unknown, unknown, Group>[]

async function filterGroups() {
await router.push({
...unref(route),
query: {
...omit(unref(route).query, 'q_displayName'),
q_displayName: unref(filterTerm),
Copy link

Copilot AI Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When filterTerm is empty, this code still adds an empty q_displayName parameter to the query. This should conditionally add the parameter only when filterTerm has a value, or explicitly remove it when empty.

Suggested change
q_displayName: unref(filterTerm),
...(unref(filterTerm) ? { q_displayName: unref(filterTerm) } : {}),

Copilot uses AI. Check for mistakes.
page: '1'
}
})
loadResourcesTask.perform()
groupSettingsStore.setSelectedGroups([])
}

onMounted(async () => {
await loadResourcesTask.perform()
})
Expand All @@ -175,3 +220,9 @@ const breadcrumbs = computed(() => {
]
})
</script>

<style lang="scss" scoped>
#groups-filter-confirm {
margin-top: calc(0.2rem + var(--oc-font-size-default));
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ vi.mock('@ownclouders/web-pkg', async (importOriginal) => ({
displayPositionedDropdown: vi.fn()
}))

vi.mock('vue-router', () => ({
useRoute: vi.fn().mockReturnValue({ query: {} }),
useRouter: vi.fn()
}))

describe('GroupsList', () => {
describe('method "orderBy"', () => {
it('should return an ascending ordered list while desc is set to false', () => {
Expand Down
49 changes: 46 additions & 3 deletions packages/web-app-admin-settings/tests/unit/views/Groups.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ClientService } from '@ownclouders/web-pkg'
import { defaultComponentMocks, defaultPlugins, mount } from '@ownclouders/web-test-helpers'
import { Group } from '@ownclouders/web-client/graph/generated'

const selectors = { batchActionsStub: 'batch-actions-stub' }
const selectors = { batchActionsStub: 'batch-actions-stub', searchInput: '#groups-filter' }
const getClientServiceMock = () => {
const clientService = mockDeep<ClientService>()
clientService.graphAuthenticated.groups.listGroups.mockResolvedValue([
Expand All @@ -17,6 +17,22 @@ vi.mock('@ownclouders/web-pkg', async (importOriginal) => ({
useAppDefaults: vi.fn()
}))

const { mockRouterPush, mockRoute } = vi.hoisted(() => {
const route = { query: {} }
const mockRouterPush = vi.fn((newRoute) => {
if (newRoute.query) {
route.query = { ...route.query, ...newRoute.query }
}
return Promise.resolve()
})
return { mockRouterPush, mockRoute: route }
})

vi.mock('vue-router', () => ({
useRoute: vi.fn(() => mockRoute),
useRouter: vi.fn().mockReturnValue({ push: mockRouterPush })
}))

describe('Groups view', () => {
describe('computed method "sideBarAvailablePanels"', () => {
describe('EditPanel', () => {
Expand Down Expand Up @@ -76,6 +92,30 @@ describe('Groups view', () => {
expect(wrapper.find(selectors.batchActionsStub).exists()).toBeTruthy()
})
})

describe('search', () => {
it('should search for groups when the search term changes', async () => {
const { wrapper, mocks } = getWrapper()
await (wrapper.vm as any).loadResourcesTask.last
const searchInput = wrapper.find(selectors.searchInput)
await searchInput.setValue('test')
await searchInput.trigger('keydown.enter')
expect(mockRouterPush).toHaveBeenCalledWith({
query: {
q_displayName: 'test',
page: '1'
}
})
expect(mocks.$clientService.graphAuthenticated.groups.listGroups).toHaveBeenCalledWith(
{
orderBy: ['displayName'],
expand: ['members'],
search: 'test'
},
{ signal: expect.any(AbortSignal) }
)
})
})
})

function getWrapper({
Expand All @@ -100,11 +140,14 @@ function getWrapper({
stubs: {
AppLoadingSpinner: true,
NoContentMessage: true,
GroupsList: true,
GroupsList: {
template: '<div><slot name="filter"></slot></div>'
},
OcBreadcrumb: true,
BatchActions: true
}
}
})
}),
mocks
}
}