Added artists index selection

(cherry picked from commit 815a16d5cfced17ca4db7f1b66991c5cc9f3b719)
This commit is contained in:
Mark McDowall 2023-01-12 09:00:37 -08:00 committed by Bogdan
parent d31c323f3c
commit 84d5f2bcee
17 changed files with 553 additions and 135 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

@ -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 NoArtist from 'Artist/NoArtist'; import NoArtist from 'Artist/NoArtist';
import { REFRESH_ARTIST, RSS_SYNC } from 'Commands/commandNames'; import { REFRESH_ARTIST, RSS_SYNC } from 'Commands/commandNames';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@ -37,6 +38,7 @@ import ArtistIndexOverviews from './Overview/ArtistIndexOverviews';
import ArtistIndexOverviewOptionsModal from './Overview/Options/ArtistIndexOverviewOptionsModal'; import ArtistIndexOverviewOptionsModal from './Overview/Options/ArtistIndexOverviewOptionsModal';
import ArtistIndexPosters from './Posters/ArtistIndexPosters'; import ArtistIndexPosters from './Posters/ArtistIndexPosters';
import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal'; import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal';
import ArtistIndexSelectAllButton from './Select/ArtistIndexSelectAllButton';
import ArtistIndexTable from './Table/ArtistIndexTable'; import ArtistIndexTable from './Table/ArtistIndexTable';
import ArtistIndexTableOptions from './Table/ArtistIndexTableOptions'; import ArtistIndexTableOptions from './Table/ArtistIndexTableOptions';
import styles from './ArtistIndex.css'; import styles from './ArtistIndex.css';
@ -88,6 +90,7 @@ const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => {
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 onRefreshArtistPress = useCallback(() => { const onRefreshArtistPress = useCallback(() => {
dispatch( dispatch(
@ -105,6 +108,10 @@ const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => {
); );
}, [dispatch]); }, [dispatch]);
const onSelectModePress = useCallback(() => {
setIsSelectMode(!isSelectMode);
}, [isSelectMode, setIsSelectMode]);
const onTableOptionChange = useCallback( const onTableOptionChange = useCallback(
(payload) => { (payload) => {
dispatch(setArtistTableOption(payload)); dispatch(setArtistTableOption(payload));
@ -202,131 +209,150 @@ const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => {
const hasNoArtist = !totalItems; const hasNoArtist = !totalItems;
return ( return (
<PageContent> <SelectProvider isSelectMode={isSelectMode} items={items}>
<PageToolbar> <PageContent>
<PageToolbarSection> <PageToolbar>
<PageToolbarButton <PageToolbarSection>
label={translate('UpdateAll')} <PageToolbarButton
iconName={icons.REFRESH} label={translate('UpdateAll')}
spinningName={icons.REFRESH} iconName={icons.REFRESH}
isSpinning={isRefreshingArtist} spinningName={icons.REFRESH}
isDisabled={hasNoArtist} isSpinning={isRefreshingArtist}
onPress={onRefreshArtistPress} isDisabled={hasNoArtist}
/> onPress={onRefreshArtistPress}
/>
<PageToolbarButton <PageToolbarButton
label={translate('RSSSync')} label={translate('RSSSync')}
iconName={icons.RSS} iconName={icons.RSS}
isSpinning={isRssSyncExecuting} isSpinning={isRssSyncExecuting}
isDisabled={hasNoArtist} isDisabled={hasNoArtist}
onPress={onRssSyncPress} onPress={onRssSyncPress}
/> />
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT} collapseButtons={false}> <PageToolbarSeparator />
{view === 'table' ? (
<TableOptionsModalWrapper <PageToolbarButton
columns={columns} label={isSelectMode ? 'Stop Selecting' : 'Select Artists'}
optionsComponent={ArtistIndexTableOptions} iconName={isSelectMode ? icons.ARTIST_ENDED : icons.CHECK}
onTableOptionChange={onTableOptionChange} onPress={onSelectModePress}
> />
{isSelectMode ? <ArtistIndexSelectAllButton /> : null}
</PageToolbarSection>
<PageToolbarSection
alignContent={align.RIGHT}
collapseButtons={false}
>
{view === 'table' ? (
<TableOptionsModalWrapper
columns={columns}
optionsComponent={ArtistIndexTableOptions}
onTableOptionChange={onTableOptionChange}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
) : (
<PageToolbarButton <PageToolbarButton
label={translate('Options')} label={translate('Options')}
iconName={icons.TABLE} iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
isDisabled={hasNoArtist}
onPress={onOptionsPress}
/> />
</TableOptionsModalWrapper> )}
) : (
<PageToolbarButton <PageToolbarSeparator />
label={translate('Options')}
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW} <ArtistIndexViewMenu
view={view}
isDisabled={hasNoArtist} isDisabled={hasNoArtist}
onPress={onOptionsPress} onViewSelect={onViewSelect}
/> />
)}
<PageToolbarSeparator /> <ArtistIndexSortMenu
sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoArtist}
onSortSelect={onSortSelect}
/>
<ArtistIndexViewMenu <ArtistIndexFilterMenu
view={view} selectedFilterKey={selectedFilterKey}
isDisabled={hasNoArtist} filters={filters}
onViewSelect={onViewSelect} customFilters={customFilters}
/> isDisabled={hasNoArtist}
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}
<ArtistIndexSortMenu {!isFetching && !!error ? (
sortKey={sortKey} <div className={styles.errorMessage}>
sortDirection={sortDirection} {getErrorMessage(error, 'Failed to load artist from API')}
isDisabled={hasNoArtist} </div>
onSortSelect={onSortSelect} ) : null}
/>
<ArtistIndexFilterMenu {isLoaded ? (
selectedFilterKey={selectedFilterKey} <div className={styles.contentBodyContainer}>
filters={filters} <ViewComponent
customFilters={customFilters} scrollerRef={scrollerRef}
isDisabled={hasNoArtist} items={items}
onFilterSelect={onFilterSelect} sortKey={sortKey}
/> sortDirection={sortDirection}
</PageToolbarSection> jumpToCharacter={jumpToCharacter}
</PageToolbar> isSelectMode={isSelectMode}
<div className={styles.pageContentBodyWrapper}> isSmallScreen={isSmallScreen}
<PageContentBody />
ref={scrollerRef}
className={styles.contentBody}
innerClassName={styles[`${view}InnerContentBody`]}
initialScrollTop={props.initialScrollTop}
onScroll={onScroll}
>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && !!error ? ( <ArtistIndexFooter />
<div className={styles.errorMessage}> </div>
{getErrorMessage(error, 'Failed to load artist from API')} ) : null}
</div>
{!error && isPopulated && !items.length ? (
<NoArtist totalItems={totalItems} />
) : null}
</PageContentBody>
{isLoaded && !!jumpBarItems.order.length ? (
<PageJumpBar
items={jumpBarItems}
onItemPress={onJumpBarItemPress}
/>
) : null} ) : null}
</div>
{isLoaded ? ( {view === 'posters' ? (
<div className={styles.contentBodyContainer}> <ArtistIndexPosterOptionsModal
<ViewComponent isOpen={isOptionsModalOpen}
scrollerRef={scrollerRef} onModalClose={onOptionsModalClose}
items={items} />
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
isSmallScreen={isSmallScreen}
/>
<ArtistIndexFooter />
</div>
) : null}
{!error && isPopulated && !items.length ? (
<NoArtist totalItems={totalItems} />
) : null}
</PageContentBody>
{isLoaded && !!jumpBarItems.order.length ? (
<PageJumpBar items={jumpBarItems} onItemPress={onJumpBarItemPress} />
) : null} ) : null}
</div> {view === 'banners' ? (
{view === 'posters' ? ( <ArtistIndexBannerOptionsModal
<ArtistIndexPosterOptionsModal isOpen={isOptionsModalOpen}
isOpen={isOptionsModalOpen} onModalClose={onOptionsModalClose}
onModalClose={onOptionsModalClose} />
/> ) : null}
) : null} {view === 'overview' ? (
{view === 'banners' ? ( <ArtistIndexOverviewOptionsModal
<ArtistIndexBannerOptionsModal isOpen={isOptionsModalOpen}
isOpen={isOptionsModalOpen} onModalClose={onOptionsModalClose}
onModalClose={onOptionsModalClose} />
/> ) : null}
) : null} </PageContent>
{view === 'overview' ? ( </SelectProvider>
<ArtistIndexOverviewOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
</PageContent>
); );
}, 'artistIndex'); }, 'artistIndex');

