Skip to content

Commit b5c2b9e

Browse files
committed
feat(settings): unified search for accounts and groups
Replace the unified search event bus subscription and the separate group sidebar search with a single input in the account management navigation. The query is shared via Vuex so UserList and AppNavigationGroupList react to the same state. Part of #53862 Fixes #59166 Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
1 parent 9fd6c4a commit b5c2b9e

5 files changed

Lines changed: 173 additions & 40 deletions

File tree

apps/settings/src/components/AppNavigationGroupList.vue

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,6 @@
3535
</template>
3636
</NcAppNavigationCaption>
3737

38-
<NcAppNavigationSearch
39-
v-model="groupsSearchQuery"
40-
:label="t('settings', 'Search groups…')" />
41-
4238
<p id="group-list-desc" class="hidden-visually">
4339
{{ t('settings', 'List of groups. This list is not fully populated for performance reasons. The groups will be loaded as you navigate or search through the list.') }}
4440
</p>
@@ -76,7 +72,6 @@ import NcActionInput from '@nextcloud/vue/components/NcActionInput'
7672
import NcActionText from '@nextcloud/vue/components/NcActionText'
7773
import NcAppNavigationCaption from '@nextcloud/vue/components/NcAppNavigationCaption'
7874
import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList'
79-
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
8075
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
8176
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
8277
import GroupListItem from './GroupListItem.vue'
@@ -123,8 +118,8 @@ const newGroupName = ref('')
123118
const loadingGroups = ref(false)
124119
/** Search offset */
125120
const offset = ref(0)
126-
/** Search query for groups */
127-
const groupsSearchQuery = ref('')
121+
/** Search query — shared via Vuex store */
122+
const groupsSearchQuery = computed(() => store.getters.getSearchQuery)
128123
const filteredGroups = computed(() => {
129124
if (isAdminOrDelegatedAdmin.value) {
130125
return userGroups.value

apps/settings/src/components/UserList.vue

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@
6565
<script>
6666
import { mdiAccountGroupOutline } from '@mdi/js'
6767
import { showError } from '@nextcloud/dialogs'
68-
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
6968
import Vue from 'vue'
7069
import { Fragment } from 'vue-frag'
7170
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
@@ -140,11 +139,14 @@ export default {
140139
141140
newUser: { ...newUser },
142141
isInitialLoad: true,
143-
searchQuery: '',
144142
}
145143
},
146144
147145
computed: {
146+
searchQuery() {
147+
return this.$store.getters.getSearchQuery
148+
},
149+
148150
showConfig() {
149151
return this.$store.getters.getShowConfig
150152
},
@@ -229,6 +231,11 @@ export default {
229231
},
230232
231233
watch: {
234+
async searchQuery() {
235+
this.$store.commit('resetUsers')
236+
await this.loadUsers()
237+
},
238+
232239
// watch url change and group select
233240
async selectedGroup(val) {
234241
this.isInitialLoad = true
@@ -258,23 +265,12 @@ export default {
258265
*/
259266
this.resetForm()
260267
261-
/**
262-
* Register search
263-
*/
264-
subscribe('nextcloud:unified-search.search', this.search)
265-
subscribe('nextcloud:unified-search.reset', this.resetSearch)
266-
267268
/**
268269
* If disabled group but empty, redirect
269270
*/
270271
await this.redirectIfDisabled()
271272
},
272273
273-
beforeDestroy() {
274-
unsubscribe('nextcloud:unified-search.search', this.search)
275-
unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
276-
},
277-
278274
methods: {
279275
async handleScrollEnd() {
280276
await this.loadUsers()
@@ -319,16 +315,6 @@ export default {
319315
})
320316
},
321317
322-
async search({ query }) {
323-
this.searchQuery = query
324-
this.$store.commit('resetUsers')
325-
await this.loadUsers()
326-
},
327-
328-
resetSearch() {
329-
this.search({ query: '' })
330-
},
331-
332318
resetForm() {
333319
// revert form to original state
334320
this.newUser = { ...newUser }

apps/settings/src/store/users.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const state = {
4242
usersLimit: 25,
4343
disabledUsersOffset: 0,
4444
disabledUsersLimit: 25,
45+
searchQuery: '',
4546
userCount: usersSettings.userCount ?? 0,
4647
showConfig: {
4748
showStoragePath: usersSettings.showConfig?.user_list_show_storage_path,
@@ -237,6 +238,10 @@ const mutations = {
237238
]
238239
},
239240

241+
setSearchQuery(state, query) {
242+
state.searchQuery = query
243+
},
244+
240245
setShowConfig(state, { key, value }) {
241246
state.showConfig[key] = value
242247
},
@@ -266,6 +271,9 @@ const getters = {
266271
getGroups(state) {
267272
return state.groups
268273
},
274+
getSearchQuery(state) {
275+
return state.searchQuery
276+
},
269277
getSubAdminGroups() {
270278
return usersSettings.subAdminGroups ?? []
271279
},
@@ -365,14 +373,6 @@ const actions = {
365373
}
366374
searchRequestCancelSource = CancelToken.source()
367375
search = typeof search === 'string' ? search : ''
368-
369-
/**
370-
* Adding filters in the search bar such as in:files, in:users, etc.
371-
* collides with this particular search, so we need to remove them
372-
* here and leave only the original search query
373-
*/
374-
search = search.replace(/in:[^\s]+/g, '').trim()
375-
376376
group = typeof group === 'string' ? group : ''
377377
if (group !== '') {
378378
return api.get(generateOcsUrl('cloud/groups/{group}/users/details?offset={offset}&limit={limit}&search={search}', { group: encodeURIComponent(group), offset, limit, search }), {

apps/settings/src/views/UserManagementNavigation.vue

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,22 @@
1717
</template>
1818
</NcAppNavigationNew>
1919

20+
<div class="account-management__search" role="search" :aria-label="t('settings', 'Search accounts and groups')">
21+
<NcInputField
22+
v-model="searchInput"
23+
:label="t('settings', 'Search accounts and groups…')"
24+
:show-trailing-button="searchInput !== ''"
25+
:trailingButtonLabel="t('settings', 'Clear search')"
26+
@trailing-button-click="clearSearch">
27+
<template #icon>
28+
<NcIconSvgWrapper :path="mdiMagnify" />
29+
</template>
30+
<template #trailing-button-icon>
31+
<NcIconSvgWrapper :path="mdiClose" />
32+
</template>
33+
</NcInputField>
34+
</div>
35+
2036
<NcAppNavigationList
2137
class="account-management__system-list"
2238
data-cy-users-settings-navigation-groups="system">
@@ -107,9 +123,10 @@
107123
</template>
108124

109125
<script setup lang="ts">
110-
import { mdiAccountOffOutline, mdiAccountOutline, mdiCogOutline, mdiHistory, mdiPlus, mdiShieldAccountOutline } from '@mdi/js'
126+
import { mdiAccountOffOutline, mdiAccountOutline, mdiClose, mdiCogOutline, mdiHistory, mdiMagnify, mdiPlus, mdiShieldAccountOutline } from '@mdi/js'
111127
import { translate as t } from '@nextcloud/l10n'
112-
import { computed, ref } from 'vue'
128+
import debounce from 'debounce'
129+
import { computed, onBeforeUnmount, ref, watch } from 'vue'
113130
import { useRoute } from 'vue-router/composables'
114131
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
115132
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
@@ -118,6 +135,7 @@ import NcAppNavigationNew from '@nextcloud/vue/components/NcAppNavigationNew'
118135
import NcButton from '@nextcloud/vue/components/NcButton'
119136
import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble'
120137
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
138+
import NcInputField from '@nextcloud/vue/components/NcInputField'
121139
import AppNavigationGroupList from '../components/AppNavigationGroupList.vue'
122140
import UserSettingsDialog from '../components/Users/UserSettingsDialog.vue'
123141
import { useFormatGroups } from '../composables/useGroupsNavigation.js'
@@ -126,6 +144,18 @@ import { useStore } from '../store/index.js'
126144
const route = useRoute()
127145
const store = useStore()
128146
147+
const searchInput = ref('')
148+
const commitSearch = debounce((query: string) => {
149+
store.commit('setSearchQuery', query)
150+
}, 300)
151+
watch(searchInput, (value) => commitSearch(value))
152+
function clearSearch() {
153+
commitSearch.clear()
154+
searchInput.value = ''
155+
store.commit('setSearchQuery', '')
156+
}
157+
onBeforeUnmount(() => commitSearch.clear())
158+
129159
/** State of the 'new-account' dialog */
130160
const isDialogOpen = ref(false)
131161
@@ -163,6 +193,11 @@ function showNewUserMenu() {
163193
will-change: scroll-position;
164194
}
165195
}
196+
&__search {
197+
padding-block: var(--default-grid-baseline, 4px);
198+
padding-inline: var(--app-navigation-padding, 8px);
199+
}
200+
166201
&__system-list {
167202
height: auto !important;
168203
overflow: visible !important;
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
/// <reference types="cypress-if" />
6+
7+
import { User } from '@nextcloud/e2e-test-server/cypress'
8+
import { clearState } from '../../support/commonUtils.ts'
9+
import { randomString } from '../../support/utils/randomString.ts'
10+
import { getUserList, getUserListRow } from './usersUtils.ts'
11+
12+
const admin = new User('admin', 'admin')
13+
14+
/** Scope role queries to the account management sidebar so they don't match
15+
* unrelated elements (e.g. the global unified search bar at the top of the page).
16+
*/
17+
function accountNav() {
18+
return cy.findByRole('navigation', { name: /account management/i })
19+
}
20+
21+
function waitForSearchRequest(alias: string, expectedSearch: string) {
22+
return cy.wait(alias).then(({ request }) => {
23+
expect(new URL(request.url).searchParams.get('search')).to.equal(expectedSearch)
24+
})
25+
}
26+
27+
describe('Settings: Unified search for accounts and groups', { testIsolation: false }, () => {
28+
// Use a stable, searchable prefix in the group name so we can match
29+
// it independently from the random user id below.
30+
const matchingGroup = `zzz-match-${randomString(5)}`
31+
const otherGroup = `aaa-other-${randomString(5)}`
32+
let alice: User
33+
let bob: User
34+
35+
after(() => {
36+
cy.deleteUser(alice)
37+
cy.deleteUser(bob)
38+
cy.runOccCommand(`group:delete '${matchingGroup}'`, { failOnNonZeroExit: false })
39+
cy.runOccCommand(`group:delete '${otherGroup}'`, { failOnNonZeroExit: false })
40+
})
41+
42+
before(() => {
43+
clearState()
44+
45+
cy.createRandomUser().then((user) => {
46+
alice = user
47+
})
48+
cy.createRandomUser().then((user) => {
49+
bob = user
50+
})
51+
52+
cy.runOccCommand(`group:add '${matchingGroup}'`)
53+
cy.runOccCommand(`group:add '${otherGroup}'`)
54+
55+
cy.login(admin)
56+
cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=*').as('initialLoadGroups')
57+
cy.intercept('GET', '**/ocs/v2.php/cloud/users/details?*').as('initialLoadUsers')
58+
cy.visit('/settings/users')
59+
cy.wait('@initialLoadGroups')
60+
cy.wait('@initialLoadUsers')
61+
})
62+
63+
beforeEach(() => {
64+
// Intercept aliases reset between tests even with testIsolation: false,
65+
// so re-register them here to capture requests triggered inside each test.
66+
cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=*').as('loadGroups')
67+
cy.intercept('GET', '**/ocs/v2.php/cloud/users/details?*').as('loadUsers')
68+
})
69+
70+
it('shows the search input in the navigation sidebar', () => {
71+
accountNav().findByRole('textbox', { name: /search accounts and groups/i })
72+
.should('be.visible')
73+
.and('have.value', '')
74+
})
75+
76+
it('dispatches the query to both the users and groups API', () => {
77+
accountNav().findByRole('textbox', { name: /search accounts and groups/i })
78+
.type(alice.userId)
79+
80+
// A single keystroke sequence debounces once (300ms), then fans out
81+
// to both APIs — both requests must carry the same search term.
82+
cy.wait('@loadUsers').its('request.url').should('include', `search=${alice.userId}`)
83+
cy.wait('@loadGroups').its('request.url').should('include', `search=${alice.userId}`)
84+
85+
// The user list reflects what the backend returned for this query.
86+
getUserListRow(alice.userId).should('exist')
87+
getUserList().should('not.contain', bob.userId)
88+
})
89+
90+
it('filters the group list when the query matches a group name', () => {
91+
accountNav().findByRole('textbox', { name: /search accounts and groups/i })
92+
.clear()
93+
.type(matchingGroup)
94+
95+
cy.wait('@loadGroups').its('request.url').should('include', `search=${matchingGroup}`)
96+
97+
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]')
98+
.should('contain', matchingGroup)
99+
.and('not.contain', otherGroup)
100+
})
101+
102+
it('resets both lists when the clear button is clicked', () => {
103+
accountNav().findByRole('button', { name: /clear search/i }).click()
104+
105+
accountNav().findByRole('textbox', { name: /search accounts and groups/i })
106+
.should('have.value', '')
107+
108+
waitForSearchRequest('@loadUsers', '')
109+
waitForSearchRequest('@loadGroups', '')
110+
111+
getUserListRow(alice.userId).should('exist')
112+
getUserListRow(bob.userId).should('exist')
113+
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]')
114+
.should('contain', matchingGroup)
115+
.and('contain', otherGroup)
116+
})
117+
})

0 commit comments

Comments
 (0)