diff --git a/README.md b/README.md index 8bcfe9111..75827d86a 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ Coming from `react-use`? Check out our — Tracks a numeric value and offers functions for manipulating it. - [**`useDebouncedState`**](https://react-hookz.github.io/web/?path=/docs/state-usedebouncedstate--example) — Like `useSafeState` but its state setter is debounced. + - [**`useList`**](https://react-hookz.github.io/web/?path=/docs/state-uselist--example) + — Tracks a list and offers functions for manipulating it. - [**`useMap`**](https://react-hookz.github.io/web/?path=/docs/state-usemap--example) — Tracks the state of a `Map`. - [**`useMediatedState`**](https://react-hookz.github.io/web/?path=/docs/state-usemediatedstate--example) diff --git a/src/__docs__/migrating-from-react-use.story.mdx b/src/__docs__/migrating-from-react-use.story.mdx index 0d0610ce2..5b62b2da2 100644 --- a/src/__docs__/migrating-from-react-use.story.mdx +++ b/src/__docs__/migrating-from-react-use.story.mdx @@ -658,7 +658,7 @@ Use [useCounter](/docs/state-usecounter--example) instead. #### useList -Not implemented yet +Implemented as [useList](/docs/state-uselist--example). #### useUpsert diff --git a/src/index.ts b/src/index.ts index a73480e1e..431727885 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ export { useLifecycleLogger } from './useLifecycleLogger/useLifecycleLogger'; // State export { useDebouncedState } from './useDebouncedState/useDebouncedState'; +export { useList } from './useList/useList'; export { useMap } from './useMap/useMap'; export { useMediatedState } from './useMediatedState/useMediatedState'; export { usePrevious } from './usePrevious/usePrevious'; diff --git a/src/useList/__docs__/example.stories.tsx b/src/useList/__docs__/example.stories.tsx new file mode 100644 index 000000000..b1dea34b3 --- /dev/null +++ b/src/useList/__docs__/example.stories.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { useList } from '../..'; + +export const Example: React.FC = () => { + const [ + list, + { + set, + push, + updateAt, + insertAt, + update, + updateFirst, + upsert, + sort, + filter, + removeAt, + clear, + reset, + }, + ] = useList([1, 2, 3, 4, 5]); + + return ( +
+ + + +
+
+ + + +
+
+ + + +
+
+ + +
+
+ + +
{JSON.stringify(list, null, 2)}
+
+ ); +}; diff --git a/src/useList/__docs__/story.mdx b/src/useList/__docs__/story.mdx new file mode 100644 index 000000000..3c753259b --- /dev/null +++ b/src/useList/__docs__/story.mdx @@ -0,0 +1,60 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './example.stories'; +import { ImportPath } from '../../storybookUtil/ImportPath'; + + + +# useList + +Tracks a list and offers functions for manipulating it. + +Manipulating the list directly will not cause a rerender. Instead, use the offered functions. + +> **_This hook provides a stable API, meaning the returned functions do not change between renders_** + +#### Example + + + + + +## Reference + +```ts +function useList(initialList: IInitialState): [T[], ListActions]; +``` + +#### Importing + + + +#### Arguments + +- _**initialList**_ _`IInitialState`_ - Initial list or function returning a list + +#### Return + +1. **list** - The current list. + +2. **actions** + + - **set** - Replaces the current list. + - **push** - Adds an item or items to the end of the list. + - **updateAt** - Replaces the item at the given index of the list. If the given index is out of + bounds, empty elements are appended to the list until the given item can be set to the given index. + - **insertAt** - Inserts an item at the given index of the list. All items following the given + index are shifted one position. If the given index is out of bounds, empty elements are appended + to the list until the given item can be set to the given index. + - **update** - Replaces all items of the list that match the given predicate with the given item. + - **updateFirst** - Replaces the first item of the list that matches the given predicate with the + given item. + - **upsert** - Replaces the first item of the list that matches the given predicate with the + given item. If none of the items match the predicate, the given item is pushed to the list. + - **sort** - Sorts the list with the given sorting function. If no sorting function is given, + the default Array.prototype.sort() sorting is used. + - **filter** - Filters the list with the given filter function. + - **removeAt** - Removes the item at the given index of the list. All items following the given + index will be shifted. If the given index is out of the bounds of the list, the list will not be + modified, but a rerender will occur. + - **clear** - Deletes all items of the list. + - **reset** - Replaces the current list with the initial list given to this hook. diff --git a/src/useList/__tests__/dom.ts b/src/useList/__tests__/dom.ts new file mode 100644 index 000000000..f89a0865a --- /dev/null +++ b/src/useList/__tests__/dom.ts @@ -0,0 +1,351 @@ +import { act, renderHook } from '@testing-library/react-hooks/dom'; +import { useList } from '../..'; + +describe('useList', () => { + it('should be defined', () => { + expect(useList).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => useList([])); + expect(result.error).toBeUndefined(); + }); + + it('should accept an initial list', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + expect(result.current[0]).toEqual([0, 1, 2]); + }); + + it('should return same actions object on every render', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const actions = result.current[1]; + + act(() => { + actions.set([3, 4, 5]); + }); + + expect(result.current[1]).toEqual(actions); + }); + + describe('set', () => { + it('should replace the current list', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { set } = result.current[1]; + + act(() => { + set([3, 4, 5]); + }); + + expect(result.current[0]).toEqual([3, 4, 5]); + }); + + it('should replace the current list with empty list', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { set } = result.current[1]; + + act(() => { + set([]); + }); + + expect(result.current[0]).toEqual([]); + }); + + it('should functionally replace the current list', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { set } = result.current[1]; + + act(() => { + set((current) => [...current, 3]); + }); + + expect(result.current[0]).toEqual([0, 1, 2, 3]); + }); + }); + + describe('push', () => { + it('should push a new item to the list', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { push } = result.current[1]; + + act(() => { + push(3); + }); + + expect(result.current[0]).toEqual([0, 1, 2, 3]); + }); + + it('should push multiple items to the list', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { push } = result.current[1]; + + act(() => { + push(3, 4, 5); + }); + + expect(result.current[0]).toEqual([0, 1, 2, 3, 4, 5]); + }); + }); + + describe('updateAt', () => { + it('should update item at given position', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { updateAt } = result.current[1]; + + act(() => { + updateAt(1, 0); + }); + + expect(result.current[0]).toEqual([0, 0, 2]); + }); + + it('should update item at position that is out of of bounds', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { updateAt } = result.current[1]; + + act(() => { + updateAt(4, 0); + }); + + expect(result.current[0]).toEqual([0, 1, 2, undefined, 0]); + }); + }); + + describe('insertAt', () => { + it('should insert item into given position in the list', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { insertAt } = result.current[1]; + + act(() => { + insertAt(1, 0); + }); + + expect(result.current[0]).toEqual([0, 0, 1, 2]); + }); + + it('should insert item into position that is out of bounds', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { insertAt } = result.current[1]; + + act(() => { + insertAt(4, 0); + }); + + expect(result.current[0]).toEqual([0, 1, 2, undefined, 0]); + }); + }); + + describe('update', () => { + it('should update all items that match given predicate', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { update } = result.current[1]; + + act(() => { + update((iteratedItem: number) => iteratedItem > 0, 0); + }); + + expect(result.current[0]).toEqual([0, 0, 0]); + }); + + it('should pass update predicate the iterated element and the replacement', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { update } = result.current[1]; + const predicate = jest.fn((_iteratedItem, _newElement) => false); + + act(() => { + update(predicate, 0); + }); + + expect(numberOfMockFunctionCalls(predicate)).toEqual(3); + expect(mockFunctionCallArgument(predicate, 0, 0)).toBe(0); + expect(mockFunctionCallArgument(predicate, 0, 1)).toBe(0); + }); + + it('should not update any items if none match given predicate', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { update } = result.current[1]; + + act(() => { + update((iteratedItem: number) => iteratedItem > 3, 0); + }); + + expect(result.current[0]).toEqual([0, 1, 2]); + }); + }); + + describe('updateFirst', () => { + it('should update the first item matching the given predicate', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { updateFirst } = result.current[1]; + + act(() => { + updateFirst((iteratedItem: number) => iteratedItem > 0, 0); + }); + + expect(result.current[0]).toEqual([0, 0, 2]); + }); + + it('should not update any items if none match given predicate', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { updateFirst } = result.current[1]; + + act(() => { + updateFirst((iteratedItem: number) => iteratedItem > 3, 0); + }); + + expect(result.current[0]).toEqual([0, 1, 2]); + }); + }); + + describe('upsert', () => { + it('should update the first item matching the given predicate', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { upsert } = result.current[1]; + + act(() => { + upsert((iteratedItem: number) => iteratedItem > 0, 0); + }); + + expect(result.current[0]).toEqual([0, 0, 2]); + }); + + it('should push given item to list, if no item matches the predicate', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { upsert } = result.current[1]; + + act(() => { + upsert((iteratedItem: number) => iteratedItem > 3, 0); + }); + + expect(result.current[0]).toEqual([0, 1, 2, 0]); + }); + + it('should pass predicate the iterated element and the new element', () => { + const { result } = renderHook(() => useList([0, 1, 2])); + const { upsert } = result.current[1]; + const predicate = jest.fn((_iteratedItem, _newElement) => false); + + act(() => { + upsert(predicate, 0); + }); + + expect(numberOfMockFunctionCalls(predicate)).toEqual(3); + expect(mockFunctionCallArgument(predicate, 0, 0)).toBe(0); + expect(mockFunctionCallArgument(predicate, 0, 1)).toBe(0); + }); + }); + + describe('sort', () => { + it('should sort list with given sorting function', () => { + const { result } = renderHook(() => useList([1, 0, 2])); + const { sort } = result.current[1]; + + act(() => { + sort((a, b) => b - a); + }); + + expect(result.current[0]).toEqual([2, 1, 0]); + }); + + it('should use default sorting if sort is called without arguments', () => { + const { result } = renderHook(() => useList([1, 0, 2])); + const { sort } = result.current[1]; + + act(() => { + sort(); + }); + + expect(result.current[0]).toEqual([0, 1, 2]); + }); + }); + + describe('filter', () => { + it('should filter list with given filter function', () => { + const { result } = renderHook(() => useList([1, 0, 2])); + const { filter } = result.current[1]; + + act(() => { + filter((a) => a > 0); + }); + + expect(result.current[0]).toEqual([1, 2]); + }); + + it('should pass element, its index and iterated list to filter function', () => { + const { result } = renderHook(() => useList([1, 0, 2])); + const { filter } = result.current[1]; + const filterFunction = jest.fn((_element, _index, _list) => false); + + act(() => { + filter(filterFunction); + }); + + expect(numberOfMockFunctionCalls(filterFunction)).toEqual(3); + expect(mockFunctionCallArgument(filterFunction, 0, 0)).toBe(1); + expect(mockFunctionCallArgument(filterFunction, 0, 1)).toBe(0); + expect(mockFunctionCallArgument(filterFunction, 0, 2)).toEqual([1, 0, 2]); + }); + }); + + describe('removeAt', () => { + it('should remove item from given index', () => { + const { result } = renderHook(() => useList([1, 0, 2])); + const { removeAt } = result.current[1]; + + act(() => { + removeAt(1); + }); + + expect(result.current[0]).toEqual([1, 2]); + }); + + it('should not remove items if given index is out of bounds', () => { + const { result } = renderHook(() => useList([1, 0, 2])); + const { removeAt } = result.current[1]; + + act(() => { + removeAt(6); + }); + + expect(result.current[0]).toEqual([1, 0, 2]); + }); + }); + + describe('clear', () => { + it('should clear the list', () => { + const { result } = renderHook(() => useList([1, 0, 2])); + const { clear } = result.current[1]; + + act(() => { + clear(); + }); + + expect(result.current[0]).toEqual([]); + }); + }); + + describe('reset', () => { + it('should reset the list to initial value', () => { + const { result } = renderHook(() => useList([1, 0, 2])); + const { reset, set } = result.current[1]; + + act(() => { + set([1, 1, 1]); + reset(); + }); + + expect(result.current[0]).toEqual([1, 0, 2]); + }); + }); +}); + +function numberOfMockFunctionCalls(mockFunction: jest.Mock) { + return mockFunction.mock.calls.length; +} + +function mockFunctionCallArgument( + mockFunction: jest.Mock, + callIndex: number, + argumentIndex: number +) { + return mockFunction.mock.calls[callIndex][argumentIndex]; +} diff --git a/src/useList/__tests__/ssr.ts b/src/useList/__tests__/ssr.ts new file mode 100644 index 000000000..f125a630d --- /dev/null +++ b/src/useList/__tests__/ssr.ts @@ -0,0 +1,13 @@ +import { renderHook } from '@testing-library/react-hooks/server'; +import { useList } from '../..'; + +describe('useList', () => { + it('should be defined', () => { + expect(useList).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => useList([1, 0, 2])); + expect(result.error).toBeUndefined(); + }); +}); diff --git a/src/useList/useList.ts b/src/useList/useList.ts new file mode 100644 index 000000000..b88f96d3f --- /dev/null +++ b/src/useList/useList.ts @@ -0,0 +1,172 @@ +import { SetStateAction, useMemo, useRef } from 'react'; +import { IInitialState, resolveHookState } from '../util/resolveHookState'; +import { useRerender, useSyncedRef } from '..'; + +export interface ListActions { + /** + * Replaces the current list. + */ + set: (newList: SetStateAction) => void; + + /** + * Adds an item or items to the end of the list. + */ + push: (...items: T[]) => void; + + /** + * Replaces the item at the given index of the list. If the given index is out of bounds, empty + * elements are appended to the list until the given item can be set to the given index. + */ + updateAt: (index: number, newItem: T) => void; + + /** + * Inserts an item at the given index of the list. All items following the given index are shifted + * one position. If the given index is out of bounds, empty elements are appended to the list until + * the given item can be set to the given index. + */ + insertAt: (index: number, item: T) => void; + + /** + * Replaces all items of the list that match the given predicate with the given item. + */ + update: (predicate: (iteratedItem: T, newItem: T) => boolean, newItem: T) => void; + + /** + * Replaces the first item of the list that matches the given predicate with the given item. + */ + updateFirst: (predicate: (iteratedItem: T, newItem: T) => boolean, newItem: T) => void; + + /** + * Replaces the first item of the list that matches the given predicate with the given item. If + * none of the items match the predicate, the given item is pushed to the list. + */ + upsert: (predicate: (iteratedItem: T, newItem: T) => boolean, newItem: T) => void; + + /** + * Sorts the list with the given sorting function. If no sorting function is given, the default + * Array.prototype.sort() sorting is used. + */ + sort: (compareFn?: (a: T, b: T) => number) => void; + + /** + * Filters the list with the given filter function. + */ + // We're allowing the type of thisArg to be any, because we are following the Array.prototype.filter API. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filter: (callbackFn: (value: T, index?: number, array?: T[]) => boolean, thisArg?: any) => void; + + /** + * Removes the item at the given index of the list. All items following the given index will be + * shifted. If the given index is out of the bounds of the list, the list will not be modified, + * but a rerender will occur. + */ + removeAt: (index: number) => void; + + /** + * Deletes all items of the list. + */ + clear: () => void; + + /** + * Replaces the current list with the initial list given to this hook. + */ + reset: () => void; +} + +export function useList(initialList: IInitialState): [T[], ListActions] { + const initial = useSyncedRef(initialList); + const list = useRef(resolveHookState(initial.current)); + const rerender = useRerender(); + + const actions = useMemo( + () => ({ + set: (newList: SetStateAction) => { + list.current = resolveHookState(newList, list.current); + rerender(); + }, + + push: (...items: T[]) => { + actions.set((currentList: T[]) => [...currentList, ...items]); + }, + + updateAt: (index: number, newItem: T) => { + actions.set((currentList: T[]) => { + const listCopy = [...currentList]; + listCopy[index] = newItem; + return listCopy; + }); + }, + + insertAt: (index: number, newItem: T) => { + actions.set((currentList: T[]) => { + const listCopy = [...currentList]; + + if (index >= listCopy.length) { + listCopy[index] = newItem; + } else { + listCopy.splice(index, 0, newItem); + } + + return listCopy; + }); + }, + + update: (predicate: (iteratedItem: T, newItem: T) => boolean, newItem: T) => { + actions.set((currentList: T[]) => + currentList.map((item: T) => (predicate(item, newItem) ? newItem : item)) + ); + }, + + updateFirst: (predicate: (iteratedItem: T, newItem: T) => boolean, newItem: T) => { + const indexOfMatch = list.current.findIndex((item: T) => predicate(item, newItem)); + + const NO_MATCH = -1; + if (indexOfMatch > NO_MATCH) { + actions.updateAt(indexOfMatch, newItem); + } + }, + + upsert: (predicate: (iteratedItem: T, newItem: T) => boolean, newItem: T) => { + const indexOfMatch = list.current.findIndex((item: T) => predicate(item, newItem)); + + const NO_MATCH = -1; + if (indexOfMatch > NO_MATCH) { + actions.updateAt(indexOfMatch, newItem); + } else { + actions.push(newItem); + } + }, + + sort: (compareFn?: (a: T, b: T) => number) => { + actions.set((currentList: T[]) => [...currentList].sort(compareFn)); + }, + + filter: (callbackFn: (value: T, index: number, array: T[]) => boolean, thisArg?: never) => { + /* + We're implementing filter based on the Array.prototype.filter API, thus the API is not going + to change, and we can turn off the no-array-callback-reference rule. Also, the filter API + requires the thisArg, so we can turn off the no-array-method-this-argument-rule. + */ + // eslint-disable-next-line unicorn/no-array-callback-reference, unicorn/no-array-method-this-argument + actions.set((currentList: T[]) => [...currentList].filter(callbackFn, thisArg)); + }, + + removeAt: (index: number) => { + actions.set((currentList: T[]) => { + const listCopy = [...currentList]; + if (index < listCopy.length) { + listCopy.splice(index, 1); + } + return listCopy; + }); + }, + + clear: () => actions.set([]), + + reset: () => actions.set([...resolveHookState(initial.current)]), + }), + [initial, rerender] + ); + + return [list.current, actions]; +}