feat(core): add multiDelete to trash page (#6798)

close #6739

https://github.com/toeverything/AFFiNE/assets/102217452/b1779cdf-f617-4188-ad29-70ec1695af1b
This commit is contained in:
JimmFly
2024-05-07 06:54:59 +00:00
parent 4751081919
commit eac55fe1c1
8 changed files with 264 additions and 86 deletions

View File

@@ -1,4 +1,9 @@
import { CloseIcon, DeleteIcon } from '@blocksuite/icons'; import {
CloseIcon,
DeleteIcon,
DeletePermanentlyIcon,
ResetIcon,
} from '@blocksuite/icons';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { FloatingToolbar } from './floating-toolbar'; import { FloatingToolbar } from './floating-toolbar';
@@ -9,23 +14,34 @@ export const ListFloatingToolbar = ({
onClose, onClose,
open, open,
onDelete, onDelete,
onRestore,
}: { }: {
open: boolean; open: boolean;
content: ReactNode; content: ReactNode;
onClose: () => void; onClose: () => void;
onDelete: () => void; onDelete?: () => void;
onRestore?: () => void;
}) => { }) => {
return ( return (
<FloatingToolbar className={styles.floatingToolbar} open={open}> <FloatingToolbar className={styles.floatingToolbar} open={open}>
<FloatingToolbar.Item>{content}</FloatingToolbar.Item> <FloatingToolbar.Item>{content}</FloatingToolbar.Item>
<FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} /> <FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} />
<FloatingToolbar.Separator /> <FloatingToolbar.Separator />
<FloatingToolbar.Button {!!onRestore && (
onClick={onDelete} <FloatingToolbar.Button
icon={<DeleteIcon />} onClick={onRestore}
type="danger" icon={<ResetIcon />}
data-testid="list-toolbar-delete" data-testid="list-toolbar-restore"
/> />
)}
{!!onDelete && (
<FloatingToolbar.Button
onClick={onDelete}
icon={onRestore ? <DeletePermanentlyIcon /> : <DeleteIcon />}
type="danger"
data-testid="list-toolbar-delete"
/>
)}
</FloatingToolbar> </FloatingToolbar>
); );
}; };

View File

@@ -22,3 +22,4 @@ export * from './use-filtered-page-metas';
export * from './utils'; export * from './utils';
export * from './view'; export * from './view';
export * from './virtualized-list'; export * from './virtualized-list';
export * from './virtualized-trash-list';

View File

@@ -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'),
},
},
});

View File

@@ -1,5 +1,4 @@
import { import {
ConfirmModal,
IconButton, IconButton,
Menu, Menu,
MenuIcon, MenuIcon,
@@ -227,7 +226,21 @@ export const TrashOperationCell = ({
onRestorePage, onRestorePage,
}: TrashOperationCellProps) => { }: TrashOperationCellProps) => {
const t = useAFFiNEI18N(); 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 ( return (
<ColWrapper flex={1}> <ColWrapper flex={1}>
<Tooltip content={t['com.affine.trashOperation.restoreIt']()} side="top"> <Tooltip content={t['com.affine.trashOperation.restoreIt']()} side="top">
@@ -248,28 +261,12 @@ export const TrashOperationCell = ({
> >
<IconButton <IconButton
data-testid="delete-page-button" data-testid="delete-page-button"
onClick={() => { onClick={onConfirmPermanentlyDelete}
setOpen(true); className={styles.deleteIcon}
}}
> >
<DeletePermanentlyIcon /> <DeletePermanentlyIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<ConfirmModal
title={`${t['com.affine.trashOperation.deletePermanently']()}?`}
description={t['com.affine.trashOperation.deleteDescription']()}
cancelText={t['com.affine.confirmModal.button.cancel']()}
confirmButtonOptions={{
type: 'error',
children: t['com.affine.trashOperation.delete'](),
}}
open={open}
onOpenChange={setOpen}
onConfirm={() => {
onPermanentlyDeletePage();
setOpen(false);
}}
/>
</ColWrapper> </ColWrapper>
); );
}; };

View File

@@ -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<ItemListHandle>(null);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
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 (
<TrashOperationCell
onPermanentlyDeletePage={onPermanentlyDeletePage}
onRestorePage={onRestorePage}
/>
);
},
[permanentlyDeletePage, restoreFromTrash, t]
);
const pageItemRenderer = useCallback((item: ListItem) => {
return <PageListItemRenderer {...item} />;
}, []);
const pageHeaderRenderer = useCallback(() => {
return <ListTableHeader headerCols={pageHeaderColsDef} />;
}, [pageHeaderColsDef]);
return (
<>
<VirtualizedList
ref={listRef}
selectable="toggle"
items={filteredPageMetas}
rowAsLink
isPreferredEdgeless={isPreferredEdgeless}
onSelectionActiveChange={setShowFloatingToolbar}
docCollection={currentWorkspace.docCollection}
operationsRenderer={pageOperationsRenderer}
itemRenderer={pageItemRenderer}
headerRenderer={pageHeaderRenderer}
selectedIds={filteredSelectedPageIds}
onSelectedIdsChange={setSelectedPageIds}
/>
<ListFloatingToolbar
open={showFloatingToolbar && filteredSelectedPageIds.length > 0}
onDelete={onConfirmPermanentlyDelete}
onClose={hideFloatingToolbar}
onRestore={handleMultiRestore}
content={
<Trans
i18nKey="com.affine.page.toolbar.selected"
count={filteredSelectedPageIds.length}
>
<div style={{ color: 'var(--affine-text-secondary-color)' }}>
{{ count: filteredSelectedPageIds.length } as any}
</div>
selected
</Trans>
}
/>
</>
);
};

