diff --git a/package-lock.json b/package-lock.json index 35b3c42..553f7a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3148,6 +3148,7 @@ "jest-resolve": "^26.6.2", "jest-util": "^26.6.2", "jest-worker": "^26.6.2", + "node-notifier": "^8.0.0", "slash": "^3.0.0", "source-map": "^0.6.0", "string-length": "^4.0.1", @@ -7051,7 +7052,8 @@ "esprima": "^4.0.1", "estraverse": "^4.2.0", "esutils": "^2.0.2", - "optionator": "^0.8.1" + "optionator": "^0.8.1", + "source-map": "~0.6.1" }, "bin": { "escodegen": "bin/escodegen.js", @@ -11273,6 +11275,7 @@ "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", + "fsevents": "^2.1.2", "graceful-fs": "^4.2.4", "jest-regex-util": "^26.0.0", "jest-serializer": "^26.6.2", @@ -13969,6 +13972,9 @@ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", "dev": true, + "dependencies": { + "graceful-fs": "^4.1.6" + }, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -14767,6 +14773,9 @@ "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-4.3.0.tgz", "integrity": "sha512-xTYd4JVHpSCW+aqDof6w/MebaMVNTVYBZhbB/vi513xXdiPT92JMVCo0Jq8W2UZnzYRFeVbQiQ+I25l13JuKvA==", "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, "bin": { "make-plural": "bin/make-plural" }, @@ -17945,6 +17954,9 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.18.0.tgz", "integrity": "sha512-LhuQQp3WpnHo3HlKCRrdMXpB6jdLsGOoXXSfMjbv74s5VdV3WZhkYJT0Z6w/EH3UgPH+g/S9T4GJrKW/5iD8TA==", "dev": true, + "dependencies": { + "fsevents": "~2.1.2" + }, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/src/QueryStateProvider.tsx b/src/QueryStateProvider.tsx deleted file mode 100644 index 3202564..0000000 --- a/src/QueryStateProvider.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import * as React from 'react' - -import QueryStateContext from './queryState.context' - -const QueryStateProvider: React.FunctionComponent = ({ children }) => { - const [state, setState] = React.useState({}) - return ( - - {children} - - ) -} - -export default QueryStateProvider diff --git a/src/_internal/isClientSide.ts b/src/_internal/isClientSide.ts new file mode 100644 index 0000000..9dbc8cb --- /dev/null +++ b/src/_internal/isClientSide.ts @@ -0,0 +1 @@ +export const isClientSide: boolean = typeof document === 'object' diff --git a/src/_internal/parseQueryString.ts b/src/_internal/parseQueryString.ts new file mode 100644 index 0000000..606417a --- /dev/null +++ b/src/_internal/parseQueryString.ts @@ -0,0 +1,45 @@ +import { isEmpty, mapValues, pickBy, isNil } from 'lodash' + +export const parseUrlString = (filter: any): any => { + if (filter instanceof Date) { + return filter.toISOString() + } + if (Array.isArray(filter)) { + if (filter.length === 0) { + return undefined + } + return filter.map(parseUrlString) + } + if (typeof filter === 'object') { + if (isEmpty(filter)) { + return undefined + } + if (filter['0']) { + return Object.values(filter) + } + return parseQueryString(filter) + } + if (filter === 'true') { + return true + } + if (filter === 'false') { + return false + } + try { + const numberInput = Number(filter.trim()) + if (!isNaN(numberInput)) { + return Math.round(numberInput) + } + } catch (e) { + // + } + return filter +} + +export const parseQueryString = (filters: any) => { + const state = pickBy(mapValues(filters, parseUrlString), (el) => !isNil(el)) + if (isEmpty(state)) { + return undefined + } + return state +} diff --git a/src/cleanQueryState.ts b/src/cleanQueryState.ts deleted file mode 100644 index 04e1de4..0000000 --- a/src/cleanQueryState.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { isEmpty, mapValues, pickBy, isNil, isFunction } from 'lodash' - -type CustomCleanState = (filter: any, key?: string | number) => any - -export const cleanState = (customCleanState?: CustomCleanState) => ( - filter: any, - key?: string | number -): any => { - const customCleanedFilters = isFunction(customCleanState) - ? customCleanState(filter, key) - : undefined - if (customCleanedFilters) { - return customCleanedFilters - } - if (filter instanceof Date) { - return filter.toISOString() - } - if (Array.isArray(filter)) { - if (filter.length === 0) { - return undefined - } - return filter.map(cleanState(customCleanState)) - } - if (typeof filter === 'object') { - if (isEmpty(filter)) { - return undefined - } - if (filter['0']) { - return Object.values(filter) - } - return cleanQueryState(filter, customCleanState) - } - if (filter === 'true') { - return true - } - if (filter === 'false') { - return false - } - try { - const numberInput = Number(filter.trim()) - if (!isNaN(numberInput)) { - return Math.round(numberInput) - } - } catch (e) { - // - } - return filter -} - -export const cleanQueryState = ( - filters: any, - customCleanFilter?: CustomCleanState -) => { - const state = pickBy( - mapValues(filters, cleanState(customCleanFilter)), - (el) => !isNil(el) - ) - if (isEmpty(state)) { - return undefined - } - return state -} - -export default cleanQueryState diff --git a/src/index.ts b/src/index.ts index 1da9df8..97d0d87 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,2 @@ -export { default as useQueryState } from './useQueryState' -export { default as cleanQueryState } from './cleanQueryState' -export { default as QueryStateProvider } from './QueryStateProvider' -export { UseQueryStateOptions, UrlOptions } from './types' +export { useQueryState } from './useQueryState' +export { UseQueryStateOptions } from './types' diff --git a/src/queryState.context.ts b/src/queryState.context.ts deleted file mode 100644 index bc1ee17..0000000 --- a/src/queryState.context.ts +++ /dev/null @@ -1,5 +0,0 @@ -import * as React from 'react' - -type QueryStateContext = { state: any; setState: (value: any) => void } - -export default React.createContext({} as QueryStateContext) diff --git a/src/types.ts b/src/types.ts index b0f498e..9f18484 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,15 +1,21 @@ -export type UrlOptions = { - shouldUseGetRoot?: boolean - persist?: boolean - persistInitial?: boolean -} - -export type UseQueryStateOptions = { +export type UseQueryStateOptions = { + /** + * Default value used in state + * @default {} + */ defaultValue?: FiltersType - customClean?: (filter: any, key?: string | number) => any | void - url?: UrlOptions - value?: FiltersType - onChange?: (groupName: string, value: FiltersType) => void - persistValue?: (groupName: string, value: FiltersType) => void - groupName?: string + /** + * Persist state in query url + * @default true + */ + persist?: boolean + /** + * If provided, state is persisted in session storage with the key provided + */ + cacheKey?: string + /** + * Overwrites default parse url function + * @param url + */ + parseSearch?: (search: string) => object | undefined } diff --git a/src/usePersistUrlFilters.ts b/src/usePersistUrlFilters.ts deleted file mode 100644 index 71c6020..0000000 --- a/src/usePersistUrlFilters.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as qs from 'qs' -import * as React from 'react' - -import { UrlOptions } from './types' - -const usePersistUrlFilters = (groupName: string, config: UrlOptions = {}) => { - const { shouldUseGetRoot = false, persist = true } = config - - return React.useCallback( - (filters = {}) => { - const location = window.location - const history = window.history - - const params = shouldUseGetRoot - ? filters - : { ...qs.parse(location.search.slice(1)), [groupName]: filters } - const url = `${location.origin}${location.pathname}` - if (params && Object.values(params).length > 0) { - if (persist) { - history.replaceState( - '', - '', - `${url}?${qs.stringify(params, { encode: false })}` - ) - } - } else { - history.replaceState('', '', url) - } - }, - [groupName, persist, shouldUseGetRoot] - ) -} - -export default usePersistUrlFilters diff --git a/src/useQueryState.ts b/src/useQueryState.ts index e95a92d..a23c98f 100644 --- a/src/useQueryState.ts +++ b/src/useQueryState.ts @@ -1,88 +1,67 @@ -import { get, isEmpty } from 'lodash' +import { isEmpty } from 'lodash' +import * as qs from 'qs' import * as React from 'react' -import cleanQueryState from './cleanQueryState' -import queryStateContext from './queryState.context' +import { isClientSide } from './_internal/isClientSide' +import { parseQueryString } from './_internal/parseQueryString' import { UseQueryStateOptions } from './types' -import usePersistUrlFilters from './usePersistUrlFilters' -import useUrlQuery from './useUrlQuery' -const useQueryState = ( - options: UseQueryStateOptions +export const useQueryState = ( + options?: UseQueryStateOptions ) => { - const initialized = React.useRef(false) - - const { state, setState } = React.useContext(queryStateContext) - - if (!state && options.onChange === undefined) { - throw new Error( - 'Cannot use uncontrolled mode of useQueryState without QueryStateProvider' - ) - } + const [state, setState] = React.useState(() => { + if (isClientSide) { + const search = window.location.search.slice(1) + const parsedQueryString = + options?.parseSearch?.(search) ?? parseQueryString(qs.parse(search)) + if (parsedQueryString) { + return parsedQueryString + } - const groupName = options.groupName ?? 'query' + if (options?.cacheKey) { + try { + const sessionStorage = window.sessionStorage.getItem(options.cacheKey) + if (!sessionStorage) { + return options.defaultValue + } + return JSON.parse(sessionStorage) + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + `Unable to parse query string state stored in session storage: ${e}` + ) + } + } - const handleCleanFilters = React.useCallback( - (dirtyFilters: any) => - cleanQueryState(dirtyFilters, options.customClean) as FiltersType, - [options.customClean] - ) + return options?.defaultValue + } + }) - const handleSetUrl = usePersistUrlFilters(groupName, options.url) - const urlFilters = useUrlQuery(groupName, options.url) - const cleanedUrlFilters = handleCleanFilters(urlFilters) + const shouldPersistUrl = options?.persist ?? true - const currentValueRef = React.useRef( - cleanedUrlFilters ?? options.defaultValue - ) - const localValue = state?.[groupName] ?? options.value - if (localValue) { - currentValueRef.current = localValue - } - const currentValue = currentValueRef.current + React.useEffect(() => { + if (isClientSide) { + if (shouldPersistUrl) { + const location = window.location + const history = window.history + const url = `${location.origin}${location.pathname}` - const handleSetFilter = React.useCallback( - ( - newFilters: FiltersType = {} as FiltersType, - filtersOptions?: { save?: boolean } - ) => { - if (groupName && get(filtersOptions, 'save', true)) { - options.persistValue && - options.persistValue(groupName, handleCleanFilters(newFilters)) + if (!isEmpty(state)) { + history.replaceState('', '', `${url}?${qs.stringify(state)}`) + } else { + history.replaceState('', '', url) + } } - options.onChange && options.onChange(groupName, newFilters) - setState && - setState((oldState: any) => ({ ...oldState, [groupName]: newFilters })) - }, - [groupName, options, setState, handleCleanFilters] - ) - React.useEffect(() => { - if ( - currentValue && - initialized.current && - currentValue !== options.defaultValue - ) { - handleSetUrl(currentValue) - } - }, [currentValue, handleSetUrl, options.defaultValue]) - - React.useEffect(() => { - if (groupName) { - if (!isEmpty(urlFilters)) { - handleSetFilter(cleanedUrlFilters, { save: false }) - } else if (options.url?.persistInitial) { - handleSetUrl(currentValue) + if (options?.cacheKey) { + try { + window.sessionStorage.setItem(options.cacheKey, JSON.stringify(state)) + } catch (e) { + console.warn(`Unable to stringify query string state: ${e}`) // eslint-disable-line no-console + } } - initialized.current = true } - }, []) // eslint-disable-line + }, [options?.cacheKey, shouldPersistUrl, state]) - return [ - currentValue, - handleSetFilter, - { isInitialized: initialized.current }, - ] as const + return [state, setState] as const } - -export default useQueryState diff --git a/src/useUrlQuery.ts b/src/useUrlQuery.ts deleted file mode 100644 index 4df4b51..0000000 --- a/src/useUrlQuery.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as qs from 'qs' - -import { UrlOptions } from './types' - -const useUrlQuery = (groupName: string, config: UrlOptions = {}) => { - const { shouldUseGetRoot = false } = config - - const search = qs.parse(window.location.search.slice(1)) - - return shouldUseGetRoot ? search : search[groupName] -} - -export default useUrlQuery diff --git a/tests/useQueryState.spec.ts b/tests/useQueryState.spec.ts index 0dc4064..27306a7 100644 --- a/tests/useQueryState.spec.ts +++ b/tests/useQueryState.spec.ts @@ -1,42 +1,36 @@ import { renderHook, act } from '@testing-library/react-hooks' import * as qs from 'qs' -import { QueryStateProvider, useQueryState } from '../src' +import { useQueryState } from '../src' describe('use query state', () => { - describe('act a normal state', () => { + describe('act as normal state', () => { it('should allow to set state', () => { - const { result } = renderHook( - () => useQueryState<{ value: string }>({}), - { wrapper: QueryStateProvider } - ) + const { result } = renderHook(() => useQueryState<{ value: string }>({})) const newState = { value: 'test' } act(() => { const [_state, setState] = result.current setState(newState) }) - expect(result.current[0]).toBe(newState) + expect(result.current[0]).toStrictEqual(newState) }) - it('should be initialized after first render', () => { + it('should be initialized at first render', () => { const defaultValue = { value: 'test' } - const { result, rerender } = renderHook( - () => useQueryState<{ value: string }>({ defaultValue }), - { wrapper: QueryStateProvider } + const { result } = renderHook(() => + useQueryState<{ value: string }>({ defaultValue }) ) - rerender() // init - expect(result.current[2]?.isInitialized).toBeTruthy() + expect(result.current[0]).toStrictEqual(defaultValue) }) it('should not change value after a rerender if value has been initialized', () => { const defaultValue = { value: 'test' } - const { result, rerender } = renderHook( - () => useQueryState<{ value: string }>({ defaultValue }), - { wrapper: QueryStateProvider } + const { result, rerender } = renderHook(() => + useQueryState<{ value: string }>({ defaultValue }) ) rerender() // init const test = result.current rerender() - expect(result.current[0]).toBe(test[0]) + expect(result.current[0]).toStrictEqual(test[0]) }) }) describe('link with url', () => { @@ -54,25 +48,16 @@ describe('use query state', () => { }) }) it('should use url value as initial value as first render', () => { - const { result } = renderHook( - () => - useQueryState<{ value: string }>({ - url: { shouldUseGetRoot: true }, - defaultValue: { value: 'bad value' }, - }), - { wrapper: QueryStateProvider } + const { result } = renderHook(() => + useQueryState<{ value: string }>({ + defaultValue: { value: 'bad value' }, + }) ) expect(result.current[0]?.value).toBe(defaultValue.value) }) it('should change the url according to the state', () => { window.history.replaceState = jest.fn() - const { result } = renderHook( - () => - useQueryState<{ value: string }>({ - url: { shouldUseGetRoot: true }, - }), - { wrapper: QueryStateProvider } - ) + const { result } = renderHook(() => useQueryState<{ value: string }>()) const newValue = { value: 'new value' } act(() => { const [_state, setState] = result.current @@ -81,7 +66,7 @@ describe('use query state', () => { expect(window.history.replaceState).toBeCalledWith( '', '', - `${origin}${pathname}?${qs.stringify(newValue, { encode: false })}` + `${origin}${pathname}?${qs.stringify(newValue)}` ) }) })