diff --git a/packages/frontend/core/src/components/page-list/components/list-floating-toolbar.tsx b/packages/frontend/core/src/components/page-list/components/list-floating-toolbar.tsx index 98ad6b4b81..68e4928e52 100644 --- a/packages/frontend/core/src/components/page-list/components/list-floating-toolbar.tsx +++ b/packages/frontend/core/src/components/page-list/components/list-floating-toolbar.tsx @@ -1,4 +1,9 @@ -import { CloseIcon, DeleteIcon } from '@blocksuite/icons'; +import { + CloseIcon, + DeleteIcon, + DeletePermanentlyIcon, + ResetIcon, +} from '@blocksuite/icons'; import type { ReactNode } from 'react'; import { FloatingToolbar } from './floating-toolbar'; @@ -9,23 +14,34 @@ export const ListFloatingToolbar = ({ onClose, open, onDelete, + onRestore, }: { open: boolean; content: ReactNode; onClose: () => void; - onDelete: () => void; + onDelete?: () => void; + onRestore?: () => void; }) => { return ( {content} } /> - } - type="danger" - data-testid="list-toolbar-delete" - /> + {!!onRestore && ( + } + data-testid="list-toolbar-restore" + /> + )} + {!!onDelete && ( + : } + type="danger" + data-testid="list-toolbar-delete" + /> + )} ); }; diff --git a/packages/frontend/core/src/components/page-list/index.tsx b/packages/frontend/core/src/components/page-list/index.tsx index 789e704257..3243f71962 100644 --- a/packages/frontend/core/src/components/page-list/index.tsx +++ b/packages/frontend/core/src/components/page-list/index.tsx @@ -22,3 +22,4 @@ export * from './use-filtered-page-metas'; export * from './utils'; export * from './view'; export * from './virtualized-list'; +export * from './virtualized-trash-list'; diff --git a/packages/frontend/core/src/components/page-list/list.css.ts b/packages/frontend/core/src/components/page-list/list.css.ts index 82310a8c78..688a9a8adf 100644 --- a/packages/frontend/core/src/components/page-list/list.css.ts +++ b/packages/frontend/core/src/components/page-list/list.css.ts @@ -82,3 +82,13 @@ export const editTagWrapper = style({ }, }, }); + +export const deleteIcon = style({ + color: cssVar('iconColor'), + selectors: { + '&:not(.without-hover):hover': { + color: cssVar('errorColor'), + background: cssVar('backgroundErrorColor'), + }, + }, +}); diff --git a/packages/frontend/core/src/components/page-list/operation-cell.tsx b/packages/frontend/core/src/components/page-list/operation-cell.tsx index 906895c8e2..ca59777487 100644 --- a/packages/frontend/core/src/components/page-list/operation-cell.tsx +++ b/packages/frontend/core/src/components/page-list/operation-cell.tsx @@ -1,5 +1,4 @@ import { - ConfirmModal, IconButton, Menu, MenuIcon, @@ -227,7 +226,21 @@ export const TrashOperationCell = ({ onRestorePage, }: TrashOperationCellProps) => { const t = useAFFiNEI18N(); - const [open, setOpen] = useState(false); + const { openConfirmModal } = useConfirmModal(); + + const onConfirmPermanentlyDelete = useCallback(() => { + openConfirmModal({ + title: `${t['com.affine.trashOperation.deletePermanently']()}?`, + description: t['com.affine.trashOperation.deleteDescription'](), + cancelText: t['Cancel'](), + confirmButtonOptions: { + type: 'error', + children: t['com.affine.trashOperation.delete'](), + }, + onConfirm: onPermanentlyDeletePage, + }); + }, [onPermanentlyDeletePage, openConfirmModal, t]); + return ( @@ -248,28 +261,12 @@ export const TrashOperationCell = ({ > { - setOpen(true); - }} + onClick={onConfirmPermanentlyDelete} + className={styles.deleteIcon} > - { - onPermanentlyDeletePage(); - setOpen(false); - }} - /> ); }; diff --git a/packages/frontend/core/src/components/page-list/virtualized-trash-list.tsx b/packages/frontend/core/src/components/page-list/virtualized-trash-list.tsx new file mode 100644 index 0000000000..bb0a3ba039 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/virtualized-trash-list.tsx @@ -0,0 +1,150 @@ +import { toast, useConfirmModal } from '@affine/component'; +import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper'; +import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; +import { Trans } from '@affine/i18n'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import type { DocMeta } from '@blocksuite/store'; +import { useService, WorkspaceService } from '@toeverything/infra'; +import { useCallback, useMemo, useRef, useState } from 'react'; + +import { usePageHelper } from '../blocksuite/block-suite-page-list/utils'; +import { ListFloatingToolbar } from './components/list-floating-toolbar'; +import { usePageHeaderColsDef } from './header-col-def'; +import { TrashOperationCell } from './operation-cell'; +import { PageListItemRenderer } from './page-group'; +import { ListTableHeader } from './page-header'; +import type { ItemListHandle, ListItem } from './types'; +import { useFilteredPageMetas } from './use-filtered-page-metas'; +import { VirtualizedList } from './virtualized-list'; + +export const VirtualizedTrashList = () => { + const currentWorkspace = useService(WorkspaceService).workspace; + const docCollection = currentWorkspace.docCollection; + const { restoreFromTrash, permanentlyDeletePage } = + useBlockSuiteMetaHelper(docCollection); + const pageMetas = useBlockSuiteDocMeta(docCollection); + const filteredPageMetas = useFilteredPageMetas(pageMetas, { + trash: true, + }); + + const { isPreferredEdgeless } = usePageHelper(docCollection); + + const listRef = useRef(null); + const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); + const [selectedPageIds, setSelectedPageIds] = useState([]); + + const { openConfirmModal } = useConfirmModal(); + const t = useAFFiNEI18N(); + const pageHeaderColsDef = usePageHeaderColsDef(); + + const filteredSelectedPageIds = useMemo(() => { + const ids = filteredPageMetas.map(page => page.id); + return selectedPageIds.filter(id => ids.includes(id)); + }, [filteredPageMetas, selectedPageIds]); + + const hideFloatingToolbar = useCallback(() => { + listRef.current?.toggleSelectable(); + }, []); + + const handleMultiDelete = useCallback(() => { + filteredSelectedPageIds.forEach(pageId => { + permanentlyDeletePage(pageId); + }); + hideFloatingToolbar(); + toast(t['com.affine.toastMessage.permanentlyDeleted']()); + }, [filteredSelectedPageIds, hideFloatingToolbar, permanentlyDeletePage, t]); + + const handleMultiRestore = useCallback(() => { + filteredSelectedPageIds.forEach(pageId => { + restoreFromTrash(pageId); + }); + hideFloatingToolbar(); + toast( + t['com.affine.toastMessage.restored']({ + title: filteredSelectedPageIds.length > 1 ? 'docs' : 'doc', + }) + ); + }, [filteredSelectedPageIds, hideFloatingToolbar, restoreFromTrash, t]); + + const onConfirmPermanentlyDelete = useCallback(() => { + openConfirmModal({ + title: `${t['com.affine.trashOperation.deletePermanently']()}?`, + description: t['com.affine.trashOperation.deleteDescription'](), + cancelText: t['Cancel'](), + confirmButtonOptions: { + type: 'error', + children: t['com.affine.trashOperation.delete'](), + }, + onConfirm: handleMultiDelete, + }); + }, [handleMultiDelete, openConfirmModal, t]); + + const pageOperationsRenderer = useCallback( + (item: ListItem) => { + const page = item as DocMeta; + const onRestorePage = () => { + restoreFromTrash(page.id); + toast( + t['com.affine.toastMessage.restored']({ + title: page.title || 'Untitled', + }) + ); + }; + const onPermanentlyDeletePage = () => { + permanentlyDeletePage(page.id); + toast(t['com.affine.toastMessage.permanentlyDeleted']()); + }; + + return ( + + ); + }, + + [permanentlyDeletePage, restoreFromTrash, t] + ); + const pageItemRenderer = useCallback((item: ListItem) => { + return ; + }, []); + const pageHeaderRenderer = useCallback(() => { + return ; + }, [pageHeaderColsDef]); + + return ( + <> + + 0} + onDelete={onConfirmPermanentlyDelete} + onClose={hideFloatingToolbar} + onRestore={handleMultiRestore} + content={ + +
+ {{ count: filteredSelectedPageIds.length } as any} +
+ selected +
+ } + /> + + ); +}; diff --git a/packages/frontend/core/src/pages/workspace/trash-page.css.ts b/packages/frontend/core/src/pages/workspace/trash-page.css.ts index c3e448d09e..12b70a6dfc 100644 --- a/packages/frontend/core/src/pages/workspace/trash-page.css.ts +++ b/packages/frontend/core/src/pages/workspace/trash-page.css.ts @@ -6,6 +6,7 @@ export const trashTitle = style({ gap: 8, padding: '0 8px', fontWeight: 600, + userSelect: 'none', }); export const body = style({ display: 'flex', diff --git a/packages/frontend/core/src/pages/workspace/trash-page.tsx b/packages/frontend/core/src/pages/workspace/trash-page.tsx index fdb1679c0a..f533d1c17e 100644 --- a/packages/frontend/core/src/pages/workspace/trash-page.tsx +++ b/packages/frontend/core/src/pages/workspace/trash-page.tsx @@ -1,23 +1,13 @@ -import { toast } from '@affine/component'; -import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils'; -import type { ListItem } from '@affine/core/components/page-list'; import { - ListTableHeader, - PageListItemRenderer, - TrashOperationCell, useFilteredPageMetas, - VirtualizedList, + VirtualizedTrashList, } from '@affine/core/components/page-list'; -import { usePageHeaderColsDef } from '@affine/core/components/page-list/header-col-def'; import { Header } from '@affine/core/components/pure/header'; -import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper'; import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { assertExists } from '@blocksuite/global/utils'; import { DeleteIcon } from '@blocksuite/icons'; -import type { DocMeta } from '@blocksuite/store'; import { useService, WorkspaceService } from '@toeverything/infra'; -import { useCallback } from 'react'; import { ViewBodyIsland, ViewHeaderIsland } from '../../modules/workbench'; import { EmptyPageList } from './page-list-empty'; @@ -47,44 +37,6 @@ export const TrashPage = () => { trash: true, }); - const { restoreFromTrash, permanentlyDeletePage } = - useBlockSuiteMetaHelper(docCollection); - const { isPreferredEdgeless } = usePageHelper(docCollection); - const t = useAFFiNEI18N(); - const pageHeaderColsDef = usePageHeaderColsDef(); - - const pageOperationsRenderer = useCallback( - (item: ListItem) => { - const page = item as DocMeta; - const onRestorePage = () => { - restoreFromTrash(page.id); - toast( - t['com.affine.toastMessage.restored']({ - title: page.title || 'Untitled', - }) - ); - }; - const onPermanentlyDeletePage = () => { - permanentlyDeletePage(page.id); - toast(t['com.affine.toastMessage.permanentlyDeleted']()); - }; - - return ( - - ); - }, - - [permanentlyDeletePage, restoreFromTrash, t] - ); - const pageItemRenderer = useCallback((item: ListItem) => { - return ; - }, []); - const pageHeaderRenderer = useCallback(() => { - return ; - }, [pageHeaderColsDef]); return ( <> @@ -93,15 +45,7 @@ export const TrashPage = () => {
{filteredPageMetas.length > 0 ? ( - + ) : ( { expect(await getPagesCount(page)).toBe(pageCount - 2); }); +test('select two pages and permanently delete', async ({ page }) => { + await openHomePage(page); + await waitForEditorLoad(page); + await clickNewPageButton(page); + await clickSideBarAllPageButton(page); + await waitForAllPagesLoad(page); + + const pageCount = await getPagesCount(page); + + await page.keyboard.down('Shift'); + await page.locator('[data-testid="page-list-item"]').nth(0).click(); + + await page.locator('[data-testid="page-list-item"]').nth(1).click(); + await page.keyboard.up('Shift'); + + // the floating popover should appear + await expect(page.locator('[data-testid="floating-toolbar"]')).toBeVisible(); + await expect(page.locator('[data-testid="floating-toolbar"]')).toHaveText( + '2 doc(s) selected' + ); + + // click delete button + await page.locator('[data-testid="list-toolbar-delete"]').click(); + + // the confirm dialog should appear + await expect(page.getByText('Delete 2 docs?')).toBeVisible(); + + await page.getByRole('button', { name: 'Delete' }).click(); + + // check the page count again + await page.waitForTimeout(300); + + expect(await getPagesCount(page)).toBe(pageCount - 2); + + await page.getByTestId('trash-page').click(); + await page.waitForTimeout(300); + const trashPageCount = await getPagesCount(page); + + expect(trashPageCount).toBe(2); + + await page.keyboard.down('Shift'); + await page.locator('[data-testid="page-list-item"]').nth(0).click(); + + await page.locator('[data-testid="page-list-item"]').nth(1).click(); + await page.keyboard.up('Shift'); + + await expect(page.locator('[data-testid="floating-toolbar"]')).toBeVisible(); + await expect(page.locator('[data-testid="floating-toolbar"]')).toHaveText( + '2 doc(s) selected' + ); + + await page.locator('[data-testid="list-toolbar-delete"]').click(); + + await page.getByRole('button', { name: 'Delete' }).click(); + + await page.waitForTimeout(300); + + expect(await getPagesCount(page)).toBe(trashPageCount - 2); +}); test('select a group of items by clicking "Select All" in group header', async ({ page,