Skip to content

Commit a238959

Browse files
authored
make use-query handle array params correctly (#24)
* make use-query handle array params correctly * remove array values correctly from url if equal to default value
1 parent d922fba commit a238959

6 files changed

Lines changed: 170 additions & 32 deletions

File tree

src/engine/query.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,12 @@ const DEFAULT_ABSTRACT_QUERY_OPTIONS: AbstractQueryOptions = {
6868
convertToQuery: (abstractQuery) => {
6969
const query: Query = {}
7070
for (const [key, value] of Object.entries(abstractQuery)) {
71-
if (value !== undefined) {
72-
query[key] = value.toString()
71+
if (Array.isArray(value)) {
72+
query[key] = value.map((v) => v.toString())
73+
} else {
74+
if (value !== undefined) {
75+
query[key] = value.toString()
76+
}
7377
}
7478
}
7579
return query

src/routers/__test__/inMemory.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,4 +186,26 @@ describe('InMemory', () => {
186186

187187
expect(result.current.query.search).toBe(search)
188188
})
189+
190+
test('should handle array query parameters with single and multiple options', () => {
191+
const optionsSchema = z.object({
192+
options: z.array(z.string()),
193+
})
194+
195+
const { result } = renderHook(() =>
196+
useQuery(optionsSchema, { options: [] }),
197+
)
198+
199+
act(() => {
200+
result.current.setQuery({ options: ['A'] })
201+
})
202+
203+
expect(result.current.query.options).toStrictEqual(['A'])
204+
205+
act(() => {
206+
result.current.setQuery({ options: ['A', 'B'] })
207+
})
208+
209+
expect(result.current.query.options).toStrictEqual(['A', 'B'])
210+
})
189211
})

src/routers/__test__/nextRouter.test.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe('NextRouter', () => {
1515

1616
const searchSchema = z.object({
1717
search: z.string().optional().catch(undefined),
18+
options: z.string().or(z.array(z.string())).optional().catch(undefined),
1819
})
1920

2021
const useNextRouterQueryWithSearch = (
@@ -289,20 +290,50 @@ describe('NextRouter', () => {
289290
expect(router.query.greeting).toBe(greeting)
290291
})
291292

292-
test('query keys with default value should not be stored in the url', () => {
293-
const defaultSearch = ''
293+
test('query keys with default value by reset should not be stored in the url', () => {
294+
const defaultSearch = 'Default'
295+
const defaultOptions = ['A', 'B']
294296

295-
router.query = { search: 'Max' }
297+
router.query = { search: 'Max', options: ['X', 'Y'] }
296298

297299
const { result } = renderHook(() =>
298-
useQuery(searchSchema, { search: defaultSearch }),
300+
useQuery(searchSchema, {
301+
search: defaultSearch,
302+
options: defaultOptions,
303+
}),
299304
)
300305

301306
act(() => {
302307
result.current.resetQuery()
303308
})
304309

305310
expect(result.current.query.search).toBe(defaultSearch)
311+
expect(result.current.query.options).toStrictEqual(defaultOptions)
312+
expect(router.query.search).toBeUndefined()
313+
})
314+
315+
test('query keys with default value by set should not be stored in the url', () => {
316+
const defaultSearch = 'Default'
317+
const defaultOptions = ['A', 'B']
318+
319+
router.query = { search: 'Max', options: ['X', 'Y'] }
320+
321+
const { result } = renderHook(() =>
322+
useQuery(searchSchema, {
323+
search: defaultSearch,
324+
options: defaultOptions,
325+
}),
326+
)
327+
328+
act(() => {
329+
result.current.setQuery({
330+
search: defaultSearch,
331+
options: defaultOptions,
332+
})
333+
})
334+
335+
expect(result.current.query.search).toBe(defaultSearch)
336+
expect(result.current.query.options).toStrictEqual(defaultOptions)
306337
expect(router.query.search).toBeUndefined()
307338
})
308339

@@ -345,4 +376,28 @@ describe('NextRouter', () => {
345376
expect(result.current.query.department).toBeUndefined()
346377
expect(result.current.query.role).toBe(defaultRole)
347378
})
379+
380+
test('should handle array query parameters with single and multiple options', () => {
381+
const optionsSchema = z.object({
382+
options: z.array(z.string()),
383+
})
384+
385+
const { result } = renderHook(() =>
386+
useQuery(optionsSchema, { options: [] }),
387+
)
388+
389+
act(() => {
390+
result.current.setQuery({ options: ['A'] })
391+
})
392+
393+
expect(result.current.query.options).toStrictEqual(['A'])
394+
expect(router.query.options).toStrictEqual(['A'])
395+
396+
act(() => {
397+
result.current.setQuery({ options: ['A', 'B'] })
398+
})
399+
400+
expect(result.current.query.options).toStrictEqual(['A', 'B'])
401+
expect(router.query.options).toStrictEqual(['A', 'B'])
402+
})
348403
})

src/routers/__test__/reactRouter.test.tsx

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ describe('ReactRouter', () => {
2020

2121
const searchSchema = z.object({
2222
search: z.string().optional().catch(undefined),
23+
options: z.string().or(z.array(z.string())).optional().catch(undefined),
2324
})
2425

2526
const useReactRouterQueryWithSearch = (
@@ -225,20 +226,50 @@ describe('ReactRouter', () => {
225226
)
226227
})
227228

228-
test('query keys with default value should not be stored in the url', () => {
229-
const defaultSearch = ''
229+
test('query keys with default value by reset should not be stored in the url', () => {
230+
const defaultSearch = 'Default'
231+
const defaultOptions = ['A', 'B']
230232

231-
window.history.pushState({}, '', `/?search=Max`)
233+
window.history.pushState({}, '', `/?search=Max&options=X&options=Y`)
232234

233235
const { result } = renderHookWithContext(() =>
234-
useQuery(searchSchema, { search: defaultSearch }),
236+
useQuery(searchSchema, {
237+
search: defaultSearch,
238+
options: defaultOptions,
239+
}),
235240
)
236241

237242
act(() => {
238243
result.current.resetQuery()
239244
})
240245

241246
expect(result.current.query.search).toBe(defaultSearch)
247+
expect(result.current.query.options).toStrictEqual(defaultOptions)
248+
expect(window.location.search).toBe('')
249+
})
250+
251+
test('query keys with default value by set should not be stored in the url', () => {
252+
const defaultSearch = 'Default'
253+
const defaultOptions = ['A', 'B']
254+
255+
window.history.pushState({}, '', `/?search=Max&options=X&options=Y`)
256+
257+
const { result } = renderHookWithContext(() =>
258+
useQuery(searchSchema, {
259+
search: defaultSearch,
260+
options: defaultOptions,
261+
}),
262+
)
263+
264+
act(() => {
265+
result.current.setQuery({
266+
search: defaultSearch,
267+
options: defaultOptions,
268+
})
269+
})
270+
271+
expect(result.current.query.search).toBe(defaultSearch)
272+
expect(result.current.query.options).toStrictEqual(defaultOptions)
242273
expect(window.location.search).toBe('')
243274
})
244275

@@ -256,4 +287,28 @@ describe('ReactRouter', () => {
256287
expect(result.current.query.search).toBe(search)
257288
expect(window.location.search).toBe(`?search=`)
258289
})
290+
291+
test('should handle array query parameters with single and multiple options', () => {
292+
const optionsSchema = z.object({
293+
options: z.string().or(z.array(z.string())),
294+
})
295+
296+
const { result } = renderHookWithContext(() =>
297+
useQuery(optionsSchema, { options: [] }),
298+
)
299+
300+
act(() => {
301+
result.current.setQuery({ options: ['A'] })
302+
})
303+
304+
expect(result.current.query.options).toStrictEqual('A')
305+
expect(window.location.search).toBe(`?options=A`)
306+
307+
act(() => {
308+
result.current.setQuery({ options: ['A', 'B'] })
309+
})
310+
311+
expect(result.current.query.options).toStrictEqual(['A', 'B'])
312+
expect(window.location.search).toBe(`?options=A&options=B`)
313+
})
259314
})

src/routers/reactRouter.ts

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
useAbstractQueryAndPagination,
1111
Router,
1212
} from '../engine'
13-
import { QUERY_ARRAY_SEPARATOR, RouterWithHistoryOptions } from './shared'
13+
import { RouterWithHistoryOptions } from './shared'
1414

1515
const DEFAULT_REACT_ROUTER_OPTIONS: RouterWithHistoryOptions = {
1616
setQueryMethod: 'replace',
@@ -30,14 +30,10 @@ const useReactRouter = (
3030
const getQuery = useCallback(
3131
(defaultQuery: Query) => {
3232
const query: Query = {}
33-
for (const [key, value] of new URLSearchParams(search).entries()) {
34-
const decodedValues = value
35-
.split(QUERY_ARRAY_SEPARATOR)
36-
.map((v) => decodeURIComponent(v))
37-
query[key] =
38-
decodedValues.length === 1
39-
? (decodedValues[0] as string)
40-
: decodedValues
33+
const urlSearchParams = new URLSearchParams(search)
34+
for (const key of urlSearchParams.keys()) {
35+
const value = urlSearchParams.getAll(key)
36+
query[key] = value.length === 1 ? (value[0] as string) : value
4137
}
4238
return { ...defaultQuery, ...query }
4339
},
@@ -48,19 +44,27 @@ const useReactRouter = (
4844
(query: Partial<Query>, defaultQuery: Query) => {
4945
const urlSearchParams = new URLSearchParams(search)
5046
for (const [key, value] of Object.entries(query)) {
51-
if (value === defaultQuery[key]) {
52-
urlSearchParams.delete(key)
53-
} else if (value !== undefined) {
54-
let values: string[]
55-
if (Array.isArray(value)) {
56-
values = value
47+
if (value !== undefined) {
48+
const defaultValue = defaultQuery[key]
49+
50+
if (
51+
defaultValue !== undefined &&
52+
value.toString() === defaultValue.toString()
53+
) {
54+
urlSearchParams.delete(key)
5755
} else {
58-
values = [value]
56+
const [firstValue, ...restValues] = Array.isArray(value)
57+
? value
58+
: [value]
59+
60+
if (firstValue !== undefined) {
61+
urlSearchParams.set(key, firstValue)
62+
}
63+
64+
for (const restValue of restValues) {
65+
urlSearchParams.append(key, restValue)
66+
}
5967
}
60-
const encodedValues = values
61-
.map((v) => encodeURIComponent(v))
62-
.join(QUERY_ARRAY_SEPARATOR)
63-
urlSearchParams.set(key, encodedValues)
6468
}
6569
}
6670
navigate(

src/routers/shared.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
export const QUERY_ARRAY_SEPARATOR = ','
2-
31
export type RouterWithHistoryOptions = {
42
/**
53
* Whether the router should push a new URL to the browser history or replace the current URL.

0 commit comments

Comments
 (0)