feat: add root pinboard & rename pivots to pinboard (#1843)

This commit is contained in:
Qi 2023-04-08 05:55:59 +08:00 committed by GitHub
parent d4b2b9ab44
commit e50bf9fbfe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 836 additions and 729 deletions

View File

@ -19,8 +19,8 @@ import {
import { useWorkspacesHelper } from '../../hooks/use-workspaces';
import { ThemeProvider } from '../../providers/ThemeProvider';
import type { BlockSuiteWorkspace } from '../../shared';
import type { PivotsProps } from '../pure/workspace-slider-bar/Pivots';
import Pivots from '../pure/workspace-slider-bar/Pivots';
import type { PinboardProps } from '../pure/workspace-slider-bar/Pinboard';
import Pinboard from '../pure/workspace-slider-bar/Pinboard';
expect.extend(matchers);
@ -35,17 +35,18 @@ const ProviderWrapper: FC<PropsWithChildren> = ({ children }) => {
};
const initPinBoard = async () => {
// create one workspace with 2 root pages and 2 pivot pages
// - hasPivotPage
// - pivot1
// - pivot2
// - noPivotPage
// create one workspace with 2 root pages and 2 pinboard pages
// - hasPinboardPage
// - hasPinboardPage
// - pinboard1
// - pinboard2
// - noPinboardPage
const mutationHook = renderHook(() => useWorkspacesHelper(), {
wrapper: ProviderWrapper,
});
const rootPageIds = ['hasPivotPage', 'noPivotPage'];
const pivotPageIds = ['pivot1', 'pivot2'];
const rootPageIds = ['hasPinboardPage', 'noPinboardPage'];
const pinboardPageIds = ['pinboard1', 'pinboard2'];
const id = await mutationHook.result.current.createLocalWorkspace('test0');
await store.get(workspacesAtom);
mutationHook.rerender();
@ -59,25 +60,32 @@ const initPinBoard = async () => {
const blockSuiteWorkspace =
currentWorkspace?.blockSuiteWorkspace as BlockSuiteWorkspace;
// create root pinboard
mutationHook.result.current.createWorkspacePage(id, 'rootPinboard');
blockSuiteWorkspace.meta.setPageMeta('rootPinboard', {
isRootPinboard: true,
subpageIds: rootPageIds,
});
// create parent
rootPageIds.forEach(rootPageId => {
mutationHook.result.current.createWorkspacePage(id, rootPageId);
blockSuiteWorkspace.meta.setPageMeta(rootPageId, {
isPivots: true,
subpageIds: rootPageId === rootPageIds[0] ? pivotPageIds : [],
subpageIds: rootPageId === rootPageIds[0] ? pinboardPageIds : [],
});
});
pivotPageIds.forEach(pivotId => {
mutationHook.result.current.createWorkspacePage(id, pivotId);
blockSuiteWorkspace.meta.setPageMeta(pivotId, {
title: pivotId,
// create children to firs parent
pinboardPageIds.forEach(pinboardId => {
mutationHook.result.current.createWorkspacePage(id, pinboardId);
blockSuiteWorkspace.meta.setPageMeta(pinboardId, {
title: pinboardId,
});
});
const App = (props: PivotsProps) => {
const App = (props: PinboardProps) => {
return (
<ThemeProvider>
<ProviderWrapper>
<Pivots {...props} />
<Pinboard {...props} />
</ProviderWrapper>
</ThemeProvider>
);
@ -93,53 +101,51 @@ const initPinBoard = async () => {
return {
rootPageIds,
pivotPageIds,
pinboardPageIds,
app,
blockSuiteWorkspace,
};
};
const openOperationMenu = async (app: RenderResult, pageId: string) => {
const rootPivot = await app.findByTestId(`pivot-${pageId}`);
const operationBtn = (await rootPivot.querySelector(
'[data-testid="pivot-operation-button"]'
const rootPinboard = await app.findByTestId(`pinboard-${pageId}`);
const operationBtn = (await rootPinboard.querySelector(
'[data-testid="pinboard-operation-button"]'
)) as HTMLElement;
await operationBtn.click();
const menu = await app.findByTestId('pivot-operation-menu');
const menu = await app.findByTestId('pinboard-operation-menu');
expect(menu).toBeInTheDocument();
};
describe('PinBoard', () => {
test('add pivot', async () => {
const { app, blockSuiteWorkspace, rootPageIds, pivotPageIds } =
await initPinBoard();
const [hasPivotPageId] = rootPageIds;
await openOperationMenu(app, hasPivotPageId);
test('add pinboard', async () => {
const { app, blockSuiteWorkspace, rootPageIds } = await initPinBoard();
const [hasChildrenPageId] = rootPageIds;
await openOperationMenu(app, hasChildrenPageId);
const addBtn = await app.findByTestId('pivot-operation-add');
const addBtn = await app.findByTestId('pinboard-operation-add');
await addBtn.click();
const metas = blockSuiteWorkspace.meta.pageMetas ?? [];
const rootPageMeta = blockSuiteWorkspace.meta.getPageMeta(hasPivotPageId);
const addedPageMeta = metas.find(
meta => !pivotPageIds.includes(meta.id) && !rootPageIds.includes(meta.id)
) as PageMeta;
const hasChildrenPageMeta =
blockSuiteWorkspace.meta.getPageMeta(hasChildrenPageId);
// Page meta have been added
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(5);
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(6);
// New page meta is added in initial page meta
expect(rootPageMeta?.subpageIds.includes(addedPageMeta.id)).toBe(true);
expect(hasChildrenPageMeta?.subpageIds.length).toBe(3);
app.unmount();
});
test('delete pivot', async () => {
test('delete pinboard', async () => {
const {
app,
blockSuiteWorkspace,
rootPageIds: [hasPivotPageId],
rootPageIds: [hasChildrenPageId],
} = await initPinBoard();
await openOperationMenu(app, hasPivotPageId);
await openOperationMenu(app, hasChildrenPageId);
const deleteBtn = await app.findByTestId('pivot-operation-move-to-trash');
const deleteBtn = await app.findByTestId(
'pinboard-operation-move-to-trash'
);
await deleteBtn.click();
const confirmBtn = await app.findByTestId('move-to-trash-confirm');
@ -153,77 +159,78 @@ describe('PinBoard', () => {
app.unmount();
});
test('rename pivot', async () => {
test('rename pinboard', async () => {
const {
app,
rootPageIds: [hasPivotPageId],
rootPageIds: [hasChildrenPageId],
} = await initPinBoard();
await openOperationMenu(app, hasPivotPageId);
await openOperationMenu(app, hasChildrenPageId);
const renameBtn = await app.findByTestId('pivot-operation-rename');
const renameBtn = await app.findByTestId('pinboard-operation-rename');
await renameBtn.click();
const input = await app.findByTestId(`pivot-input-${hasPivotPageId}`);
const input = await app.findByTestId(`pinboard-input-${hasChildrenPageId}`);
expect(input).toBeInTheDocument();
// TODO: Fix this test
// fireEvent.change(input, { target: { value: 'tteesstt' } });
//
// expect(
// blockSuiteWorkspace.meta.getPageMeta(rootPageId)?.name
// blockSuiteWorkspace.meta.getPageMeta(hasChildrenPageId)?.name
// ).toBe('tteesstt');
app.unmount();
});
test('move pivot', async () => {
test('move pinboard', async () => {
const {
app,
blockSuiteWorkspace,
rootPageIds: [hasPivotPageId],
pivotPageIds: [pivotId1, pivotId2],
rootPageIds: [hasChildrenPageId],
pinboardPageIds: [pinboardId1, pinboardId2],
} = await initPinBoard();
await openOperationMenu(app, pivotId1);
await openOperationMenu(app, pinboardId1);
const moveToBtn = await app.findByTestId('pivot-operation-move-to');
const moveToBtn = await app.findByTestId('pinboard-operation-move-to');
await moveToBtn.click();
const pivotsMenu = await app.findByTestId('pivots-menu');
expect(pivotsMenu).toBeInTheDocument();
const pinboardMenu = await app.findByTestId('pinboard-menu');
expect(pinboardMenu).toBeInTheDocument();
await (
pivotsMenu.querySelector(
`[data-testid="pivot-${pivotId2}"]`
pinboardMenu.querySelector(
`[data-testid="pinboard-${pinboardId2}"]`
) as HTMLElement
).click();
const rootPageMeta = blockSuiteWorkspace.meta.getPageMeta(hasPivotPageId);
const hasChildrenPageMeta =
blockSuiteWorkspace.meta.getPageMeta(hasChildrenPageId);
expect(rootPageMeta?.subpageIds.includes(pivotId1)).toBe(false);
expect(rootPageMeta?.subpageIds.includes(pivotId2)).toBe(true);
expect(hasChildrenPageMeta?.subpageIds.includes(pinboardId1)).toBe(false);
expect(hasChildrenPageMeta?.subpageIds.includes(pinboardId2)).toBe(true);
app.unmount();
});
test('remove from pivots', async () => {
test('remove from pinboard', async () => {
const {
app,
blockSuiteWorkspace,
rootPageIds: [hasPivotPageId],
pivotPageIds: [pivotId1],
rootPageIds: [hasChildrenPageId],
pinboardPageIds: [pinboardId1],
} = await initPinBoard();
await openOperationMenu(app, pivotId1);
await openOperationMenu(app, pinboardId1);
const moveToBtn = await app.findByTestId('pivot-operation-move-to');
const moveToBtn = await app.findByTestId('pinboard-operation-move-to');
await moveToBtn.click();
const removeFromPivotsBtn = await app.findByTestId(
'remove-from-pivots-button'
const removeFromPinboardBtn = await app.findByTestId(
'remove-from-pinboard-button'
);
removeFromPivotsBtn.click();
removeFromPinboardBtn.click();
const hasPivotsPageMeta =
blockSuiteWorkspace.meta.getPageMeta(hasPivotPageId);
const hasPinboardPageMeta =
blockSuiteWorkspace.meta.getPageMeta(hasChildrenPageId);
expect(hasPivotsPageMeta?.subpageIds.length).toBe(1);
expect(hasPivotsPageMeta?.subpageIds.includes(pivotId1)).toBe(false);
expect(hasPinboardPageMeta?.subpageIds.length).toBe(1);
expect(hasPinboardPageMeta?.subpageIds.includes(pinboardId1)).toBe(false);
app.unmount();
});
});

View File

@ -16,17 +16,16 @@ export const CopyLink = ({ onItemClick, onSelect }: CommonMenuItemProps) => {
}, [t]);
return (
<>
<MenuItem
onClick={() => {
copyUrl();
onItemClick?.();
onSelect?.();
}}
icon={<CopyIcon />}
>
{t('Copy Link')}
</MenuItem>
</>
<MenuItem
data-testid="copy-link"
onClick={() => {
copyUrl();
onItemClick?.();
onSelect?.();
}}
icon={<CopyIcon />}
>
{t('Copy Link')}
</MenuItem>
);
};

View File

@ -2,10 +2,10 @@ import { MenuItem } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { ArrowRightSmallIcon, MoveToIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import { useRef, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import type { BlockSuiteWorkspace } from '../../../shared';
import { PivotsMenu } from '../pivots';
import { PinboardMenu } from '../pinboard';
import type { CommonMenuItemProps } from './types';
export type MoveToProps = CommonMenuItemProps<{
@ -43,14 +43,17 @@ export const MoveTo = ({
>
{t('Move to')}
</MenuItem>
<PivotsMenu
<PinboardMenu
anchorEl={anchorEl}
open={open}
placement="left-start"
metas={metas.filter(meta => !meta.trash)}
metas={useMemo(
() => metas.filter(m => !m.trash && m.id !== currentMeta.id),
[metas, currentMeta]
)}
currentMeta={currentMeta}
blockSuiteWorkspace={blockSuiteWorkspace}
onPivotClick={onSelect}
onPinboardClick={onSelect}
/>
</>
);

View File

@ -0,0 +1,2 @@
export * from './pinboard-menu';
export * from './pinboard-render/';

View File

@ -7,7 +7,7 @@ import Image from 'next/legacy/image';
import React from 'react';
import { workspacePreferredModeAtom } from '../../../../atoms';
import { StyledMenuSubTitle, StyledPivot } from '../styles';
import { StyledMenuSubTitle, StyledPinboard } from '../styles';
export const SearchContent = ({
results,
@ -27,16 +27,16 @@ export const SearchContent = ({
</StyledMenuSubTitle>
{results.map(meta => {
return (
<StyledPivot
<StyledPinboard
key={meta.id}
onClick={() => {
onClick?.(meta.id);
}}
data-testid="pivot-search-result"
data-testid="pinboard-search-result"
>
{record[meta.id] === 'edgeless' ? <EdgelessIcon /> : <PageIcon />}
{meta.title}
</StyledPivot>
</StyledPinboard>
);
})}
</>

View File

@ -1,42 +1,41 @@
import type { PureMenuProps } from '@affine/component';
import { Input, PureMenu } from '@affine/component';
import { Input, PureMenu, TreeView } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { RemoveIcon, SearchIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import React, { useCallback, useState } from 'react';
import { usePinboardData } from '../../../../hooks/affine/use-pinboard-data';
import { usePinboardHandler } from '../../../../hooks/affine/use-pinboard-handler';
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
import type { BlockSuiteWorkspace } from '../../../../shared';
import { toast } from '../../../../utils';
import { usePivotData } from '../hooks/usePivotData';
import { usePivotHandler } from '../hooks/usePivotHandler';
import { PivotRender } from '../pivot-render/PivotRender';
import { PinboardRender } from '../pinboard-render/';
import {
StyledMenuContent,
StyledMenuFooter,
StyledMenuSubTitle,
StyledPivot,
StyledPinboard,
StyledSearchContainer,
} from '../styles';
import { Pivots } from './Pivots';
import { SearchContent } from './SearchContent';
export type PivotsMenuProps = {
export type PinboardMenuProps = {
metas: PageMeta[];
currentMeta: PageMeta;
blockSuiteWorkspace: BlockSuiteWorkspace;
showRemovePivots?: boolean;
onPivotClick?: (p: { dragId: string; dropId: string }) => void;
showRemovePinboard?: boolean;
onPinboardClick?: (p: { dragId: string; dropId: string }) => void;
} & PureMenuProps;
export const PivotsMenu = ({
export const PinboardMenu = ({
metas,
currentMeta,
blockSuiteWorkspace,
showRemovePivots = false,
onPivotClick,
showRemovePinboard = false,
onPinboardClick,
...pureMenuProps
}: PivotsMenuProps) => {
}: PinboardMenuProps) => {
const { t } = useTranslation();
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
const [query, setQuery] = useState('');
@ -46,7 +45,7 @@ export const PivotsMenu = ({
meta => !meta.trash && meta.title.includes(query)
);
const { handleDrop } = usePivotHandler({
const { handleDrop } = usePinboardHandler({
blockSuiteWorkspace,
metas,
});
@ -60,15 +59,15 @@ export const PivotsMenu = ({
topLine: false,
internal: true,
});
onPivotClick?.({ dragId: currentMeta.id, dropId });
onPinboardClick?.({ dragId: currentMeta.id, dropId });
toast(`Moved "${currentMeta.title}" to "${targetTitle}"`);
},
[currentMeta.id, currentMeta.title, handleDrop, metas, onPivotClick]
[currentMeta.id, currentMeta.title, handleDrop, metas, onPinboardClick]
);
const { data } = usePivotData({
const { data } = usePinboardData({
metas,
pivotRender: PivotRender,
pinboardRender: PinboardRender,
blockSuiteWorkspace,
onClick: (e, node) => {
handleClick(node.id);
@ -80,7 +79,7 @@ export const PivotsMenu = ({
width={320}
height={480}
{...pureMenuProps}
data-testid="pivots-menu"
data-testid="pinboard-menu"
>
<StyledSearchContainer>
<label>
@ -93,7 +92,7 @@ export const PivotsMenu = ({
height={32}
noBorder={true}
onClick={e => e.stopPropagation()}
data-testid="pivots-menu-search"
data-testid="pinboard-menu-search"
/>
</StyledSearchContainer>
@ -104,21 +103,16 @@ export const PivotsMenu = ({
{!isSearching && (
<>
<StyledMenuSubTitle>Suggested</StyledMenuSubTitle>
<Pivots
data={data}
blockSuiteWorkspace={blockSuiteWorkspace}
currentMeta={currentMeta}
/>
<TreeView data={data} indent={16} enableDnd={false} />
</>
)}
</StyledMenuContent>
{showRemovePivots && (
{showRemovePinboard && (
<StyledMenuFooter>
<StyledPivot
data-testid={'remove-from-pivots-button'}
<StyledPinboard
data-testid={'remove-from-pinboard-button'}
onClick={() => {
setPageMeta(currentMeta.id, { isPivots: false });
const parentMeta = metas.find(m =>
m.subpageIds.includes(currentMeta.id)
);
@ -132,11 +126,13 @@ export const PivotsMenu = ({
}}
>
<RemoveIcon />
{t('Remove from Pivots')}
</StyledPivot>
{t('Remove from Pinboard')}
</StyledPinboard>
<p>{t('RFP')}</p>
</StyledMenuFooter>
)}
</PureMenu>
);
};
export default PinboardMenu;

View File

@ -0,0 +1,14 @@
import { useTranslation } from '@affine/i18n';
import { StyledPinboard } from '../styles';
export const EmptyItem = () => {
const { t } = useTranslation();
return (
<StyledPinboard disable={true} style={{ paddingLeft: '32px' }}>
{t('No item')}
</StyledPinboard>
);
};
export default EmptyItem;

View File

@ -14,10 +14,11 @@ import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
import type { BlockSuiteWorkspace } from '../../../../shared';
import { toast } from '../../../../utils';
import { CopyLink, MoveToTrash } from '../../operation-menu-items';
import { PivotsMenu } from '../PivotsMenu/PivotsMenu';
import { PinboardMenu } from '../pinboard-menu/';
import { StyledOperationButton } from '../styles';
export type OperationButtonProps = {
isRoot: boolean;
onAdd: () => void;
onDelete: () => void;
metas: PageMeta[];
@ -28,6 +29,7 @@ export type OperationButtonProps = {
onMenuClose?: () => void;
};
export const OperationButton = ({
isRoot,
onAdd,
onDelete,
metas,
@ -44,7 +46,7 @@ export const OperationButton = ({
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [operationMenuOpen, setOperationMenuOpen] = useState(false);
const [pivotsMenuOpen, setPivotsMenuOpen] = useState(false);
const [pinboardMenuOpen, setPinboardMenuOpen] = useState(false);
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
const menuIndex = useMemo(() => modalIndex + 1, [modalIndex]);
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
@ -53,7 +55,7 @@ export const OperationButton = ({
<MuiClickAwayListener
onClickAway={() => {
setOperationMenuOpen(false);
setPivotsMenuOpen(false);
setPinboardMenuOpen(false);
}}
>
<div
@ -62,11 +64,11 @@ export const OperationButton = ({
}}
onMouseLeave={() => {
setOperationMenuOpen(false);
setPivotsMenuOpen(false);
setPinboardMenuOpen(false);
}}
>
<StyledOperationButton
data-testid="pivot-operation-button"
data-testid="pinboard-operation-button"
ref={ref => setAnchorEl(ref)}
size="small"
onClick={() => {
@ -78,7 +80,7 @@ export const OperationButton = ({
</StyledOperationButton>
<PureMenu
data-testid="pivot-operation-menu"
data-testid="pinboard-operation-menu"
width={256}
anchorEl={anchorEl}
open={operationMenuOpen}
@ -86,7 +88,7 @@ export const OperationButton = ({
zIndex={menuIndex}
>
<MenuItem
data-testid="pivot-operation-add"
data-testid="pinboard-operation-add"
onClick={() => {
onAdd();
setOperationMenuOpen(false);
@ -96,47 +98,53 @@ export const OperationButton = ({
>
{t('Add a subpage inside')}
</MenuItem>
<MenuItem
data-testid="pivot-operation-move-to"
onClick={() => {
setOperationMenuOpen(false);
setPivotsMenuOpen(true);
}}
icon={<MoveToIcon />}
>
{t('Move to')}
</MenuItem>
<MenuItem
data-testid="pivot-operation-rename"
onClick={() => {
onRename?.();
setOperationMenuOpen(false);
onMenuClose?.();
}}
icon={<PenIcon />}
>
{t('Rename')}
</MenuItem>
<MoveToTrash
testId="pivot-operation-move-to-trash"
onItemClick={() => {
setOperationMenuOpen(false);
setConfirmModalOpen(true);
onMenuClose?.();
}}
/>
{!isRoot && (
<MenuItem
data-testid="pinboard-operation-move-to"
onClick={() => {
setOperationMenuOpen(false);
setPinboardMenuOpen(true);
}}
icon={<MoveToIcon />}
>
{t('Move to')}
</MenuItem>
)}
{!isRoot && (
<MenuItem
data-testid="pinboard-operation-rename"
onClick={() => {
onRename?.();
setOperationMenuOpen(false);
onMenuClose?.();
}}
icon={<PenIcon />}
>
{t('Rename')}
</MenuItem>
)}
{!isRoot && (
<MoveToTrash
testId="pinboard-operation-move-to-trash"
onItemClick={() => {
setOperationMenuOpen(false);
setConfirmModalOpen(true);
onMenuClose?.();
}}
/>
)}
<CopyLink />
</PureMenu>
<PivotsMenu
<PinboardMenu
anchorEl={anchorEl}
open={pivotsMenuOpen}
open={pinboardMenuOpen}
placement="bottom-start"
zIndex={menuIndex}
metas={metas}
currentMeta={currentMeta}
blockSuiteWorkspace={blockSuiteWorkspace}
showRemovePivots={true}
showRemovePinboard={true}
/>
<MoveToTrash.ConfirmModal
open={confirmModalOpen}

View File

@ -0,0 +1,122 @@
import { Input } from '@affine/component';
import {
ArrowDownSmallIcon,
EdgelessIcon,
PageIcon,
PivotsIcon,
} from '@blocksuite/icons';
import { useAtomValue } from 'jotai';
import { useRouter } from 'next/router';
import { useMemo, useState } from 'react';
import { workspacePreferredModeAtom } from '../../../../atoms';
import type { PinboardNode } from '../../../../hooks/affine/use-pinboard-data';
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
import { StyledCollapsedButton, StyledPinboard } from '../styles';
import EmptyItem from './EmptyItem';
import { OperationButton } from './OperationButton';
const getIcon = (type: 'root' | 'edgeless' | 'page') => {
switch (type) {
case 'root':
return <PivotsIcon />;
case 'edgeless':
return <EdgelessIcon />;
default:
return <PageIcon />;
}
};
export const PinboardRender: PinboardNode['render'] = (
node,
{ isOver, onAdd, onDelete, collapsed, setCollapsed, isSelected },
renderProps
) => {
const {
onClick,
showOperationButton = false,
currentMeta,
metas = [],
blockSuiteWorkspace,
} = renderProps!;
const record = useAtomValue(workspacePreferredModeAtom);
const { setPageTitle } = usePageMetaHelper(blockSuiteWorkspace);
const router = useRouter();
const [isHover, setIsHover] = useState(false);
const [showRename, setShowRename] = useState(false);
const active = router.query.pageId === node.id;
const isRoot = !!currentMeta.isRootPinboard;
return (
<>
<StyledPinboard
data-testid={`pinboard-${node.id}`}
onClick={e => {
onClick?.(e, node);
}}
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
isOver={isOver || isSelected}
active={active}
>
<StyledCollapsedButton
collapse={collapsed}
show={!!node.children?.length}
onClick={e => {
e.stopPropagation();
setCollapsed(node.id, !collapsed);
}}
>
<ArrowDownSmallIcon />
</StyledCollapsedButton>
{getIcon(isRoot ? 'root' : record[node.id])}
{showRename ? (
<Input
data-testid={`pinboard-input-${node.id}`}
value={currentMeta.title || ''}
placeholder="Untitled"
onClick={e => e.stopPropagation()}
height={32}
onBlur={() => {
setShowRename(false);
}}
onChange={value => {
// FIXME: setPageTitle would make input blur, and can't input the Chinese character
setPageTitle(node.id, value);
}}
/>
) : (
<span>{isRoot ? 'Pinboard' : currentMeta.title || 'Untitled'}</span>
)}
{showOperationButton && (
<OperationButton
isRoot={isRoot}
onAdd={onAdd}
onDelete={onDelete}
metas={metas}
currentMeta={currentMeta!}
blockSuiteWorkspace={blockSuiteWorkspace!}
isHover={isHover}
onMenuClose={() => setIsHover(false)}
onRename={() => {
setShowRename(true);
setIsHover(false);
}}
/>
)}
</StyledPinboard>
{useMemo(
() =>
isRoot &&
!metas.find(m => (currentMeta.subpageIds ?? []).includes(m.id)),
[currentMeta.subpageIds, isRoot, metas]
) && <EmptyItem />}
</>
);
};
export default PinboardRender;

View File

@ -21,14 +21,14 @@ export const StyledCollapsedButton = styled('button')<{
margin: 'auto',
color: theme.colors.iconColor,
opacity: '.6',
display: show ? 'block' : 'none',
display: show ? 'flex' : 'none',
svg: {
transform: `rotate(${collapse ? '0' : '-90'}deg)`,
},
};
});
export const StyledPivot = styled('div')<{
export const StyledPinboard = styled('div')<{
disable?: boolean;
active?: boolean;
isOver?: boolean;

View File

@ -1,10 +0,0 @@
import { useTranslation } from '@affine/i18n';
import { StyledPivot } from '../styles';
export const EmptyItem = () => {
const { t } = useTranslation();
return <StyledPivot disable={true}>{t('No item')}</StyledPivot>;
};
export default EmptyItem;

View File

@ -1,70 +0,0 @@
import type { TreeViewProps } from '@affine/component';
import { MuiCollapse, TreeView } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { ArrowDownSmallIcon, PivotsIcon } from '@blocksuite/icons';
import type { MouseEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
import { toast } from '../../../../utils';
import { StyledCollapsedButton, StyledPivot } from '../styles';
import type { NodeRenderProps } from '../types';
import EmptyItem from './EmptyItem';
import type { PivotsMenuProps } from './PivotsMenu';
export type PivotsProps = {
data: TreeViewProps<NodeRenderProps>['data'];
} & Pick<PivotsMenuProps, 'blockSuiteWorkspace' | 'currentMeta'>;
export const Pivots = ({
data,
blockSuiteWorkspace,
currentMeta,
}: PivotsProps) => {
const { t } = useTranslation();
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
const [showPivot, setShowPivot] = useState(true);
const isPivotEmpty = useMemo(() => data.length === 0, [data]);
return (
<>
<StyledPivot
data-testid="root-pivot-button-in-pivots-menu"
id="root-pivot-button-in-pivots-menu"
onClick={() => {
setPageMeta(currentMeta.id, { isPivots: true });
toast(`Moved "${currentMeta.title}" to Pivots`);
}}
>
<StyledCollapsedButton
onClick={useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
setShowPivot(!showPivot);
},
[showPivot]
)}
collapse={showPivot}
>
<ArrowDownSmallIcon />
</StyledCollapsedButton>
<PivotsIcon />
{t('Pivots')}
</StyledPivot>
<MuiCollapse
in={showPivot}
style={{
maxHeight: 300,
paddingLeft: '16px',
overflowY: 'auto',
}}
>
{isPivotEmpty ? (
<EmptyItem />
) : (
<TreeView data={data} indent={16} enableDnd={false} />
)}
</MuiCollapse>
</>
);
};
export default Pivots;

View File

@ -1,74 +0,0 @@
import type { PageMeta } from '@blocksuite/store';
import { useMemo } from 'react';
import type { RenderProps, TreeNode } from '../types';
const flattenToTree = (
metas: PageMeta[],
pivotRender: TreeNode['render'],
renderProps: RenderProps
): TreeNode[] => {
// Compatibility process: the old data not has `subpageIds`, it is a root page
const rootMetas = metas
.filter(meta => {
if (meta.subpageIds) {
return (
metas.find(m => {
return m.subpageIds?.includes(meta.id);
}) === undefined
);
}
return true;
})
.filter(meta => meta.isPivots === true);
const helper = (internalMetas: PageMeta[]): TreeNode[] => {
return internalMetas.reduce<TreeNode[]>((returnedMetas, internalMeta) => {
const { subpageIds = [] } = internalMeta;
const childrenMetas = subpageIds
.map(id => metas.find(m => m.id === id)!)
.filter(m => m);
// @ts-ignore
const returnedMeta: TreeNode = {
...internalMeta,
children: helper(childrenMetas),
render: (node, props) =>
pivotRender(node, props, {
...renderProps,
currentMeta: internalMeta,
metas,
}),
};
returnedMetas.push(returnedMeta);
return returnedMetas;
}, []);
};
return helper(rootMetas);
};
export const usePivotData = ({
metas,
pivotRender,
blockSuiteWorkspace,
onClick,
showOperationButton,
}: {
metas: PageMeta[];
pivotRender: TreeNode['render'];
} & RenderProps) => {
const data = useMemo(
() =>
flattenToTree(metas, pivotRender, {
blockSuiteWorkspace,
onClick,
showOperationButton,
}),
[blockSuiteWorkspace, metas, onClick, pivotRender, showOperationButton]
);
return {
data,
};
};
export default usePivotData;

View File

@ -1,5 +0,0 @@
export * from './hooks/usePivotData';
export * from './hooks/usePivotHandler';
export * from './pivot-render/PivotRender';
export * from './PivotsMenu/PivotsMenu';
export * from './types';

View File

@ -1,93 +0,0 @@
import { Input } from '@affine/component';
import { ArrowDownSmallIcon, EdgelessIcon, PageIcon } from '@blocksuite/icons';
import { useAtomValue } from 'jotai';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { workspacePreferredModeAtom } from '../../../../atoms';
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
import { StyledCollapsedButton, StyledPivot } from '../styles';
import type { TreeNode } from '../types';
import { OperationButton } from './OperationButton';
export const PivotRender: TreeNode['render'] = (
node,
{ isOver, onAdd, onDelete, collapsed, setCollapsed, isSelected },
renderProps
) => {
const {
onClick,
showOperationButton = false,
currentMeta,
metas = [],
blockSuiteWorkspace,
} = renderProps!;
const record = useAtomValue(workspacePreferredModeAtom);
const { setPageTitle } = usePageMetaHelper(blockSuiteWorkspace);
const router = useRouter();
const [isHover, setIsHover] = useState(false);
const [showRename, setShowRename] = useState(false);
const active = router.query.pageId === node.id;
return (
<StyledPivot
data-testid={`pivot-${node.id}`}
onClick={e => {
onClick?.(e, node);
}}
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
isOver={isOver || isSelected}
active={active}
>
<StyledCollapsedButton
collapse={collapsed}
show={!!node.children?.length}
onClick={e => {
e.stopPropagation();
setCollapsed(node.id, !collapsed);
}}
>
<ArrowDownSmallIcon />
</StyledCollapsedButton>
{record[node.id] === 'edgeless' ? <EdgelessIcon /> : <PageIcon />}
{showRename ? (
<Input
data-testid={`pivot-input-${node.id}`}
value={currentMeta?.title ?? ''}
placeholder="Untitled"
onClick={e => e.stopPropagation()}
height={32}
onBlur={() => {
setShowRename(false);
}}
onChange={value => {
// FIXME: setPageTitle would make input blur, and can't input the Chinese character
setPageTitle(node.id, value);
}}
/>
) : (
<span>{currentMeta?.title || 'Untitled'}</span>
)}
{showOperationButton && (
<OperationButton
onAdd={onAdd}
onDelete={onDelete}
metas={metas}
currentMeta={currentMeta!}
blockSuiteWorkspace={blockSuiteWorkspace!}
isHover={isHover}
onMenuClose={() => setIsHover(false)}
onRename={() => {
setShowRename(true);
setIsHover(false);
}}
/>
)}
</StyledPivot>
);
};

View File

@ -1,18 +0,0 @@
import type { Node } from '@affine/component';
import type { PageMeta } from '@blocksuite/store';
import type { MouseEvent } from 'react';
import type { BlockSuiteWorkspace } from '../../../shared';
export type RenderProps = {
blockSuiteWorkspace: BlockSuiteWorkspace;
onClick?: (e: MouseEvent<HTMLDivElement>, node: TreeNode) => void;
showOperationButton?: boolean;
};
export type NodeRenderProps = RenderProps & {
metas: PageMeta[];
currentMeta: PageMeta;
};
export type TreeNode = Node<NodeRenderProps>;

View File

@ -44,6 +44,7 @@ export const OperationCell: React.FC<OperationCellProps> = ({
const { id, favorite } = pageMeta;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const OperationMenu = (
<>
<MenuItem
@ -65,20 +66,24 @@ export const OperationCell: React.FC<OperationCellProps> = ({
>
{t('Open in new tab')}
</MenuItem>
<MoveTo
metas={metas}
currentMeta={pageMeta}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
<MenuItem
data-testid="move-to-trash"
onClick={() => {
setOpen(true);
}}
icon={<DeleteTemporarilyIcon />}
>
{t('Move to Trash')}
</MenuItem>
{!pageMeta.isRootPinboard && (
<MoveTo
metas={metas}
currentMeta={pageMeta}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
)}
{!pageMeta.isRootPinboard && (
<MenuItem
data-testid="move-to-trash"
onClick={() => {
setOpen(true);
}}
icon={<DeleteTemporarilyIcon />}
>
{t('Move to Trash')}
</MenuItem>
)}
</>
);
return (
@ -90,7 +95,7 @@ export const OperationCell: React.FC<OperationCellProps> = ({
disablePortal={true}
trigger="click"
>
<IconButton>
<IconButton data-testid="page-list-operation-button">
<MoreVerticalIcon />
</IconButton>
</Menu>

View File

@ -82,17 +82,21 @@ export const EditorOptionMenu = () => {
{mode === 'page' ? t('Edgeless') : t('Page')}
</MenuItem>
<Export />
<MoveTo
metas={allMetas}
currentMeta={pageMeta}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
<MoveToTrash
testId="editor-option-menu-delete"
onItemClick={() => {
setOpenConfirm(true);
}}
/>
{!pageMeta.isRootPinboard && (
<MoveTo
metas={allMetas}
currentMeta={pageMeta}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
)}
{!pageMeta.isRootPinboard && (
<MoveToTrash
testId="editor-option-menu-delete"
onItemClick={() => {
setOpenConfirm(true);
}}
/>
)}
</>
);

View File

@ -0,0 +1,65 @@
import { TreeView } from '@affine/component';
import type { PageMeta } from '@blocksuite/store';
import type { MouseEvent } from 'react';
import { useCallback } from 'react';
import type { PinboardNode } from '../../../hooks/affine/use-pinboard-data';
import { usePinboardData } from '../../../hooks/affine/use-pinboard-data';
import { usePinboardHandler } from '../../../hooks/affine/use-pinboard-handler';
import type { BlockSuiteWorkspace } from '../../../shared';
import { PinboardRender } from '../../affine/pinboard';
export type PinboardProps = {
blockSuiteWorkspace: BlockSuiteWorkspace;
openPage: (pageId: string) => void;
allMetas: PageMeta[];
};
export const Pinboard = ({
blockSuiteWorkspace,
openPage,
allMetas,
}: PinboardProps) => {
const handlePinboardClick = useCallback(
(e: MouseEvent<HTMLDivElement>, node: PinboardNode) => {
openPage(node.id);
},
[openPage]
);
const onAdd = useCallback(
(id: string) => {
openPage(id);
},
[openPage]
);
const { data } = usePinboardData({
metas: allMetas.filter(meta => !meta.trash),
pinboardRender: PinboardRender,
blockSuiteWorkspace: blockSuiteWorkspace,
onClick: handlePinboardClick,
showOperationButton: true,
});
const { handleAdd, handleDelete, handleDrop } = usePinboardHandler({
blockSuiteWorkspace: blockSuiteWorkspace,
metas: allMetas,
onAdd,
});
if (!data.length) {
return null;
}
return (
<div data-testid="sidebar-pinboard-container">
<TreeView
data={data}
onAdd={handleAdd}
onDelete={handleDelete}
onDrop={handleDrop}
indent={16}
/>
</div>
);
};
export default Pinboard;

View File

@ -1,114 +0,0 @@
import { MuiCollapse, TreeView } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { ArrowDownSmallIcon, PivotsIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import type { MouseEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import type { BlockSuiteWorkspace } from '../../../shared';
import type { TreeNode } from '../../affine/pivots';
import {
PivotRender,
usePivotData,
usePivotHandler,
} from '../../affine/pivots';
import EmptyItem from './favorite/empty-item';
import { StyledCollapseButton, StyledListItem } from './shared-styles';
export const PivotInternal = ({
blockSuiteWorkspace,
openPage,
allMetas,
}: {
blockSuiteWorkspace: BlockSuiteWorkspace;
openPage: (pageId: string) => void;
allMetas: PageMeta[];
}) => {
const handlePivotClick = useCallback(
(e: MouseEvent<HTMLDivElement>, node: TreeNode) => {
openPage(node.id);
},
[openPage]
);
const onAdd = useCallback(
(id: string) => {
openPage(id);
},
[openPage]
);
const { data } = usePivotData({
metas: allMetas.filter(meta => !meta.trash),
pivotRender: PivotRender,
blockSuiteWorkspace: blockSuiteWorkspace,
onClick: handlePivotClick,
showOperationButton: true,
});
const { handleAdd, handleDelete, handleDrop } = usePivotHandler({
blockSuiteWorkspace: blockSuiteWorkspace,
metas: allMetas,
onAdd,
});
return (
<TreeView
data={data}
onAdd={handleAdd}
onDelete={handleDelete}
onDrop={handleDrop}
indent={16}
/>
);
};
export type PivotsProps = {
blockSuiteWorkspace: BlockSuiteWorkspace;
openPage: (pageId: string) => void;
allMetas: PageMeta[];
};
export const Pivots = ({
blockSuiteWorkspace,
openPage,
allMetas,
}: PivotsProps) => {
const { t } = useTranslation();
const [showPivot, setShowPivot] = useState(true);
const metas = useMemo(() => allMetas.filter(meta => !meta.trash), [allMetas]);
const isPivotEmpty = useMemo(
() => metas.filter(meta => meta.isPivots === true).length === 0,
[metas]
);
return (
<div data-testid="sidebar-pivots-container">
<StyledListItem
onClick={useCallback(() => {
setShowPivot(!showPivot);
}, [showPivot])}
>
<StyledCollapseButton collapse={showPivot}>
<ArrowDownSmallIcon />
</StyledCollapseButton>
<PivotsIcon />
{t('Pivots')}
</StyledListItem>
<MuiCollapse in={showPivot} style={{ paddingLeft: '16px' }}>
{isPivotEmpty ? (
<EmptyItem />
) : (
<PivotInternal
blockSuiteWorkspace={blockSuiteWorkspace}
openPage={openPage}
allMetas={metas}
/>
)}
</MuiCollapse>
</div>
);
};
export default Pivots;

View File

@ -7,8 +7,9 @@ import {
useGuideHidden,
useGuideHiddenUntilNextUpdate,
} from '../../../../hooks/affine/use-is-first-load';
import { StyledChangeLog, StyledChangeLogWarper } from '../shared-styles';
import { StyledChangeLog, StyledChangeLogWrapper } from '../shared-styles';
import { StyledLink } from '../style';
export const ChangeLog = () => {
const [guideHidden, setGuideHidden] = useGuideHidden();
const [guideHiddenUntilNextUpdate, setGuideHiddenUntilNextUpdate] =
@ -33,7 +34,7 @@ export const ChangeLog = () => {
return <></>;
}
return (
<StyledChangeLogWarper isClose={isClose}>
<StyledChangeLogWrapper isClose={isClose}>
<StyledChangeLog data-testid="change-log" isClose={isClose}>
<StyledLink href={'https://affine.pro'} target="_blank">
<NewIcon />
@ -49,7 +50,7 @@ export const ChangeLog = () => {
<CloseIcon />
</IconButton>
</StyledChangeLog>
</StyledChangeLogWarper>
</StyledChangeLogWrapper>
);
};

View File

@ -22,7 +22,7 @@ import type { AllWorkspace } from '../../../shared';
import { SidebarSwitch } from '../../affine/sidebar-switch';
import { ChangeLog } from './changeLog';
import Favorite from './favorite';
import { Pivots } from './Pivots';
import { Pinboard } from './Pinboard';
import { StyledListItem } from './shared-styles';
import {
StyledLink,
@ -188,7 +188,7 @@ export const WorkSpaceSliderBar: React.FC<WorkSpaceSliderBarProps> = ({
currentWorkspace={currentWorkspace}
/>
{!!blockSuiteWorkspace && (
<Pivots
<Pinboard
blockSuiteWorkspace={blockSuiteWorkspace}
openPage={openPage}
allMetas={pageMeta}

View File

@ -53,7 +53,7 @@ export const StyledCollapseButton = styled('button')<{
margin: 'auto',
color: theme.colors.iconColor,
opacity: '.6',
display: show ? 'block' : 'none',
display: show ? 'flex' : 'none',
svg: {
transform: `rotate(${collapse ? '0' : '-90'}deg)`,
},
@ -188,7 +188,7 @@ export const StyledChangeLog = styled('div')<{
},
};
});
export const StyledChangeLogWarper = styled('div')<{
export const StyledChangeLogWrapper = styled('div')<{
isClose?: boolean;
}>(({ isClose }) => {
return {

View File

@ -0,0 +1,79 @@
import type { Node } from '@affine/component';
import type { PageMeta } from '@blocksuite/store';
import type { MouseEvent } from 'react';
import { useMemo } from 'react';
import type { BlockSuiteWorkspace } from '../../shared';
export type RenderProps = {
blockSuiteWorkspace: BlockSuiteWorkspace;
onClick?: (e: MouseEvent<HTMLDivElement>, node: PinboardNode) => void;
showOperationButton?: boolean;
};
export type NodeRenderProps = RenderProps & {
metas: PageMeta[];
currentMeta: PageMeta;
};
export type PinboardNode = Node<NodeRenderProps>;
function flattenToTree(
metas: PageMeta[],
pinboardRender: PinboardNode['render'],
renderProps: RenderProps
): PinboardNode[] {
const rootMeta = metas.find(meta => meta.isRootPinboard);
const helper = (internalMetas: PageMeta[]): PinboardNode[] => {
return internalMetas.reduce<PinboardNode[]>(
(returnedMetas, internalMeta) => {
const { subpageIds = [] } = internalMeta;
const childrenMetas = subpageIds
.map(id => metas.find(m => m.id === id)!)
.filter(m => m);
// @ts-ignore
const returnedMeta: PinboardNode = {
...internalMeta,
children: helper(childrenMetas),
render: (node, props) =>
pinboardRender(node, props, {
...renderProps,
currentMeta: internalMeta,
metas,
}),
};
returnedMetas.push(returnedMeta);
return returnedMetas;
},
[]
);
};
return helper(rootMeta ? [{ ...rootMeta, renderTopLine: false }] : []);
}
export function usePinboardData({
metas,
pinboardRender,
blockSuiteWorkspace,
onClick,
showOperationButton,
}: {
metas: PageMeta[];
pinboardRender: PinboardNode['render'];
} & RenderProps) {
const data = useMemo(
() =>
flattenToTree(metas, pinboardRender, {
blockSuiteWorkspace,
onClick,
showOperationButton,
}),
[blockSuiteWorkspace, metas, onClick, pinboardRender, showOperationButton]
);
return {
data,
};
}
export default usePinboardData;

View File

@ -4,21 +4,21 @@ import type { PageMeta } from '@blocksuite/store';
import { nanoid } from '@blocksuite/store';
import { useCallback } from 'react';
import { useBlockSuiteWorkspaceHelper } from '../../../../hooks/use-blocksuite-workspace-helper';
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
import type { BlockSuiteWorkspace } from '../../../../shared';
import type { NodeRenderProps, TreeNode } from '../types';
import type { BlockSuiteWorkspace } from '../../shared';
import { useBlockSuiteWorkspaceHelper } from '../use-blocksuite-workspace-helper';
import { usePageMetaHelper } from '../use-page-meta';
import type { NodeRenderProps, PinboardNode } from './use-pinboard-data';
const logger = new DebugLogger('pivot');
const logger = new DebugLogger('pinboard');
const findRootIds = (metas: PageMeta[], id: string): string[] => {
function findRootIds(metas: PageMeta[], id: string): string[] {
const parentMeta = metas.find(m => m.subpageIds?.includes(id));
if (!parentMeta) {
return [id];
}
return [parentMeta.id, ...findRootIds(metas, parentMeta.id)];
};
export const usePivotHandler = ({
}
export function usePinboardHandler({
blockSuiteWorkspace,
metas,
onAdd,
@ -30,13 +30,12 @@ export const usePivotHandler = ({
onAdd?: (addedId: string, parentId: string) => void;
onDelete?: TreeViewProps<NodeRenderProps>['onDelete'];
onDrop?: TreeViewProps<NodeRenderProps>['onDrop'];
}) => {
}) {
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
const { getPageMeta, setPageMeta, shiftPageMeta } =
usePageMetaHelper(blockSuiteWorkspace);
const { getPageMeta, setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
const handleAdd = useCallback(
(node: TreeNode) => {
(node: PinboardNode) => {
const id = nanoid();
createPage(id, node.id);
onAdd?.(id, node.id);
@ -45,7 +44,7 @@ export const usePivotHandler = ({
);
const handleDelete = useCallback(
(node: TreeNode) => {
(node: PinboardNode) => {
const removeToTrash = (currentMeta: PageMeta) => {
const { subpageIds = [] } = currentMeta;
setPageMeta(currentMeta.id, {
@ -80,91 +79,57 @@ export const usePivotHandler = ({
if (dropRootIds.includes(dragId)) {
return;
}
const { topLine, bottomLine } = position;
logger.info('handleDrop', {
dragId,
dropId,
bottomLine,
position,
metas,
});
const { topLine, bottomLine } = position;
const dragParentMeta = metas.find(meta =>
meta.subpageIds?.includes(dragId)
);
if (bottomLine || topLine) {
const insertOffset = bottomLine ? 1 : 0;
const dropParentMeta = metas.find(m => m.subpageIds?.includes(dropId));
if (!dropParentMeta) {
// drop into root
logger.info('drop into root and resort');
if (dragParentMeta) {
const newSubpageIds = [...(dragParentMeta.subpageIds ?? [])];
const deleteIndex = dragParentMeta.subpageIds?.findIndex(
id => id === dragId
);
newSubpageIds.splice(deleteIndex, 1);
setPageMeta(dragParentMeta.id, {
subpageIds: newSubpageIds,
});
}
logger.info('resort root meta');
const insertIndex =
metas.findIndex(m => m.id === dropId) + insertOffset;
shiftPageMeta(dragId, insertIndex);
return onDrop?.(dragId, dropId, position);
}
if (
dragParentMeta &&
(dragParentMeta.id === dropId ||
dragParentMeta.id === dropParentMeta!.id)
) {
logger.info('drop to resort');
// need to resort
const newSubpageIds = [...(dragParentMeta.subpageIds ?? [])];
if (dropParentMeta?.id === dragParentMeta?.id) {
// same parent
const newSubpageIds = [...(dragParentMeta?.subpageIds ?? [])];
const deleteIndex = newSubpageIds.findIndex(id => id === dragId);
newSubpageIds.splice(deleteIndex, 1);
const insertIndex =
newSubpageIds.findIndex(id => id === dropId) + insertOffset;
newSubpageIds.splice(insertIndex, 0, dragId);
setPageMeta(dropParentMeta.id, {
subpageIds: newSubpageIds,
});
dragParentMeta &&
setPageMeta(dragParentMeta.id, {
subpageIds: newSubpageIds,
});
return onDrop?.(dragId, dropId, position);
}
const newDragParentSubpageIds = [...(dragParentMeta?.subpageIds ?? [])];
const deleteIndex = newDragParentSubpageIds.findIndex(
id => id === dragId
);
newDragParentSubpageIds.splice(deleteIndex, 1);
logger.info('drop into drop node parent and resort');
if (dragParentMeta) {
const metaIndex = dragParentMeta.subpageIds.findIndex(
id => id === dragId
);
const newSubpageIds = [...dragParentMeta.subpageIds];
newSubpageIds.splice(metaIndex, 1);
const newDropParentSubpageIds = [...(dropParentMeta?.subpageIds ?? [])];
const insertIndex =
newDropParentSubpageIds.findIndex(id => id === dropId) + insertOffset;
newDropParentSubpageIds.splice(insertIndex, 0, dragId);
dragParentMeta &&
setPageMeta(dragParentMeta.id, {
subpageIds: newSubpageIds,
subpageIds: newDragParentSubpageIds,
});
dropParentMeta &&
setPageMeta(dropParentMeta.id, {
subpageIds: newDropParentSubpageIds,
});
}
const newSubpageIds = [...(dropParentMeta!.subpageIds ?? [])];
const insertIndex = newSubpageIds.findIndex(id => id === dropId) + 1;
newSubpageIds.splice(insertIndex, 0, dragId);
setPageMeta(dropParentMeta.id, {
subpageIds: newSubpageIds,
});
return onDrop?.(dragId, dropId, position);
}
logger.info('drop into the drop node');
// drop into the node
if (dragParentMeta && dragParentMeta.id === dropId) {
return;
@ -185,7 +150,7 @@ export const usePivotHandler = ({
subpageIds: newSubpageIds,
});
},
[metas, onDrop, setPageMeta, shiftPageMeta]
[metas, onDrop, setPageMeta]
);
return {
@ -193,6 +158,6 @@ export const usePivotHandler = ({
handleAdd,
handleDelete,
};
};
}
export default usePivotHandler;
export default usePinboardHandler;

View File

@ -13,8 +13,7 @@ declare module '@blocksuite/store' {
trashDate?: number;
// whether to create the page with the default template
init?: boolean;
// use for subpage
isPivots?: boolean;
isRootPinboard?: boolean;
}
}
@ -44,10 +43,13 @@ export function usePageMeta(
return pageMeta;
}
export function usePageMetaHelper(blockSuiteWorkspace: BlockSuiteWorkspace) {
export function usePageMetaHelper(
blockSuiteWorkspace: BlockSuiteWorkspace | null
) {
return useMemo(
() => ({
setPageTitle: (pageId: string, newTitle: string) => {
assertExists(blockSuiteWorkspace);
const page = blockSuiteWorkspace.getPage(pageId);
assertExists(page);
const pageBlock = page
@ -58,15 +60,19 @@ export function usePageMetaHelper(blockSuiteWorkspace: BlockSuiteWorkspace) {
pageBlock.title.delete(0, pageBlock.title.length);
pageBlock.title.insert(newTitle, 0);
});
assertExists(blockSuiteWorkspace);
blockSuiteWorkspace.meta.setPageMeta(pageId, { title: newTitle });
},
setPageMeta: (pageId: string, pageMeta: Partial<PageMeta>) => {
assertExists(blockSuiteWorkspace);
blockSuiteWorkspace.meta.setPageMeta(pageId, pageMeta);
},
getPageMeta: (pageId: string) => {
assertExists(blockSuiteWorkspace);
return blockSuiteWorkspace.meta.getPageMeta(pageId);
},
shiftPageMeta: (pageId: string, index: number) => {
assertExists(blockSuiteWorkspace);
return blockSuiteWorkspace.meta.shiftPageMeta(pageId, index);
},
}),

View File

@ -19,6 +19,7 @@ import { useSyncRouterWithCurrentWorkspace } from '../../../hooks/use-sync-route
import { WorkspaceLayout } from '../../../layouts';
import { WorkspacePlugins } from '../../../plugins';
import type { NextPageWithLayout } from '../../../shared';
import { ensureRootPinboard } from '../../../utils';
const AllPage: NextPageWithLayout = () => {
const router = useRouter();
@ -35,6 +36,8 @@ const AllPage: NextPageWithLayout = () => {
}
if (currentWorkspace.flavour !== WorkspaceFlavour.LOCAL) {
// only create a new page for local workspace
// just ensure the root pinboard exists
ensureRootPinboard(currentWorkspace.blockSuiteWorkspace);
return;
}
const localProvider = currentWorkspace.providers.find(
@ -53,6 +56,8 @@ const AllPage: NextPageWithLayout = () => {
});
jumpToPage(currentWorkspace.id, pageId);
}
// no matter workspace is empty, ensure the root pinboard exists
ensureRootPinboard(currentWorkspace.blockSuiteWorkspace);
};
provider.callbacks.add(callback);
return () => {

View File

@ -1,8 +1,10 @@
import { DebugLogger } from '@affine/debug';
import markdown from '@affine/templates/Welcome-to-AFFiNE.md';
import { ContentParser } from '@blocksuite/blocks/content-parser';
import type { EditorContainer } from '@blocksuite/editor';
import type { Page } from '@blocksuite/store';
import { nanoid } from '@blocksuite/store';
import type { BlockSuiteWorkspace } from '../shared';
const demoTitle = markdown
.split('\n')
@ -15,7 +17,7 @@ const demoText = markdown.split('\n').slice(1).join('\n');
const logger = new DebugLogger('init-page');
export function initPage(page: Page, editor: Readonly<EditorContainer>): void {
export function initPage(page: Page): void {
logger.debug('initEmptyPage', page.id);
// Add page block and surface block at root level
const isFirstPage = page.meta.init === true;
@ -23,26 +25,23 @@ export function initPage(page: Page, editor: Readonly<EditorContainer>): void {
page.workspace.setPageMeta(page.id, {
init: false,
});
_initPageWithDemoMarkdown(page, editor);
_initPageWithDemoMarkdown(page);
} else {
_initEmptyPage(page, editor);
_initEmptyPage(page);
}
page.resetHistory();
}
export function _initEmptyPage(page: Page, _: Readonly<EditorContainer>) {
export function _initEmptyPage(page: Page, title?: string): void {
const pageBlockId = page.addBlock('affine:page', {
title: new page.Text(''),
title: new page.Text(title ?? ''),
});
page.addBlock('affine:surface', {}, null);
const frameId = page.addBlock('affine:frame', {}, pageBlockId);
page.addBlock('affine:paragraph', {}, frameId);
}
export function _initPageWithDemoMarkdown(
page: Page,
editor: Readonly<EditorContainer>
): void {
export function _initPageWithDemoMarkdown(page: Page): void {
logger.debug('initPageWithDefaultMarkdown', page.id);
const pageBlockId = page.addBlock('affine:page', {
title: new page.Text(demoTitle),
@ -63,3 +62,25 @@ export function _initPageWithDemoMarkdown(
});
page.workspace.setPageMeta(page.id, { demoTitle });
}
export function ensureRootPinboard(blockSuiteWorkspace: BlockSuiteWorkspace) {
const metas = blockSuiteWorkspace.meta.pageMetas;
const rootMeta = metas.find(m => m.isRootPinboard);
if (rootMeta) {
return rootMeta.id;
}
const rootPinboardPage = blockSuiteWorkspace.createPage(nanoid());
const title = `${blockSuiteWorkspace.meta.name}'s Pinboard`;
_initEmptyPage(rootPinboardPage, title);
blockSuiteWorkspace.meta.setPageMeta(rootPinboardPage.id, {
isRootPinboard: true,
title,
});
return rootPinboardPage.id;
}

View File

@ -144,11 +144,12 @@ export const TreeNode = <RenderProps,>({
}: TreeNodeProps<RenderProps>) => {
const { indent, enableDnd, collapsedIds } = otherProps;
const collapsed = collapsedIds.includes(node.id);
const { renderTopLine = true, renderBottomLine = true } = node;
return (
<StyledTreeNodeContainer ref={dragRef} isDragging={isDragging}>
<StyledTreeNodeWrapper>
{enableDnd && index === 0 && (
{enableDnd && renderTopLine && index === 0 && (
<NodeLine
node={node}
{...otherProps}
@ -174,13 +175,15 @@ export const TreeNode = <RenderProps,>({
/>
)}
{enableDnd && (!node.children?.length || collapsed) && (
<NodeLine
node={node}
{...otherProps}
allowDrop={!isDragging && allowDrop}
/>
)}
{enableDnd &&
renderBottomLine &&
(!node.children?.length || collapsed) && (
<NodeLine
node={node}
{...otherProps}
allowDrop={!isDragging && allowDrop}
/>
)}
</StyledTreeNodeWrapper>
<StyledCollapse in={!collapsed} indent={indent}>
{node.children &&

View File

@ -26,6 +26,8 @@ export type Node<RenderProps = unknown> = {
},
renderProps?: RenderProps
) => ReactNode;
renderTopLine?: boolean;
renderBottomLine?: boolean;
};
type CommonProps<RenderProps = unknown> = {

View File

@ -1,3 +1,4 @@
import type { PageMeta } from '@blocksuite/store';
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
@ -123,3 +124,9 @@ export async function loginUser(
export async function openHomePage(page: Page) {
return page.goto('http://localhost:8080');
}
export async function getMetas(page: Page): Promise<PageMeta[]> {
return page.evaluate(
() => globalThis.currentWorkspace.blockSuiteWorkspace.meta.pageMetas ?? []
);
}

View File

@ -4,8 +4,9 @@ import { expect } from '@playwright/test';
import { openHomePage } from '../libs/load-page';
import { clickPageMoreActions, newPage } from '../libs/page-logic';
import { test } from '../libs/playwright';
import { getMetas } from '../libs/utils';
async function createRootPage(page: Page, title: string) {
async function createPinboardPage(page: Page, parentId: string, title: string) {
await newPage(page);
await page.focus('.affine-default-page-block-title');
await page.type('.affine-default-page-block-title', title, {
@ -13,73 +14,249 @@ async function createRootPage(page: Page, title: string) {
});
await clickPageMoreActions(page);
await page.getByTestId('move-to-menu-item').click();
await page.getByTestId('root-pivot-button-in-pivots-menu').click();
await page
.getByTestId('pinboard-menu')
.getByTestId(`pinboard-${parentId}`)
.click();
}
async function createPivotPage(page: Page, title: string, parentTitle: string) {
await newPage(page);
await page.focus('.affine-default-page-block-title');
await page.type('.affine-default-page-block-title', title, {
delay: 100,
});
await clickPageMoreActions(page);
await page.getByTestId('move-to-menu-item').click();
await page.getByTestId('pivots-menu').getByText(parentTitle).click();
}
export async function initPinBoard(page: Page) {
async function initHomePageWithPinboard(page: Page) {
await openHomePage(page);
await createRootPage(page, 'parent1');
await createRootPage(page, 'parent2');
await createPivotPage(page, 'child1', 'parent1');
await createPivotPage(page, 'child2', 'parent1');
await page.waitForSelector('[data-testid="sidebar-pinboard-container"]');
return (await getMetas(page)).find(m => m.isRootPinboard);
}
async function openPinboardPageOperationMenu(page: Page, id: string) {
const node = await page
.getByTestId('sidebar-pinboard-container')
.getByTestId(`pinboard-${id}`);
await node.hover();
await node.getByTestId('pinboard-operation-button').click();
}
test.describe('PinBoard interaction', () => {
test('Add pivot', async ({ page }) => {
await initPinBoard(page);
test('Have initial root pinboard page when first in', async ({ page }) => {
const rootPinboardMeta = await initHomePageWithPinboard(page);
expect(rootPinboardMeta).not.toBeUndefined();
});
test('Remove pivots', async ({ page }) => {
await initPinBoard(page);
const child2Meta = await page.evaluate(() => {
return globalThis.currentWorkspace.blockSuiteWorkspace.meta.pageMetas.find(
m => m.title === 'child2'
);
});
const child2 = await page
.getByTestId('sidebar-pivots-container')
.getByTestId(`pivot-${child2Meta?.id}`);
await child2.hover();
await child2.getByTestId('pivot-operation-button').click();
await page.getByTestId('pivot-operation-move-to').click();
await page.getByTestId('remove-from-pivots-button').click();
await page.waitForTimeout(1000);
test('Root pinboard page have no operation of "trash" ,"rename" and "move to"', async ({
page,
}) => {
const rootPinboardMeta = await initHomePageWithPinboard(page);
await openPinboardPageOperationMenu(page, rootPinboardMeta?.id ?? '');
expect(
await page.locator(`[data-testid="pivot-${child2Meta.id}"]`).count()
await page
.locator(`[data-testid="pinboard-operation-move-to-trash"]`)
.count()
).toEqual(0);
expect(
await page.locator(`[data-testid="pinboard-operation-rename"]`).count()
).toEqual(0);
expect(
await page.locator(`[data-testid="pinboard-operation-move-to"]`).count()
).toEqual(0);
});
test('search pivot', async ({ page }) => {
await initPinBoard(page);
test('Click Pinboard in sidebar should open root pinboard page', async ({
page,
}) => {
const rootPinboardMeta = await initHomePageWithPinboard(page);
await page.getByTestId(`pinboard-${rootPinboardMeta?.id}`).click();
await page.waitForTimeout(200);
expect(await page.locator('affine-editor-container')).not.toBeNull();
});
const child2Meta = await page.evaluate(() => {
return globalThis.currentWorkspace.blockSuiteWorkspace.meta.pageMetas.find(
m => m.title === 'child2'
);
});
test('Add pinboard by header operation menu', async ({ page }) => {
const rootPinboardMeta = await initHomePageWithPinboard(page);
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
const meta = (await getMetas(page)).find(m => m.title === 'test1');
expect(meta).not.toBeUndefined();
expect(
await page
.getByTestId('[data-testid="sidebar-pinboard-container"]')
.getByTestId(`pinboard-${meta?.id}`)
).not.toBeNull();
});
const child2 = await page
.getByTestId('sidebar-pivots-container')
.getByTestId(`pivot-${child2Meta?.id}`);
await child2.hover();
await child2.getByTestId('pivot-operation-button').click();
await page.getByTestId('pivot-operation-move-to').click();
test('Add pinboard by sidebar operation menu', async ({ page }) => {
const rootPinboardMeta = await initHomePageWithPinboard(page);
await page.fill('[data-testid="pivots-menu-search"]', '111');
await openPinboardPageOperationMenu(page, rootPinboardMeta?.id ?? '');
await page.getByTestId('pinboard-operation-add').click();
const newPageMeta = (await getMetas(page)).find(
m => m.id !== rootPinboardMeta?.id
);
expect(
await page
.getByTestId('sidebar-pinboard-container')
.getByTestId(`pinboard-${newPageMeta?.id}`)
).not.toBeNull();
});
test('Move pinboard to another in sidebar', async ({ page }) => {
const rootPinboardMeta = await initHomePageWithPinboard(page);
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test2');
const childMeta = (await getMetas(page)).find(m => m.title === 'test1');
const childMeta2 = (await getMetas(page)).find(m => m.title === 'test2');
await openPinboardPageOperationMenu(page, childMeta?.id ?? '');
await page.getByTestId('pinboard-operation-move-to').click();
await page
.getByTestId('pinboard-menu')
.getByTestId(`pinboard-${childMeta2?.id}`)
.click();
expect(
(await getMetas(page)).find(m => m.title === 'test2')?.subpageIds.length
).toBe(1);
});
test('Should no show pinboard self in move to menu', async ({ page }) => {
const rootPinboardMeta = await initHomePageWithPinboard(page);
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test2');
const childMeta = (await getMetas(page)).find(m => m.title === 'test1');
const childMeta2 = (await getMetas(page)).find(m => m.title === 'test2');
await page.getByTestId('all-pages').click();
await page
.getByTestId(`page-list-item-${childMeta?.id}`)
.getByTestId('page-list-operation-button')
.click();
await page.getByTestId('move-to-menu-item').click();
expect(
await page
.getByTestId('pinboard-menu')
.locator(`[data-testid="pinboard-${childMeta?.id}"]`)
.count()
).toEqual(0);
});
test('Move pinboard to another in page list', async ({ page }) => {
const rootPinboardMeta = await initHomePageWithPinboard(page);
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test2');
const childMeta = (await getMetas(page)).find(m => m.title === 'test1');
const childMeta2 = (await getMetas(page)).find(m => m.title === 'test2');
await page.getByTestId('all-pages').click();
await page
.getByTestId(`page-list-item-${childMeta?.id}`)
.getByTestId('page-list-operation-button')
.click();
await page.getByTestId('move-to-menu-item').click();
await page
.getByTestId('pinboard-menu')
.getByTestId(`pinboard-${childMeta2?.id}`)
.click();
expect(
(await getMetas(page)).find(m => m.title === 'test2')?.subpageIds.length
).toBe(1);
});
test('Remove from pinboard', async ({ page }) => {
const rootPinboardMeta = await initHomePageWithPinboard(page);
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
const childMeta = (await getMetas(page)).find(m => m.title === 'test1');
await openPinboardPageOperationMenu(page, childMeta?.id ?? '');
await page.getByTestId('pinboard-operation-move-to').click();
await page.getByTestId('remove-from-pinboard-button').click();
await page.waitForTimeout(1000);
expect(
await page.locator(`[data-testid="pinboard-${childMeta?.id}"]`).count()
).toEqual(0);
});
test('search pinboard', async ({ page }) => {
const rootPinboardMeta = await initHomePageWithPinboard(page);
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
const childMeta = (await getMetas(page)).find(m => m.title === 'test1');
await openPinboardPageOperationMenu(page, childMeta?.id ?? '');
await page.getByTestId('pinboard-operation-move-to').click();
await page.fill('[data-testid="pinboard-menu-search"]', '111');
expect(await page.locator('[alt="no result"]').count()).toEqual(1);
await page.fill('[data-testid="pivots-menu-search"]', 'parent2');
await page.fill('[data-testid="pinboard-menu-search"]', 'test1');
expect(
await page.locator('[data-testid="pivot-search-result"]').count()
await page.locator('[data-testid="pinboard-search-result"]').count()
).toEqual(1);
});
test('Rename pinboard', async ({ page }) => {
const rootPinboardMeta = await initHomePageWithPinboard(page);
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
const childMeta = (await getMetas(page)).find(m => m.title === 'test1');
await openPinboardPageOperationMenu(page, childMeta?.id ?? '');
await page.getByTestId('pinboard-operation-rename').click();
await page.fill(`[data-testid="pinboard-input-${childMeta?.id}"]`, 'test2');
const title = (await page
.locator('.affine-default-page-block-title')
.textContent()) as string;
expect(title).toEqual('test2');
});
test('Move pinboard to trash', async ({ page }) => {
const rootPinboardMeta = await initHomePageWithPinboard(page);
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
const childMeta = (await getMetas(page)).find(m => m.title === 'test1');
await createPinboardPage(page, childMeta?.id ?? '', 'test2');
const grandChildMeta = (await getMetas(page)).find(
m => m.title === 'test2'
);
await openPinboardPageOperationMenu(page, childMeta?.id ?? '');
await page.getByTestId('pinboard-operation-move-to-trash').click();
(
await page.waitForSelector('[data-testid="move-to-trash-confirm"]')
).click();
await page.waitForTimeout(1000);
expect(
await page
.getByTestId('sidebar-pinboard-container')
.locator(`[data-testid="pinboard-${childMeta?.id}"]`)
.count()
).toEqual(0);
expect(
await page
.getByTestId('sidebar-pinboard-container')
.locator(`[data-testid="pinboard-${grandChildMeta?.id}"]`)
.count()
).toEqual(0);
});
// FIXME
test.skip('Copy link', async ({ page }) => {
const rootPinboardMeta = await initHomePageWithPinboard(page);
await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1');
const childMeta = (await getMetas(page)).find(m => m.title === 'test1');
await openPinboardPageOperationMenu(page, childMeta?.id ?? '');
await page.getByTestId('copy-link').click();
await page.evaluate(() => {
const input = document.createElement('input');
input.id = 'paste-input';
document.body.appendChild(input);
input.focus();
});
await page.keyboard.press(`Meta+v`, { delay: 50 });
await page.keyboard.press(`Control+v`, { delay: 50 });
const copiedValue = await page
.locator('#paste-input')
.evaluate((input: HTMLInputElement) => input.value);
expect(copiedValue).toEqual(page.url());
});
});