diff --git a/src/tests/views/Settings/AllowedGroups.spec.ts b/src/tests/views/Settings/AllowedGroups.spec.ts
index 4aa0578a1b..9dad35ee54 100644
--- a/src/tests/views/Settings/AllowedGroups.spec.ts
+++ b/src/tests/views/Settings/AllowedGroups.spec.ts
@@ -73,7 +73,7 @@ describe('AllowedGroups', () => {
NcSelect: {
name: 'NcSelect',
props: ['modelValue'],
- emits: ['update:modelValue', 'search-change'],
+ emits: ['update:modelValue', 'search'],
template: '
',
},
},
@@ -137,7 +137,7 @@ describe('AllowedGroups', () => {
NcSelect: {
name: 'NcSelect',
props: ['modelValue'],
- emits: ['update:modelValue', 'search-change'],
+ emits: ['update:modelValue', 'search'],
template: '',
},
},
@@ -156,4 +156,102 @@ describe('AllowedGroups', () => {
groups: ['admin', 'SÖ'],
})
})
+
+ it('queries the backend when the user types in the group selector (issue #7988)', async () => {
+ axiosGetMock.mockImplementation((url: string) => {
+ if (url.includes('cloud/groups/details')) {
+ return Promise.resolve({
+ data: {
+ ocs: {
+ data: {
+ groups: [
+ { id: 'finance', displayname: 'finance' },
+ ],
+ },
+ },
+ },
+ })
+ }
+
+ return Promise.resolve({ data: { ocs: { data: {} } } })
+ })
+
+ const wrapper = mount(AllowedGroups as never, {
+ global: {
+ stubs: {
+ NcSettingsSection: { template: '
' },
+ NcSelect: {
+ name: 'NcSelect',
+ props: ['modelValue'],
+ // NcSelect re-exposes vue-select's native `search` event (see @nextcloud/vue).
+ emits: ['update:modelValue', 'search'],
+ template: '',
+ },
+ },
+ },
+ })
+ await flushPromises()
+
+ // Ignore the initial onMounted load; observe only what typing triggers.
+ axiosGetMock.mockClear()
+
+ const select = wrapper.findComponent({ name: 'NcSelect' })
+ // NcSelect's `search` event passes (query, loading); loading toggles its own spinner.
+ select.vm.$emit('search', 'fin', () => {})
+ await flushPromises()
+
+ const searchCalls = axiosGetMock.mock.calls.filter((call: unknown[]) => String(call[0]).includes('cloud/groups/details'))
+ expect(searchCalls.length).toBeGreaterThan(0)
+ const lastSearch = searchCalls.at(-1) as [string, { params: { search: string } }] | undefined
+ expect(lastSearch?.[1].params.search).toBe('fin')
+ })
+
+ it('keeps the group selector enabled while searching so it never loses focus (issue #7988)', async () => {
+ axiosGetMock.mockImplementation((url: string) => {
+ if (url.includes('cloud/groups/details')) {
+ return Promise.resolve({
+ data: { ocs: { data: { groups: [{ id: 'finance', displayname: 'finance' }] } } },
+ })
+ }
+
+ return Promise.resolve({ data: { ocs: { data: {} } } })
+ })
+
+ const wrapper = mount(AllowedGroups as never, {
+ global: {
+ stubs: {
+ NcSettingsSection: { template: '
' },
+ NcSelect: {
+ name: 'NcSelect',
+ // Expose disabled/loading so the test can assert the input stays enabled.
+ props: ['modelValue', 'disabled', 'loading'],
+ emits: ['update:modelValue', 'search'],
+ template: '',
+ },
+ },
+ },
+ })
+ await flushPromises()
+
+ // Ignore the initial onMounted load; observe only what typing triggers.
+ axiosGetMock.mockClear()
+
+ const select = wrapper.findComponent({ name: 'NcSelect' })
+ const vm = wrapper.vm as unknown as { loadingGroups: boolean }
+
+ // The loading state must be driven through NcSelect's own `search`-event
+ // callback, not the reactive `loadingGroups`/`:disabled` binding — disabling
+ // the focused input is exactly what dropped focus per keystroke (#7988).
+ select.vm.$emit('search', 'fin')
+ await flushPromises()
+
+ // `loadingGroups` (and therefore `:disabled`) remains false during search so focus is kept.
+ expect(vm.loadingGroups).toBe(false)
+ expect(select.props('disabled')).toBe(false)
+
+ // And the query still reached the backend.
+ const searchCalls = axiosGetMock.mock.calls.filter((c: unknown[]) => String(c[0]).includes('cloud/groups/details'))
+ expect(searchCalls.length).toBe(1)
+ expect((searchCalls[0] as [string, { params: { search: string } }])[1].params.search).toBe('fin')
+ })
})
diff --git a/src/views/Settings/AllowedGroups.vue b/src/views/Settings/AllowedGroups.vue
index b3bba68e32..dc14a202cd 100644
--- a/src/views/Settings/AllowedGroups.vue
+++ b/src/views/Settings/AllowedGroups.vue
@@ -15,12 +15,12 @@
:aria-label-combobox="t('libresign', 'Select authorized groups that can request to sign documents. Admin group is the default group and don\'t need to be defined.')"
:close-on-select="false"
:disabled="loadingGroups"
- :loading="loadingGroups"
+ :loading="isSearching"
:multiple="true"
:options="groups"
:searchable="true"
:show-no-options="false"
- @search-change="searchGroup"
+ @search="searchGroup"
@update:modelValue="saveGroups" />
@@ -51,6 +51,7 @@ type GroupRow = {
const groupsSelected = ref>([])
const groups = ref([])
const loadingGroups = ref(false)
+const isSearching = ref(false)
const idKey = ref(0)
async function getData() {
@@ -91,19 +92,21 @@ async function saveGroups(value: Array) {
}
async function searchGroup(query: string) {
- loadingGroups.value = true
- await axios.get(generateOcsUrl('cloud/groups/details'), {
- params: {
- search: query,
- limit: 20,
- offset: 0,
- },
- })
- .then(({ data }) => {
- groups.value = data.ocs.data.groups.sort((a: GroupRow, b: GroupRow) => a.displayname.localeCompare(b.displayname))
+ isSearching.value = true
+ try {
+ const { data } = await axios.get(generateOcsUrl('cloud/groups/details'), {
+ params: {
+ search: query,
+ limit: 20,
+ offset: 0,
+ },
})
- .catch((error) => logger.debug('Could not search by groups', { error }))
- loadingGroups.value = false
+ groups.value = data.ocs.data.groups.sort((a: GroupRow, b: GroupRow) => a.displayname.localeCompare(b.displayname))
+ } catch (error) {
+ logger.debug('Could not search by groups', { error })
+ } finally {
+ isSearching.value = false
+ }
}
onMounted(async () => {
@@ -115,6 +118,7 @@ defineExpose({
groupsSelected,
groups,
loadingGroups,
+ isSearching,
idKey,
getData,
saveGroups,