feat(core): new doc list for trash page (#12429)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added "Delete permanently" and "Restore" quick actions for documents in the Trash, enabling users to restore or permanently delete trashed documents directly from the UI.
  - Introduced multi-restore support, allowing batch restoration of selected trashed documents.
- **Improvements**
  - Quick action tooltips are now localized for better international user experience.
  - Trash page now uses an updated explorer interface for a more consistent and reactive document management experience.
  - Enhanced quick actions with optional click handlers for better extensibility.
- **Documentation**
  - Added new translation keys for "Delete permanently" and "Restore" actions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
CatsJuice
2025-05-24 12:45:40 +00:00
parent dfa62f7683
commit 9599494e87
11 changed files with 294 additions and 230 deletions

View File

@@ -1,5 +1,4 @@
import { LiveData } from '@toeverything/infra'; import { LiveData } from '@toeverything/infra';
import { uniq } from 'lodash-es';
import { createContext } from 'react'; import { createContext } from 'react';
import type { ExplorerDisplayPreference } from './types'; import type { ExplorerDisplayPreference } from './types';
@@ -42,10 +41,6 @@ export const createDocExplorerContext = (
const displayPreference$ = new LiveData<ExplorerDisplayPreference>({ const displayPreference$ = new LiveData<ExplorerDisplayPreference>({
...DefaultDisplayPreference, ...DefaultDisplayPreference,
...initialState, ...initialState,
displayProperties: uniq([
...(DefaultDisplayPreference.displayProperties ?? []),
...(initialState?.displayProperties ?? []),
]),
}); });
return { return {
groups$: new LiveData<Array<{ key: string; items: string[] }>>([]), groups$: new LiveData<Array<{ key: string; items: string[] }>>([]),
@@ -93,5 +88,11 @@ export const createDocExplorerContext = (
showDragHandle$: displayPreference$.selector( showDragHandle$: displayPreference$.selector(
displayPreference => displayPreference.showDragHandle displayPreference => displayPreference.showDragHandle
), ),
quickDeletePermanently$: displayPreference$.selector(
displayPreference => displayPreference.quickDeletePermanently
),
quickRestore$: displayPreference$.selector(
displayPreference => displayPreference.quickRestore
),
} satisfies DocExplorerContextType; } satisfies DocExplorerContextType;
}; };

View File

@@ -2,12 +2,14 @@ import {
Checkbox, Checkbox,
DragHandle as DragHandleIcon, DragHandle as DragHandleIcon,
Skeleton, Skeleton,
Tooltip,
useDraggable, useDraggable,
} from '@affine/component'; } from '@affine/component';
import { DocsService } from '@affine/core/modules/doc'; import { DocsService } from '@affine/core/modules/doc';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { WorkbenchLink } from '@affine/core/modules/workbench'; import { WorkbenchLink } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd'; import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
import { import {
AutoTidyUpIcon, AutoTidyUpIcon,
PropertyIcon, PropertyIcon,
@@ -307,6 +309,7 @@ const listMoreMenuContentOptions = {
alignOffset: -4, alignOffset: -4,
} as const; } as const;
export const ListViewDoc = ({ docId }: DocListItemProps) => { export const ListViewDoc = ({ docId }: DocListItemProps) => {
const t = useI18n();
const docsService = useService(DocsService); const docsService = useService(DocsService);
const doc = useLiveData(docsService.list.doc$(docId)); const doc = useLiveData(docsService.list.doc$(docId));
const [previewSkeletonWidth] = useState( const [previewSkeletonWidth] = useState(
@@ -337,7 +340,11 @@ export const ListViewDoc = ({ docId }: DocListItemProps) => {
<div className={styles.listSpace} /> <div className={styles.listSpace} />
<ListViewProperties docId={docId} /> <ListViewProperties docId={docId} />
{quickActions.map(action => { {quickActions.map(action => {
return <action.Component key={action.key} doc={doc} />; return (
<Tooltip key={action.key} content={t.t(action.name)}>
<action.Component doc={doc} />
</Tooltip>
);
})} })}
<MoreMenuButton <MoreMenuButton
docId={docId} docId={docId}
@@ -360,6 +367,7 @@ const randomPreviewSkeleton = () => {
})); }));
}; };
export const CardViewDoc = ({ docId }: DocListItemProps) => { export const CardViewDoc = ({ docId }: DocListItemProps) => {
const t = useI18n();
const contextValue = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
const selectMode = useLiveData(contextValue.selectMode$); const selectMode = useLiveData(contextValue.selectMode$);
const docsService = useService(DocsService); const docsService = useService(DocsService);
@@ -380,7 +388,11 @@ export const CardViewDoc = ({ docId }: DocListItemProps) => {
data-testid="doc-list-item-title" data-testid="doc-list-item-title"
/> />
{quickActions.map(action => { {quickActions.map(action => {
return <action.Component size="16" key={action.key} doc={doc} />; return (
<Tooltip key={action.key} content={t.t(action.name)}>
<action.Component size="16" doc={doc} />
</Tooltip>
);
})} })}
{selectMode ? ( {selectMode ? (
<Select id={docId} className={styles.cardViewCheckbox} /> <Select id={docId} className={styles.cardViewCheckbox} />

View File

@@ -96,12 +96,17 @@ export const DocsExplorer = ({
masonryItemWidthMin, masonryItemWidthMin,
heightBase, heightBase,
heightScale, heightScale,
onRestore,
onDelete,
}: { }: {
className?: string; className?: string;
disableMultiDelete?: boolean; disableMultiDelete?: boolean;
masonryItemWidthMin?: number; masonryItemWidthMin?: number;
heightBase?: number; heightBase?: number;
heightScale?: number; heightScale?: number;
onRestore?: (ids: string[]) => void;
/** Override the default delete action */
onDelete?: (ids: string[]) => void;
}) => { }) => {
const t = useI18n(); const t = useI18n();
const contextValue = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
@@ -155,6 +160,11 @@ export const DocsExplorer = ({
if (selectedDocIds.length === 0) { if (selectedDocIds.length === 0) {
return; return;
} }
if (onDelete) {
onDelete(contextValue.selectedDocIds$.value);
handleCloseFloatingToolbar();
return;
}
openConfirmModal({ openConfirmModal({
title: t['com.affine.moveToTrash.confirmModal.title.multiple']({ title: t['com.affine.moveToTrash.confirmModal.title.multiple']({
@@ -183,10 +193,20 @@ export const DocsExplorer = ({
disableMultiDelete, disableMultiDelete,
docsService.list, docsService.list,
handleCloseFloatingToolbar, handleCloseFloatingToolbar,
onDelete,
openConfirmModal, openConfirmModal,
selectedDocIds.length, selectedDocIds.length,
t, t,
]); ]);
const handleMultiRestore = useCallback(() => {
const selectedDocIds = contextValue.selectedDocIds$.value;
onRestore?.(selectedDocIds);
handleCloseFloatingToolbar();
}, [
contextValue.selectedDocIds$.value,
handleCloseFloatingToolbar,
onRestore,
]);
useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
@@ -222,10 +242,11 @@ export const DocsExplorer = ({
[] []
)} )}
/> />
{!disableMultiDelete ? ( {!disableMultiDelete || onRestore ? (
<ListFloatingToolbar <ListFloatingToolbar
open={!!selectMode} open={!!selectMode}
onDelete={handleMultiDelete} onDelete={disableMultiDelete ? undefined : handleMultiDelete}
onRestore={onRestore ? handleMultiRestore : undefined}
onClose={handleCloseFloatingToolbar} onClose={handleCloseFloatingToolbar}
content={ content={
<Trans <Trans

View File

@@ -2,17 +2,25 @@ import {
Checkbox, Checkbox,
IconButton, IconButton,
type IconButtonProps, type IconButtonProps,
toast,
useConfirmModal, useConfirmModal,
} from '@affine/component'; } from '@affine/component';
import type { DocRecord } from '@affine/core/modules/doc'; import type { DocRecord } from '@affine/core/modules/doc';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import track from '@affine/track'; import track from '@affine/track';
import { DeleteIcon, OpenInNewIcon, SplitViewIcon } from '@blocksuite/icons/rc'; import {
DeleteIcon,
OpenInNewIcon,
ResetIcon,
SplitViewIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { memo, useCallback, useContext } from 'react'; import { memo, useCallback, useContext } from 'react';
import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper';
import { IsFavoriteIcon } from '../../pure/icons'; import { IsFavoriteIcon } from '../../pure/icons';
import { DocExplorerContext } from '../context'; import { DocExplorerContext } from '../context';
@@ -22,6 +30,7 @@ export interface QuickActionProps extends IconButtonProps {
export const QuickFavorite = memo(function QuickFavorite({ export const QuickFavorite = memo(function QuickFavorite({
doc, doc,
onClick,
...iconButtonProps ...iconButtonProps
}: QuickActionProps) { }: QuickActionProps) {
const contextValue = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
@@ -31,12 +40,13 @@ export const QuickFavorite = memo(function QuickFavorite({
const favourite = useLiveData(favAdapter.isFavorite$(doc.id, 'doc')); const favourite = useLiveData(favAdapter.isFavorite$(doc.id, 'doc'));
const toggleFavorite = useCallback( const toggleFavorite = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e);
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
favAdapter.toggle(doc.id, 'doc'); favAdapter.toggle(doc.id, 'doc');
}, },
[doc.id, favAdapter] [doc.id, favAdapter, onClick]
); );
if (!quickFavorite) { if (!quickFavorite) {
@@ -55,18 +65,20 @@ export const QuickFavorite = memo(function QuickFavorite({
export const QuickTab = memo(function QuickTab({ export const QuickTab = memo(function QuickTab({
doc, doc,
onClick,
...iconButtonProps ...iconButtonProps
}: QuickActionProps) { }: QuickActionProps) {
const contextValue = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
const quickTab = useLiveData(contextValue.quickTab$); const quickTab = useLiveData(contextValue.quickTab$);
const workbench = useService(WorkbenchService).workbench; const workbench = useService(WorkbenchService).workbench;
const onOpenInNewTab = useCallback( const onOpenInNewTab = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e);
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
workbench.openDoc(doc.id, { at: 'new-tab' }); workbench.openDoc(doc.id, { at: 'new-tab' });
}, },
[doc.id, workbench] [doc.id, onClick, workbench]
); );
if (!quickTab) { if (!quickTab) {
@@ -84,6 +96,7 @@ export const QuickTab = memo(function QuickTab({
export const QuickSplit = memo(function QuickSplit({ export const QuickSplit = memo(function QuickSplit({
doc, doc,
onClick,
...iconButtonProps ...iconButtonProps
}: QuickActionProps) { }: QuickActionProps) {
const contextValue = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
@@ -91,13 +104,14 @@ export const QuickSplit = memo(function QuickSplit({
const workbench = useService(WorkbenchService).workbench; const workbench = useService(WorkbenchService).workbench;
const onOpenInSplitView = useCallback( const onOpenInSplitView = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e);
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
track.allDocs.list.docMenu.openInSplitView(); track.allDocs.list.docMenu.openInSplitView();
workbench.openDoc(doc.id, { at: 'tail' }); workbench.openDoc(doc.id, { at: 'tail' });
}, },
[doc.id, workbench] [doc.id, onClick, workbench]
); );
if (!quickSplit) { if (!quickSplit) {
@@ -115,6 +129,7 @@ export const QuickSplit = memo(function QuickSplit({
export const QuickDelete = memo(function QuickDelete({ export const QuickDelete = memo(function QuickDelete({
doc, doc,
onClick,
...iconButtonProps ...iconButtonProps
}: QuickActionProps) { }: QuickActionProps) {
const t = useI18n(); const t = useI18n();
@@ -123,7 +138,8 @@ export const QuickDelete = memo(function QuickDelete({
const quickTrash = useLiveData(contextValue.quickTrash$); const quickTrash = useLiveData(contextValue.quickTrash$);
const onMoveToTrash = useCallback( const onMoveToTrash = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e);
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (!doc) { if (!doc) {
@@ -146,7 +162,7 @@ export const QuickDelete = memo(function QuickDelete({
}, },
}); });
}, },
[doc, openConfirmModal, t] [doc, onClick, openConfirmModal, t]
); );
if (!quickTrash) { if (!quickTrash) {
@@ -166,6 +182,7 @@ export const QuickDelete = memo(function QuickDelete({
export const QuickSelect = memo(function QuickSelect({ export const QuickSelect = memo(function QuickSelect({
doc, doc,
onClick,
...iconButtonProps ...iconButtonProps
}: QuickActionProps) { }: QuickActionProps) {
const contextValue = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
@@ -175,7 +192,8 @@ export const QuickSelect = memo(function QuickSelect({
const selected = selectedDocIds.includes(doc.id); const selected = selectedDocIds.includes(doc.id);
const onChange = useCallback( const onChange = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e);
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
contextValue.selectedDocIds$?.next( contextValue.selectedDocIds$?.next(
@@ -184,7 +202,7 @@ export const QuickSelect = memo(function QuickSelect({
: [...selectedDocIds, doc.id] : [...selectedDocIds, doc.id]
); );
}, },
[contextValue, doc.id, selected, selectedDocIds] [contextValue, doc.id, onClick, selected, selectedDocIds]
); );
if (!quickSelect) { if (!quickSelect) {
@@ -199,3 +217,118 @@ export const QuickSelect = memo(function QuickSelect({
/> />
); );
}); });
export const QuickDeletePermanently = memo(function QuickDeletePermanently({
doc,
onClick,
...iconButtonProps
}: QuickActionProps) {
const t = useI18n();
const guardService = useService(GuardService);
const contextValue = useContext(DocExplorerContext);
const { permanentlyDeletePage } = useBlockSuiteMetaHelper();
const quickDeletePermanently = useLiveData(
contextValue.quickDeletePermanently$
);
const { openConfirmModal } = useConfirmModal();
const handleDeletePermanently = useCallback(() => {
guardService
.can('Doc_Delete', doc.id)
.then(can => {
if (can) {
permanentlyDeletePage(doc.id);
toast(t['com.affine.toastMessage.permanentlyDeleted']());
} else {
toast(t['com.affine.no-permission']());
}
})
.catch(e => {
console.error(e);
});
}, [doc.id, guardService, permanentlyDeletePage, t]);
const handleConfirmDeletePermanently = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e);
e.stopPropagation();
e.preventDefault();
openConfirmModal({
title: `${t['com.affine.trashOperation.deletePermanently']()}?`,
description: t['com.affine.trashOperation.deleteDescription'](),
cancelText: t['Cancel'](),
confirmText: t['com.affine.trashOperation.delete'](),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: handleDeletePermanently,
});
},
[handleDeletePermanently, onClick, openConfirmModal, t]
);
if (!quickDeletePermanently) {
return null;
}
return (
<IconButton
data-testid="delete-page-button"
onClick={handleConfirmDeletePermanently}
icon={<DeleteIcon />}
variant="danger"
{...iconButtonProps}
/>
);
});
export const QuickRestore = memo(function QuickRestore({
doc,
onClick,
...iconButtonProps
}: QuickActionProps) {
const t = useI18n();
const contextValue = useContext(DocExplorerContext);
const quickRestore = useLiveData(contextValue.quickRestore$);
const { restoreFromTrash } = useBlockSuiteMetaHelper();
const guardService = useService(GuardService);
const handleRestore = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e);
e.stopPropagation();
e.preventDefault();
guardService
.can('Doc_Delete', doc.id)
.then(can => {
if (can) {
restoreFromTrash(doc.id);
toast(
t['com.affine.toastMessage.restored']({
title: doc.title$.value || 'Untitled',
})
);
} else {
toast(t['com.affine.no-permission']());
}
})
.catch(e => {
console.error(e);
});
},
[doc.id, doc.title$, guardService, onClick, restoreFromTrash, t]
);
if (!quickRestore) {
return null;
}
return (
<IconButton
data-testid="restore-page-button"
onClick={handleRestore}
icon={<ResetIcon />}
{...iconButtonProps}
/>
);
});

View File

@@ -3,7 +3,9 @@ import type { I18nString } from '@affine/i18n';
import { import {
type QuickActionProps, type QuickActionProps,
QuickDelete, QuickDelete,
QuickDeletePermanently,
QuickFavorite, QuickFavorite,
QuickRestore,
QuickSelect, QuickSelect,
QuickSplit, QuickSplit,
QuickTab, QuickTab,
@@ -47,6 +49,16 @@ const QUICK_ACTION_MAP: Record<QuickActionKey, QuickActionItem> = {
name: 'com.affine.all-docs.quick-action.select', name: 'com.affine.all-docs.quick-action.select',
Component: QuickSelect, Component: QuickSelect,
}, },
quickDeletePermanently: {
name: 'com.affine.all-docs.quick-action.delete-permanently',
Component: QuickDeletePermanently,
disabled: true, // can only be controlled in code
},
quickRestore: {
name: 'com.affine.all-docs.quick-action.restore',
Component: QuickRestore,
disabled: true, // can only be controlled in code
},
}; };
export const quickActions = Object.entries(QUICK_ACTION_MAP).map( export const quickActions = Object.entries(QUICK_ACTION_MAP).map(
([key, config]) => { ([key, config]) => {

View File

@@ -21,6 +21,8 @@ export interface ExplorerDisplayPreference {
quickSplit?: boolean; quickSplit?: boolean;
quickTab?: boolean; quickTab?: boolean;
quickSelect?: boolean; quickSelect?: boolean;
quickDeletePermanently?: boolean;
quickRestore?: boolean;
} }
export interface DocListPropertyProps { export interface DocListPropertyProps {

View File

@@ -19,4 +19,3 @@ export * from './use-all-doc-display-properties';
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

@@ -1,182 +0,0 @@
import { toast, useConfirmModal } from '@affine/component';
import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper';
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { DocsService } from '@affine/core/modules/doc';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { Trans, useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo, useRef, useState } from 'react';
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 { VirtualizedList } from './virtualized-list';
export const VirtualizedTrashList = ({
disableMultiDelete,
disableMultiRestore,
}: {
disableMultiDelete?: boolean;
disableMultiRestore?: boolean;
}) => {
const currentWorkspace = useService(WorkspaceService).workspace;
const docsService = useService(DocsService);
const guardService = useService(GuardService);
const docCollection = currentWorkspace.docCollection;
const { restoreFromTrash, permanentlyDeletePage } = useBlockSuiteMetaHelper();
const allTrashPageIds = useLiveData(
LiveData.from(docsService.allTrashDocIds$(), [])
);
const pageMetas = useBlockSuiteDocMeta(docCollection);
const filteredPageMetas = useMemo(() => {
return pageMetas.filter(page => allTrashPageIds.includes(page.id));
}, [pageMetas, allTrashPageIds]);
const listRef = useRef<ItemListHandle>(null);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
const { openConfirmModal } = useConfirmModal();
const t = useI18n();
const pageHeaderColsDef = usePageHeaderColsDef();
const filteredSelectedPageIds = useMemo(() => {
const ids = new Set(filteredPageMetas.map(page => page.id));
return selectedPageIds.filter(id => ids.has(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(() => {
if (filteredSelectedPageIds.length === 0) {
return;
}
openConfirmModal({
title: `${t['com.affine.trashOperation.deletePermanently']()}?`,
description: t['com.affine.trashOperation.deleteDescription'](),
cancelText: t['Cancel'](),
confirmText: t['com.affine.trashOperation.delete'](),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: handleMultiDelete,
});
}, [filteredSelectedPageIds.length, handleMultiDelete, openConfirmModal, t]);
const pageOperationsRenderer = useCallback(
(item: ListItem) => {
const page = item as DocMeta;
const onRestorePage = () => {
guardService
.can('Doc_Delete', page.id)
.then(can => {
if (can) {
restoreFromTrash(page.id);
toast(
t['com.affine.toastMessage.restored']({
title: page.title || 'Untitled',
})
);
} else {
toast(t['com.affine.no-permission']());
}
})
.catch(e => {
console.error(e);
});
};
const onPermanentlyDeletePage = () => {
guardService
.can('Doc_Delete', page.id)
.then(can => {
if (can) {
permanentlyDeletePage(page.id);
toast(t['com.affine.toastMessage.permanentlyDeleted']());
} else {
toast(t['com.affine.no-permission']());
}
})
.catch(e => {
console.error(e);
});
};
return (
<TrashOperationCell
onPermanentlyDeletePage={onPermanentlyDeletePage}
onRestorePage={onRestorePage}
/>
);
},
[guardService, 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
onSelectionActiveChange={setShowFloatingToolbar}
docCollection={currentWorkspace.docCollection}
operationsRenderer={pageOperationsRenderer}
itemRenderer={pageItemRenderer}
headerRenderer={pageHeaderRenderer}
selectedIds={filteredSelectedPageIds}
onSelectedIdsChange={setSelectedPageIds}
/>
<ListFloatingToolbar
open={showFloatingToolbar}
onDelete={disableMultiDelete ? undefined : onConfirmPermanentlyDelete}
onClose={hideFloatingToolbar}
onRestore={disableMultiRestore ? undefined : 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

@@ -1,14 +1,18 @@
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta'; import { toast } from '@affine/component';
import { VirtualizedTrashList } from '@affine/core/components/page-list'; import {
createDocExplorerContext,
DocExplorerContext,
} from '@affine/core/components/explorer/context';
import { DocsExplorer } from '@affine/core/components/explorer/docs-view/docs-list';
import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper';
import { Header } from '@affine/core/components/pure/header'; import { Header } from '@affine/core/components/pure/header';
import { DocsService } from '@affine/core/modules/doc'; import { CollectionRulesService } from '@affine/core/modules/collection-rules';
import { GlobalContextService } from '@affine/core/modules/global-context'; import { GlobalContextService } from '@affine/core/modules/global-context';
import { WorkspacePermissionService } from '@affine/core/modules/permissions'; import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { DeleteIcon } from '@blocksuite/icons/rc'; import { DeleteIcon } from '@blocksuite/icons/rc';
import { LiveData, useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { useEffect, useMemo } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
useIsActiveView, useIsActiveView,
@@ -35,23 +39,76 @@ const TrashHeader = () => {
}; };
export const TrashPage = () => { export const TrashPage = () => {
const t = useI18n();
const collectionRulesService = useService(CollectionRulesService);
const globalContextService = useService(GlobalContextService); const globalContextService = useService(GlobalContextService);
const currentWorkspace = useService(WorkspaceService).workspace;
const permissionService = useService(WorkspacePermissionService); const permissionService = useService(WorkspacePermissionService);
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
const isOwner = useLiveData(permissionService.permission.isOwner$); const { restoreFromTrash } = useBlockSuiteMetaHelper();
const docCollection = currentWorkspace.docCollection; const isActiveView = useIsActiveView();
const docsService = useService(DocsService);
const allTrashPageIds = useLiveData( const [explorerContextValue] = useState(() =>
LiveData.from(docsService.allTrashDocIds$(), []) createDocExplorerContext({
displayProperties: [
'system:createdAt',
'system:updatedAt',
'system:tags',
],
showMoreOperation: false,
showDragHandle: true,
showDocPreview: false,
quickFavorite: false,
quickDeletePermanently: true,
quickRestore: true,
})
); );
const pageMetas = useBlockSuiteDocMeta(docCollection); const isAdmin = useLiveData(permissionService.permission.isAdmin$);
const filteredPageMetas = useMemo(() => { const isOwner = useLiveData(permissionService.permission.isOwner$);
return pageMetas.filter(page => allTrashPageIds.includes(page.id)); const groups = useLiveData(explorerContextValue.groups$);
}, [pageMetas, allTrashPageIds]); const isEmpty =
groups.length === 0 ||
(groups.length > 0 && groups.every(group => !group.items?.length));
const isActiveView = useIsActiveView(); const handleMultiRestore = useCallback(
(ids: string[]) => {
ids.forEach(id => {
restoreFromTrash(id);
});
toast(
t['com.affine.toastMessage.restored']({
title: ids.length > 1 ? 'docs' : 'doc',
})
);
},
[restoreFromTrash, t]
);
useEffect(() => {
const subscription = collectionRulesService
.watch({
filters: [
{
type: 'system',
key: 'trash',
method: 'is',
value: 'true',
},
],
orderBy: {
type: 'system',
key: 'updatedAt',
desc: true,
},
})
.subscribe(result => {
explorerContextValue.groups$.next(result.groups);
});
return () => {
subscription.unsubscribe();
};
}, [collectionRulesService, explorerContextValue.groups$]);
useEffect(() => { useEffect(() => {
if (isActiveView) { if (isActiveView) {
@@ -64,9 +121,8 @@ export const TrashPage = () => {
return; return;
}, [globalContextService.globalContext.isTrash, isActiveView]); }, [globalContextService.globalContext.isTrash, isActiveView]);
const t = useI18n();
return ( return (
<> <DocExplorerContext.Provider value={explorerContextValue}>
<ViewTitle title={t['Trash']()} /> <ViewTitle title={t['Trash']()} />
<ViewIcon icon={'trash'} /> <ViewIcon icon={'trash'} />
<ViewHeader> <ViewHeader>
@@ -74,17 +130,17 @@ export const TrashPage = () => {
</ViewHeader> </ViewHeader>
<ViewBody> <ViewBody>
<div className={styles.body}> <div className={styles.body}>
{filteredPageMetas.length > 0 ? ( {isEmpty ? (
<VirtualizedTrashList
disableMultiDelete={!isAdmin && !isOwner}
disableMultiRestore={!isAdmin && !isOwner}
/>
) : (
<EmptyPageList type="trash" /> <EmptyPageList type="trash" />
) : (
<DocsExplorer
disableMultiDelete={!isAdmin && !isOwner}
onRestore={isAdmin || isOwner ? handleMultiRestore : undefined}
/>
)} )}
</div> </div>
</ViewBody> </ViewBody>
</> </DocExplorerContext.Provider>
); );
}; };

View File

@@ -7084,6 +7084,14 @@ export function useAFFiNEI18N(): {
* `Select checkbox` * `Select checkbox`
*/ */
["com.affine.all-docs.quick-action.select"](): string; ["com.affine.all-docs.quick-action.select"](): string;
/**
* `Delete permanently`
*/
["com.affine.all-docs.quick-action.delete-permanently"](): string;
/**
* `Restore`
*/
["com.affine.all-docs.quick-action.restore"](): string;
/** /**
* `All` * `All`
*/ */

View File

@@ -1768,6 +1768,8 @@
"com.affine.all-docs.quick-action.split": "Open in split view", "com.affine.all-docs.quick-action.split": "Open in split view",
"com.affine.all-docs.quick-action.tab": "Open in new tab", "com.affine.all-docs.quick-action.tab": "Open in new tab",
"com.affine.all-docs.quick-action.select": "Select checkbox", "com.affine.all-docs.quick-action.select": "Select checkbox",
"com.affine.all-docs.quick-action.delete-permanently": "Delete permanently",
"com.affine.all-docs.quick-action.restore": "Restore",
"com.affine.all-docs.pinned-collection.all": "All", "com.affine.all-docs.pinned-collection.all": "All",
"com.affine.all-docs.group.is-template": "Template", "com.affine.all-docs.group.is-template": "Template",
"com.affine.all-docs.group.is-not-template": "Not Template", "com.affine.all-docs.group.is-not-template": "Not Template",