From 8771716c1cb0a3afbea22b22f38726bb68529ede Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Fri, 10 Jul 2026 15:19:44 +0200 Subject: [PATCH 1/3] feat(core): forward per-category filter params in unified search controller Signed-off-by: Peter Ringelmann --- core/src/services/UnifiedSearchController.ts | 21 +++++- .../services/UnifiedSearchController.spec.ts | 70 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/core/src/services/UnifiedSearchController.ts b/core/src/services/UnifiedSearchController.ts index bad5e20a78113..53bffdf08da31 100644 --- a/core/src/services/UnifiedSearchController.ts +++ b/core/src/services/UnifiedSearchController.ts @@ -15,6 +15,14 @@ interface CategorySearchState { loadMoreFailed: boolean } +interface CategorySearchParams { + type?: string + since?: string + until?: string + person?: string + extraQueries?: object +} + export const REVEAL_INTERVAL_MS = 1500 /** @@ -23,6 +31,7 @@ export const REVEAL_INTERVAL_MS = 1500 */ export class UnifiedSearchController { private query: string = '' + private params: Record = {} private searchStates: Record = {} private searchGeneration: number = 0 private revealTimer: ReturnType | null = null @@ -33,14 +42,16 @@ export class UnifiedSearchController { * * @param query the search term * @param categories category ids in priority order + * @param params optional per-category search parameters * @return resolves once every category has settled */ - async search(query: string, categories: string[]): Promise { + async search(query: string, categories: string[], params?: Record): Promise { this.cancelPendingRequests() this.searchStates = {} this.searchGeneration++ const generation = this.searchGeneration this.query = query + this.params = params || {} this.startRevealTimer() @@ -67,6 +78,7 @@ export class UnifiedSearchController { type: category, query: this.query, cursor: categoryState.cursor, + ...this.params[category], }) this.pendingCancels.push(cancel) @@ -107,7 +119,11 @@ export class UnifiedSearchController { this.stopRevealTimer() } - private async searchCategory(category: string, generation: number, categories: string[]): Promise { + private async searchCategory( + category: string, + generation: number, + categories: string[], + ): Promise { this.searchStates[category] = { status: 'loading', entries: [], @@ -119,6 +135,7 @@ export class UnifiedSearchController { type: category, query: this.query, cursor: null, + ...this.params[category], }) this.pendingCancels.push(cancel) diff --git a/core/src/tests/services/UnifiedSearchController.spec.ts b/core/src/tests/services/UnifiedSearchController.spec.ts index 74378147dd53d..af5daca8fcbe7 100644 --- a/core/src/tests/services/UnifiedSearchController.spec.ts +++ b/core/src/tests/services/UnifiedSearchController.spec.ts @@ -427,6 +427,76 @@ describe('UnifiedSearchController', () => { }) }) + describe('filter pass-through', () => { + it('forwards per-category filter params on the initial search', async () => { + const files = pagedProvider() + service.search.mockReturnValue(files) + + const searchController = new UnifiedSearchController() + searchController.search('query', ['files'], { + files: { since: '2026-01-01', until: '2026-02-01', person: 'alice', extraQueries: { tag: 'important' } }, + }) + + expect(service.search).toHaveBeenCalledWith(expect.objectContaining({ + type: 'files', + query: 'query', + cursor: null, + since: '2026-01-01', + until: '2026-02-01', + person: 'alice', + extraQueries: { tag: 'important' }, + })) + }) + + it('reuses the stored filter params when paginating', async () => { + const files = pagedProvider() + service.search.mockReturnValue(files) + + const searchController = new UnifiedSearchController() + searchController.search('query', ['files'], { + files: { since: '2026-01-01', until: '2026-02-01', person: 'alice', extraQueries: { tag: 'important' } }, + }) + + files.resolvePage(0, { entries: ['a'], cursor: 'cursor-1', hasMore: true }) + await vi.advanceTimersByTimeAsync(0) + + searchController.loadMore('files') + + // Page 2 must carry the same filters as page 1, not just type/query/cursor. + expect(service.search).toHaveBeenLastCalledWith(expect.objectContaining({ + type: 'files', + query: 'query', + cursor: 'cursor-1', + since: '2026-01-01', + until: '2026-02-01', + person: 'alice', + extraQueries: { tag: 'important' }, + })) + }) + + it('dispatches the type override on both requests while keying state by category id', async () => { + const files = pagedProvider() + service.search.mockReturnValue(files) + + const searchController = new UnifiedSearchController() + // 'in-folder' is a searchFrom-style alias that dispatches to the 'files' backend. + searchController.search('query', ['in-folder'], { 'in-folder': { type: 'files' } }) + + // The request dispatches to the override type... + expect(service.search).toHaveBeenCalledWith(expect.objectContaining({ type: 'files' })) + // ...but the category stays keyed by its own id, so two aliases can't collide. + expect(Object.keys(searchController.getSnapshot())).toEqual(['in-folder']) + + files.resolvePage(0, { entries: ['a'], cursor: 'cursor-1', hasMore: true }) + await vi.advanceTimersByTimeAsync(0) + + searchController.loadMore('in-folder') + + // loadMore carries the override too, not the category id. + expect(service.search).toHaveBeenLastCalledWith(expect.objectContaining({ type: 'files', cursor: 'cursor-1' })) + }) + }) + describe('reveal timer', () => { it('marks blocked categories as loaded after a certain amount of time has elapsed', async () => { const providers = mockProviders(['files', 'talk', 'deck']) From 41168e1938584a6a1f2f4e1d34e787b4c3d857cf Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Mon, 13 Jul 2026 15:30:30 +0200 Subject: [PATCH 2/3] feat(core): add reactive adapter for the unified search controller Signed-off-by: Peter Ringelmann --- core/src/composables/useUnifiedSearch.ts | 28 ++++ core/src/services/UnifiedSearchController.ts | 49 +++--- .../composables/useUnifiedSearch.spec.ts | 136 ++++++++++++++++ .../services/UnifiedSearchController.spec.ts | 149 ++++++++++++++++++ 4 files changed, 344 insertions(+), 18 deletions(-) create mode 100644 core/src/composables/useUnifiedSearch.ts create mode 100644 core/src/tests/composables/useUnifiedSearch.spec.ts diff --git a/core/src/composables/useUnifiedSearch.ts b/core/src/composables/useUnifiedSearch.ts new file mode 100644 index 0000000000000..5100968f6ed59 --- /dev/null +++ b/core/src/composables/useUnifiedSearch.ts @@ -0,0 +1,28 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { onUnmounted, shallowRef } from 'vue' +import { UnifiedSearchController } from '../services/UnifiedSearchController.ts' + +/** + * Reactive adapter over UnifiedSearchController for use in an SFC. + */ +export function useUnifiedSearch() { + const searchStates = shallowRef({}) + + const controller = new UnifiedSearchController((states) => { + searchStates.value = states + }) + + onUnmounted(() => { + controller.dispose() + }) + + return { + searchStates, + search: controller.search.bind(controller), + loadMore: controller.loadMore.bind(controller), + } +} diff --git a/core/src/services/UnifiedSearchController.ts b/core/src/services/UnifiedSearchController.ts index 53bffdf08da31..136de96fbceeb 100644 --- a/core/src/services/UnifiedSearchController.ts +++ b/core/src/services/UnifiedSearchController.ts @@ -37,6 +37,8 @@ export class UnifiedSearchController { private revealTimer: ReturnType | null = null private pendingCancels: (() => void)[] = [] + constructor(private onChange?: (states: Record) => void) {} + /** * Start a search. Cancels and replaces any search already in flight. * @@ -67,12 +69,12 @@ export class UnifiedSearchController { */ async loadMore(category: string): Promise { const generation = this.searchGeneration - const categoryState = this.searchStates[category] - if (!categoryState || !categoryState.hasMore || categoryState.status !== 'loaded') { + const categoryState = { ...this.searchStates[category] } + if (!categoryState.hasMore || categoryState.status !== 'loaded') { return } - categoryState.status = 'loading' - categoryState.loadMoreFailed = false + + this.patchStates({ [category]: { status: 'loading', loadMoreFailed: false } }) const { request, cancel } = unifiedSearch({ type: category, @@ -89,17 +91,19 @@ export class UnifiedSearchController { return } const { entries, cursor, hasMore } = response.data.ocs.data - categoryState.entries.push(...entries) - categoryState.cursor = cursor - categoryState.hasMore = hasMore + + this.patchStates({[category]: { + entries: [...categoryState.entries, ...entries], + cursor, + hasMore, + status: 'loaded', + }}) } catch { if (this.searchGeneration !== generation) { return } - categoryState.loadMoreFailed = true + this.patchStates({ [category]: { status: 'loaded', loadMoreFailed: true } }) } - - categoryState.status = 'loaded' } /** @@ -124,13 +128,14 @@ export class UnifiedSearchController { generation: number, categories: string[], ): Promise { - this.searchStates[category] = { + this.patchStates({ [category]: { status: 'loading', entries: [], cursor: null, hasMore: false, loadMoreFailed: false, - } + } }) + const { request, cancel } = unifiedSearch({ type: category, query: this.query, @@ -148,24 +153,24 @@ export class UnifiedSearchController { } const { entries, cursor, hasMore } = response.data.ocs.data - this.searchStates[category] = { + this.patchStates({ [category]: { status: 'loaded', entries, cursor, hasMore, loadMoreFailed: false, - } + } }) } catch { if (this.searchGeneration !== generation) { return } - this.searchStates[category] = { + this.patchStates({ [category]: { status: 'failed', entries: [], cursor: null, hasMore: false, loadMoreFailed: false, - } + }}) } this.reconcileCategoryStatuses(categories) @@ -176,7 +181,7 @@ export class UnifiedSearchController { if (['loading', 'failed'].includes(this.searchStates[category].status)) { return } - this.searchStates[category].status = this.shouldBlockCategory(category, categories) ? 'blocked' : 'loaded' + this.patchStates({ [category]: { status: this.shouldBlockCategory(category, categories) ? 'blocked' : 'loaded' } }) }) } @@ -207,7 +212,7 @@ export class UnifiedSearchController { private unblockAllCategories(categories: string[]): void { categories.forEach((category) => { if (this.searchStates[category].status === 'blocked') { - this.searchStates[category].status = 'loaded' + this.patchStates({ [category]: { status: 'loaded' } }) } }) } @@ -222,4 +227,12 @@ export class UnifiedSearchController { return categoryState && ['loading', 'blocked'].includes(categoryState.status) }) } + + private patchStates(next: Record>): void { + Object.keys(next).forEach((category) => { + const categoryState = { ...this.searchStates[category], ...next[category] } + this.searchStates[category] = categoryState + }) + this.onChange?.(this.getSnapshot()) + } } diff --git a/core/src/tests/composables/useUnifiedSearch.spec.ts b/core/src/tests/composables/useUnifiedSearch.spec.ts new file mode 100644 index 0000000000000..9dea450f22cc9 --- /dev/null +++ b/core/src/tests/composables/useUnifiedSearch.spec.ts @@ -0,0 +1,136 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' + +const service = vi.hoisted(() => ({ + search: vi.fn(), + getProviders: vi.fn(), + getContacts: vi.fn(), +})) +vi.mock('../../services/UnifiedSearchService.js', () => service) + +import { useUnifiedSearch } from '../../composables/useUnifiedSearch.ts' + +/** + * Deferred stand-in for a provider's `search()` return value. Resolve it to + * make that provider arrive on demand. Mirrors the controller spec's helper. + */ +function deferredProvider() { + const { promise, resolve, reject } = Promise.withResolvers<{ entries: unknown[] }>() + return { + cancel: vi.fn(), + request: async () => { + const { entries } = await promise + return { data: { ocs: { data: { entries } } } } + }, + resolve: (entries: unknown[] = []) => resolve({ entries }), + reject, + } +} + +/** + * Deferred stand-in that serves successive pages, for exercising loadMore. + */ +function pagedProvider() { + const pages: ReturnType>[] = [] + const pageAt = (index: number) => (pages[index] ??= Promise.withResolvers()) + let call = 0 + return { + cancel: vi.fn(), + request: async () => { + const data = await pageAt(call++).promise + return { data: { ocs: { data } } } + }, + resolvePage: (index: number, data: { entries: unknown[], cursor: string | null, hasMore: boolean }) => pageAt(index).resolve(data), + } +} + +/** + * Register one deferred provider per category type on the mocked service. + */ +function mockProviders(types: string[]) { + const providers = Object.fromEntries(types.map((type) => [type, deferredProvider()])) + service.search.mockImplementation(({ type }: { type: string }) => providers[type]) + return providers +} + +/** + * Run the composable inside a real setup context (so onBeforeUnmount is wired) + * and hand the returned API plus the wrapper back to the test. + */ +function mountComposable() { + let api!: ReturnType + const wrapper = mount(defineComponent({ + setup() { + api = useUnifiedSearch() + return () => h('div') + }, + })) + return { wrapper, api } +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.clearAllMocks() + vi.useRealTimers() +}) + +describe('useUnifiedSearch', () => { + it('exposes an empty snapshot before any search', () => { + const { api } = mountComposable() + + expect(api.searchStates.value).toEqual({}) + }) + + it('reflects controller state reactively as a search resolves', async () => { + const providers = mockProviders(['files']) + const { api } = mountComposable() + + api.search('query', ['files']) + providers.files.resolve(['a result']) + await vi.advanceTimersByTimeAsync(0) + + expect(api.searchStates.value.files).toMatchObject({ + status: 'loaded', + entries: ['a result'], + }) + }) + + it('appends a page through loadMore and reflects it', async () => { + const files = pagedProvider() + service.search.mockReturnValue(files) + const { api } = mountComposable() + + api.search('query', ['files']) + files.resolvePage(0, { entries: ['a'], cursor: 'cursor-1', hasMore: true }) + await vi.advanceTimersByTimeAsync(0) + + api.loadMore('files') + files.resolvePage(1, { entries: ['b'], cursor: 'cursor-2', hasMore: false }) + await vi.advanceTimersByTimeAsync(0) + + expect(api.searchStates.value.files).toMatchObject({ + status: 'loaded', + entries: ['a', 'b'], + hasMore: false, + }) + }) + + it('cancels in-flight requests when the component unmounts', () => { + const providers = mockProviders(['files']) + const { wrapper, api } = mountComposable() + + api.search('query', ['files']) + wrapper.destroy() + + // Unmount must dispose the controller, which cancels the pending request. + expect(providers.files.cancel).toHaveBeenCalledOnce() + }) +}) diff --git a/core/src/tests/services/UnifiedSearchController.spec.ts b/core/src/tests/services/UnifiedSearchController.spec.ts index af5daca8fcbe7..6b499f93133dd 100644 --- a/core/src/tests/services/UnifiedSearchController.spec.ts +++ b/core/src/tests/services/UnifiedSearchController.spec.ts @@ -307,6 +307,84 @@ describe('UnifiedSearchController', () => { }) }) + describe('change notification', () => { + it('notifies once the initial loading states are set, before any provider resolves', () => { + service.search.mockImplementation(() => deferredProvider()) + const onChange = vi.fn() + + const searchController = new UnifiedSearchController(onChange) + searchController.search('query', ['files', 'talk']) + + // The adapter must be able to paint the loading spinners straight away, + // without waiting for the first response to land. + expect(onChange).toHaveBeenCalled() + expect(searchController.getSnapshot()).toEqual({ files: loading, talk: loading }) + }) + + it('notifies when a category resolves', async () => { + const providers = mockProviders(['files']) + const onChange = vi.fn() + + const searchController = new UnifiedSearchController(onChange) + searchController.search('query', ['files']) + onChange.mockClear() + + providers.files.resolve(['Files result']) + await vi.advanceTimersByTimeAsync(0) + + expect(onChange).toHaveBeenCalled() + }) + + it('notifies when loadMore appends a page', async () => { + const files = pagedProvider() + service.search.mockReturnValue(files) + const onChange = vi.fn() + + const searchController = new UnifiedSearchController(onChange) + searchController.search('query', ['files']) + files.resolvePage(0, { entries: ['a'], cursor: 'cursor-1', hasMore: true }) + await vi.advanceTimersByTimeAsync(0) + onChange.mockClear() + + searchController.loadMore('files') + files.resolvePage(1, { entries: ['b'], cursor: 'cursor-2', hasMore: false }) + await vi.advanceTimersByTimeAsync(0) + + expect(onChange).toHaveBeenCalled() + }) + + it('notifies when the reveal timer unblocks a category', async () => { + const providers = mockProviders(['files', 'talk']) + const onChange = vi.fn() + + const searchController = new UnifiedSearchController(onChange) + searchController.search('query', ['files', 'talk']) + + // talk arrives out of order and is blocked behind files (still loading). + providers.talk.resolve(['Talk result']) + await vi.advanceTimersByTimeAsync(0) + expect(searchController.getSnapshot().talk.status).toBe('blocked') + onChange.mockClear() + + // The timer flush promotes it to loaded; the adapter must hear about it. + await vi.advanceTimersByTimeAsync(REVEAL_INTERVAL_MS) + expect(searchController.getSnapshot().talk.status).toBe('loaded') + expect(onChange).toHaveBeenCalled() + }) + + it('works without an onChange callback', async () => { + const providers = mockProviders(['files']) + + const searchController = new UnifiedSearchController() + searchController.search('query', ['files']) + providers.files.resolve(['Files result']) + + // The callback is optional; a headless controller must still run. + await expect(vi.advanceTimersByTimeAsync(0)).resolves.not.toThrow() + expect(searchController.getSnapshot().files.status).toBe('loaded') + }) + }) + describe('pagination', () => { it('appends the next page of results when loadMore is called', async () => { const files = pagedProvider() @@ -340,6 +418,33 @@ describe('UnifiedSearchController', () => { }) }) + it('reports a loading state, with page 1 still visible, while the next page is in flight', async () => { + const files = pagedProvider() + service.search.mockReturnValue(files) + const onChange = vi.fn() + + const searchController = new UnifiedSearchController(onChange) + searchController.search('query', ['files']) + + files.resolvePage(0, { entries: ['a'], cursor: 'cursor-1', hasMore: true }) + await vi.advanceTimersByTimeAsync(0) + onChange.mockClear() + + // Page 2 is requested but has not resolved yet. + searchController.loadMore('files') + + // The view needs a loading signal for the paging spinner, without losing + // the results already on screen, and the adapter must be told at the start. + expect(searchController.getSnapshot().files).toEqual({ + status: 'loading', + entries: ['a'], + cursor: 'cursor-1', + hasMore: true, + loadMoreFailed: false, + }) + expect(onChange).toHaveBeenCalled() + }) + it('re-dispatches with the stored cursor', async () => { const files = pagedProvider() service.search.mockReturnValue(files) @@ -425,6 +530,50 @@ describe('UnifiedSearchController', () => { // The initial search is the only dispatch; loadMore must not fire another. expect(service.search).toHaveBeenCalledTimes(1) }) + + it('does not mutate a snapshot already handed out when a later page loads', async () => { + const files = pagedProvider() + service.search.mockReturnValue(files) + + const searchController = new UnifiedSearchController() + searchController.search('query', ['files']) + + files.resolvePage(0, { entries: ['a'], cursor: 'cursor-1', hasMore: true }) + await vi.advanceTimersByTimeAsync(0) + + // A consumer reads and holds the snapshot, the way the Vue adapter does. + const firstPage = searchController.getSnapshot() + + searchController.loadMore('files') + files.resolvePage(1, { entries: ['b'], cursor: 'cursor-2', hasMore: false }) + await vi.advanceTimersByTimeAsync(0) + + // loadMore must not reach back into an array a previous getSnapshot() + // already exposed; the earlier snapshot stays frozen. + expect(firstPage.files.entries).toEqual(['a']) + }) + + it('hands out fresh state and entries references across a loadMore', async () => { + const files = pagedProvider() + service.search.mockReturnValue(files) + + const searchController = new UnifiedSearchController() + searchController.search('query', ['files']) + + files.resolvePage(0, { entries: ['a'], cursor: 'cursor-1', hasMore: true }) + await vi.advanceTimersByTimeAsync(0) + const before = searchController.getSnapshot() + + searchController.loadMore('files') + files.resolvePage(1, { entries: ['b'], cursor: 'cursor-2', hasMore: false }) + await vi.advanceTimersByTimeAsync(0) + const after = searchController.getSnapshot() + + // A new object and array identity on each change is what lets the adapter's + // ref reassignment re-render instead of aliasing a mutated object. + expect(after.files).not.toBe(before.files) + expect(after.files.entries).not.toBe(before.files.entries) + }) }) describe('filter pass-through', () => { From c3ee5c22d8b20dbd976316a60f09ae3b6d3dc135 Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Tue, 14 Jul 2026 10:25:30 +0200 Subject: [PATCH 3/3] feat(core): drive the unified search modal from the search controller Signed-off-by: Peter Ringelmann --- .../UnifiedSearch/UnifiedSearchModal.vue | 228 ++++++++---------- core/src/composables/useUnifiedSearch.ts | 4 +- core/src/services/UnifiedSearchController.ts | 16 +- .../components/UnifiedSearchModal.spec.ts | 218 ++++++++++++++++- .../services/UnifiedSearchController.spec.ts | 46 ++++ 5 files changed, 376 insertions(+), 136 deletions(-) diff --git a/core/src/components/UnifiedSearch/UnifiedSearchModal.vue b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue index a91d0138a4894..b3078b633d325 100644 --- a/core/src/components/UnifiedSearch/UnifiedSearchModal.vue +++ b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue @@ -153,7 +153,7 @@ v-bind="result" />