diff --git a/apps/electron/layers/main/src/updater/index.ts b/apps/electron/layers/main/src/updater/index.ts index e60d261df3..85537de0be 100644 --- a/apps/electron/layers/main/src/updater/index.ts +++ b/apps/electron/layers/main/src/updater/index.ts @@ -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'; diff --git a/apps/web/src/components/root-app-sidebar/index.tsx b/apps/web/src/components/root-app-sidebar/index.tsx index 2efc375cc7..b816f79604 100644 --- a/apps/web/src/components/root-app-sidebar/index.tsx +++ b/apps/web/src/components/root-app-sidebar/index.tsx @@ -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) => { - 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 +>(({ currentPath, path, icon, children, isDraggedOver, ...props }, ref) => { + // Force active style when a page is dragged over + const active = isDraggedOver || currentPath === path; return ( - + {children} ); -}; +}); +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 ( <> @@ -182,6 +195,8 @@ export const RootAppSidebar = ({ } currentPath={currentPath} path={currentWorkspaceId && paths.trash(currentWorkspaceId)} diff --git a/apps/web/src/hooks/affine/use-block-suite-meta-helper.ts b/apps/web/src/hooks/affine/use-block-suite-meta-helper.ts index 69568df54a..07b7441e9f 100644 --- a/apps/web/src/hooks/affine/use-block-suite-meta-helper.ts +++ b/apps/web/src/hooks/affine/use-block-suite-meta-helper.ts @@ -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)); diff --git a/apps/web/src/layouts/workspace-layout.tsx b/apps/web/src/layouts/workspace-layout.tsx index 6871eb4f67..1e0996db86 100644 --- a/apps/web/src/layouts/workspace-layout.tsx +++ b/apps/web/src/layouts/workspace-layout.tsx @@ -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 = ({ 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 ( <> {title} - - { - assertExists(currentWorkspace); - return openPage(currentWorkspace.id, pageId); - }, - [currentWorkspace, openPage] - )} - createPage={handleCreatePage} - currentPath={router.asPath.split('?')[0]} - paths={isPublicWorkspace ? publicPathGenerator : pathGenerator} - /> - - {children} - - {/* fixme(himself65): remove this */} -
- {/* Slot for block hub */} -
- {!isPublicWorkspace && ( - + {/* This DndContext is used for drag page from all-pages list into a folder in sidebar */} + + + { + assertExists(currentWorkspace); + return openPage(currentWorkspace.id, pageId); + }, + [currentWorkspace, openPage] )} -
-
-
+ createPage={handleCreatePage} + currentPath={router.asPath.split('?')[0]} + paths={isPublicWorkspace ? publicPathGenerator : pathGenerator} + /> + + {children} + + {/* fixme(himself65): remove this */} +
+ {/* Slot for block hub */} +
+ {!isPublicWorkspace && ( + + )} +
+
+ + + ); }; + +function PageListTitleCellDragOverlay() { + const { active } = useDndContext(); + + const renderChildren = useCallback( + ({ icon, pageTitle }: DraggableTitleCellData) => { + return ( + + {icon} + + {pageTitle} + + + ); + }, + [] + ); + + return ( + + {active + ? renderChildren(active.data.current as DraggableTitleCellData) + : null} + + ); +} diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 29d3e1fee6..322870b803 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -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. diff --git a/packages/component/src/components/app-sidebar/menu-item/index.tsx b/packages/component/src/components/app-sidebar/menu-item/index.tsx index 39ea0aea0f..72b80b52df 100644 --- a/packages/component/src/components/app-sidebar/menu-item/index.tsx +++ b/packages/component/src/components/app-sidebar/menu-item/index.tsx @@ -16,62 +16,76 @@ interface MenuItemProps extends React.HTMLAttributes { interface MenuLinkItemProps extends MenuItemProps, Pick {} -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( + ( + { + 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 ( +
+ {icon && ( +
+ {collapsible && ( +
{ + e.stopPropagation(); + e.preventDefault(); // for links + onCollapsedChange?.(!collapsed); + }} + data-testid="fav-collapsed-button" + className={styles.collapsedIconContainer} + > + +
+ )} + {React.cloneElement(icon, { + className: clsx([styles.icon, icon.props.className]), + })} +
+ )} + +
{children}
+
+ ); } - return ( -
- {icon && ( -
- {collapsible && ( -
{ - e.stopPropagation(); - e.preventDefault(); // for links - onCollapsedChange?.(!collapsed); - }} - data-testid="fav-collapsed-button" - className={styles.collapsedIconContainer} - > - -
- )} - {React.cloneElement(icon, { - className: clsx([styles.icon, icon.props.className]), - })} -
- )} +); +MenuItem.displayName = 'MenuItem'; -
{children}
-
- ); -} - -export function MenuLinkItem({ href, ...props }: MenuLinkItemProps) { - return ( - - - - ); -} +export const MenuLinkItem = React.forwardRef( + ({ href, ...props }, ref) => { + return ( + + {/* The element rendered by Link does not generate display box due to `display: contents` style */} + {/* Thus ref is passed to MenuItem instead of Link */} + + + ); + } +); +MenuLinkItem.displayName = 'MenuLinkItem'; diff --git a/packages/component/src/components/page-list/all-page.tsx b/packages/component/src/components/page-list/all-page.tsx index 33854d8d6b..54b36fd4d0 100644 --- a/packages/component/src/components/page-list/all-page.tsx +++ b/packages/component/src/components/page-list/all-page.tsx @@ -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'; diff --git a/packages/component/src/components/page-list/all-pages-body.tsx b/packages/component/src/components/page-list/all-pages-body.tsx index 83b79fd9eb..6c5cc8a35a 100644 --- a/packages/component/src/components/page-list/all-pages-body.tsx +++ b/packages/component/src/components/page-list/all-pages-body.tsx @@ -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 ( {groupName && @@ -71,9 +73,15 @@ export const AllPagesBody = ({ {groupName} )} - @@ -130,3 +138,41 @@ export const AllPagesBody = ({ ); }; + +const FullSizeButton = styled('button')(() => ({ + width: '100%', + height: '100%', + display: 'block', +})); + +type DraggableTitleCellProps = { + pageId: string; + draggableData?: DraggableTitleCellData; +} & React.ComponentProps; + +function DraggableTitleCell({ + pageId, + draggableData, + ...props +}: DraggableTitleCellProps) { + const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ + id: 'page-list-item-title-' + pageId, + data: draggableData, + }); + + return ( + + {/* Use `button` for draggable element */} + {/* See https://docs.dndkit.com/api-documentation/draggable/usedraggable#role */} + {element => ( + + {element} + + )} + + ); +} diff --git a/packages/component/src/components/page-list/components/title-cell.tsx b/packages/component/src/components/page-list/components/title-cell.tsx index f116fb0302..31b768141e 100644 --- a/packages/component/src/components/page-list/components/title-cell.tsx +++ b/packages/component/src/components/page-list/components/title-cell.tsx @@ -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 ( - - - {icon} - - {text} - - - {suffix} - - ); -}; + /** + * Customize the children of the cell + * @param element + * @returns + */ + children?: (element: React.ReactElement) => React.ReactNode; +} & Omit; + +export const TitleCell = React.forwardRef( + ({ icon, text, suffix, children: render, ...props }, ref) => { + const renderChildren = useCallback(() => { + const childElement = ( + <> + + {icon} + + {text} + + + {suffix} + + ); + + return render ? render(childElement) : childElement; + }, [icon, render, suffix, text]); + + return ( + + {renderChildren()} + + ); + } +); +TitleCell.displayName = 'TitleCell'; diff --git a/packages/component/src/components/page-list/type.ts b/packages/component/src/components/page-list/type.ts index cd3bc81956..e8dc277d46 100644 --- a/packages/component/src/components/page-list/type.ts +++ b/packages/component/src/components/page-list/type.ts @@ -43,3 +43,9 @@ export type PageListProps = { onCreateNewEdgeless: () => void; onImportFile: () => void; }; + +export type DraggableTitleCellData = { + pageId: string; + pageTitle: string; + icon: React.ReactElement; +}; diff --git a/tests/parallels/drag-page-to-trash-folder.spec.ts b/tests/parallels/drag-page-to-trash-folder.spec.ts new file mode 100644 index 0000000000..ae8dd6ba1d --- /dev/null +++ b/tests/parallels/drag-page-to-trash-folder.spec.ts @@ -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); +});