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} > setOpen(true)}> - {`${count} ${t('chapter.title', { count })}`} + {`${selectedItemsCount} ${t(title, { count: selectedItemsCount })}`} = (props) => { 'aria-labelledby': 'selectionMenuButton', }} > - !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')} - /> + {children(handleClose)} ); 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 ( - onClick(action, matchingChapters)} disabled={count === 0}> + onClick(action, matchingItems)} disabled={count === 0}>