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,