From fb0969226701a47873337f961a68f3f3f82cb9d9 Mon Sep 17 00:00:00 2001 From: Gido Manders Date: Fri, 14 Jun 2024 12:10:14 +0200 Subject: [PATCH] improvement: optional location When using the browser location, the global window.location is similar to the location provided by react router for instance. When you need to use useLocation only to get query params from the current URL, that makes the code longer and harder to maintain. Made location optional and default to global window.location. --- src/queryparams.ts | 28 +++-- src/useQueryParams.ts | 24 ++-- src/withQueryParams.tsx | 8 +- tests/queryparams.test.ts | 205 ++++++++++++++++++++-------------- tests/setupTests.ts | 4 + tests/useQueryParams.test.tsx | 43 +++++++ 6 files changed, 203 insertions(+), 109 deletions(-) diff --git a/src/queryparams.ts b/src/queryparams.ts index 76a1aeb..aae5e3e 100644 --- a/src/queryparams.ts +++ b/src/queryparams.ts @@ -16,14 +16,18 @@ import { parse, ParsedQuery } from 'query-string'; */ export function queryParamsFromLocation< QueryParams extends Record ->( - location: { search?: string }, - defaultQueryParams: QueryParams, - debugName: string -): QueryParams { - const currentQueryParams = location.search - ? queryParamsFromSearch(location.search) - : {}; +>({ + location, + defaultQueryParams, + debugName +}: { + location?: { search?: string }; + defaultQueryParams: QueryParams; + debugName: string; +}): QueryParams { + const currentQueryParams = queryParamsFromSearch( + location ? location.search : window.location.search + ); const mergedQueryParams = { ...defaultQueryParams, ...currentQueryParams }; return convertQueryParamsToConcreteType( mergedQueryParams, @@ -32,9 +36,13 @@ export function queryParamsFromLocation< ); } -function queryParamsFromSearch(search: string): ParsedQuery { +function queryParamsFromSearch(search?: string): ParsedQuery { + if (!search) { + return {}; + } + if (search[0] === '?') { - search = search.substr(1); + search = search.slice(1); } return parse(search); diff --git a/src/useQueryParams.ts b/src/useQueryParams.ts index c963240..95ea54e 100644 --- a/src/useQueryParams.ts +++ b/src/useQueryParams.ts @@ -1,8 +1,8 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { queryParamsFromLocation } from './queryparams'; export type Config = { - location: { search?: string; key?: string }; + location?: { search?: string; key?: string }; defaultQueryParams: QueryParams; debugName?: string; }; @@ -55,20 +55,18 @@ export function useQueryParams>( ): QueryParams { const { location, defaultQueryParams, debugName = '' } = options; const [queryParams, setQueryParams] = useState(() => { - return queryParamsFromLocation(location, defaultQueryParams, debugName); + return queryParamsFromLocation({ location, defaultQueryParams, debugName }); }); - const [search, setSearch] = useState(location.search); - - if (search !== location.search) { - const params = queryParamsFromLocation( - location, - defaultQueryParams, - debugName + useEffect(() => { + setQueryParams( + queryParamsFromLocation({ + location, + defaultQueryParams, + debugName + }) ); - setQueryParams(params); - setSearch(location.search); - } + }, [location ? location.search : window.location.search]); return queryParams; } diff --git a/src/withQueryParams.tsx b/src/withQueryParams.tsx index 3276046..3d38fd0 100644 --- a/src/withQueryParams.tsx +++ b/src/withQueryParams.tsx @@ -50,13 +50,13 @@ export const withQueryParams = < const { location, ...props } = this.props; // @ts-expect-error accept that there might be a displayName; - const debug = WithQueryParams.displayName; + const debugName: string = WithQueryParams.displayName; - const typedQueryParams = queryParamsFromLocation( + const typedQueryParams = queryParamsFromLocation({ location, defaultQueryParams, - debug - ); + debugName + }); return ; } diff --git a/tests/queryparams.test.ts b/tests/queryparams.test.ts index ff9b6a4..7698599 100644 --- a/tests/queryparams.test.ts +++ b/tests/queryparams.test.ts @@ -2,23 +2,42 @@ import { queryParamsFromLocation } from '../src/queryparams'; describe('queryParamsFromLocation', () => { it('should know how to convert strings that start with a question mark', () => { - const location = { search: '?query=hallo' }; + Object.defineProperty(window, 'location', { + writable: true, + value: { search: '?query=hallo' } + }); expect( - queryParamsFromLocation(location, { query: 'default' }, 'debug') - ).toEqual({ query: 'hallo' }); + queryParamsFromLocation({ + defaultQueryParams: { query: 'default' }, + debugName: 'debug' + }) + ).toEqual({ + query: 'hallo' + }); }); it('should know how to convert strings that start without a question mark', () => { - const location = { search: 'query=hallo' }; + Object.defineProperty(window, 'location', { + writable: true, + value: { search: 'query=hallo' } + }); expect( - queryParamsFromLocation(location, { query: 'default' }, 'debug') - ).toEqual({ query: 'hallo' }); + queryParamsFromLocation({ + defaultQueryParams: { query: 'default' }, + debugName: 'debug' + }) + ).toEqual({ + query: 'hallo' + }); }); it('should fallback to default query parameters when params are misssing', () => { - const location = { search: '' }; + Object.defineProperty(window, 'location', { + writable: true, + value: { search: '' } + }); const defaultQueryParams = { number: 42, numbers: [1, 2, 3], @@ -31,11 +50,10 @@ describe('queryParamsFromLocation', () => { booleans: [false, true, false] }; - const queryParams = queryParamsFromLocation( - location, + const queryParams = queryParamsFromLocation({ defaultQueryParams, - 'AppComponent' - ); + debugName: 'AppComponent' + }); expect(queryParams).toEqual({ number: 42, numbers: [1, 2, 3], @@ -60,14 +78,16 @@ describe('queryParamsFromLocation', () => { it('should warn when default query parameters does not contain a query param', () => { jest.spyOn(console, 'warn').mockImplementation(() => undefined); - const location = { search: '?color=red' }; + Object.defineProperty(window, 'location', { + writable: true, + value: { search: '?color=red' } + }); const defaultQueryParams = {}; - const queryParams = queryParamsFromLocation( - location, + const queryParams = queryParamsFromLocation({ defaultQueryParams, - 'AppComponent' - ); + debugName: 'AppComponent' + }); expect(queryParams).toEqual({ color: 'red' }); expect(console.warn).toHaveBeenCalledTimes(1); @@ -77,166 +97,187 @@ describe('queryParamsFromLocation', () => { }); it('should leave strings and unhandled types alone', () => { - const location = { search: '?color=red' }; + Object.defineProperty(window, 'location', { + writable: true, + value: { search: '?color=red' } + }); const defaultQueryParams = { color: 'blue' }; - const queryParams = queryParamsFromLocation( - location, + const queryParams = queryParamsFromLocation({ defaultQueryParams, - 'AppComponent' - ); + debugName: 'AppComponent' + }); expect(queryParams).toEqual({ color: 'red' }); }); describe('number', () => { it('should know how to transform whole numbers', () => { - const location = { search: '?id=42' }; + Object.defineProperty(window, 'location', { + writable: true, + value: { search: '?id=42' } + }); const defaultQueryParams = { id: 1 }; - const queryParams = queryParamsFromLocation( - location, + const queryParams = queryParamsFromLocation({ defaultQueryParams, - 'AppComponent' - ); + debugName: 'AppComponent' + }); expect(queryParams).toEqual({ id: 42 }); }); it('should know how to transform fractured numbers', () => { - const location = { search: '?id=1.12' }; + Object.defineProperty(window, 'location', { + writable: true, + value: { search: '?id=1.12' } + }); const defaultQueryParams = { id: 33.3 }; - const queryParams = queryParamsFromLocation( - location, + const queryParams = queryParamsFromLocation({ defaultQueryParams, - 'AppComponent' - ); + debugName: 'AppComponent' + }); expect(queryParams).toEqual({ id: 1.12 }); }); }); describe('boolean', () => { it('should know how to transform string "true" to boolean true', () => { - const location = { search: '?visible=false' }; + Object.defineProperty(window, 'location', { + writable: true, + value: { search: '?visible=false' } + }); const defaultQueryParams = { visible: true }; - const queryParams = queryParamsFromLocation( - location, + const queryParams = queryParamsFromLocation({ defaultQueryParams, - 'AppComponent' - ); + debugName: 'AppComponent' + }); expect(queryParams).toEqual({ visible: false }); }); it('should know how to transform string "false" to boolean false', () => { - const location = { search: '?visible=false' }; + Object.defineProperty(window, 'location', { + writable: true, + value: { search: '?visible=false' } + }); const defaultQueryParams = { visible: true }; - const queryParams = queryParamsFromLocation( - location, + const queryParams = queryParamsFromLocation({ defaultQueryParams, - 'AppComponent' - ); + debugName: 'AppComponent' + }); expect(queryParams).toEqual({ visible: false }); }); }); describe('array', () => { it('should leave arrays of strings alone', () => { - const location = { search: '?sizes=medium&sizes=large&sizes=small' }; + Object.defineProperty(window, 'location', { + writable: true, + value: { search: '?sizes=medium&sizes=large&sizes=small' } + }); const defaultQueryParams = { sizes: ['small'] }; - const queryParams = queryParamsFromLocation( - location, + const queryParams = queryParamsFromLocation({ defaultQueryParams, - 'AppComponent' - ); + debugName: 'AppComponent' + }); expect(queryParams).toEqual({ sizes: ['medium', 'large', 'small'] }); }); it('should know how to tranform arrays of numbers', () => { - const location = { search: '?numbers=1&numbers=2&numbers=3' }; + global.window.location = { + search: '?numbers=1&numbers=2&numbers=3' + } as Location; const defaultQueryParams = { numbers: [42] }; - const queryParams = queryParamsFromLocation( - location, + const queryParams = queryParamsFromLocation({ defaultQueryParams, - 'AppComponent' - ); + debugName: 'AppComponent' + }); expect(queryParams).toEqual({ numbers: [1, 2, 3] }); }); it('should know how to tranform arrays of booleans', () => { - const location = { + global.window.location = { search: '?visible=true&visible=false&visible=false' - }; + } as Location; const defaultQueryParams = { visible: [true] }; - const queryParams = queryParamsFromLocation( - location, + const queryParams = queryParamsFromLocation({ defaultQueryParams, - 'AppComponent' - ); + debugName: 'AppComponent' + }); expect(queryParams).toEqual({ visible: [true, false, false] }); }); it('should when it cannot guess the type, due to empty default, revert to strings', () => { - const location = { search: '?sizes=medium&sizes=large&sizes=small' }; + global.window.location = { + search: '?sizes=medium&sizes=large&sizes=small' + } as Location; const defaultQueryParams = { sizes: [] }; - const queryParams = queryParamsFromLocation( - location, + const queryParams = queryParamsFromLocation({ defaultQueryParams, - 'AppComponent' - ); + debugName: 'AppComponent' + }); expect(queryParams).toEqual({ sizes: ['medium', 'large', 'small'] }); }); describe('when encountering a singular value', () => { it('should convert to an array of strings when a singular string is given', () => { - const location = { search: '?sizes=large' }; + Object.defineProperty(window, 'location', { + writable: true, + value: { search: '?sizes=large' } + }); const defaultQueryParams = { sizes: ['small'] }; - const queryParams = queryParamsFromLocation( - location, + const queryParams = queryParamsFromLocation({ defaultQueryParams, - 'AppComponent' - ); + debugName: 'AppComponent' + }); expect(queryParams).toEqual({ sizes: ['large'] }); }); it('should convert to an array of numbers when a singular number is given', () => { - const location = { search: '?numbers=1' }; + Object.defineProperty(window, 'location', { + writable: true, + value: { search: '?numbers=1' } + }); const defaultQueryParams = { numbers: [42] }; - const queryParams = queryParamsFromLocation( - location, + const queryParams = queryParamsFromLocation({ defaultQueryParams, - 'AppComponent' - ); + debugName: 'AppComponent' + }); expect(queryParams).toEqual({ numbers: [1] }); }); it('should convert to an array of booleans when a singular boolean is given', () => { - const location = { search: '?visible=true' }; + Object.defineProperty(window, 'location', { + writable: true, + value: { search: '?visible=true' } + }); const defaultQueryParams = { visible: [false] }; - const queryParams = queryParamsFromLocation( - location, + const queryParams = queryParamsFromLocation({ defaultQueryParams, - 'AppComponent' - ); + debugName: 'AppComponent' + }); expect(queryParams).toEqual({ visible: [true] }); }); it('should convert to an array of the singular value when an empty default is given', () => { - const location = { search: '?sizes=medium' }; + Object.defineProperty(window, 'location', { + writable: true, + value: { search: '?sizes=medium' } + }); const defaultQueryParams = { sizes: [] }; - const queryParams = queryParamsFromLocation( - location, + const queryParams = queryParamsFromLocation({ defaultQueryParams, - 'AppComponent' - ); + debugName: 'AppComponent' + }); expect(queryParams).toEqual({ sizes: ['medium'] }); }); }); diff --git a/tests/setupTests.ts b/tests/setupTests.ts index f905a4a..1399ce3 100644 --- a/tests/setupTests.ts +++ b/tests/setupTests.ts @@ -1,4 +1,8 @@ beforeEach(() => { jest.clearAllMocks(); jest.resetAllMocks(); + Object.defineProperty(window, 'location', { + writable: true, + value: { search: '' } + }); }); diff --git a/tests/useQueryParams.test.tsx b/tests/useQueryParams.test.tsx index 410124d..955a3f5 100644 --- a/tests/useQueryParams.test.tsx +++ b/tests/useQueryParams.test.tsx @@ -75,4 +75,47 @@ describe('useQueryParams', () => { expect(result.current).toEqual({ query: 'default' }); }); + + test('that it uses window location when not providing location', () => { + const mockSearch = jest.fn(); + Object.defineProperty(window, 'location', { + value: { + get search() { + return mockSearch(); + } + } + }); + mockSearch.mockReturnValue('?query=red'); + + const defaultQueryParams = { query: 'default' }; + + const { result, rerender } = renderHook>( + (config: Config) => useQueryParams(config), + { + initialProps: { + defaultQueryParams + } + } + ); + + // Test that the first result will be 'hallo' + const initialResult = result.current; + expect(initialResult).toEqual({ query: 'red' }); + + // Change to something else + mockSearch.mockReturnValue('?query=blue'); + rerender({ + defaultQueryParams + }); + + expect(result.current).toEqual({ query: 'blue' }); + + // Change to empty string so it uses the default + mockSearch.mockReturnValue(''); + rerender({ + defaultQueryParams + }); + + expect(result.current).toEqual({ query: 'default' }); + }); });