feat(web): drag page to trash folder (#2385)

Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
Doma 2023-05-30 13:14:10 +08:00 committed by GitHub
parent 61c417992a
commit 4175f5391e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 385 additions and 138 deletions

View File

@ -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';

View File

@ -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)}

View File

@ -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));

View File

@ -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>
);
}

View File

@ -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.

View File

@ -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';

View File

@ -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';

View File

@ -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>
);
}

View File

@ -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';

View File

@ -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;
};

View 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);
});