Last active
July 9, 2022 00:45
-
-
Save webbower/6370ae71b58e18dae5fc187e51534f59 to your computer and use it in GitHub Desktop.
Webbower Standard Library
This file contains hidden or 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
import { describe } from 'src/utils/testing'; | |
import { renderHook } from '@testing-library/react-hooks'; | |
//////// useConstant.test.ts //////// | |
import useConstant from 'src/hooks/useConstant'; | |
describe('useConstant()', async assert => { | |
const testStateValue = { value: 'abc' }; | |
const { result } = renderHook(() => useConstant(testStateValue)); | |
assert({ | |
given: 'a value', | |
should: 'return the same value', | |
actual: result.current === testStateValue, | |
expected: true, | |
}); | |
}); | |
//////// useObjectState.test.ts //////// | |
import { renderHook, act } from '@testing-library/react-hooks'; | |
import { describe } from 'src/utils/testing'; | |
import type { RenderResult } from '@testing-library/react-hooks'; | |
import { useObjectState } from './useObjectState'; | |
import type { ObjectStateApi } from './useObjectState'; | |
describe('useObjectState()', async assert => { | |
type TestObjectState = { | |
str: string; | |
num: number; | |
list: string[]; | |
flag: boolean; | |
}; | |
type HookReturnType = [TestObjectState, ObjectStateApi<TestObjectState>]; | |
const getHookStateValue = (result: RenderResult<HookReturnType>) => result.current[0]; | |
const getHookStateApi = (result: RenderResult<HookReturnType>) => result.current[1]; | |
{ | |
let messWithInitialState = false; | |
const shouldUseMessedWithInitialState = () => messWithInitialState; | |
const initialState = { | |
str: '', | |
num: 0, | |
list: ['one', 'two', 'three'], | |
flag: false, | |
}; | |
const { result } = renderHook(() => | |
useObjectState<TestObjectState>( | |
shouldUseMessedWithInitialState() | |
? { | |
...initialState, | |
num: 1, | |
} | |
: initialState | |
) | |
); | |
const initialApi = getHookStateApi(result); | |
assert({ | |
given: 'hook initialization', | |
should: 'return the initial state value and the API', | |
actual: [getHookStateValue(result), getHookStateApi(result)], | |
expected: [initialState, initialApi], | |
}); | |
act(() => { | |
getHookStateApi(result).setField('num', 1); | |
}); | |
assert({ | |
given: 'updating one field with the `.setField()` API function', | |
should: 'return the updated state and the same API object by identity', | |
actual: [getHookStateValue(result), getHookStateApi(result) === initialApi], | |
expected: [ | |
{ | |
str: '', | |
num: 1, | |
list: ['one', 'two', 'three'], | |
flag: false, | |
}, | |
true, | |
], | |
}); | |
act(() => { | |
getHookStateApi(result).update({ str: 'updated', flag: true, list: [] }); | |
// Simulate an unstable initialState arg to ensure that `.reset()` will correctly set the original | |
// initialState when called. This unstable arg may happen when the initialState is set by a prop instead of | |
// a hard-coded value. | |
messWithInitialState = true; | |
}); | |
assert({ | |
given: 'updating object state with the `.update()` API function', | |
should: 'return the updated state', | |
actual: getHookStateValue(result), | |
expected: { | |
str: 'updated', | |
num: 1, | |
list: [], | |
flag: true, | |
}, | |
}); | |
act(() => { | |
getHookStateApi(result).reset(); | |
}); | |
assert({ | |
given: 'updating object state with the `.reset()` API function', | |
should: 'return the original initial state', | |
actual: getHookStateValue(result), | |
expected: initialState, | |
}); | |
act(() => { | |
getHookStateApi(result).set({ | |
str: 'set', | |
num: -1, | |
list: ['1', '2', '3'], | |
flag: true, | |
}); | |
}); | |
assert({ | |
given: 'updating the whole object state with the `.set()` API function', | |
should: 'return the updated state', | |
actual: getHookStateValue(result), | |
expected: { | |
str: 'set', | |
num: -1, | |
list: ['1', '2', '3'], | |
flag: true, | |
}, | |
}); | |
} | |
}); | |
//////// useMilestones.test.ts //////// | |
import { renderHook, act } from '@testing-library/react-hooks'; | |
import { describe, sinon } from 'src/utils/testing'; | |
import setProp from 'src/utils/setProp'; | |
import type { RenderResult } from '@testing-library/react-hooks'; | |
import type { PlainObject } from './types'; | |
import { useMilestones } from './useMilestones'; | |
describe('useMilestones()', async assert => { | |
type HookReturnType = ReturnType<typeof useMilestones>; | |
const getHookStateValue = (result: RenderResult<HookReturnType>) => result.current[1]; | |
const getHookStateApi = (result: RenderResult<HookReturnType>) => result.current[0]; | |
// Helper function to assert shape of hook API object | |
const transformObjectKeysToTypes = (obj: PlainObject): PlainObject => | |
Object.entries(obj).reduce((res, [key, value]) => setProp(res, key, typeof value), {}); | |
{ | |
const onCompletionSpy = sinon.spy(); | |
const { result } = renderHook(() => | |
useMilestones({ trueBool: true, falseBool: false, positive: 2, negative: -2 }, onCompletionSpy) | |
); | |
const api = getHookStateApi(result); | |
assert({ | |
given: 'the hook in its initial state', | |
should: 'have its completion state as false', | |
actual: getHookStateValue(result), | |
expected: false, | |
}); | |
assert({ | |
given: 'the hook in its initial state', | |
should: 'have object keys in the returned API that match the keys of the initialization object', | |
actual: transformObjectKeysToTypes(api), | |
expected: { trueBool: 'function', falseBool: 'function', positive: 'function', negative: 'function' }, | |
}); | |
act(() => { | |
api.trueBool(); | |
}); | |
assert({ | |
given: 'the first milestone function is called', | |
should: 'still have its completion state as false and the completion callback was not called', | |
actual: [getHookStateValue(result), onCompletionSpy.called], | |
expected: [false, false], | |
}); | |
act(() => { | |
api.falseBool(); | |
api.positive(); | |
api.positive(); | |
api.negative(); | |
api.negative(); | |
}); | |
assert({ | |
given: 'the rest of the milestone functions are called to completion', | |
should: 'have its completion state as true and the completion callback was called', | |
actual: [getHookStateValue(result), onCompletionSpy.called], | |
expected: [true, true], | |
}); | |
act(() => { | |
api.trueBool(); | |
api.falseBool(); | |
api.positive(); | |
api.negative(); | |
}); | |
assert({ | |
given: 'calling the milestone functions again after all milestones are completed', | |
should: 'keep the milestone completed state as true and not call the completion callback again', | |
actual: [getHookStateValue(result), onCompletionSpy.callCount], | |
expected: [true, 1], | |
}); | |
} | |
{ | |
const { result, rerender } = renderHook(() => useMilestones({ bool: true, number: 2 })); | |
const firstRenderApi = getHookStateApi(result); | |
act(() => { | |
rerender(); | |
}); | |
const secondRenderApi = getHookStateApi(result); | |
assert({ | |
given: 'multiple renders', | |
should: 'have returned hook API be referentially stable', | |
actual: [ | |
firstRenderApi === secondRenderApi, | |
firstRenderApi.bool === secondRenderApi.bool, | |
firstRenderApi.number === secondRenderApi.number, | |
], | |
expected: [true, true, true], | |
}); | |
} | |
}); | |
//////// useConsumedUrlParams.test.ts //////// | |
import { sinon } from 'sinon'; | |
import { renderHook } from '@testing-library/react-hooks'; | |
import proxyquire from 'proxyquire'; | |
import type { Location, History } from 'history'; | |
import { useConsumedUrlParams as actualUseConsumedUrlParams } from 'src/hooks'; | |
export const locationFixtureFactory = (location: Partial<Location> = {}): Location => ({ | |
key: '', | |
pathname: '/', | |
search: '', | |
state: null, | |
hash: '', | |
...location, | |
}); | |
export const historyFixtureFactory = (history: Partial<History> = {}): History => ({ | |
length: 1, | |
action: 'PUSH', | |
location: locationFixtureFactory(), | |
push: noop, | |
replace: noop, | |
go: noop, | |
goBack: noop, | |
goForward: noop, | |
block: () => noop, | |
listen: () => noop, | |
createHref: () => 'http://localhost.com', | |
...history, | |
}); | |
const mockReplace = sinon.spy(); | |
const { useConsumedUrlParams } = proxyquire<{ useConsumedUrlParams: typeof actualUseConsumedUrlParams }>( | |
'./useConsumedUrlParams', | |
{ | |
'react-router': { | |
useHistory: () => ({ | |
replace: mockReplace, | |
}), | |
}, | |
} | |
); | |
describe('useConsumedUrlParams()', async assert => { | |
{ | |
const testLocation = locationFixtureFactory({ | |
search: 'foo=1&bar=%2Ffoo%2Fbar.jpg&baz=http%3A%2F%2Fwww.example.com%2Ffoo%2Fbar.jpg%3Fquux%3Dhello%2520world', | |
}); | |
const { result } = renderHook(() => useConsumedUrlParams(testLocation)); | |
assert({ | |
given: 'no params list', | |
should: 'returns an empty object', | |
actual: result.current, | |
expected: {}, | |
}); | |
assert({ | |
given: 'no params list', | |
should: 'does not replace history', | |
actual: mockReplace.notCalled, | |
expected: true, | |
}); | |
} | |
{ | |
mockReplace.resetHistory(); | |
const testLocation = locationFixtureFactory({ | |
pathname: '/test/pathname', | |
search: 'foo=1&bar=%2Ffoo%2Fbar.jpg&baz=http%3A%2F%2Fwww.example.com%2Ffoo%2Fbar.jpg%3Fquux%3Dhello%2520world', | |
}); | |
const { result } = renderHook(() => useConsumedUrlParams(testLocation, ['foo', 'bar'])); | |
assert({ | |
given: 'params list', | |
should: 'returns the consumed params', | |
actual: result.current, | |
expected: { | |
foo: '1', | |
bar: '/foo/bar.jpg', | |
}, | |
}); | |
assert({ | |
given: 'params list', | |
should: 'calls history replace exactly once', | |
actual: mockReplace.callCount, | |
expected: 1, | |
}); | |
assert({ | |
given: 'params list', | |
should: 'calls history replace reduced params', | |
actual: mockReplace.args[0], | |
expected: [ | |
{ | |
pathname: testLocation.pathname, | |
search: '?baz=http%3A%2F%2Fwww.example.com%2Ffoo%2Fbar.jpg%3Fquux%3Dhello%2520world', | |
}, | |
], | |
}); | |
} | |
}); | |
This file contains hidden or 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
import { useRef, useEffect, useState } from 'react'; | |
const noop = () => {}; | |
//////// useConstant.ts //////// | |
// Old one | |
const useConstant = <StateType>(val: StateType): StateType => useRef(val).current; | |
// Experimental opt-in Lazy eval | |
const useConstant = <StateType>( | |
val: StateType, | |
lazy = false | |
): StateType => { | |
const ref = useRef(); | |
if (!ref.current) { | |
ref.current = lazy && typeof val === 'function' ? val() : val; | |
} | |
return ref.current; | |
}; | |
//////// useEffectOnMounted.ts //////// | |
const useEffectOnMounted = cb => { | |
useEffect(cb, []); | |
}; | |
//////// useBooleanState.ts //////// | |
interface BooleanStateApi { | |
on: () => void; | |
off: () => void; | |
toggle: () => void; | |
} | |
const useBooleanState = (initialState = false): [boolean, BooleanStateApi] => { | |
const [state, setState] = useState(initialState); | |
const api = useConstant<BooleanStateApi>({ | |
on: () => setState(true), | |
off: () => setState(false), | |
toggle: () => setState(current => !current), | |
}); | |
return [state, api]; | |
}; | |
//////// useObjectState.ts //////// | |
import type { Dispatch, SetStateAction } from 'react'; | |
import type { PlainObject, Values } from './types'; | |
export type ObjectStateApi<T extends PlainObject = PlainObject> = { | |
set: Dispatch<SetStateAction<T>>; | |
reset: () => void; | |
setField: (fieldName: keyof T, fieldValue: Values<T>) => void; | |
update: (newPartialState: Partial<T>) => void; | |
}; | |
/** | |
* Custom hook that wraps @see useState and provides better state updater primitive functions for dealing with POJO | |
* state. | |
*/ | |
const useObjectState = <T extends PlainObject = PlainObject>( | |
initialState: T = {} as T | |
): [T, ObjectStateApi<T>] => { | |
const [state, setState] = useState(initialState); | |
const api = useConstant<ObjectStateApi<T>>({ | |
/** | |
* Overwrite the whole state. This is the default state setter. | |
*/ | |
set: setState, | |
/** | |
* Reset the state to the first `initialState` | |
*/ | |
reset: () => { | |
// NOTE This will be the first initialState because `useConstant()` for the API captures the first value in | |
// this function definition closure. | |
setState(initialState); | |
}, | |
/** | |
* Set a single field to a specific value | |
*/ | |
setField: (fieldName, fieldValue) => { | |
setState(current => ({ | |
...current, | |
[fieldName]: fieldValue, | |
})); | |
}, | |
/** | |
* Update only the fields provided in the argument | |
*/ | |
update: newPartialState => { | |
setState(current => ({ | |
...current, | |
...newPartialState, | |
})); | |
}, | |
}); | |
return [state, api]; | |
}; | |
//////// useFieldState.ts //////// | |
export const NO_ERROR_VALUE = null; | |
export type FieldState<E = string> = { | |
value: string; | |
error: E | typeof NO_ERROR_VALUE; | |
touched: boolean; | |
}; | |
type FieldStateConstructor = { | |
(state?: Partial<FieldState>): FieldState; | |
}; | |
export const FieldState: FieldStateConstructor = ( | |
{ value = '', error = NO_ERROR_VALUE, touched = false } = {} as FieldState | |
) => ({ | |
value, | |
error, | |
touched, | |
}); | |
type FieldStateApi = ObjectStateApi<FieldState> & { | |
setFieldValue: (newValue: FieldState['value']) => void; | |
clearFieldValue: () => void; | |
setFieldError: (newError: FieldState['error']) => void; | |
clearFieldError: () => void; | |
fieldWasTouched: () => void; | |
}; | |
export const useFieldState = (initialState: FieldState = FieldState()): [FieldState, FieldStateApi] => { | |
const [state, objectApi] = useObjectState(initialState); | |
const api = useConstant<FieldStateApi>({ | |
...objectApi, | |
setFieldValue(newValue) { | |
objectApi.setField('value', newValue); | |
}, | |
clearFieldValue() { | |
objectApi.setField('value', ''); | |
}, | |
setFieldError(newError) { | |
objectApi.setField('error', newError); | |
}, | |
clearFieldError() { | |
objectApi.setField('error', NO_ERROR_VALUE); | |
}, | |
fieldWasTouched() { | |
objectApi.setField('touched', true); | |
}, | |
}); | |
return [state, api]; | |
}; | |
export const getFieldValue = ({ value }: FieldState): FieldState['value'] => value; | |
export const doesFieldHaveValue = (fieldState: FieldState): boolean => getFieldValue(fieldState) !== ''; | |
export const getFieldFirstError = ({ error }: FieldState): FieldState['error'] => error; | |
export const isFieldValid = (fieldState: FieldState): boolean => !!getFieldFirstError(fieldState); | |
export const isFieldTouched = ({ touched }: FieldState): FieldState['touched'] => touched; | |
//////// useComponentApi.ts //////// | |
import isEmptyObject from 'src/utils/isEmptyObject'; | |
type ApiFunction = (...args: any[]) => unknown; | |
export type ComponentApi = Record<string, ApiFunction>; | |
export type OpaqueApiSetter = { | |
set(api: ComponentApi, id?: string): void; | |
unset(id?: string): void; | |
}; | |
/** | |
* The type for a `useComponentApi()` that only holds one, non-namespaced component API | |
*/ | |
type SingleComponentApi = ComponentApi; | |
/** | |
* The type for a `useComponentApi()` that holds multiple namespaced component APIs | |
*/ | |
type MultiComponentApi = Record<string, SingleComponentApi>; | |
/** | |
* Define a public API for a component that can be passed up to parent components | |
* | |
* These hooks provide the ability for a component to expose a public API for performing programmatic actions defined by | |
* the component. `useComponentApiDef()` is used to defined the component API and connect it to the paired | |
* `useComponentApi()` which holds the API for one or more child components by the parent component. It borrows some | |
* concepts from native DOM refs. Where a ref usually provides a variable to hold a native DOM node, requiring the | |
* component holding it to manually operate on the DOM node, and API is a defined set of functionality for the component | |
* which allows a component to define a set of actions for a parent to use which can provide consistency as public APIs | |
* are meant to. For example, with a custom component: | |
* | |
* - Using a ref for the <input> element of the custom component, if you want to programmatically focus on the <input>, | |
* you would call the `.focus()` method on the DOM node. If there was any other behavior the component needed to | |
* happen when focusing on the element, each parent component would need to include that manually. | |
* - Using an API for the custom component, the exposed `.focus()` function would not only focus on the <input> element, | |
* the custom component can define additional things that need to happen for consistency when programmatically | |
* focusing. | |
* | |
* These custom hooks store stable copies of the defined and held component APIs. | |
* | |
* - `useComponentApiDef()` takes one required arg (the defined API object) and 2 optional args: the `setApi` that comes | |
* from `useComponentApi()` and an id to optionally namespace the API for the holding component in case it needs to | |
* hold multiple component APIs. It returns the API defined by the first arg for use inside the defining component. | |
* - `useComponentApi()` takes zero args and returns a tuple of the defined API(s) and the opaque `setApi` object which | |
* is passed to the child components that define an API that will be used. If the child components include namespaces, | |
* the API object have multiple APIs under namespaces (e.g. a form holding APIs for its form fields would have each | |
* component API namespaced under the `name` prop value of the form field component). If the child component does not | |
* define a namespace, then its API will be the top-level value of the API. These are referred to as MultiComponentApi | |
* and SingleComponentApi, respectively. | |
* | |
* SingleComponentApi example: | |
* <code> | |
* interface ChildApi extends ComponentApi { | |
* func1: (...) => void; | |
* func2: (...) => void; | |
* } | |
* | |
* const Child = ({ api: setApi, onEvent, ...props }) => { | |
* useComponentApiDef<ChildApi>({ | |
* func1: (...) => {...}, | |
* func1: (...) => {...}, | |
* }, setApi); | |
* | |
* onEvent(...); | |
* | |
* return (...); | |
* }; | |
* | |
* const Parent => (props) => { | |
* const [childApi, setApi] = useComponentApi<ChildApi>(); | |
* | |
* return ( | |
* <Child api={setApi} onEvent={() => { childApi.func1(); }} /> | |
* ); | |
* }; | |
* </code> | |
* | |
* MultiComponentApi example: | |
* <code> | |
* interface InputFieldAPi extends ComponentApi { | |
* func1: (...) => void; | |
* func2: (...) => void; | |
* } | |
* | |
* const InputField = ({ name, api: setApi, onChange, ...props }) => { | |
* useComponentApiDef<ChildApi>({ | |
* func1: (...) => {...}, | |
* func1: (...) => {...}, | |
* }, setApi, name); | |
* | |
* onEvent(...); | |
* | |
* return (...); | |
* }; | |
* | |
* const Form => (props) => { | |
* // The type for MultiComponentApi must resolved to an object of string keys and `ComponentApi` values. If all the | |
* // field component APIs are the same type, you can use Record<"fieldName1" | "fieldName2" | "fieldNameN", FieldApi> | |
* // but if you have different types of fields, you will need to explicitly define the object type for each key. | |
* const [fieldApis, setApi] = useComponentApi<Record<'firstName' | 'lastName', InputFieldApi>>(); | |
* | |
* return ( | |
* <InputField name="firstName" api={setApi} onEvent={() => { fieldApis.firstName.func1(); }} /> | |
* <InputField name="lastName" api={setApi} onEvent={() => { fieldApis.lastName.func1(); }} /> | |
* ); | |
* }; | |
* </code> | |
*/ | |
export const useComponentApiDef = <Api extends ComponentApi>( | |
apiObject: Api, | |
setApi?: OpaqueApiSetter, | |
id?: string | |
): Api => { | |
const apiRef = useRef<Api>({} as Api); | |
useEffectOnMounted(() => { | |
apiRef.current = apiObject; | |
// Only attempt to set the API if the setter was provided | |
if (setApi) { | |
setApi.set(apiRef.current, id); | |
} | |
return () => { | |
// Only attempt to unset the API if the setter was provided | |
if (setApi) { | |
setApi.unset(id); | |
} | |
}; | |
}); | |
return apiRef.current; | |
}; | |
export const useComponentApi = <Api extends SingleComponentApi | MultiComponentApi>( | |
forceSingleComponentApi = false | |
): [Api, OpaqueApiSetter] => { | |
// Store the original setting to lock in single component API so that it can't be accidentally overridden later and | |
// cause unexpected behavior | |
const shouldBeSingleComponentApi = useConstant(forceSingleComponentApi); | |
const api = useRef<Api>({} as Api); | |
const setApi = useConstant<OpaqueApiSetter>({ | |
set: (componentApi, id) => { | |
if (!shouldBeSingleComponentApi && id) { | |
(api.current as MultiComponentApi)[id] = componentApi; | |
} else { | |
// If there are some components with that provide ids and one or more that don't, we want to avoid | |
// clobbering all of the ones with namespaces by a component API without an ID | |
if (!isEmptyObject(api.current)) { | |
throw new Error( | |
'Unable to set single API. API object appears to have namespaced APIs already attached and setting single API would override all of them.' | |
); | |
} | |
if (Object.keys(api.current).length > 0) { | |
throw new Error( | |
'Attempting to override single component API that is already set for this component.' | |
); | |
} | |
(api.current as SingleComponentApi) = componentApi; | |
} | |
}, | |
unset: id => { | |
if (!shouldBeSingleComponentApi && id) { | |
// If we have an `id` and ONLY if that `id` exists as a namespace on the `api` object do we delete it | |
if ((api.current as MultiComponentApi)[id]) { | |
delete (api.current as MultiComponentApi)[id]; | |
} | |
} else { | |
(api.current as SingleComponentApi) = {}; | |
} | |
}, | |
}); | |
return [api.current, setApi]; | |
}; | |
//////// useMilestones.ts //////// | |
import deepEqual from 'fast-deep-equal'; | |
type MilestoneGoal = boolean | number; | |
type MilestonesData = Record<string, MilestoneGoal>; | |
type MilestoneTrigger = () => void; | |
type MilestonesTriggers = Record<string, MilestoneTrigger>; | |
const determineInitialMilestoneState = (goal: MilestoneGoal) => (typeof goal === 'boolean' ? !goal : 0); | |
/** | |
* The useMilestones custom hook allows you to track state for the completion of a single state that is based on | |
* multiple goals (e.g. one user interaction must happen once and another interaction must happen n times). | |
* | |
* The hook takes a dict of milestone names and target boolean or number goals and an optional callback function that | |
* will be called when all milestones are completed. It returns a dict with keys that match the milestones dict that map | |
* to functions which will progress that milestone (boolean goals are flipped, number goals are incremented/decremented | |
* depending on whether they are positive or negative goals values) and a boolean signaling the completion of the | |
* milestones. | |
* | |
* <code> | |
* // Contrived example to determine whether a form field has been "touched" (it has received focus and had more than 5 | |
* // changes to its value) | |
* const [ | |
* { blurred: fieldWasBlurred, changedCount: fieldValueWasChanged }, | |
* isTouched, | |
* ] = useMilestones({ touched: true, changed: 5 }, () => { console.log('Field was touched'); }); | |
* | |
* <input | |
* onChange={ev => { | |
* const { name, value } = ev.target; | |
* // ... other state changes | |
* fieldValueWasChanged(); | |
* }} | |
* onBlur={ev => { | |
* fieldWasBlurred(); | |
* }} | |
* /> | |
* | |
* {isTouched ? <p>'Field was touched'</p> : null} | |
* </code> | |
* | |
* @param milestones A dict of milestone names and target completion values (goals) | |
* @param onMilestonesCompleted An optional callback function that will be called when all the milestones are completed | |
* @returns A tuple where the first index is the milestones progress triggers and the second index is a boolean that | |
* will be true when all the milestone goals are reached and false prior to that. | |
*/ | |
export const useMilestones = ( | |
milestones: MilestonesData, | |
onMilestonesCompleted: () => void = noop | |
): [MilestonesTriggers, boolean] => { | |
// Cache the original milestones and completion callback | |
const finalGoal = useConstant(milestones); | |
const completionCallback = useConstant(onMilestonesCompleted); | |
// We need to iterate on the milestones twice so save calling Object.entries() twice | |
const milestonesEntries = Object.entries(finalGoal); | |
// Generate the internal milestone tracking state. Set with function signature for initial state to prevent | |
// recalculation on re-renders: | |
// - Booleans are initialized as their inverse | |
// - Numbers are initialized at 0 | |
const [milestonesState, setMilestoneState] = useState(() => | |
milestonesEntries.reduce<MilestonesData>( | |
(state, [name, goal]) => (state[name] = determineInitialMilestoneState(goal), state), | |
{} | |
) | |
); | |
// Produce the returned hook API to trigger the completion/progress of milestone goals. Use function style useState | |
// initialState to prevent recalculation on re-renders. | |
const [triggers] = useState(() => | |
milestonesEntries.reduce<MilestonesTriggers>( | |
(ts, [name, goal]) => ( | |
ts[name] = () => { | |
setMilestoneState(current => { | |
const currentMilestone = current[name]; | |
// If the current value for the milestone doesn't match the goal, perform the update logic | |
if (currentMilestone !== goal) { | |
let nextValue: MilestoneGoal | null = null; | |
// Boolean goals get flipped | |
if (typeof currentMilestone === 'boolean') { | |
nextValue = !currentMilestone; | |
} | |
// Number goals get incremented or decremented | |
if (typeof currentMilestone === 'number') { | |
nextValue = goal < 0 ? currentMilestone - 1 : currentMilestone + 1; | |
} | |
// If a goal value was updated, update the internal state | |
if (nextValue != null) { | |
return { | |
...current, | |
[name]: nextValue, | |
}; | |
} | |
} | |
return current; | |
}); | |
}), ts), | |
{} | |
) | |
); | |
// Compare progress state to original milestones to determine completion | |
const areMilestonesCompleted = deepEqual(finalGoal, milestonesState); | |
// If completion happened, trigger callback | |
useEffect(() => { | |
if (areMilestonesCompleted) { | |
completionCallback(); | |
} | |
}, [areMilestonesCompleted, completionCallback]); | |
// Return boolean flag for if completion successful and triggers. Triggers come first because those will always be | |
// used whereas code may use completion state and/or completion callback so completion state could be ignored | |
return [triggers, areMilestonesCompleted]; | |
}; | |
//////// useConsumedUrlParams.ts //////// | |
import { useHistory } from 'react-router'; | |
import type { Location } from 'history'; | |
type CopiedLocationKeys = Partial<Pick<Location, 'pathname' | 'search' | 'hash'>>; | |
const copyLocationKeys = (location: Location): CopiedLocationKeys => | |
(['pathname', 'search', 'hash'] as (keyof CopiedLocationKeys)[]).reduce<CopiedLocationKeys>( | |
(l, key) => (location[key] ? Object.assign(l, { [key]: location[key] }) : l), | |
{} | |
); | |
export const extractQueryParamsFromRouterLocation = ( | |
location: Location, | |
paramList: string[] = [] | |
): [Partial<Location>, Record<string, string>] => { | |
if (paramList.length === 0) { | |
return [copyLocationKeys(location), {}]; | |
} | |
const params = new URLSearchParams(location.search); | |
const consumedParams = paramList.reduce<Record<string, string>>((result, param) => { | |
const value = params.get(param); | |
if (value !== null) { | |
params.delete(param); | |
return (result[param] = value, result); | |
} | |
return result; | |
}, {}); | |
return [ | |
{ | |
...copyLocationKeys(location), | |
search: `?${params.toString()}`, | |
}, | |
consumedParams, | |
]; | |
}; | |
type ParamsObject = Record<string, string>; | |
export const useConsumedUrlParams = (location: Location, paramsList: string[] = []): ParamsObject => { | |
const [locationWithoutParams, consumedParams] = extractQueryParamsFromRouterLocation(location, paramsList); | |
const history = useHistory(); | |
const params = useConstant(consumedParams); | |
useEffectOnMounted(() => { | |
if (Object.keys(consumedParams).length) { | |
history.replace(locationWithoutParams); | |
} | |
}); | |
return params; | |
}; | |
export default useConsumedUrlParams; |
This file contains hidden or 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
import type { MutableRefObject } from 'react'; | |
/** Global general-use and app types */ | |
export type EmptyObject = Record<string, never>; | |
/** | |
* A base type to use for when you need a type that represents a JS object | |
* | |
* TS advises against using `{}` if you want to make a value as a JS object with no specific keys defined. It advises | |
* this type for that use case. | |
*/ | |
export type PlainObject = Record<string, any>; | |
export type ForwardedRef<T> = ((instance: T | null) => void) | MutableRefObject<T | null> | null; | |
export type Nullish = null | undefined; | |
/** | |
* The type for a function that takes a value and returns true or false depending on whether the values passes a test | |
* | |
* This kind of function is used in `Array.filter()` and the `validate()` utility | |
*/ | |
export type Predicate<A = any> = (x: A) => boolean; | |
/** | |
* Interface to extend from for functional components that don't accept `children`. To be used when defining props | |
* interface for a component that uses the `FC` type. `FC` type allows for `children` by default. It can be used on its | |
* own for a component that does not accept any other props, or the component's prop interface can extend from it. | |
* | |
* "Empty" is used in the name to mimic "empty elements" in HTML which are elements that don't take child elements. | |
* | |
* @see https://developer.mozilla.org/en-US/docs/Glossary/Empty_element | |
*/ | |
export interface EmptyComponent { | |
children?: never; | |
} | |
/** | |
* Given the `typeof` a JS object const, return a union type of all the values of the object | |
* | |
* <code> | |
* const MyEnum = { | |
* ONE: 'one', | |
* TWO: 'two', | |
* THREE: 'three', | |
* } as const; | |
* | |
* type MyEnumValues = Values<typeof MyEnum>; // 'one' | 'two' | 'three' | |
* </code> | |
*/ | |
export type Values<T extends Record<string, unknown>> = T[keyof T]; | |
/** | |
* Converts an object type where all keys are made optional except those listed in RequiredKeys which become required | |
* | |
* <code> | |
* type MyProps = { | |
* prop1: string; | |
* prop2: number; | |
* prop3: string[]; | |
* prop4: OtherType[]; | |
* } | |
* | |
* type Modified = PartialExcept<MyProps, 'prop1' | 'prop3'>; | |
* // type Modified = { | |
* prop1: string; | |
* prop2?: number; | |
* prop3: string[]; | |
* prop4?: OtherType[]; | |
* // } | |
* </code> | |
*/ | |
export type PartialExcept<T, RequiredKeys extends keyof T> = Partial<Omit<T, RequiredKeys>> & | |
{ | |
[K in RequiredKeys]: T[K]; | |
}; | |
/** | |
* Type to require that a List value is non-empty | |
* | |
* TS has shortcomings where when you use this type, you'll need to provide a hard-coded first entry when you might | |
* normally just `.map()` transform. You'll have to destructure the first and rest entries and manually use the first | |
* entry to create a concrete first result entry. Otherwise, the result of an `arr.map()` won't be able to satisfy this | |
* type. | |
*/ | |
export type NonEmptyList<T> = [T, ...T[]]; | |
/** | |
* Type for factory functions that define a default value for all keys and allow for partial overriding | |
*/ | |
export type Factory<T> = (data?: Partial<T>) => T; | |
export type Factory<T, RequiredKeys extends keyof T = ''> = (data?: PartialExcept<T, RequiredKeys>) => T; | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment