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
8 changes: 6 additions & 2 deletions src/engine/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions src/routers/__test__/inMemory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
})
})
63 changes: 59 additions & 4 deletions src/routers/__test__/nextRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -289,20 +290,50 @@ 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(() => {
result.current.resetQuery()
})

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()
})

Expand Down Expand Up @@ -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'])
})
})
63 changes: 59 additions & 4 deletions src/routers/__test__/reactRouter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -225,20 +226,50 @@ 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(() => {
result.current.resetQuery()
})

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('')
})

Expand All @@ -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`)
})
})
44 changes: 24 additions & 20 deletions src/routers/reactRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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))
Comment thread
lukasvice marked this conversation as resolved.
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 }
},
Expand All @@ -48,19 +44,27 @@ const useReactRouter = (
(query: Partial<Query>, 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(
Expand Down
2 changes: 0 additions & 2 deletions src/routers/shared.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down