diff --git a/README.md b/README.md index 3c769fd..361c87e 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ import { } from 'use-query-params'; const UseQueryParamsExample = () => { - // something like: ?x=123&q=foo&filters=a_b_c in the URL + // something like: ?x=123&q=foo&filters=a&filters=b&filters=c in the URL const [query, setQuery] = useQueryParams({ x: NumberParam, q: StringParam, @@ -153,17 +153,20 @@ See [all param definitions here](https://github.com/pbeshai/use-query-params/blo Note that all nully values will encode and decode as `undefined`. +Examples in this table assume query parameter named `qp`. + | Param | Type | Example Decoded | Example Encoded | | --- | --- | --- | --- | -| StringParam | string | `'foo'` | `'foo'` | -| NumberParam | number | `123` | `'123'` | -| ObjectParam | { key: string } | `{ foo: 'bar', baz: 'zzz' }` | `'foo-bar_baz-zzz'` | -| ArrayParam | string[] | `['a','b','c']` | `'a_b_c'` | -| JsonParam | any | `{ foo: 'bar' }` | `'{"foo":"bar"}'` | -| DateParam | Date | `Date(2019, 2, 1)` | `'2019-03-01'` | -| BooleanParam | boolean | `true` | `'1'` | -| NumericObjectParam | { key: number } | `{ foo: 1, bar: 2 }` | `'foo-1_bar-2'` | -| NumericArrayParam | number[] | `[1, 2, 3]` | `'1_2_3'` | +| StringParam | string | `'foo'` | `?qp=foo` | +| NumberParam | number | `123` | `?qp=123` | +| ObjectParam | { key: string } | `{ foo: 'bar', baz: 'zzz' }` | `?qp=foo-bar_baz-zzz` | +| ArrayParam | string[] | `['a','b','c']` | `?qp=a&qp=b&qp=c` | +| JsonParam | any | `{ foo: 'bar' }` | `?qp=%7B%22foo%22%3A%22bar%22%7D` | +| DateParam | Date | `Date(2019, 2, 1)` | `?qp=2019-03-01` | +| BooleanParam | boolean | `true` | `?qp=1` | +| NumericObjectParam | { key: number } | `{ foo: 1, bar: 2 }` | `?qp=foo-1_bar-2` | +| DelimitedArrayParam | string[] | `['a','b','c']` | `?qp=a_b_c'` | +| DelimitedNumericArrayParam | number[] | `[1, 2, 3]` | `?qp=1_2_3'` | **Example** @@ -176,6 +179,26 @@ const [foo, setFoo] = useQueryParam('foo', ArrayParam); const [query, setQuery] = useQueryParams({ foo: ArrayParam }); ``` +**Example with Custom Param** + +You can define your own params if the ones shipped with this package don't work for you. There are included [serialization utility functions](https://github.com/pbeshai/use-query-params/blob/master/src/serialize.ts) to make this easier, but you can use whatever you like. + +```js +import { + encodeDelimitedArray, + decodeDelimitedArray +} from 'use-query-params'; + +/** Uses a comma to delimit entries. e.g. ['a', 'b'] => qp?=a,b */ +const CommaArrayParam = { + encode: (array: string[] | null | undefined) => + encodeDelimitedArray(array, ','), + + decode: (arrayStr: string | string[] | null | undefined) => + decodeDelimitedArray(arrayStr, ',') +}; +``` +
diff --git a/examples/react-router/src/UseQueryParamExample.tsx b/examples/react-router/src/UseQueryParamExample.tsx index f8ca1eb..71e3e8b 100644 --- a/examples/react-router/src/UseQueryParamExample.tsx +++ b/examples/react-router/src/UseQueryParamExample.tsx @@ -8,8 +8,10 @@ import { const MyParam = { encode: (val: number) => `MY_${val}`, - decode: (str: string | undefined) => - str == null ? undefined : +str.split('_')[1], + decode: (input: string | string[] | undefined) => { + const str = input instanceof Array ? input[0] : input; + return str == null ? undefined : +str.split('_')[1]; + }, }; const UseQueryParamExample = () => { diff --git a/package.json b/package.json index 5aeb569..eac4e4a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "dev": "NODE_ENV=development tsc -w", "test": "jest", "test-watch": "jest --watch", - "test-coverage": "jest --coverage" + "test-coverage": "jest --coverage", + "prepublishOnly": "npm run build" }, "repository": { "type": "git", diff --git a/src/__tests__/params-test.ts b/src/__tests__/params-test.ts index ba9e8bb..26140c4 100644 --- a/src/__tests__/params-test.ts +++ b/src/__tests__/params-test.ts @@ -3,11 +3,13 @@ import { NumberParam, ObjectParam, ArrayParam, + NumericArrayParam, JsonParam, DateParam, BooleanParam, NumericObjectParam, - NumericArrayParam, + DelimitedArrayParam, + DelimitedNumericArrayParam, } from '../index'; describe('params', () => { @@ -25,8 +27,12 @@ describe('params', () => { expect(ObjectParam.decode('foo-bar')).toEqual({ foo: 'bar' }); }); it('ArrayParam', () => { - expect(ArrayParam.encode(['foo', 'bar'])).toBe('foo_bar'); - expect(ArrayParam.decode('foo_bar')).toEqual(['foo', 'bar']); + expect(ArrayParam.encode(['foo', 'bar'])).toEqual(['foo', 'bar']); + expect(ArrayParam.decode(['foo', 'bar'])).toEqual(['foo', 'bar']); + }); + it('NumericArrayParam', () => { + expect(NumericArrayParam.encode([1, 2])).toEqual(['1', '2']); + expect(NumericArrayParam.decode(['1', '2'])).toEqual([1, 2]); }); it('JsonParam', () => { expect(JsonParam.encode({ foo: 'bar' })).toBe('{"foo":"bar"}'); @@ -47,9 +53,13 @@ describe('params', () => { expect(NumericObjectParam.encode({ foo: 123 })).toBe('foo-123'); expect(NumericObjectParam.decode('foo-123')).toEqual({ foo: 123 }); }); - it('NumericArrayParam', () => { - expect(NumericArrayParam.encode([1, 2])).toBe('1_2'); - expect(NumericArrayParam.decode('1_2')).toEqual([1, 2]); + it('DelimitedArrayParam', () => { + expect(DelimitedArrayParam.encode(['foo', 'bar'])).toBe('foo_bar'); + expect(DelimitedArrayParam.decode('foo_bar')).toEqual(['foo', 'bar']); + }); + it('DelimitedNumericArrayParam', () => { + expect(DelimitedNumericArrayParam.encode([1, 2])).toBe('1_2'); + expect(DelimitedNumericArrayParam.decode('1_2')).toEqual([1, 2]); }); }); }); diff --git a/src/__tests__/serialize-test.ts b/src/__tests__/serialize-test.ts index 1e6fe54..209ba6f 100644 --- a/src/__tests__/serialize-test.ts +++ b/src/__tests__/serialize-test.ts @@ -11,12 +11,16 @@ import { decodeJson, encodeArray, decodeArray, + encodeNumericArray, + decodeNumericArray, encodeObject, decodeObject, encodeNumericObject, - encodeNumericArray, decodeNumericObject, - decodeNumericArray, + encodeDelimitedNumericArray, + decodeDelimitedNumericArray, + encodeDelimitedArray, + decodeDelimitedArray, } from '../index'; describe('serialize', () => { @@ -60,6 +64,13 @@ describe('serialize', () => { const result = decodeDate('foo-one-two'); expect(result).not.toBeDefined(); }); + + it('handles array of values', () => { + const result = decodeDate(['2019-03-01', '2019-05-01']); + expect(result.getFullYear()).toBe(2019); + expect(result.getMonth()).toBe(2); + expect(result.getDate()).toBe(1); + }); }); describe('encodeBoolean', () => { @@ -81,6 +92,10 @@ describe('serialize', () => { it('handles malformed input', () => { expect(decodeBoolean('foo')).not.toBeDefined(); }); + + it('handles array of values', () => { + expect(decodeBoolean(['1', '0'])).toBe(true); + }); }); describe('encodeNumber', () => { @@ -102,6 +117,10 @@ describe('serialize', () => { it('handles malformed input', () => { expect(decodeNumber('foo')).not.toBeDefined(); }); + + it('handles array of values', () => { + expect(decodeNumber(['1', '0'])).toBe(1); + }); }); describe('encodeString', () => { @@ -118,6 +137,10 @@ describe('serialize', () => { expect(decodeString(undefined)).not.toBeDefined(); expect(decodeString(null)).not.toBeDefined(); }); + + it('handles array of values', () => { + expect(decodeString(['foo', 'bar'])).toBe('foo'); + }); }); describe('encodeJson', () => { @@ -146,19 +169,23 @@ describe('serialize', () => { it('handles malformed input', () => { expect(decodeJson('foo')).not.toBeDefined(); }); + + it('handles array of values', () => { + expect(decodeJson(['"foo"', '"bar"'])).toBe('foo'); + }); }); describe('encodeArray', () => { it('produces the correct value', () => { const input = ['a', 'b', 'c']; - expect(encodeArray(input)).toBe('a_b_c'); + expect(encodeArray(input)).toEqual(['a', 'b', 'c']); expect(encodeArray(undefined)).not.toBeDefined(); }); }); describe('decodeArray', () => { it('produces the correct value', () => { - const output = decodeArray('a_b_c'); + const output = decodeArray(['a', 'b', 'c']); const expectedOutput = ['a', 'b', 'c']; expect(output).toEqual(expectedOutput); @@ -167,7 +194,30 @@ describe('serialize', () => { }); it('filters empty values', () => { - expect(decodeArray('__')).toEqual([]); + expect(decodeArray(['a', '', 'b'])).toEqual(['a', 'b']); + }); + }); + + describe('encodeNumericArray', () => { + it('produces the correct value', () => { + const input = [1, 2, 3]; + expect(encodeNumericArray(input)).toEqual(['1', '2', '3']); + expect(encodeNumericArray(undefined)).not.toBeDefined(); + }); + }); + + describe('decodeNumericArray', () => { + it('produces the correct value', () => { + const output = decodeNumericArray(['1', '2', '3']); + const expectedOutput = [1, 2, 3]; + + expect(output).toEqual(expectedOutput); + expect(decodeNumericArray(undefined)).not.toBeDefined(); + expect(decodeNumericArray('')).not.toBeDefined(); + }); + + it('filters empty and NaN values', () => { + expect(decodeNumericArray(['1', '', '2', 'foo', '3'])).toEqual([1, 2, 3]); }); }); @@ -203,28 +253,66 @@ describe('serialize', () => { grill: undefined, }); }); + + it('handles array of values', () => { + expect(decodeObject(['foo-bar', 'jim-grill'])).toEqual({ foo: 'bar' }); + }); }); - describe('encodeNumericArray', () => { + describe('encodeDelimitedArray', () => { + it('produces the correct value', () => { + const input = ['a', 'b', 'c']; + expect(encodeDelimitedArray(input)).toBe('a_b_c'); + expect(encodeDelimitedArray(undefined)).not.toBeDefined(); + }); + }); + + describe('decodeDelimitedArray', () => { + it('produces the correct value', () => { + const output = decodeDelimitedArray('a_b_c'); + const expectedOutput = ['a', 'b', 'c']; + + expect(output).toEqual(expectedOutput); + expect(decodeDelimitedArray(undefined)).not.toBeDefined(); + expect(decodeDelimitedArray('')).not.toBeDefined(); + }); + + it('filters empty values', () => { + expect(decodeDelimitedArray('__')).toEqual([]); + }); + + it('handles array of values', () => { + expect(decodeDelimitedArray(['foo_bar', 'jim_grill'])).toEqual([ + 'foo', + 'bar', + ]); + }); + }); + + describe('encodeDelimitedNumericArray', () => { it('produces the correct value', () => { const input = [9, 4, 0]; - expect(encodeNumericArray(input)).toBe('9_4_0'); - expect(encodeNumericArray(undefined)).not.toBeDefined(); + expect(encodeDelimitedNumericArray(input)).toBe('9_4_0'); + expect(encodeDelimitedNumericArray(undefined)).not.toBeDefined(); }); }); - describe('decodeNumericArray', () => { + describe('decodeDelimitedNumericArray', () => { it('produces the correct value', () => { - const output = decodeNumericArray('9_4_0'); + const output = decodeDelimitedNumericArray('9_4_0'); const expectedOutput = [9, 4, 0]; expect(output).toEqual(expectedOutput); - expect(decodeNumericArray(undefined)).not.toBeDefined(); - expect(decodeNumericArray('')).not.toBeDefined(); + expect(decodeDelimitedNumericArray(undefined)).not.toBeDefined(); + expect(decodeDelimitedNumericArray('')).not.toBeDefined(); }); it('filters empty values', () => { - expect(decodeNumericArray('__')).toEqual([]); + expect(decodeDelimitedNumericArray('__')).toEqual([]); + }); + + it('handles array of values', () => { + expect(decodeDelimitedNumericArray(['1_2', '3_4'])).toEqual([1, 2]); }); }); @@ -262,5 +350,11 @@ describe('serialize', () => { grill: undefined, }); }); + + it('handles array of values', () => { + expect(decodeNumericObject(['foo-55', 'jim-100'])).toEqual({ + foo: 55, + }); + }); }); }); diff --git a/src/__tests__/useQueryParam-test.tsx b/src/__tests__/useQueryParam-test.tsx index cf0b281..5812448 100644 --- a/src/__tests__/useQueryParam-test.tsx +++ b/src/__tests__/useQueryParam-test.tsx @@ -8,7 +8,7 @@ import { calledPushQuery, calledReplaceQuery, } from './helpers'; -import { NumberParam, ArrayParam } from '../params'; +import { NumberParam, NumericArrayParam } from '../params'; // helper to setup tests function setupWrapper(query: ParsedQuery) { @@ -49,30 +49,38 @@ describe('useQueryParam', () => { }); it("doesn't decode more than necessary", () => { - const { wrapper, history, location } = setupWrapper({ foo: 'a_b_c' }); + const { wrapper, history, location } = setupWrapper({ + foo: ['1', '2', '3'], + }); const { result, rerender } = renderHook( - () => useQueryParam('foo', ArrayParam), + () => useQueryParam('foo', NumericArrayParam), { wrapper, } ); const [decodedValue, setter] = result.current; - expect(decodedValue).toEqual(['a', 'b', 'c']); + expect(decodedValue).toEqual([1, 2, 3]); rerender(); const [decodedValue2, setter2] = result.current; expect(decodedValue).toBe(decodedValue2); - setter2(['d', 'e', 'f'], 'replaceIn'); + setter2([4, 5, 6], 'replaceIn'); rerender(); const [decodedValue3, setter3] = result.current; expect(decodedValue).not.toBe(decodedValue3); - expect(decodedValue3).toEqual(['d', 'e', 'f']); + expect(decodedValue3).toEqual([4, 5, 6]); - setter3(['d', 'e', 'f'], 'push'); + setter3([4, 5, 6], 'push'); rerender(); const [decodedValue4, setter4] = result.current; expect(decodedValue3).toBe(decodedValue4); + + // if another parameter changes, this one shouldn't be affected + location.search = `${location.search}&zzz=123`; + rerender(); + const [decodedValue5, setter5] = result.current; + expect(decodedValue5).toBe(decodedValue3); }); }); diff --git a/src/__tests__/useQueryParams-test.tsx b/src/__tests__/useQueryParams-test.tsx index b8524b5..0aa05b8 100644 --- a/src/__tests__/useQueryParams-test.tsx +++ b/src/__tests__/useQueryParams-test.tsx @@ -51,7 +51,10 @@ describe('useQueryParams', () => { expect(decodedQuery).toEqual({ foo: 123, bar: 'xxx' }); setter({ foo: 555, baz: ['a', 'b'] }, 'push'); - expect(calledPushQuery(history, 0)).toEqual({ foo: '555', baz: 'a_b' }); + expect(calledPushQuery(history, 0)).toEqual({ + foo: '555', + baz: ['a', 'b'], + }); }); it('ignores unconfigured parameter', () => { diff --git a/src/params.ts b/src/params.ts index 3585f4b..3133109 100644 --- a/src/params.ts +++ b/src/params.ts @@ -49,6 +49,17 @@ export const ArrayParam: QueryParamConfig< decode: Serialize.decodeArray, }; +/** + * For flat arrays of strings, filters out undefined values during decode + */ +export const NumericArrayParam: QueryParamConfig< + number[] | null | undefined, + number[] | undefined +> = { + encode: Serialize.encodeNumericArray, + decode: Serialize.decodeNumericArray, +}; + /** * For any type of data, encoded via JSON.stringify */ @@ -94,13 +105,24 @@ export const NumericObjectParam: QueryParamConfig< decode: Serialize.decodeNumericObject, }; +/** + * For flat arrays of strings, filters out undefined values during decode + */ +export const DelimitedArrayParam: QueryParamConfig< + string[] | null | undefined, + string[] | undefined +> = { + encode: Serialize.encodeDelimitedArray, + decode: Serialize.decodeDelimitedArray, +}; + /** * For flat arrays where the values are numbers, filters out undefined values during decode */ -export const NumericArrayParam: QueryParamConfig< +export const DelimitedNumericArrayParam: QueryParamConfig< number[] | null | undefined, number[] | undefined > = { - encode: Serialize.encodeNumericArray, - decode: Serialize.decodeNumericArray, + encode: Serialize.encodeDelimitedNumericArray, + decode: Serialize.decodeDelimitedNumericArray, }; diff --git a/src/serialize.ts b/src/serialize.ts index a677fde..6cdc419 100644 --- a/src/serialize.ts +++ b/src/serialize.ts @@ -24,12 +24,19 @@ export function encodeDate(date: Date | null | undefined): string | undefined { * as necessary (aka, '2015', '2015-10', '2015-10-01'). * It will not work for dates that have times included in them. * - * @param {String} dateString String date form like '2015-10-01' + * If an array is provided, only the first entry is used. + * + * @param {String} input String date form like '2015-10-01' * @return {Date} parsed date */ export function decodeDate( - dateString: string | null | undefined + input: string | string[] | null | undefined ): Date | undefined { + if (input == null || !input.length) { + return undefined; + } + + const dateString = input instanceof Array ? input[0] : input; if (dateString == null || !dateString.length) { return undefined; } @@ -73,12 +80,20 @@ export function encodeBoolean( * Decodes a boolean from a string. "1" -> true, "0" -> false. * Everything else maps to undefined. * - * @param {String} boolStr the encoded boolean string + * If an array is provided, only the first entry is used. + * + * @param {String} input the encoded boolean string * @return {Boolean} the boolean value */ export function decodeBoolean( - boolStr: string | null | undefined + input: string | string[] | null | undefined ): boolean | undefined { + if (input == null) { + return undefined; + } + + const boolStr = input instanceof Array ? input[0] : input; + if (boolStr === '1') { return true; } else if (boolStr === '0') { @@ -105,15 +120,23 @@ export function encodeNumber( } /** - * Decodes a number from a string via parseFloat. If the number is invalid, + * Decodes a number from a string. If the number is invalid, * it returns undefined. * - * @param {String} numStr the encoded number string + * If an array is provided, only the first entry is used. + * + * @param {String} input the encoded number string * @return {Number} the number value */ export function decodeNumber( - numStr: string | null | undefined + input: string | string[] | null | undefined ): number | undefined { + if (input == null) { + return undefined; + } + + const numStr = input instanceof Array ? input[0] : input; + if (numStr == null || numStr === '') { return undefined; } @@ -130,11 +153,11 @@ export function decodeNumber( /** * Encodes a string while safely handling null and undefined values. * - * @param {String} string + * @param {String} str a string to encode * @return {String} the encoded string */ export function encodeString( - str: string | null | undefined + str: string | string[] | null | undefined ): string | undefined { if (str == null) { return undefined; @@ -146,12 +169,20 @@ export function encodeString( /** * Decodes a string while safely handling null and undefined values. * - * @param {String} str the encoded string + * If an array is provided, only the first entry is used. + * + * @param {String} input the encoded string * @return {String} the string value */ export function decodeString( - str: string | null | undefined + input: string | string[] | null | undefined ): string | undefined { + if (input == null) { + return undefined; + } + + const str = input instanceof Array ? input[0] : input; + if (str == null) { return undefined; } @@ -176,12 +207,20 @@ export function encodeJson(any: any | null | undefined): string | undefined { /** * Decodes a JSON string into javascript * - * @param {String} jsonStr The JSON string representation + * If an array is provided, only the first entry is used. + * + * @param {String} input The JSON string representation * @return {Any} The javascript representation */ export function decodeJson( - jsonStr: string | null | undefined + input: string | string[] | null | undefined ): any | undefined { + if (input == null) { + return undefined; + } + + const jsonStr = input instanceof Array ? input[0] : input; + if (!jsonStr) { return undefined; } @@ -200,9 +239,90 @@ export function decodeJson( * Encodes an array as a JSON string. * * @param {Array} array The array to be encoded - * @return {String} The JSON string representation of array + * @return {String[]} The array of strings to be put in the URL + * as repeated query parameters */ export function encodeArray( + array: string[] | null | undefined +): string[] | undefined { + if (!array) { + return undefined; + } + + return array; +} + +/** + * Decodes an array or singular value and returns it as an array + * or undefined if falsy. Filters out undefined values. + * + * @param {String | Array} input The input value + * @return {Array} The javascript representation + */ +export function decodeArray( + input: string | string[] | null | undefined +): string[] | undefined { + if (!input) { + return undefined; + } + + if (!(input instanceof Array)) { + return [input]; + } + + return input + .map(item => (item === '' ? undefined : item)) + .filter(item => item !== undefined) as string[]; +} + +/** + * Encodes a numeric array as a JSON string. + * + * @param {Array} array The array to be encoded + * @return {String[]} The array of strings to be put in the URL + * as repeated query parameters + */ +export function encodeNumericArray( + array: number[] | null | undefined +): string[] | undefined { + if (!array) { + return undefined; + } + + return array.map(d => `${d}`); +} + +/** + * Decodes an array or singular value and returns it as an array + * or undefined if falsy. Filters out undefined and NaN values. + * + * @param {String | Array} input The input value + * @return {Array} The javascript representation + */ +export function decodeNumericArray( + input: string | string[] | null | undefined +): number[] | undefined { + const arr = decodeArray(input); + + if (!arr) { + return undefined; + } + + return arr + .map(item => +item) + .filter(item => item !== undefined && !isNaN(item)) as number[]; +} + +/** + * Encodes an array as a delimited string. For example, + * ['a', 'b'] -> 'a_b' with entrySeparator='_' + * + * @param array The array to be encoded + * @param entrySeparator The string used to delimit entries + * @return The array as a string with elements joined by the + * entry separator + */ +export function encodeDelimitedArray( array: string[] | null | undefined, entrySeparator = '_' ): string | undefined { @@ -214,15 +334,26 @@ export function encodeArray( } /** - * Decodes a JSON string into javascript array + * Decodes a delimited string into javascript array. For example, + * 'a_b' -> ['a', 'b'] with entrySeparator='_' * - * @param {String} jsonStr The JSON string representation + * If an array is provided as input, only the first entry is used. + * + * @param {String} input The JSON string representation + * @param entrySeparator The array as a string with elements joined by the + * entry separator * @return {Array} The javascript representation */ -export function decodeArray( - arrayStr: string | null | undefined, +export function decodeDelimitedArray( + input: string | string[] | null | undefined, entrySeparator = '_' ): string[] | undefined { + if (input == null) { + return undefined; + } + + const arrayStr = input instanceof Array ? input[0] : input; + if (!arrayStr) { return undefined; } @@ -234,27 +365,31 @@ export function decodeArray( } /** - * Encodes a numeric array as a JSON string. (alias of encodeArray) + * Encodes a numeric array as a delimited string. (alias of encodeDelimitedArray) + * For example, [1, 2] -> '1_2' with entrySeparator='_' * * @param {Array} array The array to be encoded * @return {String} The JSON string representation of array */ -export const encodeNumericArray = encodeArray as ( +export const encodeDelimitedNumericArray = encodeDelimitedArray as ( array: number[] | null | undefined, entrySeparator?: string ) => string | undefined; /** - * Decodes a JSON string into javascript array where all entries are numbers + * Decodes a delimited string into javascript array where all entries are numbers + * For example, '1_2' -> [1, 2] with entrySeparator='_' + * + * If an array is provided as input, only the first entry is used. * * @param {String} jsonStr The JSON string representation * @return {Array} The javascript representation */ -export function decodeNumericArray( - arrayStr: string | null | undefined, +export function decodeDelimitedNumericArray( + arrayStr: string | string[] | null | undefined, entrySeparator = '_' ): number[] | undefined { - const decoded = decodeArray(arrayStr, entrySeparator); + const decoded = decodeDelimitedArray(arrayStr, entrySeparator); if (!decoded) { return undefined; @@ -262,12 +397,12 @@ export function decodeNumericArray( return decoded .map(d => (d == null ? undefined : +d)) - .filter(d => d !== undefined) as number[]; + .filter(d => d !== undefined && !isNaN(d)) as number[]; } /** - * Encode simple objects as readable strings. Currently works only for simple, - * flat objects where values are numbers, booleans or strings. + * Encode simple objects as readable strings. Works only for simple, + * flat objects where values are numbers, strings. * * For example { foo: bar, boo: baz } -> "foo-bar_boo-baz" * @@ -277,7 +412,7 @@ export function decodeNumericArray( * @return {String} The encoded object */ export function encodeObject( - obj: { [key: string]: string | undefined } | null | undefined, + obj: { [key: string]: string | number | undefined } | null | undefined, keyValSeparator = '-', entrySeparator = '_' ): string | undefined { @@ -292,20 +427,28 @@ export function encodeObject( /** * Decodes a simple object to javascript. Currently works only for simple, - * flat objects where values are numbers, booleans or strings. + * flat objects where values are strings. * * For example "foo-bar_boo-baz" -> { foo: bar, boo: baz } * - * @param {String} objStr The object string to decode + * If an array is provided as input, only the first entry is used. + * + * @param {String} input The object string to decode * @param {String} keyValSeparator="-" The separator between keys and values * @param {String} entrySeparator="_" The separator between entries * @return {Object} The javascript object */ export function decodeObject( - objStr: string | null | undefined, + input: string | string[] | null | undefined, keyValSeparator = '-', entrySeparator = '_' ): { [key: string]: string | undefined } | undefined { + if (input == null) { + return undefined; + } + + const objStr = input instanceof Array ? input[0] : input; + if (!objStr || !objStr.length) { return undefined; } @@ -341,19 +484,21 @@ export const encodeNumericObject = encodeObject as ( * * For example "foo-123_boo-521" -> { foo: 123, boo: 521 } * - * @param {String} objStr The object string to decode + * If an array is provided as input, only the first entry is used. + * + * @param {String} input The object string to decode * @param {String} keyValSeparator="-" The separator between keys and values * @param {String} entrySeparator="_" The separator between entries * @return {Object} The javascript object */ export function decodeNumericObject( - objStr: string | null | undefined, + input: string | string[] | null | undefined, keyValSeparator = '-', entrySeparator = '_' ): { [key: string]: number | undefined } | undefined { const decoded: | { [key: string]: number | string | undefined } - | undefined = decodeObject(objStr, keyValSeparator, entrySeparator); + | undefined = decodeObject(input, keyValSeparator, entrySeparator); if (!decoded) { return undefined; diff --git a/src/types.ts b/src/types.ts index 345a524..a605c91 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,16 +28,15 @@ export interface ExtendedLocation extends Location { * Encoded query parameters (all strings) */ export interface EncodedQuery { - [key: string]: string; + [key: string]: string | string[]; } /** * Encoded query parameters, possibly including null or undefined values */ export interface EncodedQueryWithNulls { - [key: string]: string | null | undefined; + [key: string]: string | string[] | null | undefined; } - /** * Configuration for a query param specifying how to encode it * (convert it to a string) and decode it (convert it from a string @@ -48,10 +47,10 @@ export interface EncodedQueryWithNulls { */ export interface QueryParamConfig { /** Convert the query param value to a string */ - encode: (value: D) => string | undefined; + encode: (value: D) => string | string[] | undefined; /** Convert the query param string value to its native type */ - decode: (value: string) => D2; + decode: (value: string | string[]) => D2; } /** diff --git a/src/useQueryParam.ts b/src/useQueryParam.ts index 7a49afc..e098d1c 100644 --- a/src/useQueryParam.ts +++ b/src/useQueryParam.ts @@ -1,5 +1,9 @@ import * as React from 'react'; -import { parse as parseQueryString, ParsedQuery } from 'query-string'; +import { + parse as parseQueryString, + stringify, + ParsedQuery, +} from 'query-string'; import { QueryParamContext } from './QueryParamProvider'; import { StringParam } from './params'; import { updateUrlQuery } from './updateUrlQuery'; @@ -28,18 +32,33 @@ export const useQueryParam = ( // read in the raw query if (!rawQuery) { - rawQuery = - (location.query as ParsedQuery) || - parseQueryString(location.search) || - {}; + rawQuery = React.useMemo( + () => + (location.query as ParsedQuery) || + parseQueryString(location.search) || + {}, + [location.query, location.search] + ); } // read in the encoded string value - const encodedValue = rawQuery[name] as string; + const encodedValue = rawQuery[name]; - // decode if the encoded value has changed, otherwise re-use memoized value - const decodedValue = React.useMemo(() => paramConfig.decode(encodedValue), [ - encodedValue, + // decode if the encoded value has changed, otherwise + // re-use memoized value + const decodedValue = React.useMemo(() => { + if (encodedValue == null) { + return undefined; + } + return paramConfig.decode(encodedValue); + + // note that we use the stringified encoded value since the encoded + // value may be an array that is recreated if a different query param + // changes. + }, [ + encodedValue instanceof Array + ? stringify({ name: encodedValue }) + : encodedValue, ]); // create the setter, memoizing via useCallback diff --git a/src/useQueryParams.ts b/src/useQueryParams.ts index a4a771f..0cdde52 100644 --- a/src/useQueryParams.ts +++ b/src/useQueryParams.ts @@ -50,8 +50,13 @@ export const useQueryParams = ( const { history, location } = React.useContext(QueryParamContext); // read in the raw query - const rawQuery = - (location.query as ParsedQuery) || parseQueryString(location.search) || {}; + const rawQuery = React.useMemo( + () => + (location.query as ParsedQuery) || + parseQueryString(location.search) || + {}, + [location.query, location.search] + ); // parse each parameter via usQueryParam const paramNames = Object.keys(paramConfigMap);