Skip to content
Merged
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
102 changes: 100 additions & 2 deletions src/tests/views/Settings/AllowedGroups.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe('AllowedGroups', () => {
NcSelect: {
name: 'NcSelect',
props: ['modelValue'],
emits: ['update:modelValue', 'search-change'],
emits: ['update:modelValue', 'search'],
template: '<div class="nc-select-stub" />',
},
},
Expand Down Expand Up @@ -137,7 +137,7 @@ describe('AllowedGroups', () => {
NcSelect: {
name: 'NcSelect',
props: ['modelValue'],
emits: ['update:modelValue', 'search-change'],
emits: ['update:modelValue', 'search'],
template: '<div class="nc-select-stub" />',
},
},
Expand All @@ -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: '<div><slot /></div>' },
NcSelect: {
name: 'NcSelect',
props: ['modelValue'],
// NcSelect re-exposes vue-select's native `search` event (see @nextcloud/vue).
emits: ['update:modelValue', 'search'],
template: '<div class="nc-select-stub" />',
},
},
},
})
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: '<div><slot /></div>' },
NcSelect: {
name: 'NcSelect',
// Expose disabled/loading so the test can assert the input stays enabled.
props: ['modelValue', 'disabled', 'loading'],
emits: ['update:modelValue', 'search'],
template: '<div class="nc-select-stub" />',
},
},
},
})
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')
})
})
32 changes: 18 additions & 14 deletions src/views/Settings/AllowedGroups.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
</NcSettingsSection>
</template>
Expand Down Expand Up @@ -51,6 +51,7 @@ type GroupRow = {
const groupsSelected = ref<Array<GroupRow | string>>([])
const groups = ref<GroupRow[]>([])
const loadingGroups = ref(false)
const isSearching = ref(false)
const idKey = ref(0)

async function getData() {
Expand Down Expand Up @@ -91,19 +92,21 @@ async function saveGroups(value: Array<GroupRow | string>) {
}

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 () => {
Expand All @@ -115,6 +118,7 @@ defineExpose({
groupsSelected,
groups,
loadingGroups,
isSearching,
idKey,
getData,
saveGroups,
Expand Down
Loading