Added series index selection

pull/5431/head
Mark McDowall 2 years ago committed by Mark McDowall
parent 5aad84dba4
commit 815a16d5cf

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

@ -15,7 +15,8 @@ import {
faHdd as farHdd, faHdd as farHdd,
faKeyboard as farKeyboard, faKeyboard as farKeyboard,
faObjectGroup as farObjectGroup, faObjectGroup as farObjectGroup,
faObjectUngroup as farObjectUngroup faObjectUngroup as farObjectUngroup,
faSquare as farSquare
} from '@fortawesome/free-regular-svg-icons'; } from '@fortawesome/free-regular-svg-icons';
// //
// Solid // Solid
@ -83,6 +84,8 @@ import {
faSortDown as fasSortDown, faSortDown as fasSortDown,
faSortUp as fasSortUp, faSortUp as fasSortUp,
faSpinner as fasSpinner, faSpinner as fasSpinner,
faSquareCheck as fasSquareCheck,
faSquareMinus as fasSquareMinus,
faStop as fasStop, faStop as fasStop,
faSync as fasSync, faSync as fasSync,
faTable as fasTable, faTable as fasTable,
@ -116,6 +119,7 @@ export const CARET_DOWN = fasCaretDown;
export const CHECK = fasCheck; export const CHECK = fasCheck;
export const CHECK_INDETERMINATE = fasMinus; export const CHECK_INDETERMINATE = fasMinus;
export const CHECK_CIRCLE = fasCheckCircle; export const CHECK_CIRCLE = fasCheckCircle;
export const CHECK_SQUARE = fasSquareCheck;
export const CIRCLE = fasCircle; export const CIRCLE = fasCircle;
export const CIRCLE_OUTLINE = farCircle; export const CIRCLE_OUTLINE = farCircle;
export const CLEAR = fasTrashAlt; export const CLEAR = fasTrashAlt;
@ -192,6 +196,8 @@ export const SORT = fasSort;
export const SORT_ASCENDING = fasSortUp; export const SORT_ASCENDING = fasSortUp;
export const SORT_DESCENDING = fasSortDown; export const SORT_DESCENDING = fasSortDown;
export const SPINNER = fasSpinner; export const SPINNER = fasSpinner;
export const SQUARE = farSquare;
export const SQUARE_MINUS = fasSquareMinus;
export const SUBTRACT = fasMinus; export const SUBTRACT = fasMinus;
export const SYSTEM = fasLaptop; export const SYSTEM = fasLaptop;
export const TABLE = fasTable; export const TABLE = fasTable;

@ -9,6 +9,7 @@ import { icons } from 'Helpers/Props';
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector'; import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar'; import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar';
import SeriesIndexPosterSelect from 'Series/Index/Select/SeriesIndexPosterSelect';
import SeriesPoster from 'Series/SeriesPoster'; import SeriesPoster from 'Series/SeriesPoster';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import dimensions from 'Styles/Variables/dimensions'; import dimensions from 'Styles/Variables/dimensions';
@ -35,6 +36,7 @@ interface SeriesIndexOverviewProps {
posterWidth: number; posterWidth: number;
posterHeight: number; posterHeight: number;
rowHeight: number; rowHeight: number;
isSelectMode: boolean;
isSmallScreen: boolean; isSmallScreen: boolean;
} }
@ -45,6 +47,7 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
posterWidth, posterWidth,
posterHeight, posterHeight,
rowHeight, rowHeight,
isSelectMode,
isSmallScreen, isSmallScreen,
} = props; } = props;
@ -135,6 +138,10 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
<div className={styles.content}> <div className={styles.content}>
<div className={styles.poster}> <div className={styles.poster}>
<div className={styles.posterContainer}> <div className={styles.posterContainer}>
{isSelectMode ? (
<SeriesIndexPosterSelect seriesId={seriesId} />
) : null}
{status === 'ended' && ( {status === 'ended' && (
<div className={styles.ended} title="Ended" /> <div className={styles.ended} title="Ended" />
)} )}

