mirror of
https://github.com/toeverything/AFFiNE.git
synced 2025-01-09 02:28:59 +03:00
feat(web): drag page to trash folder (#2385)
Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
parent
61c417992a
commit
4175f5391e
@ -1,7 +1,7 @@
|
|||||||
import { app } from "electron";
|
import { app } from 'electron';
|
||||||
|
|
||||||
import type { NamespaceHandlers } from "../type";
|
import type { NamespaceHandlers } from '../type';
|
||||||
import { checkForUpdatesAndNotify,quitAndInstall } from "./electron-updater";
|
import { checkForUpdatesAndNotify, quitAndInstall } from './electron-updater';
|
||||||
|
|
||||||
export const updaterHandlers = {
|
export const updaterHandlers = {
|
||||||
currentVersion: async () => {
|
currentVersion: async () => {
|
||||||
@ -15,4 +15,4 @@ export const updaterHandlers = {
|
|||||||
},
|
},
|
||||||
} satisfies NamespaceHandlers;
|
} satisfies NamespaceHandlers;
|
||||||
|
|
||||||
export * from "./electron-updater";
|
export * from './electron-updater';
|
||||||
|
@ -19,10 +19,10 @@ import {
|
|||||||
ShareIcon,
|
ShareIcon,
|
||||||
} from '@blocksuite/icons';
|
} from '@blocksuite/icons';
|
||||||
import type { Page } from '@blocksuite/store';
|
import type { Page } from '@blocksuite/store';
|
||||||
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import type React from 'react';
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useCallback, useEffect, useMemo } from 'react';
|
|
||||||
|
|
||||||
import { useHistoryAtom } from '../../atoms/history';
|
import { useHistoryAtom } from '../../atoms/history';
|
||||||
import type { AllWorkspace } from '../../shared';
|
import type { AllWorkspace } from '../../shared';
|
||||||
@ -45,25 +45,34 @@ export type RootAppSidebarProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const RouteMenuLinkItem = ({
|
const RouteMenuLinkItem = React.forwardRef<
|
||||||
currentPath,
|
HTMLDivElement,
|
||||||
path,
|
{
|
||||||
icon,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
currentPath: string; // todo: pass through useRouter?
|
currentPath: string; // todo: pass through useRouter?
|
||||||
path?: string | null;
|
path?: string | null;
|
||||||
icon: ReactElement;
|
icon: ReactElement;
|
||||||
children?: ReactElement;
|
children?: ReactElement;
|
||||||
} & React.HTMLAttributes<HTMLDivElement>) => {
|
isDraggedOver?: boolean;
|
||||||
const active = currentPath === path;
|
} & React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ currentPath, path, icon, children, isDraggedOver, ...props }, ref) => {
|
||||||
|
// Force active style when a page is dragged over
|
||||||
|
const active = isDraggedOver || currentPath === path;
|
||||||
return (
|
return (
|
||||||
<MenuLinkItem {...props} active={active} href={path ?? ''} icon={icon}>
|
<MenuLinkItem
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
active={active}
|
||||||
|
href={path ?? ''}
|
||||||
|
icon={icon}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</MenuLinkItem>
|
</MenuLinkItem>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
RouteMenuLinkItem.displayName = 'RouteMenuLinkItem';
|
||||||
|
|
||||||
|
// Unique droppable IDs
|
||||||
|
export const DROPPABLE_SIDEBAR_TRASH = 'trash-folder';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is for the whole affine app sidebar.
|
* This is for the whole affine app sidebar.
|
||||||
@ -126,6 +135,10 @@ export const RootAppSidebar = ({
|
|||||||
};
|
};
|
||||||
}, [history, setHistory]);
|
}, [history, setHistory]);
|
||||||
|
|
||||||
|
const trashDroppable = useDroppable({
|
||||||
|
id: DROPPABLE_SIDEBAR_TRASH,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppSidebar router={router}>
|
<AppSidebar router={router}>
|
||||||
@ -182,6 +195,8 @@ export const RootAppSidebar = ({
|
|||||||
|
|
||||||
<CategoryDivider label={t['others']()} />
|
<CategoryDivider label={t['others']()} />
|
||||||
<RouteMenuLinkItem
|
<RouteMenuLinkItem
|
||||||
|
ref={trashDroppable.setNodeRef}
|
||||||
|
isDraggedOver={trashDroppable.isOver}
|
||||||
icon={<DeleteTemporarilyIcon />}
|
icon={<DeleteTemporarilyIcon />}
|
||||||
currentPath={currentPath}
|
currentPath={currentPath}
|
||||||
path={currentWorkspaceId && paths.trash(currentWorkspaceId)}
|
path={currentWorkspaceId && paths.trash(currentWorkspaceId)}
|
||||||
|
@ -41,6 +41,8 @@ export function useBlockSuiteMetaHelper(
|
|||||||
[getPageMeta, setPageMeta]
|
[getPageMeta, setPageMeta]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TODO-Doma
|
||||||
|
// "Remove" may cause ambiguity here. Consider renaming as "moveToTrash".
|
||||||
const removeToTrash = useCallback(
|
const removeToTrash = useCallback(
|
||||||
(pageId: string, isRoot = true) => {
|
(pageId: string, isRoot = true) => {
|
||||||
const parentMeta = metas.find(m => m.subpageIds?.includes(pageId));
|
const parentMeta = metas.find(m => m.subpageIds?.includes(pageId));
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
|
import { Content, displayFlex } from '@affine/component';
|
||||||
import { appSidebarResizingAtom } from '@affine/component/app-sidebar';
|
import { appSidebarResizingAtom } from '@affine/component/app-sidebar';
|
||||||
|
import type { DraggableTitleCellData } from '@affine/component/page-list';
|
||||||
|
import { StyledTitleLink } from '@affine/component/page-list';
|
||||||
import {
|
import {
|
||||||
AppContainer,
|
AppContainer,
|
||||||
MainContainer,
|
MainContainer,
|
||||||
@ -9,6 +12,7 @@ import { DebugLogger } from '@affine/debug';
|
|||||||
import { DEFAULT_HELLO_WORLD_PAGE_ID } from '@affine/env';
|
import { DEFAULT_HELLO_WORLD_PAGE_ID } from '@affine/env';
|
||||||
import { initPage } from '@affine/env/blocksuite';
|
import { initPage } from '@affine/env/blocksuite';
|
||||||
import { setUpLanguage, useI18N } from '@affine/i18n';
|
import { setUpLanguage, useI18N } from '@affine/i18n';
|
||||||
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { createAffineGlobalChannel } from '@affine/workspace/affine/sync';
|
import { createAffineGlobalChannel } from '@affine/workspace/affine/sync';
|
||||||
import {
|
import {
|
||||||
rootCurrentPageIdAtom,
|
rootCurrentPageIdAtom,
|
||||||
@ -19,6 +23,16 @@ import {
|
|||||||
import type { BackgroundProvider } from '@affine/workspace/type';
|
import type { BackgroundProvider } from '@affine/workspace/type';
|
||||||
import { WorkspaceFlavour } from '@affine/workspace/type';
|
import { WorkspaceFlavour } from '@affine/workspace/type';
|
||||||
import { assertEquals, assertExists, nanoid } from '@blocksuite/store';
|
import { assertEquals, assertExists, nanoid } from '@blocksuite/store';
|
||||||
|
import type { DragEndEvent } from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
MouseSensor,
|
||||||
|
pointerWithin,
|
||||||
|
useDndContext,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
|
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
|
||||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
@ -34,13 +48,18 @@ import {
|
|||||||
publicWorkspaceIdAtom,
|
publicWorkspaceIdAtom,
|
||||||
} from '../atoms/public-workspace';
|
} from '../atoms/public-workspace';
|
||||||
import { HelpIsland } from '../components/pure/help-island';
|
import { HelpIsland } from '../components/pure/help-island';
|
||||||
import { RootAppSidebar } from '../components/root-app-sidebar';
|
import {
|
||||||
|
DROPPABLE_SIDEBAR_TRASH,
|
||||||
|
RootAppSidebar,
|
||||||
|
} from '../components/root-app-sidebar';
|
||||||
|
import { useBlockSuiteMetaHelper } from '../hooks/affine/use-block-suite-meta-helper';
|
||||||
import { useCurrentWorkspace } from '../hooks/current/use-current-workspace';
|
import { useCurrentWorkspace } from '../hooks/current/use-current-workspace';
|
||||||
import { useRouterHelper } from '../hooks/use-router-helper';
|
import { useRouterHelper } from '../hooks/use-router-helper';
|
||||||
import { useRouterTitle } from '../hooks/use-router-title';
|
import { useRouterTitle } from '../hooks/use-router-title';
|
||||||
import { useWorkspaces } from '../hooks/use-workspaces';
|
import { useWorkspaces } from '../hooks/use-workspaces';
|
||||||
import { ModalProvider } from '../providers/modal-provider';
|
import { ModalProvider } from '../providers/modal-provider';
|
||||||
import { pathGenerator, publicPathGenerator } from '../shared';
|
import { pathGenerator, publicPathGenerator } from '../shared';
|
||||||
|
import { toast } from '../utils';
|
||||||
|
|
||||||
const QuickSearchModal = lazy(() =>
|
const QuickSearchModal = lazy(() =>
|
||||||
import('../components/pure/quick-search-modal').then(module => ({
|
import('../components/pure/quick-search-modal').then(module => ({
|
||||||
@ -350,11 +369,50 @@ export const WorkspaceLayoutInner: FC<PropsWithChildren> = ({ children }) => {
|
|||||||
|
|
||||||
const resizing = useAtomValue(appSidebarResizingAtom);
|
const resizing = useAtomValue(appSidebarResizingAtom);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
// Delay 10ms after mousedown
|
||||||
|
// Otherwise clicks would be intercepted
|
||||||
|
useSensor(MouseSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 10,
|
||||||
|
tolerance: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { removeToTrash: moveToTrash } = useBlockSuiteMetaHelper(
|
||||||
|
currentWorkspace.blockSuiteWorkspace
|
||||||
|
);
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(e: DragEndEvent) => {
|
||||||
|
// Drag page into trash folder
|
||||||
|
if (
|
||||||
|
e.over?.id === DROPPABLE_SIDEBAR_TRASH &&
|
||||||
|
String(e.active.id).startsWith('page-list-item-')
|
||||||
|
) {
|
||||||
|
const { pageId } = e.active.data.current as DraggableTitleCellData;
|
||||||
|
// TODO-Doma
|
||||||
|
// Co-locate `moveToTrash` with the toast for reuse, as they're always used together
|
||||||
|
moveToTrash(pageId);
|
||||||
|
toast(t['Successfully deleted']());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[moveToTrash, t]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
{/* This DndContext is used for drag page from all-pages list into a folder in sidebar */}
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={pointerWithin}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
<AppContainer resizing={resizing}>
|
<AppContainer resizing={resizing}>
|
||||||
<RootAppSidebar
|
<RootAppSidebar
|
||||||
isPublicWorkspace={isPublicWorkspace}
|
isPublicWorkspace={isPublicWorkspace}
|
||||||
@ -389,7 +447,45 @@ export const WorkspaceLayoutInner: FC<PropsWithChildren> = ({ children }) => {
|
|||||||
</ToolContainer>
|
</ToolContainer>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
|
<PageListTitleCellDragOverlay />
|
||||||
|
</DndContext>
|
||||||
<QuickSearch />
|
<QuickSearch />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function PageListTitleCellDragOverlay() {
|
||||||
|
const { active } = useDndContext();
|
||||||
|
|
||||||
|
const renderChildren = useCallback(
|
||||||
|
({ icon, pageTitle }: DraggableTitleCellData) => {
|
||||||
|
return (
|
||||||
|
<StyledTitleLink>
|
||||||
|
{icon}
|
||||||
|
<Content ellipsis={true} color="inherit">
|
||||||
|
{pageTitle}
|
||||||
|
</Content>
|
||||||
|
</StyledTitleLink>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragOverlay
|
||||||
|
style={{
|
||||||
|
zIndex: 1001,
|
||||||
|
backgroundColor: 'var(--affine-black-10)',
|
||||||
|
padding: '0 30px',
|
||||||
|
cursor: 'default',
|
||||||
|
borderRadius: 10,
|
||||||
|
...displayFlex('flex-start', 'center'),
|
||||||
|
}}
|
||||||
|
dropAnimation={null}
|
||||||
|
>
|
||||||
|
{active
|
||||||
|
? renderChildren(active.data.current as DraggableTitleCellData)
|
||||||
|
: null}
|
||||||
|
</DragOverlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -80,10 +80,17 @@ For more details, see [apps/web/README.md](../apps/web/README.md)
|
|||||||
Adding test cases is strongly encouraged when you contribute new features and bug fixes.
|
Adding test cases is strongly encouraged when you contribute new features and bug fixes.
|
||||||
|
|
||||||
We use [Playwright](https://playwright.dev/) for E2E test, and [vitest](https://vitest.dev/) for unit test.
|
We use [Playwright](https://playwright.dev/) for E2E test, and [vitest](https://vitest.dev/) for unit test.
|
||||||
|
To test locally, please make sure browser binaries are already installed via `npx playwright install`.
|
||||||
To test locally, please make sure browser binaries are already installed via `npx playwright install`. Then there are multi commands to choose from:
|
Also make sure you have built the `@affine/web` workspace before running E2E tests.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
yarn build
|
||||||
# run tests in headless mode in another terminal window
|
# run tests in headless mode in another terminal window
|
||||||
yarn test
|
yarn test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
> I ran `yarn start -p 8080` after `yarn build` but the index page returned 404.
|
||||||
|
|
||||||
|
Try stopping your development server (initialized by `yarn dev:local` or something) and running `yarn build` again.
|
||||||
|
@ -16,7 +16,9 @@ interface MenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
|
|
||||||
interface MenuLinkItemProps extends MenuItemProps, Pick<LinkProps, 'href'> {}
|
interface MenuLinkItemProps extends MenuItemProps, Pick<LinkProps, 'href'> {}
|
||||||
|
|
||||||
export function MenuItem({
|
export const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
onClick,
|
onClick,
|
||||||
icon,
|
icon,
|
||||||
active,
|
active,
|
||||||
@ -25,13 +27,18 @@ export function MenuItem({
|
|||||||
collapsed,
|
collapsed,
|
||||||
onCollapsedChange,
|
onCollapsedChange,
|
||||||
...props
|
...props
|
||||||
}: MenuItemProps) {
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
const collapsible = collapsed !== undefined;
|
const collapsible = collapsed !== undefined;
|
||||||
if (collapsible && !onCollapsedChange) {
|
if (collapsible && !onCollapsedChange) {
|
||||||
throw new Error('onCollapsedChange is required when collapsed is defined');
|
throw new Error(
|
||||||
|
'onCollapsedChange is required when collapsed is defined'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
className={clsx([styles.root, props.className])}
|
className={clsx([styles.root, props.className])}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@ -66,12 +73,19 @@ export function MenuItem({
|
|||||||
<div className={styles.content}>{children}</div>
|
<div className={styles.content}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
MenuItem.displayName = 'MenuItem';
|
||||||
|
|
||||||
export function MenuLinkItem({ href, ...props }: MenuLinkItemProps) {
|
export const MenuLinkItem = React.forwardRef<HTMLDivElement, MenuLinkItemProps>(
|
||||||
|
({ href, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<Link href={href} className={styles.linkItemRoot}>
|
<Link href={href} className={styles.linkItemRoot}>
|
||||||
<MenuItem {...props}></MenuItem>
|
{/* The <a> element rendered by Link does not generate display box due to `display: contents` style */}
|
||||||
|
{/* Thus ref is passed to MenuItem instead of Link */}
|
||||||
|
<MenuItem ref={ref} {...props}></MenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
MenuLinkItem.displayName = 'MenuLinkItem';
|
||||||
|
@ -9,7 +9,8 @@ import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
|||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { ArrowDownBigIcon, ArrowUpBigIcon } from '@blocksuite/icons';
|
import { ArrowDownBigIcon, ArrowUpBigIcon } from '@blocksuite/icons';
|
||||||
import { useMediaQuery, useTheme } from '@mui/material';
|
import { useMediaQuery, useTheme } from '@mui/material';
|
||||||
import type { CSSProperties } from 'react';
|
import type React from 'react';
|
||||||
|
import { type CSSProperties } from 'react';
|
||||||
|
|
||||||
import { AllPagesBody } from './all-pages-body';
|
import { AllPagesBody } from './all-pages-body';
|
||||||
import { NewPageButton } from './components/new-page-buttton';
|
import { NewPageButton } from './components/new-page-buttton';
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { TableBody, TableCell } from '@affine/component';
|
import { styled, TableBody, TableCell } from '@affine/component';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import { useDraggable } from '@dnd-kit/core';
|
||||||
import { useMediaQuery, useTheme } from '@mui/material';
|
import { useMediaQuery, useTheme } from '@mui/material';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
@ -8,7 +9,7 @@ import { FavoriteTag } from './components/favorite-tag';
|
|||||||
import { TitleCell } from './components/title-cell';
|
import { TitleCell } from './components/title-cell';
|
||||||
import { OperationCell } from './operation-cell';
|
import { OperationCell } from './operation-cell';
|
||||||
import { StyledTableRow } from './styles';
|
import { StyledTableRow } from './styles';
|
||||||
import type { DateKey, ListData } from './type';
|
import type { DateKey, DraggableTitleCellData, ListData } from './type';
|
||||||
import { useDateGroup } from './use-date-group';
|
import { useDateGroup } from './use-date-group';
|
||||||
import { formatDate } from './utils';
|
import { formatDate } from './utils';
|
||||||
|
|
||||||
@ -63,6 +64,7 @@ export const AllPagesBody = ({
|
|||||||
},
|
},
|
||||||
index
|
index
|
||||||
) => {
|
) => {
|
||||||
|
const displayTitle = title || t['Untitled']();
|
||||||
return (
|
return (
|
||||||
<Fragment key={pageId}>
|
<Fragment key={pageId}>
|
||||||
{groupName &&
|
{groupName &&
|
||||||
@ -71,9 +73,15 @@ export const AllPagesBody = ({
|
|||||||
<GroupRow>{groupName}</GroupRow>
|
<GroupRow>{groupName}</GroupRow>
|
||||||
)}
|
)}
|
||||||
<StyledTableRow data-testid={`page-list-item-${pageId}`}>
|
<StyledTableRow data-testid={`page-list-item-${pageId}`}>
|
||||||
<TitleCell
|
<DraggableTitleCell
|
||||||
|
pageId={pageId}
|
||||||
|
draggableData={{
|
||||||
|
pageId,
|
||||||
|
pageTitle: displayTitle,
|
||||||
|
icon,
|
||||||
|
}}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
text={title || t['Untitled']()}
|
text={displayTitle}
|
||||||
data-testid="title"
|
data-testid="title"
|
||||||
onClick={onClickPage}
|
onClick={onClickPage}
|
||||||
/>
|
/>
|
||||||
@ -130,3 +138,41 @@ export const AllPagesBody = ({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FullSizeButton = styled('button')(() => ({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'block',
|
||||||
|
}));
|
||||||
|
|
||||||
|
type DraggableTitleCellProps = {
|
||||||
|
pageId: string;
|
||||||
|
draggableData?: DraggableTitleCellData;
|
||||||
|
} & React.ComponentProps<typeof TitleCell>;
|
||||||
|
|
||||||
|
function DraggableTitleCell({
|
||||||
|
pageId,
|
||||||
|
draggableData,
|
||||||
|
...props
|
||||||
|
}: DraggableTitleCellProps) {
|
||||||
|
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||||
|
id: 'page-list-item-title-' + pageId,
|
||||||
|
data: draggableData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TitleCell
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{ opacity: isDragging ? 0.5 : 1 }}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Use `button` for draggable element */}
|
||||||
|
{/* See https://docs.dndkit.com/api-documentation/draggable/usedraggable#role */}
|
||||||
|
{element => (
|
||||||
|
<FullSizeButton {...listeners} {...attributes}>
|
||||||
|
{element}
|
||||||
|
</FullSizeButton>
|
||||||
|
)}
|
||||||
|
</TitleCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -1,20 +1,26 @@
|
|||||||
import type { TableCellProps } from '@affine/component';
|
import type { TableCellProps } from '@affine/component';
|
||||||
import { Content, TableCell } from '@affine/component';
|
import { Content, TableCell } from '@affine/component';
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
|
||||||
import { StyledTitleLink } from '../styles';
|
import { StyledTitleLink } from '../styles';
|
||||||
|
|
||||||
export const TitleCell = ({
|
type TitleCellProps = {
|
||||||
icon,
|
|
||||||
text,
|
|
||||||
suffix,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
icon: JSX.Element;
|
icon: JSX.Element;
|
||||||
text: string;
|
text: string;
|
||||||
suffix?: JSX.Element;
|
suffix?: JSX.Element;
|
||||||
} & TableCellProps) => {
|
/**
|
||||||
return (
|
* Customize the children of the cell
|
||||||
<TableCell {...props}>
|
* @param element
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
children?: (element: React.ReactElement) => React.ReactNode;
|
||||||
|
} & Omit<TableCellProps, 'children'>;
|
||||||
|
|
||||||
|
export const TitleCell = React.forwardRef<HTMLTableCellElement, TitleCellProps>(
|
||||||
|
({ icon, text, suffix, children: render, ...props }, ref) => {
|
||||||
|
const renderChildren = useCallback(() => {
|
||||||
|
const childElement = (
|
||||||
|
<>
|
||||||
<StyledTitleLink>
|
<StyledTitleLink>
|
||||||
{icon}
|
{icon}
|
||||||
<Content ellipsis={true} color="inherit">
|
<Content ellipsis={true} color="inherit">
|
||||||
@ -22,6 +28,17 @@ export const TitleCell = ({
|
|||||||
</Content>
|
</Content>
|
||||||
</StyledTitleLink>
|
</StyledTitleLink>
|
||||||
{suffix}
|
{suffix}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return render ? render(childElement) : childElement;
|
||||||
|
}, [icon, render, suffix, text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableCell ref={ref} {...props}>
|
||||||
|
{renderChildren()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
TitleCell.displayName = 'TitleCell';
|
||||||
|
@ -43,3 +43,9 @@ export type PageListProps = {
|
|||||||
onCreateNewEdgeless: () => void;
|
onCreateNewEdgeless: () => void;
|
||||||
onImportFile: () => void;
|
onImportFile: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DraggableTitleCellData = {
|
||||||
|
pageId: string;
|
||||||
|
pageTitle: string;
|
||||||
|
icon: React.ReactElement;
|
||||||
|
};
|
||||||
|
43
tests/parallels/drag-page-to-trash-folder.spec.ts
Normal file
43
tests/parallels/drag-page-to-trash-folder.spec.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { test } from '@affine-test/kit/playwright';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
import { openHomePage } from '../libs/load-page';
|
||||||
|
import { waitMarkdownImported } from '../libs/page-logic';
|
||||||
|
|
||||||
|
test('drag a page from "All pages" list onto the "Trash" folder in the sidebar to move it to trash list', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// TODO-Doma
|
||||||
|
// Init test db with known workspaces and open "All Pages" page via url directly
|
||||||
|
{
|
||||||
|
await openHomePage(page);
|
||||||
|
await waitMarkdownImported(page);
|
||||||
|
await page.getByText('All Pages').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag-and-drop
|
||||||
|
// Ref: https://playwright.dev/docs/input#dragging-manually
|
||||||
|
await page.getByText('AFFiNE - not just a note taking app').hover();
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.waitForTimeout(10);
|
||||||
|
await page.getByText('Trash').hover();
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText('Successfully deleted'),
|
||||||
|
'A toast containing success message is shown'
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText('AFFiNE - not just a note taking app'),
|
||||||
|
'The deleted post is no longer on the All Page list'
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
// TODO-Doma
|
||||||
|
// Visit trash page via url
|
||||||
|
await page.getByText('Trash', { exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByText('AFFiNE - not just a note taking app'),
|
||||||
|
'The deleted post exists in the Trash list'
|
||||||
|
).toHaveCount(1);
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user