Feature/make selection logic reusable (#515)

* Introduce "selectable collection" hook

* Extract "select all" button into component

* Make "SelectionFABActionItem" reusable

Was to tightly coupled to chapters

* Make "SelectionFAB" reusable

Was to tightly coupled to chapters
This commit is contained in:
schroda
2023-12-25 21:20:00 +01:00
committed by GitHub
parent c8747d6db4
commit c115fcd573
6 changed files with 219 additions and 139 deletions

View File

@@ -0,0 +1,34 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { Tooltip } from '@mui/material';
import Checkbox from '@mui/material/Checkbox';
import { useTranslation } from 'react-i18next';
export const SelectableCollectionSelectAll = ({
areAllItemsSelected,
areNoItemsSelected,
onChange,
}: {
areAllItemsSelected: boolean;
areNoItemsSelected: boolean;
onChange: (checked: boolean) => void;
}) => {
const { t } = useTranslation();
return (
<Tooltip title={t(!areAllItemsSelected ? 'global.button.select_all' : 'global.button.clear')}>
<Checkbox
sx={{ padding: '8px' }}
checked={areAllItemsSelected}
indeterminate={!areNoItemsSelected && !areAllItemsSelected}
onChange={(_, checked) => onChange(checked)}
/>
</Tooltip>
);
};

View File

@@ -0,0 +1,41 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useState } from 'react';
export const useSelectableCollection = <Id extends number | string>(totalCount: number) => {
const [selectedItemIds, setSelectedItemIds] = useState<Id[]>([]);
const areAllItemsSelected = selectedItemIds.length === totalCount;
const areNoItemsSelected = !selectedItemIds.length;
const handleSelection = (id: Id, selected: boolean) => {
const deselect = !selected;
if (deselect) {
setSelectedItemIds(selectedItemIds.filter((selectedItemId) => selectedItemId !== id));
return;
}
setSelectedItemIds([...new Set([...selectedItemIds, id])]);
};
const handleSelectAll = (selectAll: boolean, itemIds: Id[]) => {
switch (selectAll) {
case true:
setSelectedItemIds([...itemIds]);
break;
case false:
setSelectedItemIds([]);
break;
default:
break;
}
};
return { selectedItemIds, handleSelection, handleSelectAll, areAllItemsSelected, areNoItemsSelected };
};

View File

