feat: fav page references (#2422)

Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
Peng Xiao
2023-05-18 13:18:40 +08:00
committed by GitHub
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 { 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<string, PageMeta>;
parentIds: Set<string>;
}
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' ? <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(
() => 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<string, PageMeta>),
[metas]
);
return (
<>
{favoriteList.map((pageMeta, index) => {
const active = router.query.pageId === pageMeta.id;
const icon =
record[pageMeta.id] === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
return (
<MenuLinkItem
<FavoriteMenuItem
key={`${pageMeta}-${index}`}
data-testid={`favorite-list-item-${pageMeta.id}`}
active={active}
href={`/workspace/${workspaceId}/${pageMeta.id}`}
icon={icon}
>
<span>{pageMeta.title || 'Untitled'}</span>
</MenuLinkItem>
metaMapping={metaMapping}
pageId={pageMeta.id}
// memo?
parentIds={new Set()}
workspace={currentWorkspace.blockSuiteWorkspace}
/>
);
})}
{favoriteList.length === 0 && <EmptyItem />}
@@ -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 (
<Container>
<AppSidebar>
@@ -111,11 +112,22 @@ export const WithItems: StoryFn = () => {
<SidebarScrollableContainer>
<CategoryDivider label="Favorites" />
<MenuLinkItem
collapsed={collapsed}
onCollapsedChange={setCollapsed}
icon={<SettingsIcon />}
href="/test"
onClick={() => alert('opened')}
>
Settings
Collapsible Item
</MenuLinkItem>
<MenuLinkItem
collapsed={!collapsed}
onCollapsedChange={setCollapsed}
icon={<SettingsIcon />}
href="/test"
onClick={() => alert('opened')}
>
Collapsible Item
</MenuLinkItem>
<MenuLinkItem
icon={<SettingsIcon />}
@@ -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,
});
@@ -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 (
<main style={{ width: '240px' }}>
<MenuItem icon={<SettingsIcon />} onClick={() => alert('opened')}>
@@ -29,6 +31,14 @@ export const Default: StoryFn = () => {
>
Primary Item
</MenuLinkItem>
<MenuItem
collapsed={collapsed}
onCollapsedChange={setCollapsed}
icon={<SettingsIcon />}
onClick={() => alert('opened')}
>
Collapsible Item
</MenuItem>
</main>
);
};
@@ -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<HTMLDivElement> {
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<LinkProps, 'href'> {}
@@ -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 (
<div
{...props}
@@ -28,11 +37,31 @@ export function MenuItem({
onClick={onClick}
data-active={active}
data-disabled={disabled}
data-collapsible={collapsible}
>
{icon &&
React.cloneElement(icon, {
className: clsx([styles.icon, icon.props.className]),
})}
<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>
)}
{icon &&
React.cloneElement(icon, {
className: clsx([styles.icon, icon.props.className]),
})}
</div>
<div className={styles.content}>{children}</div>
</div>
);
@@ -17,7 +17,7 @@ export const root = style({
});
export const icon = style({
marginRight: '14px',
marginRight: '8px',
color: 'var(--affine-icon-color)',
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 { 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')
@@ -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();
});