View file

@ -7,6 +7,7 @@ import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import ArtistIndexBannerInfo from 'Artist/Index/Banners/ArtistIndexBannerInfo'; import ArtistIndexBannerInfo from 'Artist/Index/Banners/ArtistIndexBannerInfo';
import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector'; import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector';
import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar';
import ArtistIndexPosterSelect from 'Artist/Index/Select/ArtistIndexPosterSelect';
import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames';
import Label from 'Components/Label'; import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
@ -23,12 +24,13 @@ import styles from './ArtistIndexBanner.css';
interface ArtistIndexBannerProps { interface ArtistIndexBannerProps {
artistId: number; artistId: number;
sortKey: string; sortKey: string;
isSelectMode: boolean;
bannerWidth: number; bannerWidth: number;
bannerHeight: number; bannerHeight: number;
} }
function ArtistIndexBanner(props: ArtistIndexBannerProps) { function ArtistIndexBanner(props: ArtistIndexBannerProps) {
const { artistId, sortKey, bannerWidth, bannerHeight } = props; const { artistId, sortKey, isSelectMode, bannerWidth, bannerHeight } = props;
const { const {
artist, artist,
@ -130,6 +132,8 @@ function ArtistIndexBanner(props: ArtistIndexBannerProps) {
return ( return (
<div className={styles.content}> <div className={styles.content}>
<div className={styles.bannerContainer}> <div className={styles.bannerContainer}>
{isSelectMode ? <ArtistIndexPosterSelect artistId={artistId} /> : null}
<Label className={styles.controls}> <Label className={styles.controls}>
<SpinnerIconButton <SpinnerIconButton
className={styles.action} className={styles.action}

View file

@ -36,6 +36,7 @@ interface CellItemData {
}; };
items: Artist[]; items: Artist[];
sortKey: string; sortKey: string;
isSelectMode: boolean;
} }
interface ArtistIndexBannersProps { interface ArtistIndexBannersProps {
@ -45,6 +46,7 @@ interface ArtistIndexBannersProps {
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, bannerWidth, bannerHeight } = layout; const { columnCount, padding, bannerWidth, bannerHeight } = 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>> = ({
<ArtistIndexBanner <ArtistIndexBanner
artistId={artist.id} artistId={artist.id}
sortKey={sortKey} sortKey={sortKey}
isSelectMode={isSelectMode}
bannerWidth={bannerWidth} bannerWidth={bannerWidth}
bannerHeight={bannerHeight} bannerHeight={bannerHeight}
/> />
@ -97,7 +98,14 @@ function getWindowScrollTopPosition() {
} }
export default function ArtistIndexBanners(props: ArtistIndexBannersProps) { export default function ArtistIndexBanners(props: ArtistIndexBannersProps) {
const { scrollerRef, items, sortKey, jumpToCharacter, isSmallScreen } = props; const {
scrollerRef,
items,
sortKey,
jumpToCharacter,
isSelectMode,
isSmallScreen,
} = props;
const { bannerOptions } = useSelector(artistIndexSelector); const { bannerOptions } = useSelector(artistIndexSelector);
const ref: React.MutableRefObject<Grid> = useRef(); const ref: React.MutableRefObject<Grid> = useRef();
@ -285,6 +293,7 @@ export default function ArtistIndexBanners(props: ArtistIndexBannersProps) {
}, },
items, items,
sortKey, sortKey,
isSelectMode,
}} }}
> >
{Cell} {Cell}

View file

@ -6,6 +6,7 @@ import ArtistPoster from 'Artist/ArtistPoster';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar';
import ArtistIndexPosterSelect from 'Artist/Index/Select/ArtistIndexPosterSelect';
import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
@ -37,6 +38,7 @@ interface ArtistIndexOverviewProps {
posterWidth: number; posterWidth: number;
posterHeight: number; posterHeight: number;
rowHeight: number; rowHeight: number;
isSelectMode: boolean;
isSmallScreen: boolean; isSmallScreen: boolean;
} }
@ -47,6 +49,7 @@ function ArtistIndexOverview(props: ArtistIndexOverviewProps) {
posterWidth, posterWidth,
posterHeight, posterHeight,
rowHeight, rowHeight,
isSelectMode,
isSmallScreen, isSmallScreen,
} = props; } = props;
@ -136,6 +139,10 @@ function ArtistIndexOverview(props: ArtistIndexOverviewProps) {
<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 ? (
<ArtistIndexPosterSelect artistId={artistId} />
) : null}
{status === 'ended' && ( {status === 'ended' && (
<div className={styles.ended} title={translate('Inactive')} /> <div className={styles.ended} title={translate('Inactive')} />
)} )}

View file

@ -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 ArtistIndexOverviewsProps {
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 ArtistIndexOverviews(props: ArtistIndexOverviewsProps) { function ArtistIndexOverviews(props: ArtistIndexOverviewsProps) {
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 ArtistIndexOverviews(props: ArtistIndexOverviewsProps) {
posterWidth, posterWidth,
posterHeight, posterHeight,
rowHeight, rowHeight,
isSelectMode,
isSmallScreen, isSmallScreen,
}} }}
> >

View file

@ -7,6 +7,7 @@ import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector'; import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector';
import ArtistIndexPosterInfo from 'Artist/Index/Posters/ArtistIndexPosterInfo'; import ArtistIndexPosterInfo from 'Artist/Index/Posters/ArtistIndexPosterInfo';
import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar';
import ArtistIndexPosterSelect from 'Artist/Index/Select/ArtistIndexPosterSelect';
import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames';
import Label from 'Components/Label'; import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
@ -23,12 +24,13 @@ import styles from './ArtistIndexPoster.css';
interface ArtistIndexPosterProps { interface ArtistIndexPosterProps {
artistId: number; artistId: number;
sortKey: string; sortKey: string;
isSelectMode: boolean;
posterWidth: number; posterWidth: number;
posterHeight: number; posterHeight: number;
} }
function ArtistIndexPoster(props: ArtistIndexPosterProps) { function ArtistIndexPoster(props: ArtistIndexPosterProps) {
const { artistId, sortKey, posterWidth, posterHeight } = props; const { artistId, sortKey, isSelectMode, posterWidth, posterHeight } = props;
const { const {
artist, artist,
@ -130,6 +132,8 @@ function ArtistIndexPoster(props: ArtistIndexPosterProps) {
return ( return (
<div className={styles.content}> <div className={styles.content}>
<div className={styles.posterContainer}> <div className={styles.posterContainer}>
{isSelectMode ? <ArtistIndexPosterSelect artistId={artistId} /> : null}
<Label className={styles.controls}> <Label className={styles.controls}>
<SpinnerIconButton <SpinnerIconButton
className={styles.action} className={styles.action}

View file

@ -36,6 +36,7 @@ interface CellItemData {
}; };
items: Artist[]; items: Artist[];
sortKey: string; sortKey: string;
isSelectMode: boolean;
} }
interface ArtistIndexPostersProps { interface ArtistIndexPostersProps {
@ -45,6 +46,7 @@ interface ArtistIndexPostersProps {
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>> = ({
<ArtistIndexPoster <ArtistIndexPoster
artistId={artist.id} artistId={artist.id}
sortKey={sortKey} sortKey={sortKey}
isSelectMode={isSelectMode}
posterWidth={posterWidth} posterWidth={posterWidth}
posterHeight={posterHeight} posterHeight={posterHeight}
/> />
@ -97,7 +98,14 @@ function getWindowScrollTopPosition() {
} }
export default function ArtistIndexPosters(props: ArtistIndexPostersProps) { export default function ArtistIndexPosters(props: ArtistIndexPostersProps) {
const { scrollerRef, items, sortKey, jumpToCharacter, isSmallScreen } = props; const {
scrollerRef,
items,
sortKey,
jumpToCharacter,
isSelectMode,
isSmallScreen,
} = props;
const { posterOptions } = useSelector(artistIndexSelector); const { posterOptions } = useSelector(artistIndexSelector);
const ref: React.MutableRefObject<Grid> = useRef(); const ref: React.MutableRefObject<Grid> = useRef();
@ -285,6 +293,7 @@ export default function ArtistIndexPosters(props: ArtistIndexPostersProps) {
}, },
items, items,
sortKey, sortKey,
isSelectMode,
}} }}
> >
{Cell} {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(--lidarrGreen);
&:hover {
color: var(--white);
}
}
.unselected {
composes: icon;
color: var(--white);
&:hover {
color: var(--lidarrGreen);
}
}

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

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

View file

@ -2,6 +2,7 @@ import classNames from 'classnames';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import AlbumTitleLink from 'Album/AlbumTitleLink'; import AlbumTitleLink from 'Album/AlbumTitleLink';
import { SelectActionType, useSelect } from 'App/SelectContext';
import { Statistics } from 'Artist/Artist'; import { Statistics } from 'Artist/Artist';
import ArtistBanner from 'Artist/ArtistBanner'; import ArtistBanner from 'Artist/ArtistBanner';
import ArtistNameLink from 'Artist/ArtistNameLink'; import ArtistNameLink from 'Artist/ArtistNameLink';
@ -17,6 +18,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 +34,11 @@ interface ArtistIndexRowProps {
artistId: number; artistId: number;
sortKey: string; sortKey: string;
columns: Column[]; columns: Column[];
isSelectMode: boolean;
} }
function ArtistIndexRow(props: ArtistIndexRowProps) { function ArtistIndexRow(props: ArtistIndexRowProps) {
const { artistId, columns } = props; const { artistId, columns, isSelectMode } = props;
const { const {
artist, artist,
@ -77,6 +80,7 @@ function ArtistIndexRow(props: ArtistIndexRowProps) {
const [hasBannerError, setHasBannerError] = useState(false); const [hasBannerError, setHasBannerError] = useState(false);
const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false); const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false);
const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false); const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false);
const [selectState, selectDispatch] = useSelect();
const onRefreshPress = useCallback(() => { const onRefreshPress = useCallback(() => {
dispatch( dispatch(
@ -121,8 +125,29 @@ function ArtistIndexRow(props: ArtistIndexRowProps) {
setIsDeleteArtistModalOpen(false); setIsDeleteArtistModalOpen(false);
}, [setIsDeleteArtistModalOpen]); }, [setIsDeleteArtistModalOpen]);
const onSelectedChange = useCallback(
({ id, value, shiftKey }) => {
selectDispatch({
type: SelectActionType.ToggleSelected,
id,
isSelected: value,
shiftKey,
});
},
[selectDispatch]
);
return ( return (
<> <>
{isSelectMode ? (
<VirtualTableSelectCell
id={artistId}
isSelected={selectState.selectedState[artistId]}
isDisabled={false}
onSelectedChange={onSelectedChange}
/>
) : null}
{columns.map((column) => { {columns.map((column) => {
const { name, isVisible } = column; const { name, isVisible } = column;

View file

@ -25,6 +25,7 @@ interface RowItemData {
items: Artist[]; items: Artist[];
sortKey: string; sortKey: string;
columns: Column[]; columns: Column[];
isSelectMode: boolean;
} }
interface ArtistIndexTableProps { interface ArtistIndexTableProps {
@ -34,6 +35,7 @@ interface ArtistIndexTableProps {
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>> = ({
artistId={artist.id} artistId={artist.id}
sortKey={sortKey} sortKey={sortKey}
columns={columns} columns={columns}
isSelectMode={isSelectMode}
/> />
</div> </div>
); );
@ -82,6 +85,7 @@ function ArtistIndexTable(props: ArtistIndexTableProps) {
sortKey, sortKey,
sortDirection, sortDirection,
jumpToCharacter, jumpToCharacter,
isSelectMode,
isSmallScreen, isSmallScreen,
scrollerRef, scrollerRef,
} = props; } = props;
@ -177,6 +181,7 @@ function ArtistIndexTable(props: ArtistIndexTableProps) {
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 ArtistIndexTable(props: ArtistIndexTableProps) {
items, items,
sortKey, sortKey,
columns, columns,
isSelectMode,
}} }}
> >
{Row} {Row}

View file

@ -1,12 +1,14 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { SelectActionType, useSelect } from 'App/SelectContext';
import ArtistIndexTableOptions from 'Artist/Index/Table/ArtistIndexTableOptions'; import ArtistIndexTableOptions from 'Artist/Index/Table/ArtistIndexTableOptions';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import Column from 'Components/Table/Column'; 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 {
@ -21,12 +23,13 @@ interface ArtistIndexTableHeaderProps {
columns: Column[]; columns: Column[];
sortKey?: string; sortKey?: string;
sortDirection?: SortDirection; sortDirection?: SortDirection;
isSelectMode: boolean;
} }
function ArtistIndexTableHeader(props: ArtistIndexTableHeaderProps) { function ArtistIndexTableHeader(props: ArtistIndexTableHeaderProps) {
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) => {
@ -42,8 +45,25 @@ function ArtistIndexTableHeader(props: ArtistIndexTableHeaderProps) {
[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;

View file

@ -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
@ -90,6 +91,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,
faStar as fasStar, faStar as fasStar,
faStop as fasStop, faStop as fasStop,
faSync as fasSync, faSync as fasSync,
@ -128,6 +131,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;
@ -205,6 +209,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 STAR_FULL = fasStar; export const STAR_FULL = fasStar;
export const SUBTRACT = fasMinus; export const SUBTRACT = fasMinus;
export const SYSTEM = fasLaptop; export const SYSTEM = fasLaptop;

View file

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