From 2f7b51d7ff877a58bce8951b8c134e7a783fd11f Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Thu, 18 May 2023 13:18:40 +0800 Subject: [PATCH] feat: fav page references (#2422) Co-authored-by: Himself65 --- .../favorite/favorite-list.tsx | 97 +++++++++++++++---- .../components/app-sidebar/index.stories.tsx | 16 ++- .../app-sidebar/menu-item/index.css.ts | 47 ++++++++- .../app-sidebar/menu-item/index.stories.tsx | 10 ++ .../app-sidebar/menu-item/index.tsx | 37 ++++++- .../quick-search-input/index.css.ts | 2 +- .../src/use-block-suite-page-references.ts | 38 ++++++++ tests/libs/page-logic.ts | 25 +++++ .../local-first-favorites-items.spec.ts | 36 ++++--- 9 files changed, 270 insertions(+), 38 deletions(-) create mode 100644 packages/hooks/src/use-block-suite-page-references.ts diff --git a/apps/web/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx b/apps/web/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx index d58efde4ec..4abc3ecc2c 100644 --- a/apps/web/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx +++ b/apps/web/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx @@ -1,40 +1,103 @@ import { MenuLinkItem } from '@affine/component/app-sidebar'; import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; +import type { PageMeta, Workspace } from '@blocksuite/store'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; +import { useBlockSuitePageReferences } from '@toeverything/hooks/use-block-suite-page-references'; import { useAtomValue } from 'jotai'; import { useRouter } from 'next/router'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { workspacePreferredModeAtom } from '../../../../atoms'; import type { FavoriteListProps } from '../index'; import EmptyItem from './empty-item'; -export const FavoriteList = ({ currentWorkspace }: FavoriteListProps) => { + +interface FavoriteMenuItemProps { + workspace: Workspace; + pageId: string; + metaMapping: Record; + parentIds: Set; +} + +function FavoriteMenuItem({ + workspace, + pageId, + metaMapping, + parentIds, +}: FavoriteMenuItemProps) { const router = useRouter(); const record = useAtomValue(workspacePreferredModeAtom); - const pageMeta = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); - const workspaceId = currentWorkspace.id; + const active = router.query.pageId === pageId; + const icon = record[pageId] === 'edgeless' ? : ; + const references = useBlockSuitePageReferences(workspace, pageId); + const referencesToShow = useMemo(() => { + return [...new Set(references.filter(ref => !parentIds.has(ref)))]; + }, [references, parentIds]); + const [collapsed, setCollapsed] = useState(true); + const collapsible = referencesToShow.length > 0 && parentIds.size === 0; + const showReferences = collapsible ? !collapsed : referencesToShow.length > 0; + const nestedItem = parentIds.size > 0; + return ( + <> + + {metaMapping[pageId]?.title || 'Untitled'} + + {showReferences && + referencesToShow.map(ref => { + return ( + + ); + })} + + ); +} + +export const FavoriteList = ({ currentWorkspace }: FavoriteListProps) => { + const metas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); const favoriteList = useMemo( - () => pageMeta.filter(p => p.favorite && !p.trash), - [pageMeta] + () => metas.filter(p => p.favorite && !p.trash), + [metas] + ); + + const metaMapping = useMemo( + () => + metas.reduce((acc, meta) => { + acc[meta.id] = meta; + return acc; + }, {} as Record), + [metas] ); return ( <> {favoriteList.map((pageMeta, index) => { - const active = router.query.pageId === pageMeta.id; - const icon = - record[pageMeta.id] === 'edgeless' ? : ; return ( - - {pageMeta.title || 'Untitled'} - + metaMapping={metaMapping} + pageId={pageMeta.id} + // memo? + parentIds={new Set()} + workspace={currentWorkspace.blockSuiteWorkspace} + /> ); })} {favoriteList.length === 0 && } diff --git a/packages/component/src/components/app-sidebar/index.stories.tsx b/packages/component/src/components/app-sidebar/index.stories.tsx index 2ba818965e..38ddd2ab08 100644 --- a/packages/component/src/components/app-sidebar/index.stories.tsx +++ b/packages/component/src/components/app-sidebar/index.stories.tsx @@ -6,7 +6,7 @@ import { } from '@blocksuite/icons'; import type { Meta, StoryFn } from '@storybook/react'; import { useAtom } from 'jotai'; -import type { PropsWithChildren } from 'react'; +import { type PropsWithChildren, useState } from 'react'; import { AppSidebar, AppSidebarFallback, appSidebarOpenAtom } from '.'; import { AddPageButton } from './add-page-button'; @@ -79,6 +79,7 @@ export const Fallback = () => { }; export const WithItems: StoryFn = () => { + const [collapsed, setCollapsed] = useState(false); return ( @@ -111,11 +112,22 @@ export const WithItems: StoryFn = () => { } href="/test" onClick={() => alert('opened')} > - Settings + Collapsible Item + + } + href="/test" + onClick={() => alert('opened')} + > + Collapsible Item } diff --git a/packages/component/src/components/app-sidebar/menu-item/index.css.ts b/packages/component/src/components/app-sidebar/menu-item/index.css.ts index 3b6d784f28..7fe396c1bb 100644 --- a/packages/component/src/components/app-sidebar/menu-item/index.css.ts +++ b/packages/component/src/components/app-sidebar/menu-item/index.css.ts @@ -8,7 +8,7 @@ export const root = style({ minHeight: '30px', userSelect: 'none', cursor: 'pointer', - padding: '0 12px', + padding: '0 8px 0 12px', fontSize: 'var(--affine-font-sm)', selectors: { '&:hover': { @@ -27,6 +27,11 @@ export const root = style({ // make this a variable? 'linear-gradient(0deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.04)), rgba(0, 0, 0, 0.04);', }, + '&[data-collapsible="true"]': { + width: 'calc(100% + 8px)', + transform: 'translateX(-8px)', + paddingLeft: '8px', + }, }, }); @@ -37,11 +42,49 @@ export const content = style({ }); export const icon = style({ - marginRight: '14px', color: 'var(--affine-icon-color)', fontSize: '20px', }); +export const collapsedIconContainer = style({ + width: '12px', + height: '12px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '2px', + transition: 'transform 0.2s', + selectors: { + '&[data-collapsed="true"]': { + transform: 'rotate(-90deg)', + }, + '&:hover': { + background: 'var(--affine-hover-color)', + }, + }, +}); + +export const iconsContainer = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + width: '28px', + selectors: { + '&[data-collapsible="true"]': { + width: '40px', + }, + }, +}); + +export const collapsedIcon = style({ + transition: 'transform 0.2s ease-in-out', + selectors: { + '&[data-collapsed="true"]': { + transform: 'rotate(-90deg)', + }, + }, +}); + export const spacer = style({ flex: 1, }); diff --git a/packages/component/src/components/app-sidebar/menu-item/index.stories.tsx b/packages/component/src/components/app-sidebar/menu-item/index.stories.tsx index d6ea793966..e67b65a961 100644 --- a/packages/component/src/components/app-sidebar/menu-item/index.stories.tsx +++ b/packages/component/src/components/app-sidebar/menu-item/index.stories.tsx @@ -1,5 +1,6 @@ import { SettingsIcon } from '@blocksuite/icons'; import type { Meta, StoryFn } from '@storybook/react'; +import { useState } from 'react'; import { MenuItem, MenuLinkItem } from '.'; @@ -9,6 +10,7 @@ export default { } satisfies Meta; export const Default: StoryFn = () => { + const [collapsed, setCollapsed] = useState(false); return (
} onClick={() => alert('opened')}> @@ -29,6 +31,14 @@ export const Default: StoryFn = () => { > Primary Item + } + onClick={() => alert('opened')} + > + Collapsible Item +
); }; 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 600ae138d8..2ca8841d77 100644 --- a/packages/component/src/components/app-sidebar/menu-item/index.tsx +++ b/packages/component/src/components/app-sidebar/menu-item/index.tsx @@ -1,3 +1,4 @@ +import { ArrowDownSmallIcon } from '@blocksuite/icons'; import clsx from 'clsx'; import type { LinkProps } from 'next/link'; import Link from 'next/link'; @@ -9,6 +10,8 @@ interface MenuItemProps extends React.HTMLAttributes { icon?: React.ReactElement; active?: boolean; disabled?: boolean; + collapsed?: boolean; // true, false, undefined. undefined means no collapse + onCollapsedChange?: (collapsed: boolean) => void; } interface MenuLinkItemProps extends MenuItemProps, Pick {} @@ -19,8 +22,14 @@ export function MenuItem({ active, children, disabled, + collapsed, + onCollapsedChange, ...props }: MenuItemProps) { + const collapsible = collapsed !== undefined; + if (collapsible && !onCollapsedChange) { + throw new Error('onCollapsedChange is required when collapsed is defined'); + } return (
- {icon && - React.cloneElement(icon, { - className: clsx([styles.icon, icon.props.className]), - })} +
+ {collapsible && ( +
{ + e.stopPropagation(); + e.preventDefault(); // for links + onCollapsedChange?.(!collapsed); + }} + data-testid="fav-collapsed-button" + className={styles.collapsedIconContainer} + > + +
+ )} + {icon && + React.cloneElement(icon, { + className: clsx([styles.icon, icon.props.className]), + })} +
+
{children}
); diff --git a/packages/component/src/components/app-sidebar/quick-search-input/index.css.ts b/packages/component/src/components/app-sidebar/quick-search-input/index.css.ts index 458738da29..7033cadd89 100644 --- a/packages/component/src/components/app-sidebar/quick-search-input/index.css.ts +++ b/packages/component/src/components/app-sidebar/quick-search-input/index.css.ts @@ -17,7 +17,7 @@ export const root = style({ }); export const icon = style({ - marginRight: '14px', + marginRight: '8px', color: 'var(--affine-icon-color)', fontSize: '20px', }); diff --git a/packages/hooks/src/use-block-suite-page-references.ts b/packages/hooks/src/use-block-suite-page-references.ts new file mode 100644 index 0000000000..de2bdb7b5b --- /dev/null +++ b/packages/hooks/src/use-block-suite-page-references.ts @@ -0,0 +1,38 @@ +import type { Page, Workspace } from '@blocksuite/store'; +import { atom, useAtomValue } from 'jotai'; +import { atomFamily } from 'jotai/utils'; + +import { useBlockSuiteWorkspacePage } from './use-block-suite-workspace-page'; + +function getPageReferences(page: Page): string[] { + // todo: is there a way to use page indexer to get all references? + return page + .getBlockByFlavour('affine:paragraph') + .flatMap(b => b.text?.toDelta()) + .map(v => v?.attributes?.reference?.pageId) + .filter(Boolean); +} + +const pageReferencesAtomFamily = atomFamily((page: Page | null) => { + if (page === null) { + return atom([]); + } + const baseAtom = atom(getPageReferences(page)); + baseAtom.onMount = set => { + const dispose = page.slots.yUpdated.on(() => { + set(getPageReferences(page)); + }); + return () => { + dispose.dispose(); + }; + }; + return baseAtom; +}); + +export function useBlockSuitePageReferences( + blockSuiteWorkspace: Workspace, + pageId: string +): string[] { + const page = useBlockSuiteWorkspacePage(blockSuiteWorkspace, pageId); + return useAtomValue(pageReferencesAtomFamily(page)); +} diff --git a/tests/libs/page-logic.ts b/tests/libs/page-logic.ts index c9dc75e74b..96d59a6b80 100644 --- a/tests/libs/page-logic.ts +++ b/tests/libs/page-logic.ts @@ -1,4 +1,5 @@ import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; export async function waitMarkdownImported(page: Page) { await page.waitForSelector('v-line'); @@ -16,6 +17,30 @@ export function getBlockSuiteEditorTitle(page: Page) { return page.locator('v-line').nth(0); } +export async function type(page: Page, content: string, delay = 50) { + await page.keyboard.type(content, { delay }); +} + +export async function pressEnter(page: Page) { + // avoid flaky test by simulate real user input + await page.keyboard.press('Enter', { delay: 50 }); +} + +export const createLinkedPage = async (page: Page, pageName?: string) => { + await page.keyboard.type('@', { delay: 50 }); + const linkedPagePopover = page.locator('.linked-page-popover'); + await expect(linkedPagePopover).toBeVisible(); + if (pageName) { + await type(page, pageName); + } else { + pageName = 'Untitled'; + } + + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Enter', { delay: 50 }); +}; + export async function clickPageMoreActions(page: Page) { return page .getByTestId('editor-header-items') diff --git a/tests/parallels/local-first-favorites-items.spec.ts b/tests/parallels/local-first-favorites-items.spec.ts index 4fa62677d9..8a7ff5b646 100644 --- a/tests/parallels/local-first-favorites-items.spec.ts +++ b/tests/parallels/local-first-favorites-items.spec.ts @@ -4,6 +4,7 @@ import { expect } from '@playwright/test'; import { openHomePage } from '../libs/load-page'; import { clickPageMoreActions, + createLinkedPage, getBlockSuiteEditorTitle, newPage, waitMarkdownImported, @@ -34,28 +35,39 @@ test('Show favorite items in sidebar', async ({ page }) => { ); }); -test('Show favorite items in favorite list', async ({ page }) => { +test('Show favorite reference in sidebar', async ({ page }) => { await openHomePage(page); await waitMarkdownImported(page); await newPage(page); await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).fill('this is a new page to favorite'); - await page.getByTestId('all-pages').click(); - const cell = page.getByRole('cell', { - name: 'this is a new page to favorite', - }); - expect(cell).not.toBeUndefined(); - await cell.click(); + + // goes to main content + await page.keyboard.press('Enter', { delay: 50 }); + + await createLinkedPage(page, 'Another page'); + + const newPageId = page.url().split('/').reverse()[0]; + await clickPageMoreActions(page); const favoriteBtn = page.getByTestId('editor-option-menu-favorite'); await favoriteBtn.click(); - await page.getByTestId('all-pages').click(); + const favItemTestId = 'favorite-list-item-' + newPageId; - expect( - page.getByRole('cell', { name: 'this is a new page to favorite' }) - ).not.toBeUndefined(); + const favoriteListItemInSidebar = page.getByTestId(favItemTestId); + expect(await favoriteListItemInSidebar.textContent()).toBe( + 'this is a new page to favorite' + ); - await page.getByRole('cell').getByRole('button').nth(0).click(); + const collapseButton = favoriteListItemInSidebar.locator( + '[data-testid="fav-collapsed-button"]' + ); + + await expect(collapseButton).toBeVisible(); + await collapseButton.click(); + await expect( + page.locator('[data-type="favorite-list-item"] >> text=Another page') + ).toBeVisible(); });