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 { checkForUpdatesAndNotify,quitAndInstall } from "./electron-updater";
import type { NamespaceHandlers } from '../type';
import { checkForUpdatesAndNotify, quitAndInstall } from './electron-updater';
export const updaterHandlers = {
currentVersion: async () => {
@ -15,4 +15,4 @@ export const updaterHandlers = {
},
} satisfies NamespaceHandlers;
export * from "./electron-updater";
export * from './electron-updater';

View File

@ -19,10 +19,10 @@ import {
ShareIcon,
} from '@blocksuite/icons';
import type { Page } from '@blocksuite/store';
import { useDroppable } from '@dnd-kit/core';
import { useAtom } from 'jotai';
import type { ReactElement } from 'react';
import type React from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import { useHistoryAtom } from '../../atoms/history';
import type { AllWorkspace } from '../../shared';
@ -45,25 +45,34 @@ export type RootAppSidebarProps = {
};
};
const RouteMenuLinkItem = ({
currentPath,
path,
icon,
children,
...props
}: {
currentPath: string; // todo: pass through useRouter?
path?: string | null;
icon: ReactElement;
children?: ReactElement;
} & React.HTMLAttributes<HTMLDivElement>) => {
const active = currentPath === path;
const RouteMenuLinkItem = React.forwardRef<
HTMLDivElement,
{
currentPath: string; // todo: pass through useRouter?
path?: string | null;
icon: ReactElement;
children?: ReactElement;
isDraggedOver?: boolean;
} & 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 (
<MenuLinkItem {...props} active={active} href={path ?? ''} icon={icon}>
<MenuLinkItem
ref={ref}
{...props}
active={active}
href={path ?? ''}
icon={icon}
>
{children}
</MenuLinkItem>
);
};
});
RouteMenuLinkItem.displayName = 'RouteMenuLinkItem';
// Unique droppable IDs
export const DROPPABLE_SIDEBAR_TRASH = 'trash-folder';
/**
* This is for the whole affine app sidebar.
@ -126,6 +135,10 @@ export const RootAppSidebar = ({
};
}, [history, setHistory]);
const trashDroppable = useDroppable({
id: DROPPABLE_SIDEBAR_TRASH,
});
return (
<>
<AppSidebar router={router}>
@ -182,6 +195,8 @@ export const RootAppSidebar = ({
<CategoryDivider label={t['others']()} />
<RouteMenuLinkItem
ref={trashDroppable.setNodeRef}
isDraggedOver={trashDroppable.isOver}
icon={<DeleteTemporarilyIcon />}
currentPath={currentPath}
path={currentWorkspaceId && paths.trash(currentWorkspaceId)}

View File

@ -41,6 +41,8 @@ export function useBlockSuiteMetaHelper(
[getPageMeta, setPageMeta]
);
// TODO-Doma
// "Remove" may cause ambiguity here. Consider renaming as "moveToTrash".
const removeToTrash = useCallback(
(pageId: string, isRoot = true) => {
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 type { DraggableTitleCellData } from '@affine/component/page-list';
import { StyledTitleLink } from '@affine/component/page-list';
import {
AppContainer,
MainContainer,
@ -9,6 +12,7 @@ import { DebugLogger } from '@affine/debug';
import { DEFAULT_HELLO_WORLD_PAGE_ID } from '@affine/env';
import { initPage } from '@affine/env/blocksuite';
import { setUpLanguage, useI18N } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { createAffineGlobalChannel } from '@affine/workspace/affine/sync';
import {
rootCurrentPageIdAtom,
@ -19,6 +23,16 @@ import {
import type { BackgroundProvider } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
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 { useAtom, useAtomValue, useSetAtom } from 'jotai';
import Head from 'next/head';
@ -34,13 +48,18 @@ import {
publicWorkspaceIdAtom,
} from '../atoms/public-workspace';
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 { useRouterHelper } from '../hooks/use-router-helper';
import { useRouterTitle } from '../hooks/use-router-title';
import { useWorkspaces } from '../hooks/use-workspaces';
import { ModalProvider } from '../providers/modal-provider';
import { pathGenerator, publicPathGenerator } from '../shared';
import { toast } from '../utils';
const QuickSearchModal = lazy(() =>
import('../components/pure/quick-search-modal').then(module => ({
@ -350,46 +369,123 @@ export const WorkspaceLayoutInner: FC<PropsWithChildren> = ({ children }) => {
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 (
<>
<Head>
<title>{title}</title>
</Head>
<AppContainer resizing={resizing}>
<RootAppSidebar
isPublicWorkspace={isPublicWorkspace}
onOpenQuickSearchModal={handleOpenQuickSearchModal}
currentWorkspace={currentWorkspace}
onOpenWorkspaceListModal={handleOpenWorkspaceListModal}
openPage={useCallback(
(pageId: string) => {
assertExists(currentWorkspace);
return openPage(currentWorkspace.id, pageId);
},
[currentWorkspace, openPage]
)}
createPage={handleCreatePage}
currentPath={router.asPath.split('?')[0]}
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']
}
/>
{/* 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}>
<RootAppSidebar
isPublicWorkspace={isPublicWorkspace}
onOpenQuickSearchModal={handleOpenQuickSearchModal}
currentWorkspace={currentWorkspace}
onOpenWorkspaceListModal={handleOpenWorkspaceListModal}
openPage={useCallback(
(pageId: string) => {
assertExists(currentWorkspace);
return openPage(currentWorkspace.id, pageId);
},
[currentWorkspace, openPage]
)}
</ToolContainer>
</MainContainer>
</AppContainer>
createPage={handleCreatePage}
currentPath={router.asPath.split('?')[0]}
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 />
</>
);
};
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.
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`. Then there are multi commands to choose from:
To test locally, please make sure browser binaries are already installed via `npx playwright install`.
Also make sure you have built the `@affine/web` workspace before running E2E tests.
```sh
yarn build
# run tests in headless mode in another terminal window
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'> {}
export function MenuItem({
onClick,
icon,
active,
children,
disabled,
collapsed,
onCollapsedChange,
...props
}: MenuItemProps) {
const collapsible = collapsed !== undefined;
if (collapsible && !onCollapsedChange) {
throw new Error('onCollapsedChange is required when collapsed is defined');
export const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
(
{
onClick,
icon,
active,
children,
disabled,
collapsed,
onCollapsedChange,
...props
},
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
{...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>
)}
);
MenuItem.displayName = 'MenuItem';
<div className={styles.content}>{children}</div>
</div>
);
}
export function MenuLinkItem({ href, ...props }: MenuLinkItemProps) {
return (
<Link href={href} className={styles.linkItemRoot}>
<MenuItem {...props}></MenuItem>
</Link>
);
}
export const MenuLinkItem = React.forwardRef<HTMLDivElement, MenuLinkItemProps>(
({ 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 */}
{/* Thus ref is passed to MenuItem instead of Link */}
<MenuItem ref={ref} {...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 { ArrowDownBigIcon, ArrowUpBigIcon } from '@blocksuite/icons';
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 { 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 { useDraggable } from '@dnd-kit/core';
import { useMediaQuery, useTheme } from '@mui/material';
import type { ReactNode } from 'react';
import { Fragment } from 'react';
@ -8,7 +9,7 @@ import { FavoriteTag } from './components/favorite-tag';
import { TitleCell } from './components/title-cell';
import { OperationCell } from './operation-cell';
import { StyledTableRow } from './styles';
import type { DateKey, ListData } from './type';
import type { DateKey, DraggableTitleCellData, ListData } from './type';
import { useDateGroup } from './use-date-group';
import { formatDate } from './utils';
@ -63,6 +64,7 @@ export const AllPagesBody = ({
},
index
) => {
const displayTitle = title || t['Untitled']();
return (
<Fragment key={pageId}>
{groupName &&
@ -71,9 +73,15 @@ export const AllPagesBody = ({
<GroupRow>{groupName}</GroupRow>
)}
<StyledTableRow data-testid={`page-list-item-${pageId}`}>
<TitleCell
<DraggableTitleCell
pageId={pageId}
draggableData={{
pageId,
pageTitle: displayTitle,
icon,
}}
icon={icon}
text={title || t['Untitled']()}
text={displayTitle}
data-testid="title"
onClick={onClickPage}
/>
@ -130,3 +138,41 @@ export const AllPagesBody = ({
</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 { Content, TableCell } from '@affine/component';
import React, { useCallback } from 'react';
import { StyledTitleLink } from '../styles';
export const TitleCell = ({
icon,
text,
suffix,
...props
}: {
type TitleCellProps = {
icon: JSX.Element;
text: string;
suffix?: JSX.Element;
} & TableCellProps) => {
return (
<TableCell {...props}>
<StyledTitleLink>
{icon}
<Content ellipsis={true} color="inherit">
{text}
</Content>
</StyledTitleLink>
{suffix}
</TableCell>
);
};
/**
* Customize the children of the cell
* @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>
{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;
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);
});