From ee5fed8522778b6e1a70a78e711b599dc3f52374 Mon Sep 17 00:00:00 2001 From: Robin Dadswell <19610103+RobinDadswell@users.noreply.github.com> Date: Fri, 14 Apr 2023 00:56:55 +0100 Subject: [PATCH] Added movie index selection Author: Mark McDowall --- frontend/src/App/SelectContext.tsx | 170 +++++++++++++ frontend/src/Helpers/Props/icons.js | 10 +- frontend/src/Movie/Index/MovieIndex.tsx | 236 ++++++++++-------- .../Index/Overview/MovieIndexOverview.tsx | 4 + .../Index/Overview/MovieIndexOverviews.tsx | 12 +- .../Movie/Index/Posters/MovieIndexPoster.tsx | 6 +- .../Movie/Index/Posters/MovieIndexPosters.tsx | 15 +- .../Index/Select/MovieIndexPosterSelect.css | 36 +++ .../Select/MovieIndexPosterSelect.css.d.ts | 10 + .../Index/Select/MovieIndexPosterSelect.tsx | 41 +++ .../Select/MovieIndexSelectAllButton.tsx | 35 +++ .../src/Movie/Index/Table/MovieIndexRow.tsx | 27 +- .../src/Movie/Index/Table/MovieIndexTable.tsx | 14 +- .../Index/Table/MovieIndexTableHeader.tsx | 21 ++ .../src/Utilities/Table/toggleSelected.js | 16 +- src/NzbDrone.Core/Localization/Core/en.json | 1 + 16 files changed, 535 insertions(+), 119 deletions(-) create mode 100644 frontend/src/App/SelectContext.tsx create mode 100644 frontend/src/Movie/Index/Select/MovieIndexPosterSelect.css create mode 100644 frontend/src/Movie/Index/Select/MovieIndexPosterSelect.css.d.ts create mode 100644 frontend/src/Movie/Index/Select/MovieIndexPosterSelect.tsx create mode 100644 frontend/src/Movie/Index/Select/MovieIndexSelectAllButton.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 124549719..7a0ac6318 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 @@ -37,7 +38,6 @@ import { faCaretDown as fasCaretDown, faCheck as fasCheck, faCheckCircle as fasCheckCircle, - faCheckSquare as fasCheckSquare, faChevronCircleDown as fasChevronCircleDown, faChevronCircleRight as fasChevronCircleRight, faChevronCircleUp as fasChevronCircleUp, @@ -91,6 +91,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, @@ -128,7 +130,7 @@ export const CARET_DOWN = fasCaretDown; export const CHECK = fasCheck; export const CHECK_INDETERMINATE = fasMinus; export const CHECK_CIRCLE = fasCheckCircle; -export const CHECK_SQUARE = fasCheckSquare; +export const CHECK_SQUARE = fasSquareCheck; export const CIRCLE = fasCircle; export const CIRCLE_OUTLINE = farCircle; export const CLEAR = fasTrashAlt; @@ -211,6 +213,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 STUDIO = fasBuilding; export const SUBTRACT = fasMinus; export const SYSTEM = fasLaptop; diff --git a/frontend/src/Movie/Index/MovieIndex.tsx b/frontend/src/Movie/Index/MovieIndex.tsx index 45947cde8..0b49c6699 100644 --- a/frontend/src/Movie/Index/MovieIndex.tsx +++ b/frontend/src/Movie/Index/MovieIndex.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { SelectProvider } from 'App/SelectContext'; import { REFRESH_MOVIE, RSS_SYNC } from 'Commands/commandNames'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; @@ -25,6 +26,7 @@ import scrollPositions from 'Store/scrollPositions'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createMovieClientSideCollectionItemsSelector from 'Store/Selectors/createMovieClientSideCollectionItemsSelector'; +import translate from 'Utilities/String/translate'; import MovieIndexFilterMenu from './Menus/MovieIndexFilterMenu'; import MovieIndexSortMenu from './Menus/MovieIndexSortMenu'; import MovieIndexViewMenu from './Menus/MovieIndexViewMenu'; @@ -33,6 +35,7 @@ import MovieIndexOverviews from './Overview/MovieIndexOverviews'; import MovieIndexOverviewOptionsModal from './Overview/Options/MovieIndexOverviewOptionsModal'; import MovieIndexPosters from './Posters/MovieIndexPosters'; import MovieIndexPosterOptionsModal from './Posters/Options/MovieIndexPosterOptionsModal'; +import MovieIndexSelectAllButton from './Select/MovieIndexSelectAllButton'; import MovieIndexTable from './Table/MovieIndexTable'; import MovieIndexTableOptions from './Table/MovieIndexTableOptions'; import styles from './MovieIndex.css'; @@ -53,7 +56,7 @@ interface MovieIndexProps { initialScrollTop?: number; } -const MovieIndex = withScrollPosition((props) => { +const MovieIndex = withScrollPosition((props: MovieIndexProps) => { const { isFetching, isPopulated, @@ -80,6 +83,7 @@ const MovieIndex = withScrollPosition((props) => { const scrollerRef = useRef(); const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); const [jumpToCharacter, setJumpToCharacter] = useState(null); + const [isSelectMode, setIsSelectMode] = useState(false); const onRefreshMoviePress = useCallback(() => { dispatch( @@ -89,6 +93,10 @@ const MovieIndex = withScrollPosition((props) => { ); }, [dispatch]); + const onSelectModePress = useCallback(() => { + setIsSelectMode(!isSelectMode); + }, [isSelectMode, setIsSelectMode]); + const onRssSyncPress = useCallback(() => { dispatch( executeCommand({ @@ -194,118 +202,144 @@ const MovieIndex = withScrollPosition((props) => { const hasNoMovie = !totalItems; return ( - - - - + + + + + - - - - - {view === 'table' ? ( - - - - ) : ( - )} - + - - - + - - - -
- - {isFetching && !isPopulated ? : null} - - {!isFetching && !!error ?
Unable to load movie
: null} - - {isLoaded ? ( -
- : null} + + + + {view === 'table' ? ( + + + + ) : ( + + )} - -
- ) : null} + - {!error && isPopulated && !items.length ? ( - - ) : null} -
+ - {isLoaded && !!jumpBarItems.order.length ? ( - + + + + + +
+ + {isFetching && !isPopulated ? : null} + + {!isFetching && !!error ?
Unable to load movie
: null} + + {isLoaded ? ( +
+ + + +
+ ) : null} + + {!error && isPopulated && !items.length ? ( + + ) : null} +
+ + {isLoaded && !!jumpBarItems.order.length ? ( + + ) : null} +
+ {view === 'posters' ? ( + + ) : null} + {view === 'overview' ? ( + ) : null} -
- {view === 'posters' ? ( - - ) : null} - {view === 'overview' ? ( - - ) : null} -
+
+ ); }, 'movieIndex'); diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverview.tsx b/frontend/src/Movie/Index/Overview/MovieIndexOverview.tsx index da6e42252..481c4a94e 100644 --- a/frontend/src/Movie/Index/Overview/MovieIndexOverview.tsx +++ b/frontend/src/Movie/Index/Overview/MovieIndexOverview.tsx @@ -12,6 +12,7 @@ import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks'; import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar'; +import MovieIndexPosterSelect from 'Movie/Index/Select/MovieIndexPosterSelect'; import MoviePoster from 'Movie/MoviePoster'; import { executeCommand } from 'Store/Actions/commandActions'; import dimensions from 'Styles/Variables/dimensions'; @@ -39,6 +40,7 @@ interface MovieIndexOverviewProps { posterWidth: number; posterHeight: number; rowHeight: number; + isSelectMode: boolean; isSmallScreen: boolean; } @@ -49,6 +51,7 @@ function MovieIndexOverview(props: MovieIndexOverviewProps) { posterWidth, posterHeight, rowHeight, + isSelectMode, isSmallScreen, } = props; @@ -132,6 +135,7 @@ function MovieIndexOverview(props: MovieIndexOverviewProps) {
+ {isSelectMode ? : null} ; + isSelectMode: boolean; isSmallScreen: boolean; } @@ -65,7 +67,14 @@ function getWindowScrollTopPosition() { } function MovieIndexOverviews(props: MovieIndexOverviewsProps) { - 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 MovieIndexOverviews(props: MovieIndexOverviewsProps) { posterWidth, posterHeight, rowHeight, + isSelectMode, isSmallScreen, }} > diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPoster.tsx b/frontend/src/Movie/Index/Posters/MovieIndexPoster.tsx index a5e579998..360f53eec 100644 --- a/frontend/src/Movie/Index/Posters/MovieIndexPoster.tsx +++ b/frontend/src/Movie/Index/Posters/MovieIndexPoster.tsx @@ -12,6 +12,7 @@ import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks'; import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar'; +import MovieIndexPosterSelect from 'Movie/Index/Select/MovieIndexPosterSelect'; import MoviePoster from 'Movie/MoviePoster'; import { executeCommand } from 'Store/Actions/commandActions'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; @@ -24,12 +25,13 @@ import styles from './MovieIndexPoster.css'; interface MovieIndexPosterProps { movieId: number; sortKey: string; + isSelectMode: boolean; posterWidth: number; posterHeight: number; } function MovieIndexPoster(props: MovieIndexPosterProps) { - const { movieId, sortKey, posterWidth, posterHeight } = props; + const { movieId, sortKey, isSelectMode, posterWidth, posterHeight } = props; const { movie, qualityProfile, isRefreshingMovie, isSearchingMovie } = useSelector(createMovieIndexItemSelector(props.movieId)); @@ -124,6 +126,8 @@ function MovieIndexPoster(props: MovieIndexPosterProps) { return (
+ {isSelectMode ? : null} +
); }; @@ -78,6 +85,7 @@ function MovieIndexTable(props: MovieIndexTableProps) { sortKey, sortDirection, jumpToCharacter, + isSelectMode, isSmallScreen, scrollerRef, } = props; @@ -172,6 +180,7 @@ function MovieIndexTable(props: MovieIndexTableProps) { columns={columns} sortKey={sortKey} sortDirection={sortDirection} + isSelectMode={isSelectMode} /> ref={listRef} @@ -188,6 +197,7 @@ function MovieIndexTable(props: MovieIndexTableProps) { items, sortKey, columns, + isSelectMode, }} > {Row} diff --git a/frontend/src/Movie/Index/Table/MovieIndexTableHeader.tsx b/frontend/src/Movie/Index/Table/MovieIndexTableHeader.tsx index 0645f98b5..5e208387f 100644 --- a/frontend/src/Movie/Index/Table/MovieIndexTableHeader.tsx +++ b/frontend/src/Movie/Index/Table/MovieIndexTableHeader.tsx @@ -1,11 +1,13 @@ import classNames from 'classnames'; import React, { useCallback } from 'react'; import { useDispatch } from 'react-redux'; +import { SelectActionType, useSelect } from 'App/SelectContext'; import IconButton from 'Components/Link/IconButton'; 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 { @@ -19,11 +21,13 @@ interface MovieIndexTableHeaderProps { columns: Column[]; sortKey?: string; sortDirection?: SortDirection; + isSelectMode: boolean; } function MovieIndexTableHeader(props: MovieIndexTableHeaderProps) { const { columns, sortKey, sortDirection, isSelectMode } = props; const dispatch = useDispatch(); + const [selectState, selectDispatch] = useSelect(); const onSortPress = useCallback( (value) => { @@ -39,8 +43,25 @@ function MovieIndexTableHeader(props: MovieIndexTableHeaderProps) { [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 d03dcef36..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, idProp = 'id') { - 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][idProp]] = selected; + nextSelectedState[items[i].id] = selected; } } return { - ...areAllSelected(selectedState), + ...areAllSelected(nextSelectedState), lastToggled: id, - selectedState + selectedState: nextSelectedState }; } diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 3ea948433..8cd92fdac 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -1081,6 +1081,7 @@ "UnableToLoadLanguages": "Unable to load languages", "UnableToLoadListExclusions": "Unable to load List Exclusions", "UnableToLoadListOptions": "Unable to load list options", + "StopSelecting": "Stop Selecting", "UnableToLoadLists": "Unable to load Lists", "UnableToLoadManualImportItems": "Unable to load manual import items", "UnableToLoadMediaManagementSettings": "Unable to load Media Management settings",