diff --git a/src/engine/query.ts b/src/engine/query.ts index 3912f12..3958bcf 100644 --- a/src/engine/query.ts +++ b/src/engine/query.ts @@ -68,8 +68,12 @@ const DEFAULT_ABSTRACT_QUERY_OPTIONS: AbstractQueryOptions = { convertToQuery: (abstractQuery) => { const query: Query = {} for (const [key, value] of Object.entries(abstractQuery)) { - if (value !== undefined) { - query[key] = value.toString() + if (Array.isArray(value)) { + query[key] = value.map((v) => v.toString()) + } else { + if (value !== undefined) { + query[key] = value.toString() + } } } return query diff --git a/src/routers/__test__/inMemory.test.ts b/src/routers/__test__/inMemory.test.ts index df0ccb6..509f70d 100644 --- a/src/routers/__test__/inMemory.test.ts +++ b/src/routers/__test__/inMemory.test.ts @@ -186,4 +186,26 @@ describe('InMemory', () => { expect(result.current.query.search).toBe(search) }) + + test('should handle array query parameters with single and multiple options', () => { + const optionsSchema = z.object({ + options: z.array(z.string()), + }) + + const { result } = renderHook(() => + useQuery(optionsSchema, { options: [] }), + ) + + act(() => { + result.current.setQuery({ options: ['A'] }) + }) + + expect(result.current.query.options).toStrictEqual(['A']) + + act(() => { + result.current.setQuery({ options: ['A', 'B'] }) + }) + + expect(result.current.query.options).toStrictEqual(['A', 'B']) + }) }) diff --git a/src/routers/__test__/nextRouter.test.ts b/src/routers/__test__/nextRouter.test.ts index 4f9a5b6..eebfa81 100644 --- a/src/routers/__test__/nextRouter.test.ts +++ b/src/routers/__test__/nextRouter.test.ts @@ -15,6 +15,7 @@ describe('NextRouter', () => { const searchSchema = z.object({ search: z.string().optional().catch(undefined), + options: z.string().or(z.array(z.string())).optional().catch(undefined), }) const useNextRouterQueryWithSearch = ( @@ -289,13 +290,17 @@ describe('NextRouter', () => { expect(router.query.greeting).toBe(greeting) }) - test('query keys with default value should not be stored in the url', () => { - const defaultSearch = '' + test('query keys with default value by reset should not be stored in the url', () => { + const defaultSearch = 'Default' + const defaultOptions = ['A', 'B'] - router.query = { search: 'Max' } + router.query = { search: 'Max', options: ['X', 'Y'] } const { result } = renderHook(() => - useQuery(searchSchema, { search: defaultSearch }), + useQuery(searchSchema, { + search: defaultSearch, + options: defaultOptions, + }), ) act(() => { @@ -303,6 +308,32 @@ describe('NextRouter', () => { }) expect(result.current.query.search).toBe(defaultSearch) + expect(result.current.query.options).toStrictEqual(defaultOptions) + expect(router.query.search).toBeUndefined() + }) + + test('query keys with default value by set should not be stored in the url', () => { + const defaultSearch = 'Default' + const defaultOptions = ['A', 'B'] + + router.query = { search: 'Max', options: ['X', 'Y'] } + + const { result } = renderHook(() => + useQuery(searchSchema, { + search: defaultSearch, + options: defaultOptions, + }), + ) + + act(() => { + result.current.setQuery({ + search: defaultSearch, + options: defaultOptions, + }) + }) + + expect(result.current.query.search).toBe(defaultSearch) + expect(result.current.query.options).toStrictEqual(defaultOptions) expect(router.query.search).toBeUndefined() }) @@ -345,4 +376,28 @@ describe('NextRouter', () => { expect(result.current.query.department).toBeUndefined() expect(result.current.query.role).toBe(defaultRole) }) + + test('should handle array query parameters with single and multiple options', () => { + const optionsSchema = z.object({ + options: z.array(z.string()), + }) + + const { result } = renderHook(() => + useQuery(optionsSchema, { options: [] }), + ) + + act(() => { + result.current.setQuery({ options: ['A'] }) + }) + + expect(result.current.query.options).toStrictEqual(['A']) + expect(router.query.options).toStrictEqual(['A']) + + act(() => { + result.current.setQuery({ options: ['A', 'B'] }) + }) + + expect(result.current.query.options).toStrictEqual(['A', 'B']) + expect(router.query.options).toStrictEqual(['A', 'B']) + }) }) diff --git a/src/routers/__test__/reactRouter.test.tsx b/src/routers/__test__/reactRouter.test.tsx index 7d3c5da..88a1cbd 100644 --- a/src/routers/__test__/reactRouter.test.tsx +++ b/src/routers/__test__/reactRouter.test.tsx @@ -20,6 +20,7 @@ describe('ReactRouter', () => { const searchSchema = z.object({ search: z.string().optional().catch(undefined), + options: z.string().or(z.array(z.string())).optional().catch(undefined), }) const useReactRouterQueryWithSearch = ( @@ -225,13 +226,17 @@ describe('ReactRouter', () => { ) }) - test('query keys with default value should not be stored in the url', () => { - const defaultSearch = '' + test('query keys with default value by reset should not be stored in the url', () => { + const defaultSearch = 'Default' + const defaultOptions = ['A', 'B'] - window.history.pushState({}, '', `/?search=Max`) + window.history.pushState({}, '', `/?search=Max&options=X&options=Y`) const { result } = renderHookWithContext(() => - useQuery(searchSchema, { search: defaultSearch }), + useQuery(searchSchema, { + search: defaultSearch, + options: defaultOptions, + }), ) act(() => { @@ -239,6 +244,32 @@ describe('ReactRouter', () => { }) expect(result.current.query.search).toBe(defaultSearch) + expect(result.current.query.options).toStrictEqual(defaultOptions) + expect(window.location.search).toBe('') + }) + + test('query keys with default value by set should not be stored in the url', () => { + const defaultSearch = 'Default' + const defaultOptions = ['A', 'B'] + + window.history.pushState({}, '', `/?search=Max&options=X&options=Y`) + + const { result } = renderHookWithContext(() => + useQuery(searchSchema, { + search: defaultSearch, + options: defaultOptions, + }), + ) + + act(() => { + result.current.setQuery({ + search: defaultSearch, + options: defaultOptions, + }) + }) + + expect(result.current.query.search).toBe(defaultSearch) + expect(result.current.query.options).toStrictEqual(defaultOptions) expect(window.location.search).toBe('') }) @@ -256,4 +287,28 @@ describe('ReactRouter', () => { expect(result.current.query.search).toBe(search) expect(window.location.search).toBe(`?search=`) }) + + test('should handle array query parameters with single and multiple options', () => { + const optionsSchema = z.object({ + options: z.string().or(z.array(z.string())), + }) + + const { result } = renderHookWithContext(() => + useQuery(optionsSchema, { options: [] }), + ) + + act(() => { + result.current.setQuery({ options: ['A'] }) + }) + + expect(result.current.query.options).toStrictEqual('A') + expect(window.location.search).toBe(`?options=A`) + + act(() => { + result.current.setQuery({ options: ['A', 'B'] }) + }) + + expect(result.current.query.options).toStrictEqual(['A', 'B']) + expect(window.location.search).toBe(`?options=A&options=B`) + }) }) diff --git a/src/routers/reactRouter.ts b/src/routers/reactRouter.ts index 744e2ce..f522102 100644 --- a/src/routers/reactRouter.ts +++ b/src/routers/reactRouter.ts @@ -10,7 +10,7 @@ import { useAbstractQueryAndPagination, Router, } from '../engine' -import { QUERY_ARRAY_SEPARATOR, RouterWithHistoryOptions } from './shared' +import { RouterWithHistoryOptions } from './shared' const DEFAULT_REACT_ROUTER_OPTIONS: RouterWithHistoryOptions = { setQueryMethod: 'replace', @@ -30,14 +30,10 @@ const useReactRouter = ( const getQuery = useCallback( (defaultQuery: Query) => { const query: Query = {} - for (const [key, value] of new URLSearchParams(search).entries()) { - const decodedValues = value - .split(QUERY_ARRAY_SEPARATOR) - .map((v) => decodeURIComponent(v)) - query[key] = - decodedValues.length === 1 - ? (decodedValues[0] as string) - : decodedValues + const urlSearchParams = new URLSearchParams(search) + for (const key of urlSearchParams.keys()) { + const value = urlSearchParams.getAll(key) + query[key] = value.length === 1 ? (value[0] as string) : value } return { ...defaultQuery, ...query } }, @@ -48,19 +44,27 @@ const useReactRouter = ( (query: Partial, defaultQuery: Query) => { const urlSearchParams = new URLSearchParams(search) for (const [key, value] of Object.entries(query)) { - if (value === defaultQuery[key]) { - urlSearchParams.delete(key) - } else if (value !== undefined) { - let values: string[] - if (Array.isArray(value)) { - values = value + if (value !== undefined) { + const defaultValue = defaultQuery[key] + + if ( + defaultValue !== undefined && + value.toString() === defaultValue.toString() + ) { + urlSearchParams.delete(key) } else { - values = [value] + const [firstValue, ...restValues] = Array.isArray(value) + ? value + : [value] + + if (firstValue !== undefined) { + urlSearchParams.set(key, firstValue) + } + + for (const restValue of restValues) { + urlSearchParams.append(key, restValue) + } } - const encodedValues = values - .map((v) => encodeURIComponent(v)) - .join(QUERY_ARRAY_SEPARATOR) - urlSearchParams.set(key, encodedValues) } } navigate( diff --git a/src/routers/shared.ts b/src/routers/shared.ts index 13971dd..8064e31 100644 --- a/src/routers/shared.ts +++ b/src/routers/shared.ts @@ -1,5 +1,3 @@ -export const QUERY_ARRAY_SEPARATOR = ',' - export type RouterWithHistoryOptions = { /** * Whether the router should push a new URL to the browser history or replace the current URL.