From 032d9a720c89286dc8c1931775144f0a65a6149e Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Fri, 24 Mar 2023 17:41:17 -0700 Subject: [PATCH] Extract useSelectState from SelectContext --- frontend/src/App/SelectContext.tsx | 150 ++++-------------- frontend/src/Helpers/Hooks/useSelectState.tsx | 113 +++++++++++++ .../Index/Select/SeriesIndexPosterSelect.tsx | 4 +- .../Select/SeriesIndexSelectAllButton.tsx | 6 +- .../Select/SeriesIndexSelectAllMenuItem.tsx | 6 +- .../Index/Select/SeriesIndexSelectFooter.tsx | 4 +- .../Select/SeriesIndexSelectModeButton.tsx | 4 +- .../Select/SeriesIndexSelectModeMenuItem.tsx | 4 +- .../src/Series/Index/Table/SeriesIndexRow.tsx | 4 +- .../Index/Table/SeriesIndexTableHeader.tsx | 4 +- 10 files changed, 164 insertions(+), 135 deletions(-) create mode 100644 frontend/src/Helpers/Hooks/useSelectState.tsx diff --git a/frontend/src/App/SelectContext.tsx b/frontend/src/App/SelectContext.tsx index 6980129c1..66be388ce 100644 --- a/frontend/src/App/SelectContext.tsx +++ b/frontend/src/App/SelectContext.tsx @@ -1,58 +1,28 @@ import { cloneDeep } from 'lodash'; -import React, { useEffect } from 'react'; -import areAllSelected from 'Utilities/Table/areAllSelected'; -import selectAll from 'Utilities/Table/selectAll'; -import toggleSelected from 'Utilities/Table/toggleSelected'; +import React, { useCallback, useEffect } from 'react'; +import useSelectState, { SelectState } from 'Helpers/Hooks/useSelectState'; import ModelBase from './ModelBase'; -export enum SelectActionType { - Reset, - SelectAll, - UnselectAll, - ToggleSelected, - RemoveItem, - UpdateItems, -} - -type SelectedState = Record; - -interface SelectState { - selectedState: SelectedState; - lastToggled: number | null; - allSelected: boolean; - allUnselected: boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - items: any[]; -} - -type SelectAction = - | { type: SelectActionType.Reset } - | { type: SelectActionType.SelectAll } - | { type: SelectActionType.UnselectAll } +export type SelectContextAction = + | { type: 'reset' } + | { type: 'selectAll' } + | { type: 'unselectAll' } | { - type: SelectActionType.ToggleSelected; + type: 'toggleSelected'; id: number; isSelected: boolean; shiftKey: boolean; } | { - type: SelectActionType.RemoveItem; + type: 'removeItem'; id: number; } | { - type: SelectActionType.UpdateItems; + type: 'updateItems'; items: ModelBase[]; }; -type Dispatch = (action: SelectAction) => void; - -const initialState = { - selectedState: {}, - lastToggled: null, - allSelected: false, - allUnselected: true, - items: [], -}; +export type SelectDispatch = (action: SelectContextAction) => void; interface SelectProviderOptions { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -60,90 +30,40 @@ interface SelectProviderOptions { items: Array; } -function getSelectedState(items: ModelBase[], existingState: SelectedState) { - return items.reduce((acc: SelectedState, item) => { - const id = item.id; - - acc[id] = existingState[id] ?? false; - - return acc; - }, {}); -} - -// TODO: Can this be reused? - -const SelectContext = React.createContext<[SelectState, Dispatch] | undefined>( - cloneDeep(undefined) -); - -function selectReducer(state: SelectState, action: SelectAction): SelectState { - const { items, selectedState } = state; - - switch (action.type) { - case SelectActionType.Reset: { - return cloneDeep(initialState); - } - case SelectActionType.SelectAll: { - return { - items, - ...selectAll(selectedState, true), - }; - } - case SelectActionType.UnselectAll: { - return { - items, - ...selectAll(selectedState, false), - }; - } - case SelectActionType.ToggleSelected: { - const result = { - items, - ...toggleSelected( - state, - items, - action.id, - action.isSelected, - action.shiftKey - ), - }; - - return result; - } - case SelectActionType.UpdateItems: { - const nextSelectedState = getSelectedState(action.items, selectedState); - - return { - ...state, - ...areAllSelected(nextSelectedState), - selectedState: nextSelectedState, - items: action.items, - }; - } - default: { - throw new Error(`Unhandled action type: ${action.type}`); - } - } -} +const SelectContext = React.createContext< + [SelectState, SelectDispatch] | undefined +>(cloneDeep(undefined)); export function SelectProvider( props: SelectProviderOptions ) { const { items } = props; - const selectedState = getSelectedState(items, {}); + const [state, dispatch] = useSelectState(); - const [state, dispatch] = React.useReducer(selectReducer, { - selectedState, - lastToggled: null, - allSelected: false, - allUnselected: true, - items, - }); + const dispatchWrapper = useCallback( + (action: SelectContextAction) => { + switch (action.type) { + case 'reset': + case 'removeItem': + dispatch(action); + break; - const value: [SelectState, Dispatch] = [state, dispatch]; + default: + dispatch({ + ...action, + items, + }); + break; + } + }, + [items, dispatch] + ); + + const value: [SelectState, SelectDispatch] = [state, dispatchWrapper]; useEffect(() => { - dispatch({ type: SelectActionType.UpdateItems, items }); - }, [items]); + dispatch({ type: 'updateItems', items }); + }, [items, dispatch]); return ( diff --git a/frontend/src/Helpers/Hooks/useSelectState.tsx b/frontend/src/Helpers/Hooks/useSelectState.tsx new file mode 100644 index 000000000..3ec9f2aed --- /dev/null +++ b/frontend/src/Helpers/Hooks/useSelectState.tsx @@ -0,0 +1,113 @@ +import { cloneDeep } from 'lodash'; +import { useReducer } from 'react'; +import ModelBase from 'App/ModelBase'; +import areAllSelected from 'Utilities/Table/areAllSelected'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; + +type SelectedState = Record; + +export interface SelectState { + selectedState: SelectedState; + lastToggled: number | null; + allSelected: boolean; + allUnselected: boolean; +} + +export type SelectAction = + | { type: 'reset' } + | { type: 'selectAll'; items: ModelBase[] } + | { type: 'unselectAll'; items: ModelBase[] } + | { + type: 'toggleSelected'; + id: number; + isSelected: boolean; + shiftKey: boolean; + items: ModelBase[]; + } + | { + type: 'removeItem'; + id: number; + } + | { + type: 'updateItems'; + items: ModelBase[]; + }; + +export type Dispatch = (action: SelectAction) => void; + +const initialState = { + selectedState: {}, + lastToggled: null, + allSelected: false, + allUnselected: true, + items: [], +}; + +function getSelectedState(items: ModelBase[], existingState: SelectedState) { + return items.reduce((acc: SelectedState, item) => { + const id = item.id; + + acc[id] = existingState[id] ?? false; + + return acc; + }, {}); +} + +function selectReducer(state: SelectState, action: SelectAction): SelectState { + const { selectedState } = state; + + switch (action.type) { + case 'reset': { + return cloneDeep(initialState); + } + case 'selectAll': { + return { + ...selectAll(selectedState, true), + }; + } + case 'unselectAll': { + return { + ...selectAll(selectedState, false), + }; + } + case 'toggleSelected': { + const result = { + ...toggleSelected( + state, + action.items, + action.id, + action.isSelected, + action.shiftKey + ), + }; + + return result; + } + case 'updateItems': { + const nextSelectedState = getSelectedState(action.items, selectedState); + + return { + ...state, + ...areAllSelected(nextSelectedState), + selectedState: nextSelectedState, + }; + } + default: { + throw new Error(`Unhandled action type: ${action.type}`); + } + } +} + +export default function useSelectState(): [SelectState, Dispatch] { + const selectedState = getSelectedState([], {}); + + const [state, dispatch] = useReducer(selectReducer, { + selectedState, + lastToggled: null, + allSelected: false, + allUnselected: true, + }); + + return [state, dispatch]; +} diff --git a/frontend/src/Series/Index/Select/SeriesIndexPosterSelect.tsx b/frontend/src/Series/Index/Select/SeriesIndexPosterSelect.tsx index d67b24ddc..afc759f7a 100644 --- a/frontend/src/Series/Index/Select/SeriesIndexPosterSelect.tsx +++ b/frontend/src/Series/Index/Select/SeriesIndexPosterSelect.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { SelectActionType, useSelect } from 'App/SelectContext'; +import { useSelect } from 'App/SelectContext'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; import { icons } from 'Helpers/Props'; @@ -19,7 +19,7 @@ function SeriesIndexPosterSelect(props: SeriesIndexPosterSelectProps) { const shiftKey = event.nativeEvent.shiftKey; selectDispatch({ - type: SelectActionType.ToggleSelected, + type: 'toggleSelected', id: seriesId, isSelected: !isSelected, shiftKey, diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectAllButton.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectAllButton.tsx index 6b5741d41..42b5e8001 100644 --- a/frontend/src/Series/Index/Select/SeriesIndexSelectAllButton.tsx +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectAllButton.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { SelectActionType, useSelect } from 'App/SelectContext'; +import { useSelect } from 'App/SelectContext'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import { icons } from 'Helpers/Props'; @@ -24,9 +24,7 @@ function SeriesIndexSelectAllButton(props: SeriesIndexSelectAllButtonProps) { const onPress = useCallback(() => { selectDispatch({ - type: allSelected - ? SelectActionType.UnselectAll - : SelectActionType.SelectAll, + type: allSelected ? 'unselectAll' : 'selectAll', }); }, [allSelected, selectDispatch]); diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectAllMenuItem.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectAllMenuItem.tsx index bc7094949..edb41335b 100644 --- a/frontend/src/Series/Index/Select/SeriesIndexSelectAllMenuItem.tsx +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectAllMenuItem.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { SelectActionType, useSelect } from 'App/SelectContext'; +import { useSelect } from 'App/SelectContext'; import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem'; import { icons } from 'Helpers/Props'; @@ -25,9 +25,7 @@ function SeriesIndexSelectAllMenuItem( const onPressWrapper = useCallback(() => { selectDispatch({ - type: allSelected - ? SelectActionType.UnselectAll - : SelectActionType.SelectAll, + type: allSelected ? 'unselectAll' : 'selectAll', }); }, [allSelected, selectDispatch]); diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx index 8f033d4a0..ad56f3ed5 100644 --- a/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { createSelector } from 'reselect'; -import { SelectActionType, useSelect } from 'App/SelectContext'; +import { useSelect } from 'App/SelectContext'; import { RENAME_SERIES } from 'Commands/commandNames'; import SpinnerButton from 'Components/Link/SpinnerButton'; import PageContentFooter from 'Components/Page/PageContentFooter'; @@ -158,7 +158,7 @@ function SeriesIndexSelectFooter() { useEffect(() => { if (!isDeleting && !deleteError) { - selectDispatch({ type: SelectActionType.UnselectAll }); + selectDispatch({ type: 'unselectAll' }); } }, [isDeleting, deleteError, selectDispatch]); diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectModeButton.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectModeButton.tsx index 2f52c6ba0..314c09c50 100644 --- a/frontend/src/Series/Index/Select/SeriesIndexSelectModeButton.tsx +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectModeButton.tsx @@ -1,6 +1,6 @@ import { IconDefinition } from '@fortawesome/fontawesome-common-types'; import React, { useCallback } from 'react'; -import { SelectActionType, useSelect } from 'App/SelectContext'; +import { useSelect } from 'App/SelectContext'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; interface SeriesIndexSelectModeButtonProps { @@ -18,7 +18,7 @@ function SeriesIndexSelectModeButton(props: SeriesIndexSelectModeButtonProps) { const onPressWrapper = useCallback(() => { if (isSelectMode) { selectDispatch({ - type: SelectActionType.Reset, + type: 'reset', }); } diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectModeMenuItem.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectModeMenuItem.tsx index 8b74e541f..06da15dde 100644 --- a/frontend/src/Series/Index/Select/SeriesIndexSelectModeMenuItem.tsx +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectModeMenuItem.tsx @@ -1,6 +1,6 @@ import { IconDefinition } from '@fortawesome/fontawesome-common-types'; import React, { useCallback } from 'react'; -import { SelectActionType, useSelect } from 'App/SelectContext'; +import { useSelect } from 'App/SelectContext'; import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem'; interface SeriesIndexSelectModeMenuItemProps { @@ -19,7 +19,7 @@ function SeriesIndexSelectModeMenuItem( const onPressWrapper = useCallback(() => { if (isSelectMode) { selectDispatch({ - type: SelectActionType.Reset, + type: 'reset', }); } diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx index 83b70bb5f..753add771 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import React, { useCallback, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { SelectActionType, useSelect } from 'App/SelectContext'; +import { useSelect } from 'App/SelectContext'; import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames'; import CheckInput from 'Components/Form/CheckInput'; import HeartRating from 'Components/HeartRating'; @@ -139,7 +139,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { const onSelectedChange = useCallback( ({ id, value, shiftKey }) => { selectDispatch({ - type: SelectActionType.ToggleSelected, + type: 'toggleSelected', id, isSelected: value, shiftKey, diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.tsx b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.tsx index 141950169..e1fb295ab 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.tsx +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import React, { useCallback } from 'react'; import { useDispatch } from 'react-redux'; -import { SelectActionType, useSelect } from 'App/SelectContext'; +import { useSelect } from 'App/SelectContext'; import IconButton from 'Components/Link/IconButton'; import Column from 'Components/Table/Column'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; @@ -48,7 +48,7 @@ function SeriesIndexTableHeader(props: SeriesIndexTableHeaderProps) { const onSelectAllChange = useCallback( ({ value }) => { selectDispatch({ - type: value ? SelectActionType.SelectAll : SelectActionType.UnselectAll, + type: value ? 'selectAll' : 'unselectAll', }); }, [selectDispatch]