feat(library): add opt-in "purge reading data" toggle to delete confirm (#4698) (#4705)

Replace the standalone "Purge Data" menu item with an opt-in toggle on the
delete confirmation alert (default off). When enabled, the delete escalates
to a full purge that also wipes the book's reading-data sidecars (config and
nav), instead of leaving the metadata folder behind. The single, bulk, and
multi-select deletes all share the same alert, so this also covers batch
deletes that previously kept every metadata folder.

- Alert: add optional children, confirmLabel, confirmButtonClassName slots
- DeleteConfirmAlert: new wrapper owning the toggle and red escalation
- BookDetailView: drop the Purge Data menu item and onPurge prop
- BookDetailModal: route the standard delete to purge when the toggle is on
- Bookshelf/page: route the bulk delete batch to purge when the toggle is on

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-22 00:24:29 +08:00
committed by GitHub
parent 4fa7f76bc1
commit 799fc0e0ab
9 changed files with 274 additions and 49 deletions
@@ -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 ? <div>{children}</div> : 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;
}) => (
<div data-testid='delete-confirm' data-purge-toggle={String(!!showPurgeToggle)}>
<button onClick={() => onConfirm(false)}>confirm-keep-data</button>
<button onClick={() => onConfirm(true)}>confirm-purge-data</button>
</div>
),
}));
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(
<DropdownProvider>
<BookDetailModal
book={makeBook()}
isOpen
onClose={vi.fn()}
handleBookDelete={handlers.handleBookDelete}
handleBookDeleteCloudBackup={vi.fn()}
handleBookDeleteLocalCopy={vi.fn()}
handleBookPurge={handlers.handleBookPurge}
/>
</DropdownProvider>,
);
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();
});
});
@@ -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();
});
});
@@ -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 <Alert> 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<React.ComponentProps<typeof DeleteConfirmAlert>>) => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<DeleteConfirmAlert
title='Confirm Deletion'
message='Are you sure to delete the selected book?'
showPurgeToggle
onCancel={onCancel}
onConfirm={onConfirm}
{...props}
/>,
);
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);
});
});
@@ -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<boolean>;
handleBookUpload: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleBookPurge: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void;
handleLibraryNavigation: (targetGroup: string) => void;
@@ -146,6 +147,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handleBookUpload,
handleBookDownload,
handleBookDelete,
handleBookPurge,
handleSetSelectMode,
handleShowDetailsBook,
handleLibraryNavigation,
@@ -390,8 +392,12 @@ const Bookshelf: React.FC<BookshelfProps> = ({
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<BookshelfProps> = ({
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<BookshelfProps> = ({
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
>
<Alert
<DeleteConfirmAlert
title={_('Confirm Deletion')}
message={_('Are you sure to delete {{count}} selected book(s)?', {
count: getBooksToDelete().length,
})}
showPurgeToggle
onCancel={() => {
abortDeletionRef.current = true;
setShowDeleteAlert(false);
@@ -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}
+17 -3
View File
@@ -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<{
<div className='text-start text-sm'>{message}</div>
</div>
</div>
{children}
<div className='buttons flex items-center justify-end gap-2'>
<button className='btn btn-sm btn-neutral' onClick={onCancel}>
{_('Cancel')}
</button>
<button
className={clsx('btn btn-sm btn-warning', { 'btn-disabled': isProcessing })}
className={clsx('btn btn-sm', confirmButtonClassName, { 'btn-disabled': isProcessing })}
onClick={() => {
setIsProcessing(true);
onConfirm();
}}
>
{_('Confirm')}
{confirmLabel ?? _('Confirm')}
</button>
</div>
</div>
@@ -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 (
<Alert
title={title}
message={message}
confirmLabel={purgeData ? _('Purge & Delete') : _('Delete')}
confirmButtonClassName={purgeData ? 'btn-error' : 'btn-warning'}
onCancel={onCancel}
onConfirm={() => onConfirm(purgeData)}
>
{showPurgeToggle && (
<label
className={clsx(
'eink-bordered flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors',
purgeData
? 'not-eink:border-error/40 not-eink:bg-error/10'
: 'not-eink:border-base-content/10 not-eink:bg-base-100/60',
)}
>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<span className={clsx('text-sm font-medium', purgeData && 'not-eink:text-error')}>
{_('Purge all reading data')}
</span>
<span className='text-neutral-content text-xs'>
{purgeData
? _(
'This permanently erases reading progress, notes, and bookmarks. This cannot be undone.',
)
: _('Also erase reading progress, notes, and bookmarks.')}
</span>
</div>
<input
type='checkbox'
className={clsx('toggle toggle-sm shrink-0', purgeData && 'not-eink:toggle-error')}
checked={purgeData}
onChange={(e) => setPurgeData(e.target.checked)}
aria-label={_('Purge all reading data')}
/>
</label>
)}
</Alert>
);
};
export default DeleteConfirmAlert;
@@ -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<DeleteAction, 'purge'>;
interface DeleteConfig {
title: string;
message: string;
handler?: (book: Book) => void;
showPurgeToggle?: boolean;
}
const BookDetailModal: React.FC<BookDetailModalProps> = ({
@@ -54,7 +59,7 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
const { envConfig, appService } = useEnv();
const { user } = useAuth();
const { safeAreaInsets } = useThemeStore();
const [activeDeleteAction, setActiveDeleteAction] = useState<DeleteAction | null>(null);
const [activeDeleteAction, setActiveDeleteAction] = useState<DeleteMenuAction | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [editMode, setEditMode] = useState(false);
const [bookMeta, setBookMeta] = useState<BookMetadata | null>(null);
@@ -83,11 +88,12 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
resetToOriginal,
} = useMetadataEdit(bookMeta);
const deleteConfigs: Record<DeleteAction, DeleteConfig> = {
const deleteConfigs: Record<DeleteMenuAction, DeleteConfig> = {
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<BookDetailModalProps> = ({
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<BookDetailModalProps> = ({
}
};
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<BookDetailModalProps> = ({
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<BookDetailModalProps> = ({
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<BookDetailModalProps> = ({
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
>
<Alert
<DeleteConfirmAlert
title={currentDeleteConfig.title}
message={currentDeleteConfig.message}
showPurgeToggle={currentDeleteConfig.showPurgeToggle}
onCancel={cancelDeleteAction}
onConfirm={confirmDeleteAction}
/>
@@ -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<BookDetailViewProps> = ({
onDelete,
onDeleteCloudBackup,
onDeleteLocalCopy,
onPurge,
onDownload,
onUpload,
onShare,
@@ -153,16 +151,6 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
onClick={onDeleteLocalCopy}
disabled={!book.downloadedAt}
/>
{onPurge && (
<MenuItem
noIcon
transient
label={_('Purge Data')}
labelClass='text-red-500'
tooltip={_('Erase the book and all its data, including reading progress')}
onClick={onPurge}
/>
)}
</div>
</Dropdown>
)}