From f78e46f90e112f33df063b97cd38f636dfc5b212 Mon Sep 17 00:00:00 2001 From: Whitewater Date: Wed, 2 Sep 2026 21:01:45 +0800 Subject: [PATCH] feat: add select all action to trash (#15556) image ## Description Add a select-all action next to the Trash page title after document multi-selection starts. - Select every unique document ID from the explorer groups, including items outside the rendered viewport. - Change the action to Clear selection when every Trash document is selected. - Reuse the existing Trash bulk-operation permissions without introducing artificial grouping. ## Testing - Trash page Playwright E2E: 2 passed - Targeted TypeScript project build - lint-staged - lint:ox - oxfmt --check - git diff --check ## Checklist - [x] I have signed the AFFiNE Contributor License Agreement - [x] The PR targets the canary branch and its title follows Conventional Commits - [x] Tests are added or updated where it makes sense - [ ] Full yarn lint and yarn typecheck pass locally ## Summary by CodeRabbit - **New Features** - Added a select-all and clear-selection control to the Trash view when multiple documents can be managed. - Select-all actions now update the document selection toolbar. - **Bug Fixes** - Improved Trash view controls for users with administrator or owner permissions. - **Tests** - Added end-to-end coverage for selecting, selecting all, and clearing multiple trashed documents. --- .../desktop/pages/workspace/trash-page.css.ts | 9 +++ .../desktop/pages/workspace/trash-page.tsx | 51 ++++++++++++++--- .../e2e/local-first-trash-page.spec.ts | 55 ++++++++++++++++--- 3 files changed, 98 insertions(+), 17 deletions(-) diff --git a/packages/frontend/core/src/desktop/pages/workspace/trash-page.css.ts b/packages/frontend/core/src/desktop/pages/workspace/trash-page.css.ts index 12b70a6dfc..e09ab64272 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/trash-page.css.ts +++ b/packages/frontend/core/src/desktop/pages/workspace/trash-page.css.ts @@ -1,4 +1,5 @@ import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; import { style } from '@vanilla-extract/css'; export const trashTitle = style({ display: 'flex', @@ -19,3 +20,11 @@ export const trashIcon = style({ color: cssVar('iconColor'), fontSize: cssVar('fontH5'), }); +export const selectAllButton = style({ + height: 24, + padding: '2px 4px', + color: cssVarV2.text.secondary, + fontSize: 12, + fontWeight: 400, + lineHeight: '20px', +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/trash-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/trash-page.tsx index f2b7d2a164..756920c3a2 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/trash-page.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/trash-page.tsx @@ -1,4 +1,4 @@ -import { toast, useConfirmModal } from '@affine/component'; +import { Button, toast, useConfirmModal } from '@affine/component'; import { createDocExplorerContext, DocExplorerContext, @@ -12,7 +12,7 @@ import { WorkspacePermissionService } from '@affine/core/modules/permissions'; import { useI18n } from '@affine/i18n'; import { DeleteIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useIsActiveView, @@ -24,14 +24,48 @@ import { import { EmptyPageList } from './page-list-empty'; import * as styles from './trash-page.css'; -const TrashHeader = () => { +const TrashHeader = ({ canManageTrash }: { canManageTrash: boolean }) => { const t = useI18n(); + const contextValue = useContext(DocExplorerContext); + const groups = useLiveData(contextValue.groups$); + const selectMode = useLiveData(contextValue.selectMode$); + const selectedDocIds = useLiveData(contextValue.selectedDocIds$); + + const allDocIds = useMemo( + () => Array.from(new Set(groups.flatMap(group => group.items))), + [groups] + ); + const allSelected = useMemo(() => { + const selectedDocIdSet = new Set(selectedDocIds); + return ( + allDocIds.length > 0 && allDocIds.every(id => selectedDocIdSet.has(id)) + ); + }, [allDocIds, selectedDocIds]); + + const handleToggleSelectAll = useCallback(() => { + contextValue.selectedDocIds$.next(allSelected ? [] : allDocIds); + contextValue.prevCheckAnchorId$?.next(null); + }, [allDocIds, allSelected, contextValue]); + return (
{t['com.affine.workspaceSubPath.trash']()} + {selectMode && canManageTrash && allDocIds.length > 0 ? ( + + ) : null} } /> @@ -69,6 +103,7 @@ export const TrashPage = () => { const isAdmin = useLiveData(permissionService.permission.isAdmin$); const isOwner = useLiveData(permissionService.permission.isOwner$); + const canManageTrash = !!isAdmin || !!isOwner; const groups = useLiveData(explorerContextValue.groups$); const isEmpty = groups.length === 0 || @@ -171,7 +206,7 @@ export const TrashPage = () => { - +
@@ -179,11 +214,9 @@ export const TrashPage = () => { ) : ( )}
diff --git a/tests/affine-local/e2e/local-first-trash-page.spec.ts b/tests/affine-local/e2e/local-first-trash-page.spec.ts index a06c76bc3c..15c6756810 100644 --- a/tests/affine-local/e2e/local-first-trash-page.spec.ts +++ b/tests/affine-local/e2e/local-first-trash-page.spec.ts @@ -8,7 +8,23 @@ import { waitForEditorLoad, } from '@affine-test/kit/utils/page-logic'; import { getCurrentDocIdFromUrl } from '@affine-test/kit/utils/url'; -import { expect } from '@playwright/test'; +import { expect, type Page } from '@playwright/test'; + +const movePageToTrash = async (page: Page, docId: string) => { + await getPageOperationButton(page, docId).click(); + await page.getByTestId('doc-list-operation-trash').click(); + await expect(page.getByRole('dialog', { name: 'Delete doc?' })).toBeVisible(); + await page.getByRole('button', { name: 'Delete' }).click(); +}; + +const createAndTrashPage = async (page: Page, title: string) => { + await clickNewPageButton(page); + await getBlockSuiteEditorTitle(page).click(); + await getBlockSuiteEditorTitle(page).fill(title); + const docId = getCurrentDocIdFromUrl(page); + await page.getByTestId('all-pages').click(); + await movePageToTrash(page, docId); +}; test('New a page , then delete it in all pages, finally find it in trash', async ({ page, @@ -24,13 +40,7 @@ test('New a page , then delete it in all pages, finally find it in trash', async const cell = await getPageByTitle(page, 'this is a new page to delete'); await expect(cell).toBeVisible(); - await getPageOperationButton(page, newPageId).click(); - const deleteBtn = page.getByTestId('doc-list-operation-trash'); - await deleteBtn.click(); - const confirmTip = page.getByRole('dialog', { name: 'Delete doc?' }); - await expect(confirmTip).toBeVisible(); - - await page.getByRole('button', { name: 'Delete' }).click(); + await movePageToTrash(page, newPageId); await page.getByTestId('trash-page').click(); await expect(page.getByText('this is a new page to delete')).toBeVisible(); @@ -38,3 +48,32 @@ test('New a page , then delete it in all pages, finally find it in trash', async expect(currentWorkspace.meta.flavour).toContain('local'); }); + +test('select all trashed pages from the Trash title', async ({ page }) => { + await openHomePage(page); + await waitForEditorLoad(page); + await createAndTrashPage(page, 'trash select all 1'); + await createAndTrashPage(page, 'trash select all 2'); + await page.getByTestId('trash-page').click(); + + const selectAllButton = page.getByTestId('trash-select-all'); + await expect(selectAllButton).not.toBeVisible(); + + await page + .locator('[data-testid="doc-list-item"]') + .first() + .click({ modifiers: ['Shift'] }); + + await expect(selectAllButton).toHaveText('Select all'); + await selectAllButton.click(); + await expect(page.getByTestId('floating-toolbar')).toHaveText( + '2 doc(s) selected' + ); + await expect(selectAllButton).toHaveText('Clear selection'); + + await selectAllButton.click(); + await expect(page.getByTestId('floating-toolbar')).toHaveText( + '0 doc(s) selected' + ); + await expect(selectAllButton).toHaveText('Select all'); +});