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] = React.useReducer(selectReducer, { - selectedState, - lastToggled: null, - allSelected: false, - allUnselected: true, - items, - }); + const [state, dispatch] = useSelectState(); + + const dispatchWrapper = useCallback( + (action: SelectContextAction) => { + switch (action.type) { + case 'reset': + case 'removeItem': + dispatch(action); + break; + + default: + dispatch({ + ...action, + items, + }); + break; + } + }, + [items, dispatch] + ); - const value: [SelectState, Dispatch] = [state, 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..8fb96e42a --- /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'; + +export 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/Movie/Index/Select/MovieIndexPosterSelect.tsx b/frontend/src/Movie/Index/Select/MovieIndexPosterSelect.tsx index 594e9c7d0..583bacd16 100644 --- a/frontend/src/Movie/Index/Select/MovieIndexPosterSelect.tsx +++ b/frontend/src/Movie/Index/Select/MovieIndexPosterSelect.tsx @@ -1,5 +1,5 @@ import React, { SyntheticEvent, 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'; @@ -20,7 +20,7 @@ function MovieIndexPosterSelect(props: MovieIndexPosterSelectProps) { const shiftKey = nativeEvent.shiftKey; selectDispatch({ - type: SelectActionType.ToggleSelected, + type: 'toggleSelected', id: movieId, isSelected: !isSelected, shiftKey, diff --git a/frontend/src/Movie/Index/Select/MovieIndexSelectAllButton.tsx b/frontend/src/Movie/Index/Select/MovieIndexSelectAllButton.tsx index 7860814b3..4de13125e 100644 --- a/frontend/src/Movie/Index/Select/MovieIndexSelectAllButton.tsx +++ b/frontend/src/Movie/Index/Select/MovieIndexSelectAllButton.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 MovieIndexSelectAllButton(props: MovieIndexSelectAllButtonProps) { const onPress = useCallback(() => { selectDispatch({ - type: allSelected - ? SelectActionType.UnselectAll - : SelectActionType.SelectAll, + type: allSelected ? 'unselectAll' : 'selectAll', }); }, [allSelected, selectDispatch]); diff --git a/frontend/src/Movie/Index/Select/MovieIndexSelectAllMenuItem.tsx b/frontend/src/Movie/Index/Select/MovieIndexSelectAllMenuItem.tsx index 64b03bdae..b79afb414 100644 --- a/frontend/src/Movie/Index/Select/MovieIndexSelectAllMenuItem.tsx +++ b/frontend/src/Movie/Index/Select/MovieIndexSelectAllMenuItem.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'; @@ -23,9 +23,7 @@ function MovieIndexSelectAllMenuItem(props: MovieIndexSelectAllMenuItemProps) { const onPressWrapper = useCallback(() => { selectDispatch({ - type: allSelected - ? SelectActionType.UnselectAll - : SelectActionType.SelectAll, + type: allSelected ? 'unselectAll' : 'selectAll', }); }, [allSelected, selectDispatch]); diff --git a/frontend/src/Movie/Index/Select/MovieIndexSelectFooter.tsx b/frontend/src/Movie/Index/Select/MovieIndexSelectFooter.tsx index 8de28027f..d3ed279eb 100644 --- a/frontend/src/Movie/Index/Select/MovieIndexSelectFooter.tsx +++ b/frontend/src/Movie/Index/Select/MovieIndexSelectFooter.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_MOVIE } from 'Commands/commandNames'; import SpinnerButton from 'Components/Link/SpinnerButton'; import PageContentFooter from 'Components/Page/PageContentFooter'; @@ -128,7 +128,7 @@ function MovieIndexSelectFooter() { useEffect(() => { if (!isDeleting && !deleteError) { - selectDispatch({ type: SelectActionType.UnselectAll }); + selectDispatch({ type: 'unselectAll' }); } }, [isDeleting, deleteError, selectDispatch]); diff --git a/frontend/src/Movie/Index/Select/MovieIndexSelectModeButton.tsx b/frontend/src/Movie/Index/Select/MovieIndexSelectModeButton.tsx index 9eff931cc..6fde3f5e5 100644 --- a/frontend/src/Movie/Index/Select/MovieIndexSelectModeButton.tsx +++ b/frontend/src/Movie/Index/Select/MovieIndexSelectModeButton.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 MovieIndexSelectModeButtonProps { @@ -18,7 +18,7 @@ function MovieIndexSelectModeButton(props: MovieIndexSelectModeButtonProps) { const onPressWrapper = useCallback(() => { if (isSelectMode) { selectDispatch({ - type: SelectActionType.Reset, + type: 'reset', }); } diff --git a/frontend/src/Movie/Index/Select/MovieIndexSelectModeMenuItem.tsx b/frontend/src/Movie/Index/Select/MovieIndexSelectModeMenuItem.tsx index 7113e12c2..29d8879db 100644 --- a/frontend/src/Movie/Index/Select/MovieIndexSelectModeMenuItem.tsx +++ b/frontend/src/Movie/Index/Select/MovieIndexSelectModeMenuItem.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 MovieIndexSelectModeMenuItemProps { @@ -19,7 +19,7 @@ function MovieIndexSelectModeMenuItem( const onPressWrapper = useCallback(() => { if (isSelectMode) { selectDispatch({ - type: SelectActionType.Reset, + type: 'reset', }); } diff --git a/frontend/src/Movie/Index/Table/MovieIndexRow.tsx b/frontend/src/Movie/Index/Table/MovieIndexRow.tsx index 0651ea7d9..debcc4db8 100644 --- a/frontend/src/Movie/Index/Table/MovieIndexRow.tsx +++ b/frontend/src/Movie/Index/Table/MovieIndexRow.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { SelectActionType, useSelect } from 'App/SelectContext'; +import { useSelect } from 'App/SelectContext'; import { MOVIE_SEARCH, REFRESH_MOVIE } from 'Commands/commandNames'; import Icon from 'Components/Icon'; import ImdbRating from 'Components/ImdbRating'; @@ -121,7 +121,7 @@ function MovieIndexRow(props: MovieIndexRowProps) { const onSelectedChange = useCallback( ({ id, value, shiftKey }) => { selectDispatch({ - type: SelectActionType.ToggleSelected, + type: 'toggleSelected', id, isSelected: value, shiftKey, diff --git a/frontend/src/Movie/Index/Table/MovieIndexTableHeader.tsx b/frontend/src/Movie/Index/Table/MovieIndexTableHeader.tsx index 5e208387f..e112f0d8f 100644 --- a/frontend/src/Movie/Index/Table/MovieIndexTableHeader.tsx +++ b/frontend/src/Movie/Index/Table/MovieIndexTableHeader.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'; @@ -46,7 +46,7 @@ function MovieIndexTableHeader(props: MovieIndexTableHeaderProps) { const onSelectAllChange = useCallback( ({ value }) => { selectDispatch({ - type: value ? SelectActionType.SelectAll : SelectActionType.UnselectAll, + type: value ? 'selectAll' : 'unselectAll', }); }, [selectDispatch]