diff --git a/apps/readest-app/src/__tests__/components/BookDetailModal.test.tsx b/apps/readest-app/src/__tests__/components/BookDetailModal.test.tsx index 77f6619f..ae2692d0 100644 --- a/apps/readest-app/src/__tests__/components/BookDetailModal.test.tsx +++ b/apps/readest-app/src/__tests__/components/BookDetailModal.test.tsx @@ -3,6 +3,7 @@ import { render, cleanup, fireEvent, waitFor, screen } from '@testing-library/re import { Book } from '@/types/book'; import BookDetailModal from '@/components/metadata/BookDetailModal'; +import { DropdownProvider } from '@/context/DropdownContext'; vi.mock('@/hooks/useTranslation', () => ({ useTranslation: () => (s: string) => s, @@ -72,7 +73,23 @@ vi.mock('@/components/Dialog', () => ({ default: ({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) => isOpen ?
{children}
: null, })); -vi.mock('@/components/Alert', () => ({ __esModule: true, default: () => null })); +// Expose the confirm callback with the purge flag so we can assert routing +// without driving the real toggle UI (covered by DeleteConfirmAlert.test.tsx). +vi.mock('@/components/DeleteConfirmAlert', () => ({ + __esModule: true, + default: ({ + showPurgeToggle, + onConfirm, + }: { + showPurgeToggle?: boolean; + onConfirm: (purgeData: boolean) => void; + }) => ( +
+ + +
+ ), +})); vi.mock('@/components/metadata/SourceSelector', () => ({ __esModule: true, default: () => null })); vi.mock('@/components/Spinner', () => ({ __esModule: true, default: () => null })); @@ -122,3 +139,49 @@ describe('BookDetailModal cover refresh after save', () => { expect(screen.getByTestId('cover').getAttribute('src')).toBe('_blank'); }); }); + +describe('BookDetailModal purge-on-delete routing', () => { + const renderModal = (handlers: { handleBookDelete: () => void; handleBookPurge: () => void }) => + render( + + + , + ); + + const openStandardDelete = (container: HTMLElement) => { + fireEvent.click(container.querySelector('button[aria-label="Delete Book Options"]')!); + fireEvent.click(screen.getByText('Remove from Cloud & Device')); + }; + + it('shows the purge toggle on the standard delete and routes to purge when enabled', () => { + const handleBookDelete = vi.fn(); + const handleBookPurge = vi.fn(); + const { container } = renderModal({ handleBookDelete, handleBookPurge }); + + openStandardDelete(container); + expect(screen.getByTestId('delete-confirm').getAttribute('data-purge-toggle')).toBe('true'); + + fireEvent.click(screen.getByText('confirm-purge-data')); + expect(handleBookPurge).toHaveBeenCalledTimes(1); + expect(handleBookDelete).not.toHaveBeenCalled(); + }); + + it('routes the standard delete to a plain delete when the toggle is off', () => { + const handleBookDelete = vi.fn(); + const handleBookPurge = vi.fn(); + const { container } = renderModal({ handleBookDelete, handleBookPurge }); + + openStandardDelete(container); + fireEvent.click(screen.getByText('confirm-keep-data')); + expect(handleBookDelete).toHaveBeenCalledTimes(1); + expect(handleBookPurge).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/BookDetailView.test.tsx b/apps/readest-app/src/__tests__/components/BookDetailView.test.tsx index dec935ad..6603e929 100644 --- a/apps/readest-app/src/__tests__/components/BookDetailView.test.tsx +++ b/apps/readest-app/src/__tests__/components/BookDetailView.test.tsx @@ -166,23 +166,17 @@ describe('BookDetailView More menu (Goodreads + Share)', () => { }); }); -describe('BookDetailView Purge Data action', () => { - it('offers Purge Data in the delete dropdown and calls onPurge', () => { - const onPurge = vi.fn(); - const { container, getByText } = renderView({ onPurge }); - const toggle = container.querySelector('button[aria-label="Delete Book Options"]'); - fireEvent.click(toggle!); - const purgeButton = getByText('Purge Data').closest('button'); - expect(purgeButton).toBeTruthy(); - fireEvent.click(purgeButton!); - expect(onPurge).toHaveBeenCalledTimes(1); - }); - - it('omits Purge Data when onPurge is not provided', () => { +describe('BookDetailView delete dropdown (purge folded into the confirm alert)', () => { + it('no longer offers a Purge Data action and keeps the three remove options', () => { const { container, queryByText } = renderView(); const toggle = container.querySelector('button[aria-label="Delete Book Options"]'); fireEvent.click(toggle!); + // Purge is now an opt-in toggle on the delete confirmation alert, not a + // standalone menu item. expect(queryByText('Purge Data')).toBeNull(); + expect(queryByText('Remove from Cloud & Device')).toBeTruthy(); + expect(queryByText('Remove from Cloud Only')).toBeTruthy(); + expect(queryByText('Remove from Device Only')).toBeTruthy(); }); }); diff --git a/apps/readest-app/src/__tests__/components/DeleteConfirmAlert.test.tsx b/apps/readest-app/src/__tests__/components/DeleteConfirmAlert.test.tsx new file mode 100644 index 00000000..2a5d1ce2 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/DeleteConfirmAlert.test.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, fireEvent, screen } from '@testing-library/react'; + +import DeleteConfirmAlert from '@/components/DeleteConfirmAlert'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (s: string) => s, +})); + +// The wrapped calls useKeyDownActions, which reads these. +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ appService: null }), +})); + +vi.mock('@/store/deviceStore', () => ({ + useDeviceControlStore: () => ({ + acquireBackKeyInterception: vi.fn(), + releaseBackKeyInterception: vi.fn(), + }), +})); + +afterEach(() => cleanup()); + +const setup = (props?: Partial>) => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + render( + , + ); + return { onConfirm, onCancel }; +}; + +describe('DeleteConfirmAlert purge toggle', () => { + it('renders the purge toggle OFF by default and confirms without purging', () => { + const { onConfirm } = setup(); + + const toggle = screen.getByRole('checkbox') as HTMLInputElement; + expect(toggle.checked).toBe(false); + + const confirm = screen.getByText('Delete').closest('button')!; + expect(confirm.className).toContain('btn-warning'); + + fireEvent.click(confirm); + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledWith(false); + }); + + it('escalates to a destructive purge when the toggle is turned on', () => { + const { onConfirm } = setup(); + + const toggle = screen.getByRole('checkbox') as HTMLInputElement; + fireEvent.click(toggle); + expect(toggle.checked).toBe(true); + + const confirm = screen.getByText('Purge & Delete').closest('button')!; + expect(confirm.className).toContain('btn-error'); + + fireEvent.click(confirm); + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledWith(true); + }); + + it('omits the toggle and always confirms without purging when showPurgeToggle is false', () => { + const { onConfirm } = setup({ showPurgeToggle: false }); + + expect(screen.queryByRole('checkbox')).toBeNull(); + + fireEvent.click(screen.getByText('Delete').closest('button')!); + expect(onConfirm).toHaveBeenCalledWith(false); + }); + + it('invokes onCancel from the Cancel button', () => { + const { onCancel } = setup(); + fireEvent.click(screen.getByText('Cancel').closest('button')!); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index 33396bf6..76b5c30f 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -51,7 +51,7 @@ import { MIMETYPES, EXTS } from '@/libs/document'; import { makeSafeFilename } from '@/utils/misc'; import { useSpatialNavigation } from '../hooks/useSpatialNavigation'; -import Alert from '@/components/Alert'; +import DeleteConfirmAlert from '@/components/DeleteConfirmAlert'; import Spinner from '@/components/Spinner'; import ModalPortal from '@/components/ModalPortal'; import BookshelfItem, { generateBookshelfItems } from './BookshelfItem'; @@ -74,6 +74,7 @@ interface BookshelfProps { ) => Promise; handleBookUpload: (book: Book, syncBooks?: boolean) => Promise; handleBookDelete: (book: Book, syncBooks?: boolean) => Promise; + handleBookPurge: (book: Book, syncBooks?: boolean) => Promise; handleSetSelectMode: (selectMode: boolean) => void; handleShowDetailsBook: (book: Book) => void; handleLibraryNavigation: (targetGroup: string) => void; @@ -146,6 +147,7 @@ const Bookshelf: React.FC = ({ handleBookUpload, handleBookDownload, handleBookDelete, + handleBookPurge, handleSetSelectMode, handleShowDetailsBook, handleLibraryNavigation, @@ -390,8 +392,12 @@ const Bookshelf: React.FC = ({ return filteredBooks.filter((book) => wanted.has(book.hash) && !book.deletedAt); }; - const confirmDelete = async () => { + const confirmDelete = async (purgeData: boolean) => { const books = getBooksToDelete(); + // Toggling "purge all reading data" on the confirmation routes the whole + // batch through the purge path, which also wipes each book's reading-data + // sidecars (config/nav) instead of leaving the metadata folder behind. + const deleteBook = purgeData ? handleBookPurge : handleBookDelete; const concurrency = 20; for (let i = 0; i < books.length; i += concurrency) { @@ -400,7 +406,7 @@ const Bookshelf: React.FC = ({ break; } const batch = books.slice(i, i + concurrency); - await Promise.all(batch.map((book) => handleBookDelete(book, false))); + await Promise.all(batch.map((book) => deleteBook(book, false))); } handlePushLibrary(); setSelectedBooks([]); @@ -843,11 +849,12 @@ const Bookshelf: React.FC = ({ paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`, }} > - { abortDeletionRef.current = true; setShowDeleteAlert(false); diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 7dd172fe..feef5c56 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -1453,6 +1453,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP handleBookUpload={handleBookUpload} handleBookDownload={handleBookDownload} handleBookDelete={handleBookDelete('both')} + handleBookPurge={handleBookDelete('purge')} handleSetSelectMode={handleSetSelectMode} handleShowDetailsBook={handleShowDetailsBook} handleLibraryNavigation={handleLibraryNavigation} diff --git a/apps/readest-app/src/components/Alert.tsx b/apps/readest-app/src/components/Alert.tsx index 82bc750a..736a600f 100644 --- a/apps/readest-app/src/components/Alert.tsx +++ b/apps/readest-app/src/components/Alert.tsx @@ -8,7 +8,20 @@ const Alert: React.FC<{ message: string; onCancel: () => void; onConfirm: () => void; -}> = ({ title, message, onCancel, onConfirm }) => { + // Optional content rendered between the title/message and the actions row + // (e.g. the delete confirmation's "purge reading data" toggle). + children?: React.ReactNode; + confirmLabel?: string; + confirmButtonClassName?: string; +}> = ({ + title, + message, + onCancel, + onConfirm, + children, + confirmLabel, + confirmButtonClassName = 'btn-warning', +}) => { const _ = useTranslation(); const [isProcessing, setIsProcessing] = React.useState(false); const divRef = useKeyDownActions({ onCancel, onConfirm }); @@ -50,18 +63,19 @@ const Alert: React.FC<{
{message}
+ {children}
diff --git a/apps/readest-app/src/components/DeleteConfirmAlert.tsx b/apps/readest-app/src/components/DeleteConfirmAlert.tsx new file mode 100644 index 00000000..641786a3 --- /dev/null +++ b/apps/readest-app/src/components/DeleteConfirmAlert.tsx @@ -0,0 +1,71 @@ +import clsx from 'clsx'; +import React, { useState } from 'react'; +import { useTranslation } from '@/hooks/useTranslation'; +import Alert from './Alert'; + +/** + * Delete confirmation alert with an optional, opt-in "purge all reading data" + * toggle. Used by both the single-book delete (BookDetailModal) and the + * bulk/multi-select delete (Bookshelf), so the same purge-on-delete affordance + * covers every standard delete path (issue #4698). + * + * The toggle defaults to OFF and is ephemeral — it resets every time the alert + * mounts. When turned ON the confirm action escalates to a destructive purge + * (wipes the book's reading progress, notes, and bookmarks), signalled by the + * red button + relabel so the irreversible choice reads clearly even on e-ink + * where colour alone is not enough. + */ +const DeleteConfirmAlert: React.FC<{ + title: string; + message: string; + showPurgeToggle?: boolean; + onCancel: () => void; + onConfirm: (purgeData: boolean) => void; +}> = ({ title, message, showPurgeToggle = false, onCancel, onConfirm }) => { + const _ = useTranslation(); + const [purgeData, setPurgeData] = useState(false); + + return ( + onConfirm(purgeData)} + > + {showPurgeToggle && ( + + )} + + ); +}; + +export default DeleteConfirmAlert; diff --git a/apps/readest-app/src/components/metadata/BookDetailModal.tsx b/apps/readest-app/src/components/metadata/BookDetailModal.tsx index d117bde4..53e24137 100644 --- a/apps/readest-app/src/components/metadata/BookDetailModal.tsx +++ b/apps/readest-app/src/components/metadata/BookDetailModal.tsx @@ -12,7 +12,7 @@ import { useMetadataEdit } from './useMetadataEdit'; import { DeleteAction } from '@/types/system'; import { eventDispatcher } from '@/utils/event'; import { isWebAppPlatform } from '@/services/environment'; -import Alert from '@/components/Alert'; +import DeleteConfirmAlert from '@/components/DeleteConfirmAlert'; import Dialog from '@/components/Dialog'; import BookDetailView from './BookDetailView'; import BookDetailEdit from './BookDetailEdit'; @@ -32,10 +32,15 @@ interface BookDetailModalProps { handleBookMetadataUpdate?: (book: Book, updatedMetadata: BookMetadata) => void; } +// Purge is no longer a standalone menu action — it is an opt-in toggle on the +// standard ('both') delete confirmation, so the menu only triggers these three. +type DeleteMenuAction = Exclude; + interface DeleteConfig { title: string; message: string; handler?: (book: Book) => void; + showPurgeToggle?: boolean; } const BookDetailModal: React.FC = ({ @@ -54,7 +59,7 @@ const BookDetailModal: React.FC = ({ const { envConfig, appService } = useEnv(); const { user } = useAuth(); const { safeAreaInsets } = useThemeStore(); - const [activeDeleteAction, setActiveDeleteAction] = useState(null); + const [activeDeleteAction, setActiveDeleteAction] = useState(null); const [isLoading, setIsLoading] = useState(false); const [editMode, setEditMode] = useState(false); const [bookMeta, setBookMeta] = useState(null); @@ -83,11 +88,12 @@ const BookDetailModal: React.FC = ({ resetToOriginal, } = useMetadataEdit(bookMeta); - const deleteConfigs: Record = { + const deleteConfigs: Record = { both: { title: _('Confirm Deletion'), message: _('Are you sure to delete the selected book?'), handler: handleBookDelete, + showPurgeToggle: !!handleBookPurge, }, cloud: { title: _('Confirm Deletion'), @@ -99,13 +105,6 @@ const BookDetailModal: React.FC = ({ message: _('Are you sure to delete the local copy of the selected book?'), handler: handleBookDeleteLocalCopy, }, - purge: { - title: _('Purge Book Data'), - message: _( - 'This permanently erases the book and all its data, including reading progress, notes, and bookmarks. This cannot be undone.', - ), - handler: handleBookPurge, - }, }; useEffect(() => { @@ -157,17 +156,22 @@ const BookDetailModal: React.FC = ({ } }; - const handleDeleteAction = (action: DeleteAction) => { + const handleDeleteAction = (action: DeleteMenuAction) => { setActiveDeleteAction(action); }; - const confirmDeleteAction = async () => { + const confirmDeleteAction = async (purgeData: boolean) => { if (!activeDeleteAction) return; const config = deleteConfigs[activeDeleteAction]; handleClose(); - if (config.handler) { + // The standard "Cloud & Device" delete escalates to a full purge when the + // user opts in via the confirmation toggle. The cloud-only / device-only + // variants keep the library entry, so purging reading data does not apply. + if (activeDeleteAction === 'both' && purgeData && handleBookPurge) { + handleBookPurge(book); + } else if (config.handler) { config.handler(book); } }; @@ -179,7 +183,6 @@ const BookDetailModal: React.FC = ({ const handleDelete = () => handleDeleteAction('both'); const handleDeleteCloudBackup = () => handleDeleteAction('cloud'); const handleDeleteLocalCopy = () => handleDeleteAction('local'); - const handlePurge = () => handleDeleteAction('purge'); const handleShare = () => { // Close this modal first, then hand off to the share dialog hosted by @@ -267,7 +270,6 @@ const BookDetailModal: React.FC = ({ handleBookDeleteCloudBackup ? handleDeleteCloudBackup : undefined } onDeleteLocalCopy={handleBookDeleteLocalCopy ? handleDeleteLocalCopy : undefined} - onPurge={handleBookPurge ? handlePurge : undefined} onDownload={handleBookDownload ? handleRedownload : undefined} onUpload={handleBookUpload ? handleReupload : undefined} onShare={handleShare} @@ -300,9 +302,10 @@ const BookDetailModal: React.FC = ({ paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`, }} > - diff --git a/apps/readest-app/src/components/metadata/BookDetailView.tsx b/apps/readest-app/src/components/metadata/BookDetailView.tsx index 96bdff15..dd5e2c4b 100644 --- a/apps/readest-app/src/components/metadata/BookDetailView.tsx +++ b/apps/readest-app/src/components/metadata/BookDetailView.tsx @@ -39,7 +39,6 @@ interface BookDetailViewProps { onDelete?: () => void; onDeleteCloudBackup?: () => void; onDeleteLocalCopy?: () => void; - onPurge?: () => void; onDownload?: () => void; onUpload?: () => void; onShare?: () => void; @@ -55,7 +54,6 @@ const BookDetailView: React.FC = ({ onDelete, onDeleteCloudBackup, onDeleteLocalCopy, - onPurge, onDownload, onUpload, onShare, @@ -153,16 +151,6 @@ const BookDetailView: React.FC = ({ onClick={onDeleteLocalCopy} disabled={!book.downloadedAt} /> - {onPurge && ( - - )} )}