feat: fav page references (#2422)

Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
Peng Xiao
2023-05-17 22:18:40 -07:00
committed by GitHub
co-authored by Himself65
parent b7cee3185e
commit 2f7b51d7ff
9 changed files with 270 additions and 38 deletions
@@ -1,40 +1,103 @@
import { MenuLinkItem } from '@affine/component/app-sidebar'; import { MenuLinkItem } from '@affine/component/app-sidebar';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
import type { PageMeta, Workspace } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; 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 { useAtomValue } from 'jotai';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { workspacePreferredModeAtom } from '../../../../atoms'; import { workspacePreferredModeAtom } from '../../../../atoms';
import type { FavoriteListProps } from '../index'; import type { FavoriteListProps } from '../index';
import EmptyItem from './empty-item'; import EmptyItem from './empty-item';
export const FavoriteList = ({ currentWorkspace }: FavoriteListProps) => {
interface FavoriteMenuItemProps {
workspace: Workspace;
pageId: string;
metaMapping: Record<string, PageMeta>;
parentIds: Set<string>;
}
function FavoriteMenuItem({
workspace,
pageId,
metaMapping,
parentIds,
}: FavoriteMenuItemProps) {
const router = useRouter(); const router = useRouter();
const record = useAtomValue(workspacePreferredModeAtom); const record = useAtomValue(workspacePreferredModeAtom);
const pageMeta = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); const active = router.query.pageId === pageId;
const workspaceId = currentWorkspace.id; const icon = record[pageId] === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
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 (
<>
<MenuLinkItem
style={{
marginLeft: nestedItem ? '12px' : undefined,
width: nestedItem ? 'calc(100% - 12px)' : undefined,
}}
data-type="favorite-list-item"
data-testid={`favorite-list-item-${pageId}`}
active={active}
href={`/workspace/${workspace.id}/${pageId}`}
icon={icon}
collapsed={collapsible ? collapsed : undefined}
onCollapsedChange={setCollapsed}
>
<span>{metaMapping[pageId]?.title || 'Untitled'}</span>
</MenuLinkItem>
{showReferences &&
referencesToShow.map(ref => {
return (
<FavoriteMenuItem
key={ref}
workspace={workspace}
pageId={ref}
metaMapping={metaMapping}
parentIds={new Set([...parentIds, pageId])}
/>
);
})}
</>
);
}
export const FavoriteList = ({ currentWorkspace }: FavoriteListProps) => {
const metas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
const favoriteList = useMemo( const favoriteList = useMemo(
() => pageMeta.filter(p => p.favorite && !p.trash), () => metas.filter(p => p.favorite && !p.trash),
[pageMeta] [metas]
);
const metaMapping = useMemo(
() =>
metas.reduce((acc, meta) => {
acc[meta.id] = meta;
return acc;
}, {} as Record<string, PageMeta>),
[metas]
); );
return ( return (
<> <>
{favoriteList.map((pageMeta, index) => { {favoriteList.map((pageMeta, index) => {
const active = router.query.pageId === pageMeta.id;
const icon =
record[pageMeta.id] === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
return ( return (
<MenuLinkItem <FavoriteMenuItem
key={`${pageMeta}-${index}`} key={`${pageMeta}-${index}`}
data-testid={`favorite-list-item-${pageMeta.id}`} metaMapping={metaMapping}
active={active} pageId={pageMeta.id}
href={`/workspace/${workspaceId}/${pageMeta.id}`} // memo?
icon={icon} parentIds={new Set()}
> workspace={currentWorkspace.blockSuiteWorkspace}
<span>{pageMeta.title || 'Untitled'}</span> />
</MenuLinkItem>
); );
})} })}
{favoriteList.length === 0 && <EmptyItem />} {favoriteList.length === 0 && <EmptyItem />}
@@ -6,7 +6,7 @@ import {
} from '@blocksuite/icons'; } from '@blocksuite/icons';
import type { Meta, StoryFn } from '@storybook/react'; import type { Meta, StoryFn } from '@storybook/react';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import type { PropsWithChildren } from 'react'; import { type PropsWithChildren, useState } from 'react';
import { AppSidebar, AppSidebarFallback, appSidebarOpenAtom } from '.'; import { AppSidebar, AppSidebarFallback, appSidebarOpenAtom } from '.';
import { AddPageButton } from './add-page-button'; import { AddPageButton } from './add-page-button';
@@ -79,6 +79,7 @@ export const Fallback = () => {
}; };
export const WithItems: StoryFn = () => { export const WithItems: StoryFn = () => {
const [collapsed, setCollapsed] = useState(false);
return ( return (
<Container> <Container>
<AppSidebar> <AppSidebar>
@@ -111,11 +112,22 @@ export const WithItems: StoryFn = () => {
<SidebarScrollableContainer> <SidebarScrollableContainer>
<CategoryDivider label="Favorites" /> <CategoryDivider label="Favorites" />
<MenuLinkItem <MenuLinkItem
collapsed={collapsed}
onCollapsedChange={setCollapsed}
icon={<SettingsIcon />} icon={<SettingsIcon />}
href="/test" href="/test"
onClick={() => alert('opened')} onClick={() => alert('opened')}
> >
Settings Collapsible Item
</MenuLinkItem>
<MenuLinkItem
collapsed={!collapsed}
onCollapsedChange={setCollapsed}
icon={<SettingsIcon />}
href="/test"
onClick={() => alert('opened')}
>
Collapsible Item
</MenuLinkItem> </MenuLinkItem>
<MenuLinkItem <MenuLinkItem
icon={<SettingsIcon />} icon={<SettingsIcon />}
@@ -8,7 +8,7 @@ export const root = style({
minHeight: '30px', minHeight: '30px',
userSelect: 'none', userSelect: 'none',
cursor: 'pointer', cursor: 'pointer',
padding: '0 12px', padding: '0 8px 0 12px',
fontSize: 'var(--affine-font-sm)', fontSize: 'var(--affine-font-sm)',
selectors: { selectors: {
'&:hover': { '&:hover': {
@@ -27,6 +27,11 @@ export const root = style({
// make this a variable? // 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);', '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({ export const icon = style({
marginRight: '14px',
color: 'var(--affine-icon-color)', color: 'var(--affine-icon-color)',
fontSize: '20px', 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({ export const spacer = style({
flex: 1, flex: 1,
}); });
@@ -1,5 +1,6 @@
import { SettingsIcon } from '@blocksuite/icons'; import { SettingsIcon } from '@blocksuite/icons';
import type { Meta, StoryFn } from '@storybook/react'; import type { Meta, StoryFn } from '@storybook/react';
import { useState } from 'react';
import { MenuItem, MenuLinkItem } from '.'; import { MenuItem, MenuLinkItem } from '.';
@@ -9,6 +10,7 @@ export default {
} satisfies Meta; } satisfies Meta;
export const Default: StoryFn = () => { export const Default: StoryFn = () => {
const [collapsed, setCollapsed] = useState(false);
return ( return (
<main style={{ width: '240px' }}> <main style={{ width: '240px' }}>
<MenuItem icon={<SettingsIcon />} onClick={() => alert('opened')}> <MenuItem icon={<SettingsIcon />} onClick={() => alert('opened')}>
@@ -29,6 +31,14 @@ export const Default: StoryFn = () => {
> >
Primary Item Primary Item
</MenuLinkItem> </MenuLinkItem>
<MenuItem
collapsed={collapsed}
onCollapsedChange={setCollapsed}
icon={<SettingsIcon />}
onClick={() => alert('opened')}
>
Collapsible Item
</MenuItem>
</main> </main>
); );
}; };
@@ -1,3 +1,4 @@
import { ArrowDownSmallIcon } from '@blocksuite/icons';
import clsx from 'clsx'; import clsx from 'clsx';
import type { LinkProps } from 'next/link'; import type { LinkProps } from 'next/link';
import Link from 'next/link'; import Link from 'next/link';
@@ -9,6 +10,8 @@ interface MenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
icon?: React.ReactElement; icon?: React.ReactElement;
active?: boolean; active?: boolean;
disabled?: boolean; disabled?: boolean;
collapsed?: boolean; // true, false, undefined. undefined means no collapse
onCollapsedChange?: (collapsed: boolean) => void;
} }
interface MenuLinkItemProps extends MenuItemProps, Pick<LinkProps, 'href'> {} interface MenuLinkItemProps extends MenuItemProps, Pick<LinkProps, 'href'> {}
@@ -19,8 +22,14 @@ export function MenuItem({
active, active,
children, children,
disabled, disabled,
collapsed,
onCollapsedChange,
...props ...props
}: MenuItemProps) { }: MenuItemProps) {
const collapsible = collapsed !== undefined;
if (collapsible && !onCollapsedChange) {
throw new Error('onCollapsedChange is required when collapsed is defined');
}
return ( return (
<div <div
{...props} {...props}
@@ -28,11 +37,31 @@ export function MenuItem({
onClick={onClick} onClick={onClick}
data-active={active} data-active={active}
data-disabled={disabled} data-disabled={disabled}
data-collapsible={collapsible}
> >
{icon && <div className={styles.iconsContainer} data-collapsible={collapsible}>
React.cloneElement(icon, { {collapsible && (
className: clsx([styles.icon, icon.props.className]), <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>
)}
{icon &&
React.cloneElement(icon, {
className: clsx([styles.icon, icon.props.className]),
})}
</div>
<div className={styles.content}>{children}</div> <div className={styles.content}>{children}</div>
</div> </div>
); );
@@ -17,7 +17,7 @@ export const root = style({
}); });
export const icon = style({ export const icon = style({
marginRight: '14px', marginRight: '8px',
color: 'var(--affine-icon-color)', color: 'var(--affine-icon-color)',
fontSize: '20px', fontSize: '20px',
}); });
@@ -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<string[]>(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));
}
+25
View File
@@ -1,4 +1,5 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
export async function waitMarkdownImported(page: Page) { export async function waitMarkdownImported(page: Page) {
await page.waitForSelector('v-line'); await page.waitForSelector('v-line');
@@ -16,6 +17,30 @@ export function getBlockSuiteEditorTitle(page: Page) {
return page.locator('v-line').nth(0); 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) { export async function clickPageMoreActions(page: Page) {
return page return page
.getByTestId('editor-header-items') .getByTestId('editor-header-items')
@@ -4,6 +4,7 @@ import { expect } from '@playwright/test';
import { openHomePage } from '../libs/load-page'; import { openHomePage } from '../libs/load-page';
import { import {
clickPageMoreActions, clickPageMoreActions,
createLinkedPage,
getBlockSuiteEditorTitle, getBlockSuiteEditorTitle,
newPage, newPage,
waitMarkdownImported, 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 openHomePage(page);
await waitMarkdownImported(page); await waitMarkdownImported(page);
await newPage(page); await newPage(page);
await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('this is a new page to favorite'); await getBlockSuiteEditorTitle(page).fill('this is a new page to favorite');
await page.getByTestId('all-pages').click();
const cell = page.getByRole('cell', { // goes to main content
name: 'this is a new page to favorite', await page.keyboard.press('Enter', { delay: 50 });
});
expect(cell).not.toBeUndefined(); await createLinkedPage(page, 'Another page');
await cell.click();
const newPageId = page.url().split('/').reverse()[0];
await clickPageMoreActions(page); await clickPageMoreActions(page);
const favoriteBtn = page.getByTestId('editor-option-menu-favorite'); const favoriteBtn = page.getByTestId('editor-option-menu-favorite');
await favoriteBtn.click(); await favoriteBtn.click();
await page.getByTestId('all-pages').click(); const favItemTestId = 'favorite-list-item-' + newPageId;
expect( const favoriteListItemInSidebar = page.getByTestId(favItemTestId);
page.getByRole('cell', { name: 'this is a new page to favorite' }) expect(await favoriteListItemInSidebar.textContent()).toBe(
).not.toBeUndefined(); '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();
}); });