Added movie index selection

Author: Mark McDowall <mark@mcdowall.ca>
This commit is contained in:
Robin Dadswell 2023-04-14 00:56:55 +01:00 committed by Qstick
parent df26229e4d
commit ee5fed8522
16 changed files with 535 additions and 119 deletions

View File

@ -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<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;
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<T extends ModelBase> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children: any;
isSelectMode: boolean;
items: Array<T>;
}
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<T extends ModelBase>(
props: SelectProviderOptions<T>
) {
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 (
<SelectContext.Provider value={value}>
{props.children}
</SelectContext.Provider>
);
}
export function useSelect() {
const context = React.useContext(SelectContext);
if (context === undefined) {
throw new Error('useSelect must be used within a SelectProvider');
}
return context;
}

View File

@ -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;

View File

@ -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<HTMLDivElement>();
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
const [jumpToCharacter, setJumpToCharacter] = useState<string | null>(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 (
<PageContent>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Update all"
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isRefreshingMovie}
isDisabled={hasNoMovie}
onPress={onRefreshMoviePress}
/>
<PageToolbarButton
label="RSS Sync"
iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
isDisabled={hasNoMovie}
onPress={onRssSyncPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT} collapseButtons={false}>
{view === 'table' ? (
<TableOptionsModalWrapper
columns={columns}
optionsComponent={MovieIndexTableOptions}
onTableOptionChange={onTableOptionChange}
>
<PageToolbarButton label="Options" iconName={icons.TABLE} />
</TableOptionsModalWrapper>
) : (
<SelectProvider isSelectMode={isSelectMode} items={items}>
<PageContent>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Options"
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
label={translate('UpdateAll')}
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isRefreshingMovie}
isDisabled={hasNoMovie}
onPress={onOptionsPress}
onPress={onRefreshMoviePress}
/>
)}
<PageToolbarSeparator />
<PageToolbarButton
label={translate('RSSSync')}
iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
isDisabled={hasNoMovie}
onPress={onRssSyncPress}
/>
<MovieIndexViewMenu
view={view}
isDisabled={hasNoMovie}
onViewSelect={onViewSelect}
/>
<PageToolbarSeparator />
<MovieIndexSortMenu
sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoMovie}
onSortSelect={onSortSelect}
/>
<PageToolbarButton
label={
isSelectMode
? translate('StopSelecting')
: translate('SelectMovie')
}
iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
onPress={onSelectModePress}
/>
<MovieIndexFilterMenu
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
isDisabled={hasNoMovie}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<div className={styles.pageContentBodyWrapper}>
<PageContentBody
ref={scrollerRef}
className={styles.contentBody}
innerClassName={styles[`${view}InnerContentBody`]}
initialScrollTop={props.initialScrollTop}
onScroll={onScroll}
>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{isSelectMode ? <MovieIndexSelectAllButton /> : null}
</PageToolbarSection>
{!isFetching && !!error ? <div>Unable to load movie</div> : null}
{isLoaded ? (
<div className={styles.contentBodyContainer}>
<ViewComponent
scrollerRef={scrollerRef}
items={items}
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
isSmallScreen={isSmallScreen}
<PageToolbarSection
alignContent={align.RIGHT}
collapseButtons={false}
>
{view === 'table' ? (
<TableOptionsModalWrapper
columns={columns}
optionsComponent={MovieIndexTableOptions}
onTableOptionChange={onTableOptionChange}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
) : (
<PageToolbarButton
label={translate('Options')}
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
isDisabled={hasNoMovie}
onPress={onOptionsPress}
/>
)}
<MovieIndexFooter />
</div>
<PageToolbarSeparator />
<MovieIndexViewMenu
view={view}
isDisabled={hasNoMovie}
onViewSelect={onViewSelect}
/>
<MovieIndexSortMenu
sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoMovie}
onSortSelect={onSortSelect}
/>
<MovieIndexFilterMenu
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
isDisabled={hasNoMovie}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<div className={styles.pageContentBodyWrapper}>
<PageContentBody
ref={scrollerRef}
className={styles.contentBody}
innerClassName={styles[`${view}InnerContentBody`]}
initialScrollTop={props.initialScrollTop}
onScroll={onScroll}
>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && !!error ? <div>Unable to load movie</div> : null}
{isLoaded ? (
<div className={styles.contentBodyContainer}>
<ViewComponent
scrollerRef={scrollerRef}
items={items}
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
isSelectMode={isSelectMode}
isSmallScreen={isSmallScreen}
/>
<MovieIndexFooter />
</div>
) : null}
{!error && isPopulated && !items.length ? (
<NoMovie totalItems={totalItems} />
) : null}
</PageContentBody>
{isLoaded && !!jumpBarItems.order.length ? (
<PageJumpBar
items={jumpBarItems}
onItemPress={onJumpBarItemPress}
/>
) : null}
{!error && isPopulated && !items.length ? (
<NoMovie totalItems={totalItems} />
) : null}
</PageContentBody>
{isLoaded && !!jumpBarItems.order.length ? (
<PageJumpBar items={jumpBarItems} onItemPress={onJumpBarItemPress} />
</div>
{view === 'posters' ? (
<MovieIndexPosterOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
</div>
{view === 'posters' ? (
<MovieIndexPosterOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
{view === 'overview' ? (
<MovieIndexOverviewOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
</PageContent>
{view === 'overview' ? (
<MovieIndexOverviewOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
</PageContent>
</SelectProvider>
);
}, 'movieIndex');

View File

@ -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) {
<div className={styles.content}>
<div className={styles.poster}>
<div className={styles.posterContainer}>
{isSelectMode ? <MovieIndexPosterSelect movieId={movieId} /> : null}
<Link className={styles.link} style={elementStyle} to={link}>
<MoviePoster
className={styles.poster}

View File

@ -27,6 +27,7 @@ interface RowItemData {
posterWidth: number;
posterHeight: number;
rowHeight: number;
isSelectMode: boolean;
isSmallScreen: boolean;
}
@ -37,6 +38,7 @@ interface MovieIndexOverviewsProps {
jumpToCharacter?: string;
scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>;
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,
}}
>

View File

@ -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 (
<div className={styles.content}>
<div className={styles.posterContainer}>
{isSelectMode ? <MovieIndexPosterSelect movieId={movieId} /> : null}
<Label className={styles.controls}>
<SpinnerIconButton
name={icons.REFRESH}

View File

@ -36,6 +36,7 @@ interface CellItemData {
};
items: Movie[];
sortKey: string;
isSelectMode: boolean;
}
interface MovieIndexPostersProps {
@ -45,6 +46,7 @@ interface MovieIndexPostersProps {
jumpToCharacter?: string;
scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>;
isSelectMode: boolean;
isSmallScreen: boolean;
}
@ -63,7 +65,7 @@ const Cell: React.FC<GridChildComponentProps<CellItemData>> = ({
style,
data,
}) => {
const { layout, items, sortKey } = data;
const { layout, items, sortKey, isSelectMode } = data;
const { columnCount, padding, posterWidth, posterHeight } = layout;
@ -85,6 +87,7 @@ const Cell: React.FC<GridChildComponentProps<CellItemData>> = ({
<MovieIndexPoster
movieId={movie.id}
sortKey={sortKey}
isSelectMode={isSelectMode}
posterWidth={posterWidth}
posterHeight={posterHeight}
/>
@ -97,7 +100,14 @@ function getWindowScrollTopPosition() {
}
export default function MovieIndexPosters(props: MovieIndexPostersProps) {
const { scrollerRef, items, sortKey, jumpToCharacter, isSmallScreen } = props;
const {
scrollerRef,
items,
sortKey,
jumpToCharacter,
isSelectMode,
isSmallScreen,
} = props;
const { posterOptions } = useSelector(movieIndexSelector);
const ref: React.MutableRefObject<Grid> = useRef();
@ -276,6 +286,7 @@ export default function MovieIndexPosters(props: MovieIndexPostersProps) {
},
items,
sortKey,
isSelectMode,
}}
>
{Cell}

View File

@ -0,0 +1,36 @@
.checkContainer {
position: absolute;
top: 10px;
left: 10px;
z-index: 3;
width: 18px;
height: 18px;
border-radius: 50%;
background-color: var(--defaultColor);
}
.icon {
position: absolute;
top: -1px;
left: -1px;
}
.selected {
composes: icon;
color: var(--sonarrBlue);
&:hover {
color: var(--white);
}
}
.unselected {
composes: icon;
color: var(--white);
&:hover {
color: var(--sonarrBlue);
}
}

View File

@ -0,0 +1,10 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'checkContainer': string;
'icon': string;
'selected': string;
'unselected': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@ -0,0 +1,41 @@
import React, { useCallback } from 'react';
import { SelectActionType, useSelect } from 'App/SelectContext';
import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props';
import styles from './MovieIndexPosterSelect.css';
interface MovieIndexPosterSelectProps {
movieId: number;
}
function MovieIndexPosterSelect(props: MovieIndexPosterSelectProps) {
const { movieId } = props;
const [selectState, selectDispatch] = useSelect();
const isSelected = selectState.selectedState[movieId];
const onSelectPress = useCallback(
(event) => {
const shiftKey = event.nativeEvent.shiftKey;
selectDispatch({
type: SelectActionType.ToggleSelected,
id: movieId,
isSelected: !isSelected,
shiftKey,
});
},
[movieId, isSelected, selectDispatch]
);
return (
<IconButton
className={styles.checkContainer}
iconClassName={isSelected ? styles.selected : styles.unselected}
name={isSelected ? icons.CHECK_CIRCLE : icons.CIRCLE_OUTLINE}
size={20}
onPress={onSelectPress}
/>
);
}
export default MovieIndexPosterSelect;

View File

@ -0,0 +1,35 @@
import React, { useCallback } from 'react';
import { SelectActionType, useSelect } from 'App/SelectContext';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import { icons } from 'Helpers/Props';
function MovieIndexSelectAllButton() {
const [selectState, selectDispatch] = useSelect();
const { allSelected, allUnselected } = selectState;
let icon = icons.SQUARE_MINUS;
if (allSelected) {
icon = icons.CHECK_SQUARE;
} else if (allUnselected) {
icon = icons.SQUARE;
}
const onPress = useCallback(() => {
selectDispatch({
type: allSelected
? SelectActionType.UnselectAll
: SelectActionType.SelectAll,
});
}, [allSelected, selectDispatch]);
return (
<PageToolbarButton
label={allSelected ? 'Unselect All' : 'Select All'}
iconName={icon}
onPress={onPress}
/>
);
}
export default MovieIndexSelectAllButton;

View File

@ -8,6 +8,7 @@ import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import RottenTomatoRating from 'Components/RottenTomatoRating';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
import Column from 'Components/Table/Column';
import TagListConnector from 'Components/TagListConnector';
import TmdbRating from 'Components/TmdbRating';
@ -27,15 +28,17 @@ import translate from 'Utilities/String/translate';
import MovieStatusCell from './MovieStatusCell';
import selectTableOptions from './selectTableOptions';
import styles from './MovieIndexRow.css';
import { SelectActionType, useSelect } from 'App/SelectContext';
interface MovieIndexRowProps {
movieId: number;
sortKey: string;
columns: Column[];
isSelectMode: boolean;
}
function MovieIndexRow(props: MovieIndexRowProps) {
const { movieId, columns } = props;
const { movieId, columns, isSelectMode } = props;
const { movie, qualityProfile, isRefreshingMovie, isSearchingMovie } =
useSelector(createMovieIndexItemSelector(props.movieId));
@ -75,6 +78,7 @@ function MovieIndexRow(props: MovieIndexRowProps) {
const dispatch = useDispatch();
const [isEditMovieModalOpen, setIsEditMovieModalOpen] = useState(false);
const [isDeleteMovieModalOpen, setIsDeleteMovieModalOpen] = useState(false);
const [selectState, selectDispatch] = useSelect();
const onRefreshPress = useCallback(() => {
dispatch(
@ -111,8 +115,29 @@ function MovieIndexRow(props: MovieIndexRowProps) {
setIsDeleteMovieModalOpen(false);
}, [setIsDeleteMovieModalOpen]);
const onSelectedChange = useCallback(
({ id, value, shiftKey }) => {
selectDispatch({
type: SelectActionType.ToggleSelected,
id,
isSelected: value,
shiftKey,
});
},
[selectDispatch]
);
return (
<>
{isSelectMode ? (
<VirtualTableSelectCell
id={movieId}
isSelected={selectState.selectedState[movieId]}
isDisabled={false}
onSelectedChange={onSelectedChange}
/>
) : null}
{columns.map((column) => {
const { name, isVisible } = column;

View File

@ -25,6 +25,7 @@ interface RowItemData {
items: Movie[];
sortKey: string;
columns: Column[];
isSelectMode: boolean;
}
interface MovieIndexTableProps {
@ -34,6 +35,7 @@ interface MovieIndexTableProps {
jumpToCharacter?: string;
scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>;
isSelectMode: boolean;
isSmallScreen: boolean;
}
@ -47,7 +49,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
style,
data,
}) => {
const { items, sortKey, columns } = data;
const { items, sortKey, columns, isSelectMode } = data;
if (index >= items.length) {
return null;
@ -63,7 +65,12 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
...style,
}}
>
<MovieIndexRow movieId={movie.id} sortKey={sortKey} columns={columns} />
<MovieIndexRow
movieId={movie.id}
sortKey={sortKey}
columns={columns}
isSelectMode={isSelectMode}
/>
</div>
);
};
@ -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}
/>
<List<RowItemData>
ref={listRef}
@ -188,6 +197,7 @@ function MovieIndexTable(props: MovieIndexTableProps) {
items,
sortKey,
columns,
isSelectMode,
}}
>
{Row}

View File

@ -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 (
<VirtualTableHeader>
{isSelectMode ? (
<VirtualTableSelectAllHeaderCell
allSelected={selectState.allSelected}
allUnselected={selectState.allUnselected}
onSelectAllChange={onSelectAllChange}
/>
) : null}
{columns.map((column) => {
const { name, label, isSortable, isVisible } = column;

View File

@ -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
};
}

View File

@ -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",