@@ -8,10 +8,9 @@
import { Box, CircularProgress, Stack, styled, Tooltip } from '@mui/material'; import { Box, CircularProgress, Stack, styled, Tooltip } from '@mui/material';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import React, { ComponentProps, useMemo, useState } from 'react'; import React, { ComponentProps, useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Checkbox from '@mui/material/Checkbox';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import DownloadIcon from '@mui/icons-material/Download'; import DownloadIcon from '@mui/icons-material/Download';
import DoneAllIcon from '@mui/icons-material/DoneAll'; import DoneAllIcon from '@mui/icons-material/DoneAll';
@@ -27,6 +26,9 @@ import { SelectionFAB } from '@/components/manga/SelectionFAB';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab'; import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab';
import { DownloadType, UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.ts'; import { DownloadType, UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.ts';
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts'; import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
import { useSelectableCollection } from '@/components/collection/useSelectableCollection.ts';
import { SelectableCollectionSelectAll } from '@/components/collection/SelectableCollectionSelectAll.tsx';
import { ChapterSelectionFABActionItems } from '@/components/manga/ChapterSelectionFABActionItems.tsx';
const ChapterListHeader = styled(Stack)(({ theme }) => ({ const ChapterListHeader = styled(Stack)(({ theme }) => ({
margin: 8, margin: 8,
@@ -96,7 +98,6 @@ interface IProps {
export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => { export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [selection, setSelection] = useState<number[] | null>(null);
const { data: downloaderData } = requestManager.useDownloadSubscription(); const { data: downloaderData } = requestManager.useDownloadSubscription();
const queue = (downloaderData?.downloadChanged.queue as DownloadType[]) ?? []; const queue = (downloaderData?.downloadChanged.queue as DownloadType[]) ?? [];
@@ -104,6 +105,9 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
const { data: chaptersData, loading: isLoading } = requestManager.useGetMangaChapters(manga.id); const { data: chaptersData, loading: isLoading } = requestManager.useGetMangaChapters(manga.id);
const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]); const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]);
const { areNoItemsSelected, areAllItemsSelected, selectedItemIds, handleSelectAll, handleSelection } =
useSelectableCollection(chapters.length);
const { settings: metadataServerSettings } = useMetadataServerSettings(); const { settings: metadataServerSettings } = useMetadataServerSettings();
const visibleChapters = useMemo(() => filterAndSortChapters(chapters, options), [chapters, options]); const visibleChapters = useMemo(() => filterAndSortChapters(chapters, options), [chapters, options]);
@@ -111,41 +115,13 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
const nextChapterIndexToRead = (manga.lastReadChapter?.sourceOrder ?? 0) + 1; const nextChapterIndexToRead = (manga.lastReadChapter?.sourceOrder ?? 0) + 1;
const isLatestChapterRead = manga.chapters.totalCount === manga.lastReadChapter?.sourceOrder; const isLatestChapterRead = manga.chapters.totalCount === manga.lastReadChapter?.sourceOrder;
const areAllChaptersSelected = selection?.length === chapters.length;
const areNoneChaptersSelected = !selection;
const areAllChaptersRead = manga.unreadCount === 0; const areAllChaptersRead = manga.unreadCount === 0;
const areAllChaptersDownloaded = manga.downloadCount === manga.chapters.totalCount; const areAllChaptersDownloaded = manga.downloadCount === manga.chapters.totalCount;
const handleSelection = (index: number) => { const handleFabAction: ComponentProps<typeof ChapterSelectionFABActionItems>['onAction'] = (
const chapter = visibleChapters[index]; action,
if (!chapter) return; actionChapters,
) => {
if (selection === null) {
setSelection([chapter.id]);
} else if (selection.includes(chapter.id)) {
const newSelection = selection.filter((cid) => cid !== chapter.id);
setSelection(newSelection.length > 0 ? newSelection : null);
} else {
setSelection([...selection, chapter.id]);
}
};
const handleSelectAll = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectAll = event.target.checked;
switch (selectAll) {
case true:
setSelection(visibleChapters.map((c) => c.id));
break;
case false:
setSelection(null);
break;
default:
break;
}
};
const handleFabAction: ComponentProps<typeof SelectionFAB>['onAction'] = (action, actionChapters) => {
if (actionChapters.length === 0) return; if (actionChapters.length === 0) return;
const chapterIds = actionChapters const chapterIds = actionChapters
.filter(({ chapter }) => { .filter(({ chapter }) => {
@@ -215,27 +191,31 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
const downloadChapter = queue?.find( const downloadChapter = queue?.find(
(cd) => cd.chapter.sourceOrder === chapter.sourceOrder && cd.chapter.manga.id === chapter.manga.id, (cd) => cd.chapter.sourceOrder === chapter.sourceOrder && cd.chapter.manga.id === chapter.manga.id,
); );
const selected = selection?.includes(chapter.id) ?? null; const selected = !areNoItemsSelected ? selectedItemIds.includes(chapter.id) : null;
return { return {
chapter, chapter,
downloadChapter, downloadChapter,
selected, selected,
}; };
}), }),
[queue, selection, visibleChapters], [queue, selectedItemIds, visibleChapters],
); );
const selectedChapters = useMemo(() => {
if (!selection) {
return null;
}
return chaptersWithMeta.filter(({ chapter }) => selection.includes(chapter.id));
}, [selection, chapters]);
const chapterListFAB = useMemo(() => { const chapterListFAB = useMemo(() => {
if (selectedChapters) { const selectedChapters = chaptersWithMeta.filter((chapter) => chapter.selected);
return <SelectionFAB selectedChapters={selectedChapters} onAction={handleFabAction} />;
if (selectedChapters.length) {
return (
<SelectionFAB selectedItemsCount={selectedChapters.length} title="chapter.title">
{(handleClose) => (
<ChapterSelectionFABActionItems
selectedChapters={selectedChapters}
onAction={handleFabAction}
handleClose={handleClose}
/>
)}
</SelectionFAB>
);
} }
if (!isLatestChapterRead) { if (!isLatestChapterRead) {
@@ -243,7 +223,7 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
} }
return null; return null;
}, [selectedChapters, isLatestChapterRead]); }, [chaptersWithMeta, isLatestChapterRead]);
if (isLoading || (noChaptersFound && isRefreshing)) { if (isLoading || (noChaptersFound && isRefreshing)) {
return ( return (
@@ -287,16 +267,13 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<ChaptersToolbarMenu options={options} optionsDispatch={dispatch} /> <ChaptersToolbarMenu options={options} optionsDispatch={dispatch} />
<Tooltip <SelectableCollectionSelectAll
title={t(!areAllChaptersSelected ? 'global.button.select_all' : 'global.button.clear')} areAllItemsSelected={areAllItemsSelected}
> areNoItemsSelected={areNoItemsSelected}
<Checkbox onChange={(checked) =>
sx={{ padding: '8px' }} handleSelectAll(checked, checked ? chapters.map((chapter) => chapter.id) : [])
checked={areAllChaptersSelected} }
indeterminate={!areAllChaptersSelected && !areNoneChaptersSelected}
onChange={handleSelectAll}
/> />
</Tooltip>
</Stack> </Stack>
</ChapterListHeader> </ChapterListHeader>
@@ -317,7 +294,7 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
{...chaptersWithMeta[index]} {...chaptersWithMeta[index]}
allChapters={chapters} allChapters={chapters}
showChapterNumber={options.showChapterNumber} showChapterNumber={options.showChapterNumber}
onSelect={() => handleSelection(index)} onSelect={(selected) => handleSelection(chaptersWithMeta[index].chapter.id, selected)}
/> />
)} )}
useWindowScroll={window.innerWidth < 900} useWindowScroll={window.innerWidth < 900}

View File

@@ -0,0 +1,85 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import Download from '@mui/icons-material/Download';
import Delete from '@mui/icons-material/Delete';
import BookmarkAdd from '@mui/icons-material/BookmarkAdd';
import BookmarkRemove from '@mui/icons-material/BookmarkRemove';
import Done from '@mui/icons-material/Done';
import RemoveDone from '@mui/icons-material/RemoveDone';
import { useTranslation } from 'react-i18next';
import { SelectionFABActionItem } from '@/components/manga/SelectionFABActionItem.tsx';
import { IChapterWithMeta } from '@/components/manga/ChapterList.tsx';
type SelectionAction = 'download' | 'delete' | 'bookmark' | 'unbookmark' | 'mark_as_read' | 'mark_as_unread';
export const ChapterSelectionFABActionItems = ({
selectedChapters,
onAction,
handleClose,
}: {
selectedChapters: IChapterWithMeta[];
onAction: (action: SelectionAction, chapters: IChapterWithMeta[]) => void;
handleClose: () => void;
}) => {
const { t } = useTranslation();
const handleAction = (action: SelectionAction, chapters: IChapterWithMeta[]) => {
onAction(action, chapters);
handleClose();
};
return (
<>
<SelectionFABActionItem
action="download"
Icon={Download}
matchingItems={selectedChapters.filter(
({ chapter: c, downloadChapter: dc }) => !c.isDownloaded && dc === undefined,
)}
onClick={handleAction}
title={t('chapter.action.download.add.button.selected')}
/>
<SelectionFABActionItem
action="delete"
Icon={Delete}
matchingItems={selectedChapters.filter(({ chapter }) => chapter.isDownloaded)}
onClick={handleAction}
title={t('chapter.action.download.delete.button.selected')}
/>
<SelectionFABActionItem
action="bookmark"
Icon={BookmarkAdd}
matchingItems={selectedChapters.filter(({ chapter }) => !chapter.isBookmarked)}
onClick={handleAction}
title={t('chapter.action.bookmark.add.button.selected')}
/>
<SelectionFABActionItem
action="unbookmark"
Icon={BookmarkRemove}
matchingItems={selectedChapters.filter(({ chapter }) => chapter.isBookmarked)}
onClick={handleAction}
title={t('chapter.action.bookmark.remove.button.selected')}
/>
<SelectionFABActionItem
action="mark_as_read"
Icon={Done}
matchingItems={selectedChapters.filter(({ chapter }) => !chapter.isRead)}
onClick={handleAction}
title={t('chapter.action.mark_as_read.add.button.selected')}
/>
<SelectionFABActionItem
action="mark_as_unread"
Icon={RemoveDone}
matchingItems={selectedChapters.filter(({ chapter }) => chapter.isRead)}
onClick={handleAction}
title={t('chapter.action.mark_as_read.remove.button.selected')}
/>
</>
);
};

View File

@@ -10,32 +10,22 @@ import MoreHoriz from '@mui/icons-material/MoreHoriz';
import { Fab, Menu, Box } from '@mui/material'; import { Fab, Menu, Box } from '@mui/material';
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { IChapterWithMeta } from '@/components/manga/ChapterList';
import { SelectionFABActionItem } from '@/components/manga/SelectionFABActionItem';
import { DEFAULT_FAB_STYLE } from '@/components/util/StyledFab'; import { DEFAULT_FAB_STYLE } from '@/components/util/StyledFab';
import { TranslationKey } from '@/typings.ts';
export type SelectionAction = 'download' | 'delete' | 'bookmark' | 'unbookmark' | 'mark_as_read' | 'mark_as_unread';
interface SelectionFABProps { interface SelectionFABProps {
selectedChapters: IChapterWithMeta[]; children: (handleClose: () => void) => React.ReactNode;
onAction: (action: SelectionAction, chapters: IChapterWithMeta[]) => void; selectedItemsCount: number;
title: TranslationKey;
} }
export const SelectionFAB: React.FC<SelectionFABProps> = (props) => { export const SelectionFAB: React.FC<SelectionFABProps> = ({ children, selectedItemsCount, title }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { selectedChapters, onAction } = props;
const count = selectedChapters.length;
const anchorEl = useRef<HTMLElement>(); const anchorEl = useRef<HTMLElement>();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const handleClose = () => setOpen(false); const handleClose = () => setOpen(false);
const handleAction = (action: SelectionAction, chapters: IChapterWithMeta[]) => {
onAction(action, chapters);
handleClose();
};
return ( return (
<Box <Box
sx={{ sx={{
@@ -47,7 +37,7 @@ export const SelectionFAB: React.FC<SelectionFABProps> = (props) => {
ref={anchorEl} ref={anchorEl}
> >
<Fab variant="extended" color="primary" id="selectionMenuButton" onClick={() => setOpen(true)}> <Fab variant="extended" color="primary" id="selectionMenuButton" onClick={() => setOpen(true)}>
{`${count} ${t('chapter.title', { count })}`} {`${selectedItemsCount} ${t(title, { count: selectedItemsCount })}`}
<MoreHoriz sx={{ ml: 1 }} /> <MoreHoriz sx={{ ml: 1 }} />
</Fab> </Fab>
<Menu <Menu
@@ -61,44 +51,7 @@ export const SelectionFAB: React.FC<SelectionFABProps> = (props) => {
'aria-labelledby': 'selectionMenuButton', 'aria-labelledby': 'selectionMenuButton',
}} }}
> >
<SelectionFABActionItem {children(handleClose)}
action="download"
matchingChapters={selectedChapters.filter(
({ chapter: c, downloadChapter: dc }) => !c.isDownloaded && dc === undefined,
)}
onClick={handleAction}
title={t('chapter.action.download.add.button.selected')}
/>
<SelectionFABActionItem
action="delete"
matchingChapters={selectedChapters.filter(({ chapter }) => chapter.isDownloaded)}
onClick={handleAction}
title={t('chapter.action.download.delete.button.selected')}
/>
<SelectionFABActionItem
action="bookmark"
matchingChapters={selectedChapters.filter(({ chapter }) => !chapter.isBookmarked)}
onClick={handleAction}
title={t('chapter.action.bookmark.add.button.selected')}
/>
<SelectionFABActionItem
action="unbookmark"
matchingChapters={selectedChapters.filter(({ chapter }) => chapter.isBookmarked)}
onClick={handleAction}
title={t('chapter.action.bookmark.remove.button.selected')}
/>
<SelectionFABActionItem
action="mark_as_read"
matchingChapters={selectedChapters.filter(({ chapter }) => !chapter.isRead)}
onClick={handleAction}
title={t('chapter.action.mark_as_read.add.button.selected')}
/>
<SelectionFABActionItem
action="mark_as_unread"
matchingChapters={selectedChapters.filter(({ chapter }) => chapter.isRead)}
onClick={handleAction}
title={t('chapter.action.mark_as_read.remove.button.selected')}
/>
</Menu> </Menu>
</Box> </Box>
); );

View File

@@ -6,38 +6,28 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import BookmarkAdd from '@mui/icons-material/BookmarkAdd';
import BookmarkRemove from '@mui/icons-material/BookmarkRemove';
import Delete from '@mui/icons-material/Delete';
import Done from '@mui/icons-material/Done';
import Download from '@mui/icons-material/Download';
import RemoveDone from '@mui/icons-material/RemoveDone';
import { ListItemIcon, ListItemText, MenuItem } from '@mui/material'; import { ListItemIcon, ListItemText, MenuItem } from '@mui/material';
import React from 'react'; import { OverridableComponent } from '@mui/material/OverridableComponent';
import type { IChapterWithMeta } from '@/components/manga/ChapterList'; import { SvgIconTypeMap } from '@mui/material/SvgIcon/SvgIcon';
import type { SelectionAction } from '@/components/manga/SelectionFAB';
interface IProps { interface IProps<Action, Item> {
action: SelectionAction; action: Action;
matchingChapters: IChapterWithMeta[]; matchingItems: Item[];
title: string; title: string;
onClick: (action: SelectionAction, chapters: IChapterWithMeta[]) => void; Icon: OverridableComponent<SvgIconTypeMap> & { muiName: string };
onClick: (action: Action, items: Item[]) => void;
} }
const ICONS = { export const SelectionFABActionItem = <Action extends string, Item>({
download: Download, action,
delete: Delete, matchingItems,
bookmark: BookmarkAdd, onClick,
unbookmark: BookmarkRemove, title,
mark_as_read: Done, Icon,
mark_as_unread: RemoveDone, }: IProps<Action, Item>) => {
}; const count = matchingItems.length;
export const SelectionFABActionItem: React.FC<IProps> = ({ action, matchingChapters, onClick, title }) => {
const count = matchingChapters.length;
const Icon = ICONS[action];
return ( return (
<MenuItem onClick={() => onClick(action, matchingChapters)} disabled={count === 0}> <MenuItem onClick={() => onClick(action, matchingItems)} disabled={count === 0}>
<ListItemIcon> <ListItemIcon>
<Icon fontSize="small" /> <Icon fontSize="small" />
</ListItemIcon> </ListItemIcon>