Skip to content

Commit 41168e1

Browse files
committed
feat(core): add reactive adapter for the unified search controller
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
1 parent 8771716 commit 41168e1

4 files changed

Lines changed: 344 additions & 18 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { onUnmounted, shallowRef } from 'vue'
7+
import { UnifiedSearchController } from '../services/UnifiedSearchController.ts'
8+
9+
/**
10+
* Reactive adapter over UnifiedSearchController for use in an SFC.
11+
*/
12+
export function useUnifiedSearch() {
13+
const searchStates = shallowRef({})
14+
15+
const controller = new UnifiedSearchController((states) => {
16+
searchStates.value = states
17+
})
18+
19+
onUnmounted(() => {
20+
controller.dispose()
21+
})
22+
23+
return {
24+
searchStates,
25+
search: controller.search.bind(controller),
26+
loadMore: controller.loadMore.bind(controller),
27+
}
28+
}

core/src/services/UnifiedSearchController.ts

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export class UnifiedSearchController {
3737
private revealTimer: ReturnType<typeof setTimeout> | null = null
3838
private pendingCancels: (() => void)[] = []
3939

40+
constructor(private onChange?: (states: Record<string, CategorySearchState>) => void) {}
41+
4042
/**
4143
* Start a search. Cancels and replaces any search already in flight.
4244
*
@@ -67,12 +69,12 @@ export class UnifiedSearchController {
6769
*/
6870
async loadMore(category: string): Promise<void> {
6971
const generation = this.searchGeneration
70-
const categoryState = this.searchStates[category]
71-
if (!categoryState || !categoryState.hasMore || categoryState.status !== 'loaded') {
72+
const categoryState = { ...this.searchStates[category] }
73+
if (!categoryState.hasMore || categoryState.status !== 'loaded') {
7274
return
7375
}
74-
categoryState.status = 'loading'
75-
categoryState.loadMoreFailed = false
76+
77+
this.patchStates({ [category]: { status: 'loading', loadMoreFailed: false } })
7678

7779
const { request, cancel } = unifiedSearch({
7880
type: category,
@@ -89,17 +91,19 @@ export class UnifiedSearchController {
8991
return
9092
}
9193
const { entries, cursor, hasMore } = response.data.ocs.data
92-
categoryState.entries.push(...entries)
93-
categoryState.cursor = cursor
94-
categoryState.hasMore = hasMore
94+
95+
this.patchStates({[category]: {
96+
entries: [...categoryState.entries, ...entries],
97+
cursor,
98+
hasMore,
99+
status: 'loaded',
100+
}})
95101
} catch {
96102
if (this.searchGeneration !== generation) {
97103
return
98104
}
99-
categoryState.loadMoreFailed = true
105+
this.patchStates({ [category]: { status: 'loaded', loadMoreFailed: true } })
100106
}
101-
102-
categoryState.status = 'loaded'
103107
}
104108

105109
/**
@@ -124,13 +128,14 @@ export class UnifiedSearchController {
124128
generation: number,
125129
categories: string[],
126130
): Promise<void> {
127-
this.searchStates[category] = {
131+
this.patchStates({ [category]: {
128132
status: 'loading',
129133
entries: [],
130134
cursor: null,
131135
hasMore: false,
132136
loadMoreFailed: false,
133-
}
137+
} })
138+
134139
const { request, cancel } = unifiedSearch({
135140
type: category,
136141
query: this.query,
@@ -148,24 +153,24 @@ export class UnifiedSearchController {
148153
}
149154

150155
const { entries, cursor, hasMore } = response.data.ocs.data
151-
this.searchStates[category] = {
156+
this.patchStates({ [category]: {
152157
status: 'loaded',
153158
entries,
154159
cursor,
155160
hasMore,
156161
loadMoreFailed: false,
157-
}
162+
} })
158163
} catch {
159164
if (this.searchGeneration !== generation) {
160165
return
161166
}
162-
this.searchStates[category] = {
167+
this.patchStates({ [category]: {
163168
status: 'failed',
164169
entries: [],
165170
cursor: null,
166171
hasMore: false,
167172
loadMoreFailed: false,
168-
}
173+
}})
169174
}
170175

171176
this.reconcileCategoryStatuses(categories)
@@ -176,7 +181,7 @@ export class UnifiedSearchController {
176181
if (['loading', 'failed'].includes(this.searchStates[category].status)) {
177182
return
178183
}
179-
this.searchStates[category].status = this.shouldBlockCategory(category, categories) ? 'blocked' : 'loaded'
184+
this.patchStates({ [category]: { status: this.shouldBlockCategory(category, categories) ? 'blocked' : 'loaded' } })
180185
})
181186
}
182187

@@ -207,7 +212,7 @@ export class UnifiedSearchController {
207212
private unblockAllCategories(categories: string[]): void {
208213
categories.forEach((category) => {
209214
if (this.searchStates[category].status === 'blocked') {
210-
this.searchStates[category].status = 'loaded'
215+
this.patchStates({ [category]: { status: 'loaded' } })
211216
}
212217
})
213218
}
@@ -222,4 +227,12 @@ export class UnifiedSearchController {
222227
return categoryState && ['loading', 'blocked'].includes(categoryState.status)
223228
})
224229
}
230+
231+
private patchStates(next: Record<string, Partial<CategorySearchState>>): void {
232+
Object.keys(next).forEach((category) => {
233+
const categoryState = { ...this.searchStates[category], ...next[category] }
234+
this.searchStates[category] = categoryState
235+
})
236+
this.onChange?.(this.getSnapshot())
237+
}
225238
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
import { mount } from '@vue/test-utils'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { defineComponent, h } from 'vue'
8+
9+
const service = vi.hoisted(() => ({
10+
search: vi.fn(),
11+
getProviders: vi.fn(),
12+
getContacts: vi.fn(),
13+
}))
14+
vi.mock('../../services/UnifiedSearchService.js', () => service)
15+
16+
import { useUnifiedSearch } from '../../composables/useUnifiedSearch.ts'
17+
18+
/**
19+
* Deferred stand-in for a provider's `search()` return value. Resolve it to
20+
* make that provider arrive on demand. Mirrors the controller spec's helper.
21+
*/
22+
function deferredProvider() {
23+
const { promise, resolve, reject } = Promise.withResolvers<{ entries: unknown[] }>()
24+
return {
25+
cancel: vi.fn(),
26+
request: async () => {
27+
const { entries } = await promise
28+
return { data: { ocs: { data: { entries } } } }
29+
},
30+
resolve: (entries: unknown[] = []) => resolve({ entries }),
31+
reject,
32+
}
33+
}
34+
35+
/**
36+
* Deferred stand-in that serves successive pages, for exercising loadMore.
37+
*/
38+
function pagedProvider() {
39+
const pages: ReturnType<typeof Promise.withResolvers<{ entries: unknown[], cursor: string | null, hasMore: boolean }>>[] = []
40+
const pageAt = (index: number) => (pages[index] ??= Promise.withResolvers())
41+
let call = 0
42+
return {
43+
cancel: vi.fn(),
44+
request: async () => {
45+
const data = await pageAt(call++).promise
46+
return { data: { ocs: { data } } }
47+
},
48+
resolvePage: (index: number, data: { entries: unknown[], cursor: string | null, hasMore: boolean }) => pageAt(index).resolve(data),
49+
}
50+
}
51+
52+
/**
53+
* Register one deferred provider per category type on the mocked service.
54+
*/
55+
function mockProviders(types: string[]) {
56+
const providers = Object.fromEntries(types.map((type) => [type, deferredProvider()]))
57+
service.search.mockImplementation(({ type }: { type: string }) => providers[type])
58+
return providers
59+
}
60+
61+
/**
62+
* Run the composable inside a real setup context (so onBeforeUnmount is wired)
63+
* and hand the returned API plus the wrapper back to the test.
64+
*/
65+
function mountComposable() {
66+
let api!: ReturnType<typeof useUnifiedSearch>
67+
const wrapper = mount(defineComponent({
68+
setup() {
69+
api = useUnifiedSearch()
70+
return () => h('div')
71+
},
72+
}))
73+
return { wrapper, api }
74+
}
75+
76+
beforeEach(() => {
77+
vi.useFakeTimers()
78+
})
79+
80+
afterEach(() => {
81+
vi.clearAllMocks()
82+
vi.useRealTimers()
83+
})
84+
85+
describe('useUnifiedSearch', () => {
86+
it('exposes an empty snapshot before any search', () => {
87+
const { api } = mountComposable()
88+
89+
expect(api.searchStates.value).toEqual({})
90+
})
91+
92+
it('reflects controller state reactively as a search resolves', async () => {
93+
const providers = mockProviders(['files'])
94+
const { api } = mountComposable()
95+
96+
api.search('query', ['files'])
97+
providers.files.resolve(['a result'])
98+
await vi.advanceTimersByTimeAsync(0)
99+
100+
expect(api.searchStates.value.files).toMatchObject({
101+
status: 'loaded',
102+
entries: ['a result'],
103+
})
104+
})
105+
106+
it('appends a page through loadMore and reflects it', async () => {
107+
const files = pagedProvider()
108+
service.search.mockReturnValue(files)
109+
const { api } = mountComposable()
110+
111+
api.search('query', ['files'])
112+
files.resolvePage(0, { entries: ['a'], cursor: 'cursor-1', hasMore: true })
113+
await vi.advanceTimersByTimeAsync(0)
114+
115+
api.loadMore('files')
116+
files.resolvePage(1, { entries: ['b'], cursor: 'cursor-2', hasMore: false })
117+
await vi.advanceTimersByTimeAsync(0)
118+
119+
expect(api.searchStates.value.files).toMatchObject({
120+
status: 'loaded',
121+
entries: ['a', 'b'],
122+
hasMore: false,
123+
})
124+
})
125+
126+
it('cancels in-flight requests when the component unmounts', () => {
127+
const providers = mockProviders(['files'])
128+
const { wrapper, api } = mountComposable()
129+
130+
api.search('query', ['files'])
131+
wrapper.destroy()
132+
133+
// Unmount must dispose the controller, which cancels the pending request.
134+
expect(providers.files.cancel).toHaveBeenCalledOnce()
135+
})
136+
})

0 commit comments

Comments
 (0)