-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
200 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
import { act, renderHook } from '@testing-library/react'; | ||
import { Provider, useAtom } from 'jotai'; | ||
import { RESET } from 'jotai/utils'; | ||
|
||
import { atomWithQueryParam } from '../src/atomWithQueryParam'; | ||
|
||
let pushStateSpy: jest.SpyInstance; | ||
let replaceStateSpy: jest.SpyInstance; | ||
|
||
beforeEach(() => { | ||
pushStateSpy = jest.spyOn(window.history, 'pushState'); | ||
replaceStateSpy = jest.spyOn(window.history, 'replaceState'); | ||
}); | ||
|
||
afterEach(() => { | ||
pushStateSpy.mockRestore(); | ||
replaceStateSpy.mockRestore(); | ||
}); | ||
|
||
describe('atomWithQueryParam', () => { | ||
it('should return a default value for the atom if the query parameter is not present', () => { | ||
const queryParamAtom = atomWithQueryParam('test', 'default'); | ||
const { result } = renderHook(() => useAtom(queryParamAtom), { | ||
// the provider scopes the atoms to a store so their values dont persist between tests | ||
wrapper: Provider, | ||
}); | ||
expect(result.current[0]).toEqual('default'); | ||
}); | ||
|
||
it('should sync an atom to a query parameter', () => { | ||
const queryParamAtom = atomWithQueryParam('test', { | ||
value: 'default', | ||
}); | ||
const { result } = renderHook(() => useAtom(queryParamAtom), { | ||
// the provider scopes the atoms to a store so their values dont persist between tests | ||
wrapper: Provider, | ||
}); | ||
|
||
act(() => { | ||
result.current[1]({ value: 'test value' }); | ||
}); | ||
|
||
expect(result.current[0]).toEqual({ value: 'test value' }); | ||
expect( | ||
(window.history.pushState as jest.Mock).mock.calls[0][2].toString(), | ||
).toEqual( | ||
expect.stringContaining('?test=%7B%22value%22%3A%22test+value%22%7D'), | ||
); | ||
}); | ||
|
||
it('should read an atom from a query parameter', () => { | ||
const queryParamAtom = atomWithQueryParam('test', { | ||
value: 'default', | ||
}); | ||
act(() => { | ||
window.history.pushState( | ||
null, | ||
'', | ||
'?test=%7B%22value%22%3A%22test+value%22%7D', | ||
); | ||
}); | ||
const { result } = renderHook(() => useAtom(queryParamAtom), { | ||
// the provider scopes the atoms to a store so their values dont persist between tests | ||
wrapper: Provider, | ||
}); | ||
expect(result.current[0]).toEqual({ value: 'test value' }); | ||
}); | ||
|
||
it('should allow passing custom serialization and deserialization functions', () => { | ||
const queryParamAtom = atomWithQueryParam('test', 'default', { | ||
serialize: (val) => val.toUpperCase(), | ||
deserialize: (str) => str.toLowerCase(), | ||
}); | ||
const { result } = renderHook(() => useAtom(queryParamAtom), { | ||
// the provider scopes the atoms to a store so their values dont persist between tests | ||
wrapper: Provider, | ||
}); | ||
|
||
act(() => { | ||
result.current[1]('new value'); | ||
}); | ||
|
||
expect(result.current[0]).toEqual('new value'); | ||
expect( | ||
(window.history.pushState as jest.Mock).mock.calls[0][2].toString(), | ||
).toEqual(expect.stringContaining('?test=NEW+VALUE')); | ||
}); | ||
|
||
it('should allow resetting the query parameter', () => { | ||
const queryParamAtom = atomWithQueryParam('test', 'default'); | ||
const { result } = renderHook(() => useAtom(queryParamAtom), { | ||
// the provider scopes the atoms to a store so their values dont persist between tests | ||
wrapper: Provider, | ||
}); | ||
act(() => { | ||
result.current[1]('new value'); | ||
}); | ||
expect(result.current[0]).toEqual('new value'); | ||
act(() => { | ||
result.current[1](RESET); | ||
}); | ||
expect(result.current[0]).toEqual('default'); | ||
}); | ||
|
||
it('should allow replacing the search params instead of pushing', () => { | ||
const queryParamAtom = atomWithQueryParam('test', 'default', { | ||
replace: true, | ||
}); | ||
const { result } = renderHook(() => useAtom(queryParamAtom), { | ||
// the provider scopes the atoms to a store so their values dont persist between tests | ||
wrapper: Provider, | ||
}); | ||
act(() => { | ||
result.current[1]('new value'); | ||
}); | ||
expect( | ||
// replaceState instead of pushState | ||
(window.history.replaceState as jest.Mock).mock.calls[0][2].toString(), | ||
).toEqual(expect.stringContaining('?test=%22new+value%22')); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
import { atom } from 'jotai/vanilla'; | ||
import { RESET } from 'jotai/vanilla/utils'; | ||
|
||
import { atomWithLocation } from './atomWithLocation'; | ||
import { SetStateActionWithReset, safeJSONParse } from './utils'; | ||
|
||
/** | ||
* Creates an atom that syncs its value with a specific query parameter in the URL. | ||
* | ||
* @param key The name of the query parameter. | ||
* @param initialValue The initial value of the atom if the query parameter is not present. | ||
* @param options Additional options for the atom: | ||
* - serialize: A custom function to serialize the atom value to the hash. Defaults to JSON.stringify. | ||
* - deserialize: A custom function to deserialize the hash to the atom value. Defaults to JSON.parse. | ||
* - subscribe: A custom function to subscribe to location change | ||
* - replace: A boolean to indicate to use replaceState instead of pushState. Defaults to false. | ||
*/ | ||
export const atomWithQueryParam = <Value>( | ||
key: string, | ||
initialValue: Value, | ||
options?: { | ||
serialize?: (val: Value) => string; | ||
deserialize?: (str: string) => Value; | ||
subscribe?: (callback: () => void) => () => void; | ||
replace?: boolean; | ||
}, | ||
) => { | ||
const locationAtom = atomWithLocation(options); | ||
|
||
const serialize = options?.serialize || JSON.stringify; | ||
const deserialize = | ||
options?.deserialize || | ||
(safeJSONParse(initialValue) as (str: string) => Value); | ||
|
||
const valueAtom = atom((get) => { | ||
const location = get(locationAtom); | ||
const value = location.searchParams?.get(key); | ||
return value == null ? initialValue : deserialize(value); | ||
}); | ||
|
||
// Create a derived atom that focuses on the specific query parameter | ||
const queryParamAtom = atom( | ||
(get) => get(valueAtom), | ||
(get, set, update: SetStateActionWithReset<Value>) => { | ||
const nextValue = | ||
typeof update === 'function' | ||
? (update as (prev: Value) => Value | typeof RESET)(get(valueAtom)) | ||
: update; | ||
const location = get(locationAtom); | ||
const params = new URLSearchParams(location.searchParams); | ||
if (nextValue === RESET) { | ||
params.delete(key); | ||
} else { | ||
params.set(key, serialize(nextValue)); | ||
} | ||
set(locationAtom, { | ||
...location, | ||
searchParams: params, | ||
}); | ||
}, | ||
); | ||
|
||
return queryParamAtom; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { RESET } from 'jotai/vanilla/utils'; | ||
|
||
export type SetStateActionWithReset<Value> = | ||
| Value | ||
| typeof RESET | ||
| ((prev: Value) => Value | typeof RESET); | ||
|
||
export const safeJSONParse = (initialValue: unknown) => (str: string) => { | ||
try { | ||
return JSON.parse(str); | ||
} catch (e) { | ||
return initialValue; | ||
} | ||
}; | ||