@ -27,6 +27,7 @@ interface RowItemData {
posterWidth: number; posterWidth: number;
posterHeight: number; posterHeight: number;
rowHeight: number; rowHeight: number;
isSelectMode: boolean;
isSmallScreen: boolean; isSmallScreen: boolean;
} }
@ -37,6 +38,7 @@ interface SeriesIndexOverviewsProps {
jumpToCharacter?: string; jumpToCharacter?: string;
scrollTop?: number; scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>; scrollerRef: React.MutableRefObject<HTMLElement>;
isSelectMode: boolean;
isSmallScreen: boolean; isSmallScreen: boolean;
} }
@ -65,7 +67,14 @@ function getWindowScrollTopPosition() {
} }
function SeriesIndexOverviews(props: SeriesIndexOverviewsProps) { function SeriesIndexOverviews(props: SeriesIndexOverviewsProps) {
const { items, sortKey, jumpToCharacter, isSmallScreen, scrollerRef } = props; const {
items,
sortKey,
jumpToCharacter,
scrollerRef,
isSelectMode,
isSmallScreen,
} = props;
const { size: posterSize, detailedProgressBar } = useSelector( const { size: posterSize, detailedProgressBar } = useSelector(
selectOverviewOptions selectOverviewOptions
@ -191,6 +200,7 @@ function SeriesIndexOverviews(props: SeriesIndexOverviewsProps) {
posterWidth, posterWidth,
posterHeight, posterHeight,
rowHeight, rowHeight,
isSelectMode,
isSmallScreen, isSmallScreen,
}} }}
> >

@ -9,6 +9,7 @@ import { icons } from 'Helpers/Props';
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector'; import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar'; import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar';
import SeriesIndexPosterSelect from 'Series/Index/Select/SeriesIndexPosterSelect';
import SeriesPoster from 'Series/SeriesPoster'; import SeriesPoster from 'Series/SeriesPoster';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
@ -21,12 +22,13 @@ import styles from './SeriesIndexPoster.css';
interface SeriesIndexPosterProps { interface SeriesIndexPosterProps {
seriesId: number; seriesId: number;
sortKey: string; sortKey: string;
isSelectMode: boolean;
posterWidth: number; posterWidth: number;
posterHeight: number; posterHeight: number;
} }
function SeriesIndexPoster(props: SeriesIndexPosterProps) { function SeriesIndexPoster(props: SeriesIndexPosterProps) {
const { seriesId, sortKey, posterWidth, posterHeight } = props; const { seriesId, sortKey, isSelectMode, posterWidth, posterHeight } = props;
const { series, qualityProfile, isRefreshingSeries, isSearchingSeries } = const { series, qualityProfile, isRefreshingSeries, isSearchingSeries } =
useSelector(createSeriesIndexItemSelector(props.seriesId)); useSelector(createSeriesIndexItemSelector(props.seriesId));
@ -120,6 +122,8 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
return ( return (
<div className={styles.content}> <div className={styles.content}>
<div className={styles.posterContainer}> <div className={styles.posterContainer}>
{isSelectMode ? <SeriesIndexPosterSelect seriesId={seriesId} /> : null}
<Label className={styles.controls}> <Label className={styles.controls}>
<SpinnerIconButton <SpinnerIconButton
className={styles.action} className={styles.action}

@ -36,6 +36,7 @@ interface CellItemData {
}; };
items: Series[]; items: Series[];
sortKey: string; sortKey: string;
isSelectMode: boolean;
} }
interface SeriesIndexPostersProps { interface SeriesIndexPostersProps {
@ -45,6 +46,7 @@ interface SeriesIndexPostersProps {
jumpToCharacter?: string; jumpToCharacter?: string;
scrollTop?: number; scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>; scrollerRef: React.MutableRefObject<HTMLElement>;
isSelectMode: boolean;
isSmallScreen: boolean; isSmallScreen: boolean;
} }
@ -63,10 +65,8 @@ const Cell: React.FC<GridChildComponentProps<CellItemData>> = ({
style, style,
data, data,
}) => { }) => {
const { layout, items, sortKey } = data; const { layout, items, sortKey, isSelectMode } = data;
const { columnCount, padding, posterWidth, posterHeight } = layout; const { columnCount, padding, posterWidth, posterHeight } = layout;
const index = rowIndex * columnCount + columnIndex; const index = rowIndex * columnCount + columnIndex;
if (index >= items.length) { if (index >= items.length) {
@ -85,6 +85,7 @@ const Cell: React.FC<GridChildComponentProps<CellItemData>> = ({
<SeriesIndexPoster <SeriesIndexPoster
seriesId={series.id} seriesId={series.id}
sortKey={sortKey} sortKey={sortKey}
isSelectMode={isSelectMode}
posterWidth={posterWidth} posterWidth={posterWidth}
posterHeight={posterHeight} posterHeight={posterHeight}
/> />
@ -97,7 +98,14 @@ function getWindowScrollTopPosition() {
} }
export default function SeriesIndexPosters(props: SeriesIndexPostersProps) { export default function SeriesIndexPosters(props: SeriesIndexPostersProps) {
const { scrollerRef, items, sortKey, jumpToCharacter, isSmallScreen } = props; const {
scrollerRef,
items,
sortKey,
jumpToCharacter,
isSelectMode,
isSmallScreen,
} = props;
const { posterOptions } = useSelector(seriesIndexSelector); const { posterOptions } = useSelector(seriesIndexSelector);
const ref: React.MutableRefObject<Grid> = useRef(); const ref: React.MutableRefObject<Grid> = useRef();
@ -273,6 +281,7 @@ export default function SeriesIndexPosters(props: SeriesIndexPostersProps) {
}, },
items, items,
sortKey, sortKey,
isSelectMode,
}} }}
> >
{Cell} {Cell}

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

@ -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 './SeriesIndexPosterSelect.css';
interface SeriesIndexPosterSelectProps {
seriesId: number;
}
function SeriesIndexPosterSelect(props: SeriesIndexPosterSelectProps) {
const { seriesId } = props;
const [selectState, selectDispatch] = useSelect();
const isSelected = selectState.selectedState[seriesId];
const onSelectPress = useCallback(
(event) => {
const shiftKey = event.nativeEvent.shiftKey;
selectDispatch({
type: SelectActionType.ToggleSelected,
id: seriesId,
isSelected: !isSelected,
shiftKey,
});
},
[seriesId, 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 SeriesIndexPosterSelect;

@ -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 SeriesIndexSelectAllButton() {
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 SeriesIndexSelectAllButton;

@ -1,5 +1,6 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { SelectProvider } from 'App/SelectContext';
import { REFRESH_SERIES, RSS_SYNC } from 'Commands/commandNames'; import { REFRESH_SERIES, RSS_SYNC } from 'Commands/commandNames';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent'; import PageContent from 'Components/Page/PageContent';
@ -32,6 +33,7 @@ import SeriesIndexOverviewOptionsModal from './Overview/Options/SeriesIndexOverv
import SeriesIndexOverviews from './Overview/SeriesIndexOverviews'; import SeriesIndexOverviews from './Overview/SeriesIndexOverviews';
import SeriesIndexPosterOptionsModal from './Posters/Options/SeriesIndexPosterOptionsModal'; import SeriesIndexPosterOptionsModal from './Posters/Options/SeriesIndexPosterOptionsModal';
import SeriesIndexPosters from './Posters/SeriesIndexPosters'; import SeriesIndexPosters from './Posters/SeriesIndexPosters';
import SeriesIndexSelectAllButton from './Select/SeriesIndexSelectAllButton';
import SeriesIndexFooter from './SeriesIndexFooter'; import SeriesIndexFooter from './SeriesIndexFooter';
import SeriesIndexTable from './Table/SeriesIndexTable'; import SeriesIndexTable from './Table/SeriesIndexTable';
import SeriesIndexTableOptions from './Table/SeriesIndexTableOptions'; import SeriesIndexTableOptions from './Table/SeriesIndexTableOptions';
@ -53,7 +55,7 @@ interface SeriesIndexProps {
initialScrollTop?: number; initialScrollTop?: number;
} }
const SeriesIndex = withScrollPosition((props) => { const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
const { const {
isFetching, isFetching,
isPopulated, isPopulated,
@ -80,6 +82,7 @@ const SeriesIndex = withScrollPosition((props) => {
const scrollerRef = useRef<HTMLDivElement>(); const scrollerRef = useRef<HTMLDivElement>();
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
const [jumpToCharacter, setJumpToCharacter] = useState<string | null>(null); const [jumpToCharacter, setJumpToCharacter] = useState<string | null>(null);
const [isSelectMode, setIsSelectMode] = useState(false);
const onRefreshSeriesPress = useCallback(() => { const onRefreshSeriesPress = useCallback(() => {
dispatch( dispatch(
@ -97,6 +100,10 @@ const SeriesIndex = withScrollPosition((props) => {
); );
}, [dispatch]); }, [dispatch]);
const onSelectModePress = useCallback(() => {
setIsSelectMode(!isSelectMode);
}, [isSelectMode, setIsSelectMode]);
const onTableOptionChange = useCallback( const onTableOptionChange = useCallback(
(payload) => { (payload) => {
dispatch(setSeriesTableOption(payload)); dispatch(setSeriesTableOption(payload));
@ -194,118 +201,137 @@ const SeriesIndex = withScrollPosition((props) => {
const hasNoSeries = !totalItems; const hasNoSeries = !totalItems;
return ( return (
<PageContent> <SelectProvider isSelectMode={isSelectMode} items={items}>
<PageToolbar> <PageContent>
<PageToolbarSection> <PageToolbar>
<PageToolbarButton <PageToolbarSection>
label="Update all" <PageToolbarButton
iconName={icons.REFRESH} label="Update all"
spinningName={icons.REFRESH} iconName={icons.REFRESH}
isSpinning={isRefreshingSeries} spinningName={icons.REFRESH}
isDisabled={hasNoSeries} isSpinning={isRefreshingSeries}
onPress={onRefreshSeriesPress} isDisabled={hasNoSeries}
/> onPress={onRefreshSeriesPress}
/>
<PageToolbarButton
label="RSS Sync"
iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
isDisabled={hasNoSeries}
onPress={onRssSyncPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT} collapseButtons={false}>
{view === 'table' ? (
<TableOptionsModalWrapper
columns={columns}
optionsComponent={SeriesIndexTableOptions}
onTableOptionChange={onTableOptionChange}
>
<PageToolbarButton label="Options" iconName={icons.TABLE} />
</TableOptionsModalWrapper>
) : (
<PageToolbarButton <PageToolbarButton
label="Options" label="RSS Sync"
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW} iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
isDisabled={hasNoSeries} isDisabled={hasNoSeries}
onPress={onOptionsPress} onPress={onRssSyncPress}
/> />
)}
<PageToolbarSeparator /> <PageToolbarSeparator />
<SeriesIndexViewMenu <PageToolbarButton
view={view} label={isSelectMode ? 'Stop Selecting' : 'Select Series'}
isDisabled={hasNoSeries} iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
onViewSelect={onViewSelect} onPress={onSelectModePress}
/> />
<SeriesIndexSortMenu
sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoSeries}
onSortSelect={onSortSelect}
/>
<SeriesIndexFilterMenu {isSelectMode ? <SeriesIndexSelectAllButton /> : null}
selectedFilterKey={selectedFilterKey} </PageToolbarSection>
filters={filters}
customFilters={customFilters} <PageToolbarSection
isDisabled={hasNoSeries} alignContent={align.RIGHT}
onFilterSelect={onFilterSelect} collapseButtons={false}
/> >
</PageToolbarSection> {view === 'table' ? (
</PageToolbar> <TableOptionsModalWrapper
<div className={styles.pageContentBodyWrapper}> columns={columns}
<PageContentBody optionsComponent={SeriesIndexTableOptions}
ref={scrollerRef} onTableOptionChange={onTableOptionChange}
className={styles.contentBody} >
innerClassName={styles[`${view}InnerContentBody`]} <PageToolbarButton label="Options" iconName={icons.TABLE} />
initialScrollTop={props.initialScrollTop} </TableOptionsModalWrapper>
onScroll={onScroll} ) : (
> <PageToolbarButton
{isFetching && !isPopulated ? <LoadingIndicator /> : null} label="Options"
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
{!isFetching && !!error ? <div>Unable to load series</div> : null} isDisabled={hasNoSeries}
onPress={onOptionsPress}
{isLoaded ? (
<div className={styles.contentBodyContainer}>
<ViewComponent
scrollerRef={scrollerRef}
items={items}
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
isSmallScreen={isSmallScreen}
/> />
)}
<SeriesIndexFooter /> <PageToolbarSeparator />
</div>
) : null}
{!error && isPopulated && !items.length ? ( <SeriesIndexViewMenu
<NoSeries totalItems={totalItems} /> view={view}
) : null} isDisabled={hasNoSeries}
</PageContentBody> onViewSelect={onViewSelect}
/>
{isLoaded && !!jumpBarItems.order.length ? ( <SeriesIndexSortMenu
<PageJumpBar items={jumpBarItems} onItemPress={onJumpBarItemPress} /> sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoSeries}
onSortSelect={onSortSelect}
/>
<SeriesIndexFilterMenu
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
isDisabled={hasNoSeries}
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 series</div> : null}
{isLoaded ? (
<div className={styles.contentBodyContainer}>
<ViewComponent
scrollerRef={scrollerRef}
items={items}
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
isSelectMode={isSelectMode}
isSmallScreen={isSmallScreen}
/>
<SeriesIndexFooter />
</div>
) : null}
{!error && isPopulated && !items.length ? (
<NoSeries totalItems={totalItems} />
) : null}
</PageContentBody>
{isLoaded && !!jumpBarItems.order.length ? (
<PageJumpBar
items={jumpBarItems}
onItemPress={onJumpBarItemPress}
/>
) : null}
</div>
{view === 'posters' ? (
<SeriesIndexPosterOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
{view === 'overview' ? (
<SeriesIndexOverviewOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null} ) : null}
</div> </PageContent>
{view === 'posters' ? ( </SelectProvider>
<SeriesIndexPosterOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
{view === 'overview' ? (
<SeriesIndexOverviewOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
</PageContent>
); );
}, 'seriesIndex'); }, 'seriesIndex');

@ -11,6 +11,7 @@ import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar'; import ProgressBar from 'Components/ProgressBar';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
import Column from 'Components/Table/Column'; import Column from 'Components/Table/Column';
import TagListConnector from 'Components/TagListConnector'; import TagListConnector from 'Components/TagListConnector';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
@ -32,10 +33,11 @@ interface SeriesIndexRowProps {
seriesId: number; seriesId: number;
sortKey: string; sortKey: string;
columns: Column[]; columns: Column[];
isSelectMode: boolean;
} }
function SeriesIndexRow(props: SeriesIndexRowProps) { function SeriesIndexRow(props: SeriesIndexRowProps) {
const { seriesId, columns } = props; const { seriesId, columns, isSelectMode } = props;
const { const {
series, series,
@ -82,6 +84,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
const [hasBannerError, setHasBannerError] = useState(false); const [hasBannerError, setHasBannerError] = useState(false);
const [isEditSeriesModalOpen, setIsEditSeriesModalOpen] = useState(false); const [isEditSeriesModalOpen, setIsEditSeriesModalOpen] = useState(false);
const [isDeleteSeriesModalOpen, setIsDeleteSeriesModalOpen] = useState(false); const [isDeleteSeriesModalOpen, setIsDeleteSeriesModalOpen] = useState(false);
const [selectState, selectDispatch] = useSelect();
const onRefreshPress = useCallback(() => { const onRefreshPress = useCallback(() => {
dispatch( dispatch(
@ -130,8 +133,29 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
// Mock handler to satisfy `onChange` being required for `CheckInput`. // Mock handler to satisfy `onChange` being required for `CheckInput`.
}, []); }, []);
const onSelectedChange = useCallback(
({ id, value, shiftKey }) => {
selectDispatch({
type: SelectActionType.ToggleSelected,
id,
isSelected: value,
shiftKey,
});
},
[selectDispatch]
);
return ( return (
<> <>
{isSelectMode ? (
<VirtualTableSelectCell
id={seriesId}
isSelected={selectState.selectedState[seriesId]}
isDisabled={false}
onSelectedChange={onSelectedChange}
/>
) : null}
{columns.map((column) => { {columns.map((column) => {
const { name, isVisible } = column; const { name, isVisible } = column;

@ -25,6 +25,7 @@ interface RowItemData {
items: Series[]; items: Series[];
sortKey: string; sortKey: string;
columns: Column[]; columns: Column[];
isSelectMode: boolean;
} }
interface SeriesIndexTableProps { interface SeriesIndexTableProps {
@ -34,6 +35,7 @@ interface SeriesIndexTableProps {
jumpToCharacter?: string; jumpToCharacter?: string;
scrollTop?: number; scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>; scrollerRef: React.MutableRefObject<HTMLElement>;
isSelectMode: boolean;
isSmallScreen: boolean; isSmallScreen: boolean;
} }
@ -47,7 +49,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
style, style,
data, data,
}) => { }) => {
const { items, sortKey, columns } = data; const { items, sortKey, columns, isSelectMode } = data;
if (index >= items.length) { if (index >= items.length) {
return null; return null;
@ -67,6 +69,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
seriesId={series.id} seriesId={series.id}
sortKey={sortKey} sortKey={sortKey}
columns={columns} columns={columns}
isSelectMode={isSelectMode}
/> />
</div> </div>
); );
@ -82,6 +85,7 @@ function SeriesIndexTable(props: SeriesIndexTableProps) {
sortKey, sortKey,
sortDirection, sortDirection,
jumpToCharacter, jumpToCharacter,
isSelectMode,
isSmallScreen, isSmallScreen,
scrollerRef, scrollerRef,
} = props; } = props;
@ -177,6 +181,7 @@ function SeriesIndexTable(props: SeriesIndexTableProps) {
columns={columns} columns={columns}
sortKey={sortKey} sortKey={sortKey}
sortDirection={sortDirection} sortDirection={sortDirection}
isSelectMode={isSelectMode}
/> />
<List<RowItemData> <List<RowItemData>
ref={listRef} ref={listRef}
@ -193,6 +198,7 @@ function SeriesIndexTable(props: SeriesIndexTableProps) {
items, items,
sortKey, sortKey,
columns, columns,
isSelectMode,
}} }}
> >
{Row} {Row}

@ -7,6 +7,7 @@ import Column from 'Components/Table/Column';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection'; import SortDirection from 'Helpers/Props/SortDirection';
import { import {
@ -22,12 +23,13 @@ interface SeriesIndexTableHeaderProps {
columns: Column[]; columns: Column[];
sortKey?: string; sortKey?: string;
sortDirection?: SortDirection; sortDirection?: SortDirection;
isSelectMode: boolean;
} }
function SeriesIndexTableHeader(props: SeriesIndexTableHeaderProps) { function SeriesIndexTableHeader(props: SeriesIndexTableHeaderProps) {
const { showBanners, columns, sortKey, sortDirection } = props; const { showBanners, columns, sortKey, sortDirection, isSelectMode } = props;
const dispatch = useDispatch(); const dispatch = useDispatch();
const [selectState, selectDispatch] = useSelect();
const onSortPress = useCallback( const onSortPress = useCallback(
(value) => { (value) => {
@ -43,8 +45,25 @@ function SeriesIndexTableHeader(props: SeriesIndexTableHeaderProps) {
[dispatch] [dispatch]
); );
const onSelectAllChange = useCallback(
({ value }) => {
selectDispatch({
type: value ? SelectActionType.SelectAll : SelectActionType.UnselectAll,
});
},
[selectDispatch]
);
return ( return (
<VirtualTableHeader> <VirtualTableHeader>
{isSelectMode ? (
<VirtualTableSelectAllHeaderCell
allSelected={selectState.allSelected}
allUnselected={selectState.allUnselected}
onSelectAllChange={onSelectAllChange}
/>
) : null}
{columns.map((column) => { {columns.map((column) => {
const { name, label, isSortable, isVisible } = column; const { name, label, isSortable, isVisible } = column;

@ -1,29 +1,29 @@
import areAllSelected from './areAllSelected'; import areAllSelected from './areAllSelected';
import getToggledRange from './getToggledRange'; import getToggledRange from './getToggledRange';
function toggleSelected(state, items, id, selected, shiftKey) { function toggleSelected(selectedState, items, id, selected, shiftKey) {
const lastToggled = state.lastToggled; const lastToggled = selectedState.lastToggled;
const selectedState = { const nextSelectedState = {
...state.selectedState, ...selectedState.selectedState,
[id]: selected [id]: selected
}; };
if (selected == null) { if (selected == null) {
delete selectedState[id]; delete nextSelectedState[id];
} }
if (shiftKey && lastToggled) { if (shiftKey && lastToggled) {
const { lower, upper } = getToggledRange(items, id, lastToggled); const { lower, upper } = getToggledRange(items, id, lastToggled);
for (let i = lower; i < upper; i++) { for (let i = lower; i < upper; i++) {
selectedState[items[i].id] = selected; nextSelectedState[items[i].id] = selected;
} }
} }
return { return {
...areAllSelected(selectedState), ...areAllSelected(nextSelectedState),
lastToggled: id, lastToggled: id,
selectedState selectedState: nextSelectedState
}; };
} }

Loading…
Cancel
Save