Extract useSelectState from SelectContext

This commit is contained in:
Mark McDowall 2023-03-24 17:41:17 -07:00
parent 2020e074db
commit 032d9a720c
10 changed files with 164 additions and 135 deletions

View File

@ -1,58 +1,28 @@
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import React, { useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import areAllSelected from 'Utilities/Table/areAllSelected'; import useSelectState, { SelectState } from 'Helpers/Hooks/useSelectState';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import ModelBase from './ModelBase'; import ModelBase from './ModelBase';
export enum SelectActionType { export type SelectContextAction =
Reset, | { type: 'reset' }
SelectAll, | { type: 'selectAll' }
UnselectAll, | { type: 'unselectAll' }
ToggleSelected,
RemoveItem,
UpdateItems,
}
type SelectedState = Record<number, boolean>;
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 }
| { | {
type: SelectActionType.ToggleSelected; type: 'toggleSelected';
id: number; id: number;
isSelected: boolean; isSelected: boolean;
shiftKey: boolean; shiftKey: boolean;
} }
| { | {
type: SelectActionType.RemoveItem; type: 'removeItem';
id: number; id: number;
} }
| { | {
type: SelectActionType.UpdateItems; type: 'updateItems';
items: ModelBase[]; items: ModelBase[];
}; };
type Dispatch = (action: SelectAction) => void; export type SelectDispatch = (action: SelectContextAction) => void;
const initialState = {
selectedState: {},
lastToggled: null,
allSelected: false,
allUnselected: true,
items: [],
};
interface SelectProviderOptions<T extends ModelBase> { interface SelectProviderOptions<T extends ModelBase> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -60,90 +30,40 @@ interface SelectProviderOptions<T extends ModelBase> {
items: Array<T>; items: Array<T>;
} }
function getSelectedState(items: ModelBase[], existingState: SelectedState) { const SelectContext = React.createContext<
return items.reduce((acc: SelectedState, item) => { [SelectState, SelectDispatch] | undefined
const id = item.id; >(cloneDeep(undefined));
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}`);
}
}
}
export function SelectProvider<T extends ModelBase>( export function SelectProvider<T extends ModelBase>(
props: SelectProviderOptions<T> props: SelectProviderOptions<T>
) { ) {
const { items } = props; const { items } = props;
const selectedState = getSelectedState(items, {}); const [state, dispatch] = useSelectState();
const [state, dispatch] = React.useReducer(selectReducer, { const dispatchWrapper = useCallback(
selectedState, (action: SelectContextAction) => {
lastToggled: null, switch (action.type) {
allSelected: false, case 'reset':
allUnselected: true, case 'removeItem':
items, dispatch(action);
}); break;
const value: [SelectState, Dispatch] = [state, dispatch]; default:
dispatch({
...action,
items,
});
break;
}
},
[items, dispatch]
);
const value: [SelectState, SelectDispatch] = [state, dispatchWrapper];
useEffect(() => { useEffect(() => {
dispatch({ type: SelectActionType.UpdateItems, items }); dispatch({ type: 'updateItems', items });
}, [items]); }, [items, dispatch]);
return ( return (
<SelectContext.Provider value={value}> <SelectContext.Provider value={value}>

View File

@ -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<number, boolean>;
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];
}

View File

@ -1,5 +1,5 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { SelectActionType, useSelect } from 'App/SelectContext'; import { useSelect } from 'App/SelectContext';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
@ -19,7 +19,7 @@ function SeriesIndexPosterSelect(props: SeriesIndexPosterSelectProps) {
const shiftKey = event.nativeEvent.shiftKey; const shiftKey = event.nativeEvent.shiftKey;
selectDispatch({ selectDispatch({
type: SelectActionType.ToggleSelected, type: 'toggleSelected',
id: seriesId, id: seriesId,
isSelected: !isSelected, isSelected: !isSelected,
shiftKey, shiftKey,

View File

@ -1,5 +1,5 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { SelectActionType, useSelect } from 'App/SelectContext'; import { useSelect } from 'App/SelectContext';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
@ -24,9 +24,7 @@ function SeriesIndexSelectAllButton(props: SeriesIndexSelectAllButtonProps) {
const onPress = useCallback(() => { const onPress = useCallback(() => {
selectDispatch({ selectDispatch({
type: allSelected type: allSelected ? 'unselectAll' : 'selectAll',
? SelectActionType.UnselectAll
: SelectActionType.SelectAll,
}); });
}, [allSelected, selectDispatch]); }, [allSelected, selectDispatch]);

View File

@ -1,5 +1,5 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { SelectActionType, useSelect } from 'App/SelectContext'; import { useSelect } from 'App/SelectContext';
import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem'; import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
@ -25,9 +25,7 @@ function SeriesIndexSelectAllMenuItem(
const onPressWrapper = useCallback(() => { const onPressWrapper = useCallback(() => {
selectDispatch({ selectDispatch({
type: allSelected type: allSelected ? 'unselectAll' : 'selectAll',
? SelectActionType.UnselectAll
: SelectActionType.SelectAll,
}); });
}, [allSelected, selectDispatch]); }, [allSelected, selectDispatch]);

View File

@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { SelectActionType, useSelect } from 'App/SelectContext'; import { useSelect } from 'App/SelectContext';
import { RENAME_SERIES } from 'Commands/commandNames'; import { RENAME_SERIES } from 'Commands/commandNames';
import SpinnerButton from 'Components/Link/SpinnerButton'; import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter'; import PageContentFooter from 'Components/Page/PageContentFooter';
@ -158,7 +158,7 @@ function SeriesIndexSelectFooter() {
useEffect(() => { useEffect(() => {
if (!isDeleting && !deleteError) { if (!isDeleting && !deleteError) {
selectDispatch({ type: SelectActionType.UnselectAll }); selectDispatch({ type: 'unselectAll' });
} }
}, [isDeleting, deleteError, selectDispatch]); }, [isDeleting, deleteError, selectDispatch]);

View File

@ -1,6 +1,6 @@
import { IconDefinition } from '@fortawesome/fontawesome-common-types'; import { IconDefinition } from '@fortawesome/fontawesome-common-types';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { SelectActionType, useSelect } from 'App/SelectContext'; import { useSelect } from 'App/SelectContext';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
interface SeriesIndexSelectModeButtonProps { interface SeriesIndexSelectModeButtonProps {
@ -18,7 +18,7 @@ function SeriesIndexSelectModeButton(props: SeriesIndexSelectModeButtonProps) {
const onPressWrapper = useCallback(() => { const onPressWrapper = useCallback(() => {
if (isSelectMode) { if (isSelectMode) {
selectDispatch({ selectDispatch({
type: SelectActionType.Reset, type: 'reset',
}); });
} }

View File

@ -1,6 +1,6 @@
import { IconDefinition } from '@fortawesome/fontawesome-common-types'; import { IconDefinition } from '@fortawesome/fontawesome-common-types';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { SelectActionType, useSelect } from 'App/SelectContext'; import { useSelect } from 'App/SelectContext';
import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem'; import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem';
interface SeriesIndexSelectModeMenuItemProps { interface SeriesIndexSelectModeMenuItemProps {
@ -19,7 +19,7 @@ function SeriesIndexSelectModeMenuItem(
const onPressWrapper = useCallback(() => { const onPressWrapper = useCallback(() => {
if (isSelectMode) { if (isSelectMode) {
selectDispatch({ selectDispatch({
type: SelectActionType.Reset, type: 'reset',
}); });
} }

View File

@ -1,7 +1,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; 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 { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames';
import CheckInput from 'Components/Form/CheckInput'; import CheckInput from 'Components/Form/CheckInput';
import HeartRating from 'Components/HeartRating'; import HeartRating from 'Components/HeartRating';
@ -139,7 +139,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
const onSelectedChange = useCallback( const onSelectedChange = useCallback(
({ id, value, shiftKey }) => { ({ id, value, shiftKey }) => {
selectDispatch({ selectDispatch({
type: SelectActionType.ToggleSelected, type: 'toggleSelected',
id, id,
isSelected: value, isSelected: value,
shiftKey, shiftKey,

View File

@ -1,7 +1,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { SelectActionType, useSelect } from 'App/SelectContext'; import { useSelect } from 'App/SelectContext';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import Column from 'Components/Table/Column'; import Column from 'Components/Table/Column';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
@ -48,7 +48,7 @@ function SeriesIndexTableHeader(props: SeriesIndexTableHeaderProps) {
const onSelectAllChange = useCallback( const onSelectAllChange = useCallback(
({ value }) => { ({ value }) => {
selectDispatch({ selectDispatch({
type: value ? SelectActionType.SelectAll : SelectActionType.UnselectAll, type: value ? 'selectAll' : 'unselectAll',
}); });
}, },
[selectDispatch] [selectDispatch]