Skip to content

Commit 7880bfa

Browse files
authored
Merge pull request #59626 from nextcloud/feat/59166/unified-user-group-search
feat(settings): unified search for accounts and groups
2 parents 9fd18b2 + 946d109 commit 7880bfa

13 files changed

Lines changed: 236 additions & 50 deletions

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
@@ -64,7 +64,6 @@
6464
<script>
6565
import { mdiAccountGroupOutline } from '@mdi/js'
6666
import { showError } from '@nextcloud/dialogs'
67-
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
6867
import Vue from 'vue'
6968
import { Fragment } from 'vue-frag'
7069
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
@@ -139,11 +138,14 @@ export default {
139138
140139
newUser: { ...newUser },
141140
isInitialLoad: true,
142-
searchQuery: '',
143141
}
144142
},
145143
146144
computed: {
145+
searchQuery() {
146+
return this.$store.getters.getSearchQuery
147+
},
148+
147149
showConfig() {
148150
return this.$store.getters.getShowConfig
149151
},
@@ -224,6 +226,11 @@ export default {
224226
},
225227
226228
watch: {
229+
async searchQuery() {
230+
this.$store.commit('resetUsers')
231+
await this.loadUsers()
232+
},
233+
227234
// watch url change and group select
228235
async selectedGroup(val) {
229236
this.isInitialLoad = true
@@ -253,23 +260,12 @@ export default {
253260
*/
254261
this.resetForm()
255262
256-
/**
257-
* Register search
258-
*/
259-
subscribe('nextcloud:unified-search.search', this.search)
260-
subscribe('nextcloud:unified-search.reset', this.resetSearch)
261-
262263
/**
263264
* If disabled group but empty, redirect
264265
*/
265266
await this.redirectIfDisabled()
266267
},
267268
268-
beforeDestroy() {
269-
unsubscribe('nextcloud:unified-search.search', this.search)
270-
unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
271-
},
272-
273269
methods: {
274270
async handleScrollEnd() {
275271
await this.loadUsers()
@@ -314,16 +310,6 @@ export default {
314310
})
315311
},
316312
317-
async search({ query }) {
318-
this.searchQuery = query
319-
this.$store.commit('resetUsers')
320-
await this.loadUsers()
321-
},
322-
323-
resetSearch() {
324-
this.search({ query: '' })
325-
},
326-
327313
resetForm() {
328314
// revert form to original state
329315
this.newUser = { ...newUser }

apps/settings/src/components/Users/UserSettingsDialog.vue

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,16 +103,30 @@
103103
taggable
104104
@option:selected="setDefaultQuota" />
105105
</NcAppSettingsSection>
106+
107+
<NcAppSettingsShortcutsSection>
108+
<NcHotkeyList :label="t('settings', 'Search')">
109+
<NcHotkey :label="t('settings', 'Focus search')" hotkey="Control F" />
110+
</NcHotkeyList>
111+
<NcHotkeyList :label="t('settings', 'Help')">
112+
<NcHotkey :label="t('settings', 'Show those shortcuts')" hotkey="?" />
113+
</NcHotkeyList>
114+
</NcAppSettingsShortcutsSection>
106115
</NcAppSettingsDialog>
107116
</template>
108117

109118
<script>
110119
import axios from '@nextcloud/axios'
111120
import { formatFileSize, parseFileSize } from '@nextcloud/files'
112121
import { generateUrl } from '@nextcloud/router'
122+
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
123+
import { nextTick } from 'vue'
113124
import NcAppSettingsDialog from '@nextcloud/vue/components/NcAppSettingsDialog'
114125
import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection'
126+
import NcAppSettingsShortcutsSection from '@nextcloud/vue/components/NcAppSettingsShortcutsSection'
115127
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
128+
import NcHotkey from '@nextcloud/vue/components/NcHotkey'
129+
import NcHotkeyList from '@nextcloud/vue/components/NcHotkeyList'
116130
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
117131
import NcSelect from '@nextcloud/vue/components/NcSelect'
118132
import { GroupSorting } from '../../constants/GroupManagement.ts'
@@ -125,7 +139,10 @@ export default {
125139
components: {
126140
NcAppSettingsDialog,
127141
NcAppSettingsSection,
142+
NcAppSettingsShortcutsSection,
128143
NcCheckboxRadioSwitch,
144+
NcHotkey,
145+
NcHotkeyList,
129146
NcNoteCard,
130147
NcSelect,
131148
},
@@ -137,6 +154,20 @@ export default {
137154
},
138155
},
139156
157+
emits: ['update:open'],
158+
159+
setup(_, { emit }) {
160+
// ? opens the settings dialog on the keyboard shortcuts section
161+
useHotKey('?', async () => {
162+
emit('update:open', true)
163+
await nextTick()
164+
document.getElementById('settings-section_keyboard-shortcuts')?.scrollIntoView({
165+
behavior: 'smooth',
166+
inline: 'nearest',
167+
})
168+
}, { stop: true, prevent: true })
169+
},
170+
140171
data() {
141172
return {
142173
selectedQuota: false,

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: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@
1717
</template>
1818
</NcAppNavigationNew>
1919

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

109126
<script setup lang="ts">
110-
import { mdiAccountOffOutline, mdiAccountOutline, mdiCogOutline, mdiHistory, mdiPlus, mdiShieldAccountOutline } from '@mdi/js'
127+
import { mdiAccountOffOutline, mdiAccountOutline, mdiClose, mdiCogOutline, mdiHistory, mdiMagnify, mdiPlus, mdiShieldAccountOutline } from '@mdi/js'
111128
import { translate as t } from '@nextcloud/l10n'
112-
import { computed, ref } from 'vue'
129+
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
130+
import debounce from 'debounce'
131+
import { computed, onBeforeUnmount, ref, watch } from 'vue'
113132
import { useRoute } from 'vue-router/composables'
114133
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
115134
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
@@ -118,6 +137,7 @@ import NcAppNavigationNew from '@nextcloud/vue/components/NcAppNavigationNew'
118137
import NcButton from '@nextcloud/vue/components/NcButton'
119138
import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble'
120139
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
140+
import NcInputField from '@nextcloud/vue/components/NcInputField'
121141
import AppNavigationGroupList from '../components/AppNavigationGroupList.vue'
122142
import UserSettingsDialog from '../components/Users/UserSettingsDialog.vue'
123143
import { useFormatGroups } from '../composables/useGroupsNavigation.js'
@@ -126,6 +146,24 @@ import { useStore } from '../store/index.js'
126146
const route = useRoute()
127147
const store = useStore()
128148
149+
const searchField = ref<InstanceType<typeof NcInputField>>()
150+
const searchInput = ref('')
151+
const commitSearch = debounce((query: string) => {
152+
store.commit('setSearchQuery', query)
153+
}, 300)
154+
watch(searchInput, (value) => commitSearch(value))
155+
function clearSearch() {
156+
commitSearch.clear()
157+
searchInput.value = ''
158+
store.commit('setSearchQuery', '')
159+
}
160+
onBeforeUnmount(() => commitSearch.clear())
161+
162+
// Intercept Ctrl/Cmd+F to focus the local search. useHotKey ignores the
163+
// event when an input/textarea is already focused, so a second press falls
164+
// through to the browser's native find-in-page.
165+
useHotKey('f', () => searchField.value?.focus(), { ctrl: true, stop: true, prevent: true })
166+
129167
/** State of the 'new-account' dialog */
130168
const isDialogOpen = ref(false)
131169
@@ -163,6 +201,11 @@ function showNewUserMenu() {
163201
will-change: scroll-position;
164202
}
165203
}
204+
&__search {
205+
padding-block: var(--default-grid-baseline, 4px);
206+
padding-inline: var(--app-navigation-padding, 8px);
207+
}
208+
166209
&__system-list {
167210
height: auto !important;
168211
overflow: visible !important;

core/src/views/UnifiedSearch.vue

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,17 @@ export default defineComponent({
8383
*/
8484
supportsLocalSearch() {
8585
// TODO: Make this an API
86-
const providerPaths = ['/settings/users', '/apps/deck', '/settings/apps']
86+
const providerPaths = ['/apps/deck', '/settings/apps']
87+
return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path))
88+
},
89+
90+
/**
91+
* Current page handles the Ctrl+F shortcut itself (e.g. has a dedicated
92+
* search input). UnifiedSearch should stay out of the way on these pages.
93+
*/
94+
appHandlesSearchShortcut() {
95+
// TODO: Make this an API
96+
const providerPaths = ['/settings/users']
8797
return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path))
8898
},
8999
},
@@ -135,6 +145,10 @@ export default defineComponent({
135145
*/
136146
onKeyDown(event: KeyboardEvent) {
137147
if (event.ctrlKey && event.key === 'f') {
148+
// Skip on pages that handle Ctrl+F themselves (e.g. a dedicated search input).
149+
if (this.appHandlesSearchShortcut) {
150+
return
151+
}
138152
// only handle search if not already open - in this case the browser native search should be used
139153
if (!this.showLocalSearch && !this.showUnifiedSearch) {
140154
event.preventDefault()

0 commit comments

Comments
 (0)