Added series index selection

This commit is contained in:
Mark McDowall 2023-01-12 09:00:37 -08:00 committed by Mark McDowall
parent 5aad84dba4
commit 815a16d5cf
14 changed files with 513 additions and 120 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
@ -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;

View File

@ -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) {
<div className={styles.content}>
<div className={styles.poster}>
<div className={styles.posterContainer}>
{isSelectMode ? (
<SeriesIndexPosterSelect seriesId={seriesId} />
) : null}
{status === 'ended' && (
<div className={styles.ended} title="Ended" />
)}

View File

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

View File

@ -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 (
<div className={styles.content}>
<div className={styles.posterContainer}>
{isSelectMode ? <SeriesIndexPosterSelect seriesId={seriesId} /> : null}
<Label className={styles.controls}>
<SpinnerIconButton
className={styles.action}

View File

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

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

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_SERIES, RSS_SYNC } from 'Commands/commandNames';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
@ -32,6 +33,7 @@ import SeriesIndexOverviewOptionsModal from './Overview/Options/SeriesIndexOverv
import SeriesIndexOverviews from './Overview/SeriesIndexOverviews';
import SeriesIndexPosterOptionsModal from './Posters/Options/SeriesIndexPosterOptionsModal';
import SeriesIndexPosters from './Posters/SeriesIndexPosters';
import SeriesIndexSelectAllButton from './Select/SeriesIndexSelectAllButton';
import SeriesIndexFooter from './SeriesIndexFooter';
import SeriesIndexTable from './Table/SeriesIndexTable';
import SeriesIndexTableOptions from './Table/SeriesIndexTableOptions';
@ -53,7 +55,7 @@ interface SeriesIndexProps {
initialScrollTop?: number;
}
const SeriesIndex = withScrollPosition((props) => {
const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
const {
isFetching,
isPopulated,
@ -80,6 +82,7 @@ const SeriesIndex = withScrollPosition((props) => {
const scrollerRef = useRef<HTMLDivElement>();
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
const [jumpToCharacter, setJumpToCharacter] = useState<string | null>(null);
const [isSelectMode, setIsSelectMode] = useState(false);
const onRefreshSeriesPress = useCallback(() => {
dispatch(
@ -97,6 +100,10 @@ const SeriesIndex = withScrollPosition((props) => {
);
}, [dispatch]);
const onSelectModePress = useCallback(() => {
setIsSelectMode(!isSelectMode);
}, [isSelectMode, setIsSelectMode]);
const onTableOptionChange = useCallback(
(payload) => {
dispatch(setSeriesTableOption(payload));
@ -194,6 +201,7 @@ const SeriesIndex = withScrollPosition((props) => {
const hasNoSeries = !totalItems;
return (
<SelectProvider isSelectMode={isSelectMode} items={items}>
<PageContent>
<PageToolbar>
<PageToolbarSection>
@ -213,9 +221,22 @@ const SeriesIndex = withScrollPosition((props) => {
isDisabled={hasNoSeries}
onPress={onRssSyncPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={isSelectMode ? 'Stop Selecting' : 'Select Series'}
iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
onPress={onSelectModePress}
/>
{isSelectMode ? <SeriesIndexSelectAllButton /> : null}
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT} collapseButtons={false}>
<PageToolbarSection
alignContent={align.RIGHT}
collapseButtons={false}
>
{view === 'table' ? (
<TableOptionsModalWrapper
columns={columns}
@ -277,6 +298,7 @@ const SeriesIndex = withScrollPosition((props) => {
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
isSelectMode={isSelectMode}
isSmallScreen={isSmallScreen}
/>
@ -290,7 +312,10 @@ const SeriesIndex = withScrollPosition((props) => {
</PageContentBody>
{isLoaded && !!jumpBarItems.order.length ? (
<PageJumpBar items={jumpBarItems} onItemPress={onJumpBarItemPress} />
<PageJumpBar
items={jumpBarItems}
onItemPress={onJumpBarItemPress}
/>
) : null}
</div>
{view === 'posters' ? (
@ -306,6 +331,7 @@ const SeriesIndex = withScrollPosition((props) => {
/>
) : null}
</PageContent>
</SelectProvider>
);
}, 'seriesIndex');

View File

@ -11,6 +11,7 @@ import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar';
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 { icons } from 'Helpers/Props';
@ -32,10 +33,11 @@ interface SeriesIndexRowProps {
seriesId: number;
sortKey: string;
columns: Column[];
isSelectMode: boolean;
}
function SeriesIndexRow(props: SeriesIndexRowProps) {
const { seriesId, columns } = props;
const { seriesId, columns, isSelectMode } = props;
const {
series,
@ -82,6 +84,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
const [hasBannerError, setHasBannerError] = useState(false);
const [isEditSeriesModalOpen, setIsEditSeriesModalOpen] = useState(false);
const [isDeleteSeriesModalOpen, setIsDeleteSeriesModalOpen] = useState(false);
const [selectState, selectDispatch] = useSelect();
const onRefreshPress = useCallback(() => {
dispatch(
@ -130,8 +133,29 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
// 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 (
<>
{isSelectMode ? (
<VirtualTableSelectCell
id={seriesId}
isSelected={selectState.selectedState[seriesId]}
isDisabled={false}
onSelectedChange={onSelectedChange}
/>
) : null}
{columns.map((column) => {
const { name, isVisible } = column;

View File

@ -25,6 +25,7 @@ interface RowItemData {
items: Series[];
sortKey: string;
columns: Column[];
isSelectMode: boolean;
}
interface SeriesIndexTableProps {
@ -34,6 +35,7 @@ interface SeriesIndexTableProps {
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;
@ -67,6 +69,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
seriesId={series.id}
sortKey={sortKey}
columns={columns}
isSelectMode={isSelectMode}
/>
</div>
);
@ -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}
/>
<List<RowItemData>
ref={listRef}
@ -193,6 +198,7 @@ function SeriesIndexTable(props: SeriesIndexTableProps) {
items,
sortKey,
columns,
isSelectMode,
}}
>
{Row}

View File

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