From 815a16d5cfced17ca4db7f1b66991c5cc9f3b719 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Thu, 12 Jan 2023 09:00:37 -0800 Subject: [PATCH] Added series index selection --- frontend/src/App/SelectContext.tsx | 170 +++++++++++++ frontend/src/Helpers/Props/icons.js | 8 +- .../Index/Overview/SeriesIndexOverview.tsx | 7 + .../Index/Overview/SeriesIndexOverviews.tsx | 12 +- .../Index/Posters/SeriesIndexPoster.tsx | 6 +- .../Index/Posters/SeriesIndexPosters.tsx | 17 +- .../Index/Select/SeriesIndexPosterSelect.css | 36 +++ .../Index/Select/SeriesIndexPosterSelect.tsx | 41 ++++ .../Select/SeriesIndexSelectAllButton.tsx | 35 +++ frontend/src/Series/Index/SeriesIndex.tsx | 228 ++++++++++-------- .../src/Series/Index/Table/SeriesIndexRow.tsx | 26 +- .../Series/Index/Table/SeriesIndexTable.tsx | 8 +- .../Index/Table/SeriesIndexTableHeader.tsx | 23 +- .../src/Utilities/Table/toggleSelected.js | 16 +- 14 files changed, 513 insertions(+), 120 deletions(-) create mode 100644 frontend/src/App/SelectContext.tsx create mode 100644 frontend/src/Series/Index/Select/SeriesIndexPosterSelect.css create mode 100644 frontend/src/Series/Index/Select/SeriesIndexPosterSelect.tsx create mode 100644 frontend/src/Series/Index/Select/SeriesIndexSelectAllButton.tsx diff --git a/frontend/src/App/SelectContext.tsx b/frontend/src/App/SelectContext.tsx new file mode 100644 index 000000000..05ee42791 --- /dev/null +++ b/frontend/src/App/SelectContext.tsx @@ -0,0 +1,170 @@ +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 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 } + | { + type: SelectActionType.ToggleSelected; + id: number; + isSelected: boolean; + shiftKey: boolean; + } + | { + type: SelectActionType.RemoveItem; + id: number; + } + | { + type: SelectActionType.UpdateItems; + items: ModelBase[]; + }; + +type Dispatch = (action: SelectAction) => void; + +const initialState = { + selectedState: {}, + lastToggled: null, + allSelected: false, + allUnselected: true, + items: [], +}; + +interface SelectProviderOptions { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + children: any; + isSelectMode: boolean; + 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: { + var 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, + }; + } + default: { + throw new Error(`Unhandled action type: ${action.type}`); + } + } +} + +export function SelectProvider( + props: SelectProviderOptions +) { + const { isSelectMode, items } = props; + const selectedState = getSelectedState(items, {}); + + const [state, dispatch] = React.useReducer(selectReducer, { + selectedState, + lastToggled: null, + allSelected: false, + allUnselected: true, + items, + }); + + const value: [SelectState, Dispatch] = [state, dispatch]; + + useEffect(() => { + if (!isSelectMode) { + dispatch({ type: SelectActionType.Reset }); + } + }, [isSelectMode]); + + useEffect(() => { + dispatch({ type: SelectActionType.UpdateItems, items }); + }, [items]); + + return ( + + {props.children} + + ); +} + +export function useSelect() { + const context = React.useContext(SelectContext); + + if (context === undefined) { + throw new Error('useSelect must be used within a SelectProvider'); + } + + return context; +} diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index c8df8817b..0c7fb7a40 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -15,7 +15,8 @@ import { faHdd as farHdd, faKeyboard as farKeyboard, faObjectGroup as farObjectGroup, - faObjectUngroup as farObjectUngroup + faObjectUngroup as farObjectUngroup, + faSquare as farSquare } from '@fortawesome/free-regular-svg-icons'; // // Solid @@ -83,6 +84,8 @@ import { faSortDown as fasSortDown, faSortUp as fasSortUp, faSpinner as fasSpinner, + faSquareCheck as fasSquareCheck, + faSquareMinus as fasSquareMinus, faStop as fasStop, faSync as fasSync, faTable as fasTable, @@ -116,6 +119,7 @@ export const CARET_DOWN = fasCaretDown; export const CHECK = fasCheck; export const CHECK_INDETERMINATE = fasMinus; export const CHECK_CIRCLE = fasCheckCircle; +export const CHECK_SQUARE = fasSquareCheck; export const CIRCLE = fasCircle; export const CIRCLE_OUTLINE = farCircle; export const CLEAR = fasTrashAlt; @@ -192,6 +196,8 @@ export const SORT = fasSort; export const SORT_ASCENDING = fasSortUp; export const SORT_DESCENDING = fasSortDown; export const SPINNER = fasSpinner; +export const SQUARE = farSquare; +export const SQUARE_MINUS = fasSquareMinus; export const SUBTRACT = fasMinus; export const SYSTEM = fasLaptop; export const TABLE = fasTable; diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverview.tsx b/frontend/src/Series/Index/Overview/SeriesIndexOverview.tsx index a2bef4188..87cb694db 100644 --- a/frontend/src/Series/Index/Overview/SeriesIndexOverview.tsx +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverview.tsx @@ -9,6 +9,7 @@ import { icons } from 'Helpers/Props'; import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector'; import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar'; +import SeriesIndexPosterSelect from 'Series/Index/Select/SeriesIndexPosterSelect'; import SeriesPoster from 'Series/SeriesPoster'; import { executeCommand } from 'Store/Actions/commandActions'; import dimensions from 'Styles/Variables/dimensions'; @@ -35,6 +36,7 @@ interface SeriesIndexOverviewProps { posterWidth: number; posterHeight: number; rowHeight: number; + isSelectMode: boolean; isSmallScreen: boolean; } @@ -45,6 +47,7 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) { posterWidth, posterHeight, rowHeight, + isSelectMode, isSmallScreen, } = props; @@ -135,6 +138,10 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
+ {isSelectMode ? ( + + ) : null} + {status === 'ended' && (
)} diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviews.tsx b/frontend/src/Series/Index/Overview/SeriesIndexOverviews.tsx index a52ac31f4..7d473dc94 100644 --- a/frontend/src/Series/Index/Overview/SeriesIndexOverviews.tsx +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviews.tsx @@ -27,6 +27,7 @@ interface RowItemData { posterWidth: number; posterHeight: number; rowHeight: number; + isSelectMode: boolean; isSmallScreen: boolean; } @@ -37,6 +38,7 @@ interface SeriesIndexOverviewsProps { jumpToCharacter?: string; scrollTop?: number; scrollerRef: React.MutableRefObject; + isSelectMode: boolean; isSmallScreen: boolean; } @@ -65,7 +67,14 @@ function getWindowScrollTopPosition() { } function SeriesIndexOverviews(props: SeriesIndexOverviewsProps) { - const { items, sortKey, jumpToCharacter, isSmallScreen, scrollerRef } = props; + const { + items, + sortKey, + jumpToCharacter, + scrollerRef, + isSelectMode, + isSmallScreen, + } = props; const { size: posterSize, detailedProgressBar } = useSelector( selectOverviewOptions @@ -191,6 +200,7 @@ function SeriesIndexOverviews(props: SeriesIndexOverviewsProps) { posterWidth, posterHeight, rowHeight, + isSelectMode, isSmallScreen, }} > diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPoster.tsx b/frontend/src/Series/Index/Posters/SeriesIndexPoster.tsx index 594298860..c45b6c4fe 100644 --- a/frontend/src/Series/Index/Posters/SeriesIndexPoster.tsx +++ b/frontend/src/Series/Index/Posters/SeriesIndexPoster.tsx @@ -9,6 +9,7 @@ import { icons } from 'Helpers/Props'; import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector'; import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar'; +import SeriesIndexPosterSelect from 'Series/Index/Select/SeriesIndexPosterSelect'; import SeriesPoster from 'Series/SeriesPoster'; import { executeCommand } from 'Store/Actions/commandActions'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; @@ -21,12 +22,13 @@ import styles from './SeriesIndexPoster.css'; interface SeriesIndexPosterProps { seriesId: number; sortKey: string; + isSelectMode: boolean; posterWidth: number; posterHeight: number; } function SeriesIndexPoster(props: SeriesIndexPosterProps) { - const { seriesId, sortKey, posterWidth, posterHeight } = props; + const { seriesId, sortKey, isSelectMode, posterWidth, posterHeight } = props; const { series, qualityProfile, isRefreshingSeries, isSearchingSeries } = useSelector(createSeriesIndexItemSelector(props.seriesId)); @@ -120,6 +122,8 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) { return (
+ {isSelectMode ? : null} +
); @@ -82,6 +85,7 @@ function SeriesIndexTable(props: SeriesIndexTableProps) { sortKey, sortDirection, jumpToCharacter, + isSelectMode, isSmallScreen, scrollerRef, } = props; @@ -177,6 +181,7 @@ function SeriesIndexTable(props: SeriesIndexTableProps) { columns={columns} sortKey={sortKey} sortDirection={sortDirection} + isSelectMode={isSelectMode} /> ref={listRef} @@ -193,6 +198,7 @@ function SeriesIndexTable(props: SeriesIndexTableProps) { items, sortKey, columns, + isSelectMode, }} > {Row} diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.tsx b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.tsx index e268a34c2..141950169 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.tsx +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.tsx @@ -7,6 +7,7 @@ import Column from 'Components/Table/Column'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell'; import { icons } from 'Helpers/Props'; import SortDirection from 'Helpers/Props/SortDirection'; import { @@ -22,12 +23,13 @@ interface SeriesIndexTableHeaderProps { columns: Column[]; sortKey?: string; sortDirection?: SortDirection; + isSelectMode: boolean; } function SeriesIndexTableHeader(props: SeriesIndexTableHeaderProps) { - const { showBanners, columns, sortKey, sortDirection } = props; - + const { showBanners, columns, sortKey, sortDirection, isSelectMode } = props; const dispatch = useDispatch(); + const [selectState, selectDispatch] = useSelect(); const onSortPress = useCallback( (value) => { @@ -43,8 +45,25 @@ function SeriesIndexTableHeader(props: SeriesIndexTableHeaderProps) { [dispatch] ); + const onSelectAllChange = useCallback( + ({ value }) => { + selectDispatch({ + type: value ? SelectActionType.SelectAll : SelectActionType.UnselectAll, + }); + }, + [selectDispatch] + ); + return ( + {isSelectMode ? ( + + ) : null} + {columns.map((column) => { const { name, label, isSortable, isVisible } = column; diff --git a/frontend/src/Utilities/Table/toggleSelected.js b/frontend/src/Utilities/Table/toggleSelected.js index dbc0d6223..ec8870b0b 100644 --- a/frontend/src/Utilities/Table/toggleSelected.js +++ b/frontend/src/Utilities/Table/toggleSelected.js @@ -1,29 +1,29 @@ import areAllSelected from './areAllSelected'; import getToggledRange from './getToggledRange'; -function toggleSelected(state, items, id, selected, shiftKey) { - const lastToggled = state.lastToggled; - const selectedState = { - ...state.selectedState, +function toggleSelected(selectedState, items, id, selected, shiftKey) { + const lastToggled = selectedState.lastToggled; + const nextSelectedState = { + ...selectedState.selectedState, [id]: selected }; if (selected == null) { - delete selectedState[id]; + delete nextSelectedState[id]; } if (shiftKey && lastToggled) { const { lower, upper } = getToggledRange(items, id, lastToggled); for (let i = lower; i < upper; i++) { - selectedState[items[i].id] = selected; + nextSelectedState[items[i].id] = selected; } } return { - ...areAllSelected(selectedState), + ...areAllSelected(nextSelectedState), lastToggled: id, - selectedState + selectedState: nextSelectedState }; }