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
No known key found for this signature in database
GPG Key ID: 14A6F56854E1BED7
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 { 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 (
<FloatingToolbar className={styles.floatingToolbar} open={open}>
<FloatingToolbar.Item>{content}</FloatingToolbar.Item>
<FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} />
<FloatingToolbar.Separator />
<FloatingToolbar.Button
onClick={onDelete}
icon={<DeleteIcon />}
type="danger"
data-testid="list-toolbar-delete"
/>
{!!onRestore && (
<FloatingToolbar.Button
onClick={onRestore}
icon={<ResetIcon />}
data-testid="list-toolbar-restore"
/>
)}
{!!onDelete && (
<FloatingToolbar.Button
onClick={onDelete}
icon={onRestore ? <DeletePermanentlyIcon /> : <DeleteIcon />}
type="danger"
data-testid="list-toolbar-delete"
/>
)}
</FloatingToolbar>
);
};

View File

@ -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';

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 {
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 (
<ColWrapper flex={1}>
<Tooltip content={t['com.affine.trashOperation.restoreIt']()} side="top">
@ -248,28 +261,12 @@ export const TrashOperationCell = ({
>
<IconButton
data-testid="delete-page-button"
onClick={() => {
setOpen(true);
}}
onClick={onConfirmPermanentlyDelete}
className={styles.deleteIcon}
>
<DeletePermanentlyIcon />
</IconButton>
</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>
);
};

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,
padding: '0 8px',
fontWeight: 600,
userSelect: 'none',
});
export const body = style({
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 {
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 (
<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 (
<>
<ViewHeaderIsland>
@ -93,15 +45,7 @@ export const TrashPage = () => {
<ViewBodyIsland>
<div className={styles.body}>
{filteredPageMetas.length > 0 ? (
<VirtualizedList
items={filteredPageMetas}
rowAsLink
isPreferredEdgeless={isPreferredEdgeless}
docCollection={currentWorkspace.docCollection}
operationsRenderer={pageOperationsRenderer}
itemRenderer={pageItemRenderer}
headerRenderer={pageHeaderRenderer}
/>
<VirtualizedTrashList />
) : (
<EmptyPageList
type="trash"

View File

@ -228,6 +228,65 @@ test('select two pages and delete', async ({ page }) => {
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,