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:
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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]) => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user