From c115fcd5739ed17c3de26f5146c6d6e2a3381cda Mon Sep 17 00:00:00 2001
From: schroda <50052685+schroda@users.noreply.github.com>
Date: Mon, 25 Dec 2023 21:20:00 +0100
Subject: [PATCH] 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
---
.../SelectableCollectionSelectAll.tsx | 34 +++++++
.../collection/useSelectableCollection.ts | 41 ++++++++
src/components/manga/ChapterList.tsx | 95 +++++++------------
.../manga/ChapterSelectionFABActionItems.tsx | 85 +++++++++++++++++
src/components/manga/SelectionFAB.tsx | 61 ++----------
.../manga/SelectionFABActionItem.tsx | 42 ++++----
6 files changed, 219 insertions(+), 139 deletions(-)
create mode 100644 src/components/collection/SelectableCollectionSelectAll.tsx
create mode 100644 src/components/collection/useSelectableCollection.ts
create mode 100644 src/components/manga/ChapterSelectionFABActionItems.tsx
diff --git a/src/components/collection/SelectableCollectionSelectAll.tsx b/src/components/collection/SelectableCollectionSelectAll.tsx
new file mode 100644
index 00000000..9452fe87
--- /dev/null
+++ b/src/components/collection/SelectableCollectionSelectAll.tsx
@@ -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 (
+
+ onChange(checked)}
+ />
+
+ );
+};
diff --git a/src/components/collection/useSelectableCollection.ts b/src/components/collection/useSelectableCollection.ts
new file mode 100644
index 00000000..df9ddde1
--- /dev/null
+++ b/src/components/collection/useSelectableCollection.ts
@@ -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 = (totalCount: number) => {
+ const [selectedItemIds, setSelectedItemIds] = useState([]);
+
+ 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 };
+};
diff --git a/src/components/manga/ChapterList.tsx b/src/components/manga/ChapterList.tsx
index 6dc08949..28806d05 100644
--- a/src/components/manga/ChapterList.tsx
+++ b/src/components/manga/ChapterList.tsx
@@ -8,10 +8,9 @@
import { Box, CircularProgress, Stack, styled, Tooltip } from '@mui/material';
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 { useTranslation } from 'react-i18next';
-import Checkbox from '@mui/material/Checkbox';
import IconButton from '@mui/material/IconButton';
import DownloadIcon from '@mui/icons-material/Download';
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 { DownloadType, UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.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 }) => ({
margin: 8,
@@ -96,7 +98,6 @@ interface IProps {
export const ChapterList: React.FC = ({ manga, isRefreshing }) => {
const { t } = useTranslation();
- const [selection, setSelection] = useState(null);
const { data: downloaderData } = requestManager.useDownloadSubscription();
const queue = (downloaderData?.downloadChanged.queue as DownloadType[]) ?? [];
@@ -104,6 +105,9 @@ export const ChapterList: React.FC = ({ manga, isRefreshing }) => {
const { data: chaptersData, loading: isLoading } = requestManager.useGetMangaChapters(manga.id);
const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]);
+ const { areNoItemsSelected, areAllItemsSelected, selectedItemIds, handleSelectAll, handleSelection } =
+ useSelectableCollection(chapters.length);
+
const { settings: metadataServerSettings } = useMetadataServerSettings();
const visibleChapters = useMemo(() => filterAndSortChapters(chapters, options), [chapters, options]);
@@ -111,41 +115,13 @@ export const ChapterList: React.FC = ({ manga, isRefreshing }) => {
const nextChapterIndexToRead = (manga.lastReadChapter?.sourceOrder ?? 0) + 1;
const isLatestChapterRead = manga.chapters.totalCount === manga.lastReadChapter?.sourceOrder;
- const areAllChaptersSelected = selection?.length === chapters.length;
- const areNoneChaptersSelected = !selection;
-
const areAllChaptersRead = manga.unreadCount === 0;
const areAllChaptersDownloaded = manga.downloadCount === manga.chapters.totalCount;
- const handleSelection = (index: number) => {
- const chapter = visibleChapters[index];
- if (!chapter) return;
-
- 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) => {
- 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['onAction'] = (action, actionChapters) => {
+ const handleFabAction: ComponentProps['onAction'] = (
+ action,
+ actionChapters,
+ ) => {
if (actionChapters.length === 0) return;
const chapterIds = actionChapters
.filter(({ chapter }) => {
@@ -215,27 +191,31 @@ export const ChapterList: React.FC = ({ manga, isRefreshing }) => {
const downloadChapter = queue?.find(
(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 {
chapter,
downloadChapter,
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(() => {
- if (selectedChapters) {
- return ;
+ const selectedChapters = chaptersWithMeta.filter((chapter) => chapter.selected);
+
+ if (selectedChapters.length) {
+ return (
+
+ {(handleClose) => (
+
+ )}
+
+ );
}
if (!isLatestChapterRead) {
@@ -243,7 +223,7 @@ export const ChapterList: React.FC = ({ manga, isRefreshing }) => {
}
return null;
- }, [selectedChapters, isLatestChapterRead]);
+ }, [chaptersWithMeta, isLatestChapterRead]);
if (isLoading || (noChaptersFound && isRefreshing)) {
return (
@@ -287,16 +267,13 @@ export const ChapterList: React.FC = ({ manga, isRefreshing }) => {
-
-
-
+
+ handleSelectAll(checked, checked ? chapters.map((chapter) => chapter.id) : [])
+ }
+ />
@@ -317,7 +294,7 @@ export const ChapterList: React.FC = ({ manga, isRefreshing }) => {
{...chaptersWithMeta[index]}
allChapters={chapters}
showChapterNumber={options.showChapterNumber}
- onSelect={() => handleSelection(index)}
+ onSelect={(selected) => handleSelection(chaptersWithMeta[index].chapter.id, selected)}
/>
)}
useWindowScroll={window.innerWidth < 900}
diff --git a/src/components/manga/ChapterSelectionFABActionItems.tsx b/src/components/manga/ChapterSelectionFABActionItems.tsx
new file mode 100644
index 00000000..3a1dcd7f
--- /dev/null
+++ b/src/components/manga/ChapterSelectionFABActionItems.tsx
@@ -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 (
+ <>
+ !c.isDownloaded && dc === undefined,
+ )}
+ onClick={handleAction}
+ title={t('chapter.action.download.add.button.selected')}
+ />
+ chapter.isDownloaded)}
+ onClick={handleAction}
+ title={t('chapter.action.download.delete.button.selected')}
+ />
+ !chapter.isBookmarked)}
+ onClick={handleAction}
+ title={t('chapter.action.bookmark.add.button.selected')}
+ />
+ chapter.isBookmarked)}
+ onClick={handleAction}
+ title={t('chapter.action.bookmark.remove.button.selected')}
+ />
+ !chapter.isRead)}
+ onClick={handleAction}
+ title={t('chapter.action.mark_as_read.add.button.selected')}
+ />
+ chapter.isRead)}
+ onClick={handleAction}
+ title={t('chapter.action.mark_as_read.remove.button.selected')}
+ />
+ >
+ );
+};
diff --git a/src/components/manga/SelectionFAB.tsx b/src/components/manga/SelectionFAB.tsx
index 22a6dfe6..1cc7f6d1 100644
--- a/src/components/manga/SelectionFAB.tsx
+++ b/src/components/manga/SelectionFAB.tsx
@@ -10,32 +10,22 @@ import MoreHoriz from '@mui/icons-material/MoreHoriz';
import { Fab, Menu, Box } from '@mui/material';
import React, { useRef, useState } from 'react';
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';
-
-export type SelectionAction = 'download' | 'delete' | 'bookmark' | 'unbookmark' | 'mark_as_read' | 'mark_as_unread';
+import { TranslationKey } from '@/typings.ts';
interface SelectionFABProps {
- selectedChapters: IChapterWithMeta[];
- onAction: (action: SelectionAction, chapters: IChapterWithMeta[]) => void;
+ children: (handleClose: () => void) => React.ReactNode;
+ selectedItemsCount: number;
+ title: TranslationKey;
}
-export const SelectionFAB: React.FC = (props) => {
+export const SelectionFAB: React.FC = ({ children, selectedItemsCount, title }) => {
const { t } = useTranslation();
- const { selectedChapters, onAction } = props;
- const count = selectedChapters.length;
-
const anchorEl = useRef();
const [open, setOpen] = useState(false);
const handleClose = () => setOpen(false);
- const handleAction = (action: SelectionAction, chapters: IChapterWithMeta[]) => {
- onAction(action, chapters);
- handleClose();
- };
-
return (
= (props) => {
ref={anchorEl}
>
);
diff --git a/src/components/manga/SelectionFABActionItem.tsx b/src/components/manga/SelectionFABActionItem.tsx
index fbf0c8c3..23976853 100644
--- a/src/components/manga/SelectionFABActionItem.tsx
+++ b/src/components/manga/SelectionFABActionItem.tsx
@@ -6,38 +6,28 @@
* 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 React from 'react';
-import type { IChapterWithMeta } from '@/components/manga/ChapterList';
-import type { SelectionAction } from '@/components/manga/SelectionFAB';
+import { OverridableComponent } from '@mui/material/OverridableComponent';
+import { SvgIconTypeMap } from '@mui/material/SvgIcon/SvgIcon';
-interface IProps {
- action: SelectionAction;
- matchingChapters: IChapterWithMeta[];
+interface IProps {
+ action: Action;
+ matchingItems: Item[];
title: string;
- onClick: (action: SelectionAction, chapters: IChapterWithMeta[]) => void;
+ Icon: OverridableComponent & { muiName: string };
+ onClick: (action: Action, items: Item[]) => void;
}
-const ICONS = {
- download: Download,
- delete: Delete,
- bookmark: BookmarkAdd,
- unbookmark: BookmarkRemove,
- mark_as_read: Done,
- mark_as_unread: RemoveDone,
-};
-
-export const SelectionFABActionItem: React.FC = ({ action, matchingChapters, onClick, title }) => {
- const count = matchingChapters.length;
- const Icon = ICONS[action];
+export const SelectionFABActionItem = ({
+ action,
+ matchingItems,
+ onClick,
+ title,
+ Icon,
+}: IProps) => {
+ const count = matchingItems.length;
return (
-