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 Himself65
parent f7487ad037
commit 2ed8d63d8a
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, currentPath: string; // todo: pass through useRouter?
children, path?: string | null;
...props icon: ReactElement;
}: { children?: ReactElement;
currentPath: string; // todo: pass through useRouter? isDraggedOver?: boolean;
path?: string | null; } & React.HTMLAttributes<HTMLDivElement>
icon: ReactElement; >(({ currentPath, path, icon, children, isDraggedOver, ...props }, ref) => {
children?: ReactElement; // Force active style when a page is dragged over
} & React.HTMLAttributes<HTMLDivElement>) => { const active = isDraggedOver || currentPath === path;
const active = 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,46 +369,123 @@ 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>
<AppContainer resizing={resizing}> {/* This DndContext is used for drag page from all-pages list into a folder in sidebar */}
<RootAppSidebar <DndContext
isPublicWorkspace={isPublicWorkspace} sensors={sensors}
onOpenQuickSearchModal={handleOpenQuickSearchModal} collisionDetection={pointerWithin}
currentWorkspace={currentWorkspace} onDragEnd={handleDragEnd}
onOpenWorkspaceListModal={handleOpenWorkspaceListModal} >
openPage={useCallback( <AppContainer resizing={resizing}>
(pageId: string) => { <RootAppSidebar
assertExists(currentWorkspace); isPublicWorkspace={isPublicWorkspace}
return openPage(currentWorkspace.id, pageId); onOpenQuickSearchModal={handleOpenQuickSearchModal}
}, currentWorkspace={currentWorkspace}
[currentWorkspace, openPage] onOpenWorkspaceListModal={handleOpenWorkspaceListModal}
)} openPage={useCallback(
createPage={handleCreatePage} (pageId: string) => {
currentPath={router.asPath.split('?')[0]} assertExists(currentWorkspace);
paths={isPublicWorkspace ? publicPathGenerator : pathGenerator} return openPage(currentWorkspace.id, pageId);
/> },
<MainContainer> [currentWorkspace, openPage]
{children}
<ToolContainer>
{/* fixme(himself65): remove this */}
<div id="toolWrapper" style={{ marginBottom: '12px' }}>
{/* Slot for block hub */}
</div>
{!isPublicWorkspace && (
<HelpIsland
showList={
router.query.pageId ? undefined : ['whatNew', 'contact']
}
/>
)} )}
</ToolContainer> createPage={handleCreatePage}
</MainContainer> currentPath={router.asPath.split('?')[0]}
</AppContainer> paths={isPublicWorkspace ? publicPathGenerator : pathGenerator}
/>
<MainContainer>
{children}
<ToolContainer>
{/* fixme(himself65): remove this */}
<div id="toolWrapper" style={{ marginBottom: '12px' }}>
{/* Slot for block hub */}
</div>
{!isPublicWorkspace && (
<HelpIsland
showList={
router.query.pageId ? undefined : ['whatNew', 'contact']
}
/>
)}
</ToolContainer>
</MainContainer>
</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,62 +16,76 @@ 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, (
icon, {
active, onClick,
children, icon,
disabled, active,
collapsed, children,
onCollapsedChange, disabled,
...props collapsed,
}: MenuItemProps) { onCollapsedChange,
const collapsible = collapsed !== undefined; ...props
if (collapsible && !onCollapsedChange) { },
throw new Error('onCollapsedChange is required when collapsed is defined'); ref
) => {
const collapsible = collapsed !== undefined;
if (collapsible && !onCollapsedChange) {
throw new Error(
'onCollapsedChange is required when collapsed is defined'
);
}
return (
<div
ref={ref}
{...props}
className={clsx([styles.root, props.className])}
onClick={onClick}
data-active={active}
data-disabled={disabled}
data-collapsible={collapsible}
>
{icon && (
<div className={styles.iconsContainer} data-collapsible={collapsible}>
{collapsible && (
<div
onClick={e => {
e.stopPropagation();
e.preventDefault(); // for links
onCollapsedChange?.(!collapsed);
}}
data-testid="fav-collapsed-button"
className={styles.collapsedIconContainer}
>
<ArrowDownSmallIcon
className={styles.collapsedIcon}
data-collapsed={collapsed}
/>
</div>
)}
{React.cloneElement(icon, {
className: clsx([styles.icon, icon.props.className]),
})}
</div>
)}
<div className={styles.content}>{children}</div>
</div>
);
} }
return ( );
<div MenuItem.displayName = 'MenuItem';
{...props}
className={clsx([styles.root, props.className])}
onClick={onClick}
data-active={active}
data-disabled={disabled}
data-collapsible={collapsible}
>
{icon && (
<div className={styles.iconsContainer} data-collapsible={collapsible}>
{collapsible && (
<div
onClick={e => {
e.stopPropagation();
e.preventDefault(); // for links
onCollapsedChange?.(!collapsed);
}}
data-testid="fav-collapsed-button"
className={styles.collapsedIconContainer}
>
<ArrowDownSmallIcon
className={styles.collapsedIcon}
data-collapsed={collapsed}
/>
</div>
)}
{React.cloneElement(icon, {
className: clsx([styles.icon, icon.props.className]),
})}
</div>
)}
<div className={styles.content}>{children}</div> export const MenuLinkItem = React.forwardRef<HTMLDivElement, MenuLinkItemProps>(
</div> ({ href, ...props }, ref) => {
); return (
} <Link href={href} className={styles.linkItemRoot}>
{/* The <a> element rendered by Link does not generate display box due to `display: contents` style */}
export function MenuLinkItem({ href, ...props }: MenuLinkItemProps) { {/* Thus ref is passed to MenuItem instead of Link */}
return ( <MenuItem ref={ref} {...props}></MenuItem>
<Link href={href} className={styles.linkItemRoot}> </Link>
<MenuItem {...props}></MenuItem> );
</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,27 +1,44 @@
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
<StyledTitleLink> * @returns
{icon} */
<Content ellipsis={true} color="inherit"> children?: (element: React.ReactElement) => React.ReactNode;
{text} } & Omit<TableCellProps, 'children'>;
</Content>
</StyledTitleLink> export const TitleCell = React.forwardRef<HTMLTableCellElement, TitleCellProps>(
{suffix} ({ icon, text, suffix, children: render, ...props }, ref) => {
</TableCell> const renderChildren = useCallback(() => {
); const childElement = (
}; <>
<StyledTitleLink>
{icon}
<Content ellipsis={true} color="inherit">
{text}
</Content>
</StyledTitleLink>
{suffix}
</>
);
return render ? render(childElement) : childElement;
}, [icon, render, suffix, text]);
return (
<TableCell ref={ref} {...props}>
{renderChildren()}
</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);
});