View File

@@ -6,6 +6,7 @@ export const trashTitle = style({
gap: 8, gap: 8,
padding: '0 8px', padding: '0 8px',
fontWeight: 600, fontWeight: 600,
userSelect: 'none',
}); });
export const body = style({ export const body = style({
display: 'flex', display: 'flex',

View File

@@ -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 { import {
ListTableHeader,
PageListItemRenderer,
TrashOperationCell,
useFilteredPageMetas, useFilteredPageMetas,
VirtualizedList, VirtualizedTrashList,
} from '@affine/core/components/page-list'; } 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 { 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 { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils'; import { assertExists } from '@blocksuite/global/utils';
import { DeleteIcon } from '@blocksuite/icons'; import { DeleteIcon } from '@blocksuite/icons';
import type { DocMeta } from '@blocksuite/store';
import { useService, WorkspaceService } from '@toeverything/infra'; import { useService, WorkspaceService } from '@toeverything/infra';
import { useCallback } from 'react';
import { ViewBodyIsland, ViewHeaderIsland } from '../../modules/workbench'; import { ViewBodyIsland, ViewHeaderIsland } from '../../modules/workbench';
import { EmptyPageList } from './page-list-empty'; import { EmptyPageList } from './page-list-empty';
@@ -47,44 +37,6 @@ export const TrashPage = () => {
trash: true, 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 (
<TrashOperationCell
onPermanentlyDeletePage={onPermanentlyDeletePage}
onRestorePage={onRestorePage}
/>
);
},
[permanentlyDeletePage, restoreFromTrash, t]
);
const pageItemRenderer = useCallback((item: ListItem) => {
return <PageListItemRenderer {...item} />;
}, []);
const pageHeaderRenderer = useCallback(() => {
return <ListTableHeader headerCols={pageHeaderColsDef} />;
}, [pageHeaderColsDef]);
return ( return (
<> <>
<ViewHeaderIsland> <ViewHeaderIsland>
@@ -93,15 +45,7 @@ export const TrashPage = () => {
<ViewBodyIsland> <ViewBodyIsland>
<div className={styles.body}> <div className={styles.body}>
{filteredPageMetas.length > 0 ? ( {filteredPageMetas.length > 0 ? (
<VirtualizedList <VirtualizedTrashList />
items={filteredPageMetas}
rowAsLink
isPreferredEdgeless={isPreferredEdgeless}
docCollection={currentWorkspace.docCollection}
operationsRenderer={pageOperationsRenderer}
itemRenderer={pageItemRenderer}
headerRenderer={pageHeaderRenderer}
/>
) : ( ) : (
<EmptyPageList <EmptyPageList
type="trash" type="trash"

View File

@@ -228,6 +228,65 @@ test('select two pages and delete', async ({ page }) => {
expect(await getPagesCount(page)).toBe(pageCount - 2); 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 ({ test('select a group of items by clicking "Select All" in group header', async ({
page, page,