mirror of
https://github.com/toeverything/AFFiNE.git
synced 2025-01-02 09:32:56 +03:00
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:
parent
4751081919
commit
eac55fe1c1
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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';
|
||||
|
@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -6,6 +6,7 @@ export const trashTitle = style({
|
||||
gap: 8,
|
||||
padding: '0 8px',
|
||||
fontWeight: 600,
|
||||
userSelect: 'none',
|
||||
});
|
||||
export const body = style({
|
||||
display: 'flex',
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user