mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-09-20 07:57:29 +03:00
feat: support for view management (#2892)
This commit is contained in:
parent
d3393cb0fc
commit
9d0db78f64
@ -34,7 +34,7 @@
|
||||
"@blocksuite/blocks": "0.0.0-20230629103121-76e6587d-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20230629103121-76e6587d-nightly",
|
||||
"@blocksuite/global": "0.0.0-20230629103121-76e6587d-nightly",
|
||||
"@blocksuite/icons": "^2.1.21",
|
||||
"@blocksuite/icons": "^2.1.23",
|
||||
"@blocksuite/lit": "0.0.0-20230629103121-76e6587d-nightly",
|
||||
"@blocksuite/store": "0.0.0-20230629103121-76e6587d-nightly",
|
||||
"react": "18.3.0-canary-8ec962d82-20230623",
|
||||
|
@ -23,7 +23,7 @@
|
||||
"@blocksuite/blocks": "0.0.0-20230629103121-76e6587d-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20230629103121-76e6587d-nightly",
|
||||
"@blocksuite/global": "0.0.0-20230629103121-76e6587d-nightly",
|
||||
"@blocksuite/icons": "^2.1.21",
|
||||
"@blocksuite/icons": "^2.1.23",
|
||||
"@blocksuite/lit": "0.0.0-20230629103121-76e6587d-nightly",
|
||||
"@blocksuite/store": "0.0.0-20230629103121-76e6587d-nightly",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
|
@ -336,10 +336,10 @@ export const AffineAdapter: WorkspaceAdapter<WorkspaceFlavour.AFFINE> = {
|
||||
</>
|
||||
);
|
||||
},
|
||||
PageList: ({ blockSuiteWorkspace, onOpenPage, view }) => {
|
||||
PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => {
|
||||
return (
|
||||
<BlockSuitePageList
|
||||
view={view}
|
||||
collection={collection}
|
||||
listType="all"
|
||||
onOpenPage={onOpenPage}
|
||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||
|
@ -95,11 +95,11 @@ export const LocalAdapter: WorkspaceAdapter<WorkspaceFlavour.LOCAL> = {
|
||||
</>
|
||||
);
|
||||
},
|
||||
PageList: ({ blockSuiteWorkspace, onOpenPage, view }) => {
|
||||
PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => {
|
||||
return (
|
||||
<BlockSuitePageList
|
||||
listType="all"
|
||||
view={view}
|
||||
collection={collection}
|
||||
onOpenPage={onOpenPage}
|
||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||
/>
|
||||
|
@ -1,11 +1,7 @@
|
||||
import { Empty } from '@affine/component';
|
||||
import type { ListData, TrashListData } from '@affine/component/page-list';
|
||||
import {
|
||||
filterByFilterList,
|
||||
PageList,
|
||||
PageListTrashView,
|
||||
} from '@affine/component/page-list';
|
||||
import type { View } from '@affine/env/filter';
|
||||
import { PageList, PageListTrashView } from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
|
||||
@ -18,8 +14,10 @@ import { useMemo } from 'react';
|
||||
|
||||
import { allPageModeSelectAtom } from '../../../atoms';
|
||||
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
|
||||
import { useGetPageInfoById } from '../../../hooks/use-get-page-info';
|
||||
import type { BlockSuiteWorkspace } from '../../../shared';
|
||||
import { toast } from '../../../utils';
|
||||
import { filterPage } from '../../../utils/filter';
|
||||
import { emptyDescButton, emptyDescKbd, pageListEmptyStyle } from './index.css';
|
||||
import { usePageHelper } from './utils';
|
||||
|
||||
@ -28,7 +26,7 @@ export type BlockSuitePageListProps = {
|
||||
listType: 'all' | 'trash' | 'shared' | 'public';
|
||||
isPublic?: true;
|
||||
onOpenPage: (pageId: string, newTab?: boolean) => void;
|
||||
view?: View;
|
||||
collection?: Collection;
|
||||
};
|
||||
|
||||
const filter = {
|
||||
@ -97,7 +95,7 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
|
||||
onOpenPage,
|
||||
listType,
|
||||
isPublic = false,
|
||||
view,
|
||||
collection,
|
||||
}) => {
|
||||
const pageMetas = useBlockSuitePageMeta(blockSuiteWorkspace);
|
||||
const {
|
||||
@ -111,6 +109,7 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
|
||||
const { createPage, createEdgeless, importFile, isPreferredEdgeless } =
|
||||
usePageHelper(blockSuiteWorkspace);
|
||||
const t = useAFFiNEI18N();
|
||||
const getPageInfo = useGetPageInfoById();
|
||||
const list = useMemo(
|
||||
() =>
|
||||
pageMetas
|
||||
@ -131,16 +130,12 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
|
||||
if (!filter[listType](pageMeta, pageMetas)) {
|
||||
return false;
|
||||
}
|
||||
if (!view) {
|
||||
if (!collection) {
|
||||
return true;
|
||||
}
|
||||
return filterByFilterList(view.filterList, {
|
||||
'Is Favourited': !!pageMeta.favorite,
|
||||
Created: pageMeta.createDate,
|
||||
Updated: pageMeta.updatedDate ?? pageMeta.createDate,
|
||||
});
|
||||
return filterPage(collection, pageMeta);
|
||||
}),
|
||||
[pageMetas, filterMode, isPreferredEdgeless, listType, view]
|
||||
[pageMetas, filterMode, isPreferredEdgeless, listType, collection]
|
||||
);
|
||||
|
||||
if (listType === 'trash') {
|
||||
@ -222,9 +217,9 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<PageList
|
||||
getPageInfo={getPageInfo}
|
||||
onCreateNewPage={createPage}
|
||||
onCreateNewEdgeless={createEdgeless}
|
||||
onImportFile={importFile}
|
||||
|
@ -0,0 +1,274 @@
|
||||
import { Menu } from '@affine/component';
|
||||
import { MenuItem } from '@affine/component/app-sidebar';
|
||||
import {
|
||||
EditCollectionModel,
|
||||
useAllPageSetting,
|
||||
useSavedCollections,
|
||||
} from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import {
|
||||
DeleteIcon,
|
||||
FilterIcon,
|
||||
MoreHorizontalIcon,
|
||||
UnpinIcon,
|
||||
ViewLayersIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ReactElement } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useGetPageInfoById } from '../../../../hooks/use-get-page-info';
|
||||
import type { AllWorkspace } from '../../../../shared';
|
||||
import { filterPage } from '../../../../utils/filter';
|
||||
import type { CollectionsListProps } from '../index';
|
||||
import { Page } from './page';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const Collections_DROP_AREA_PREFIX = 'collections-';
|
||||
const isCollectionsDropArea = (id?: string | number) => {
|
||||
return typeof id === 'string' && id.startsWith(Collections_DROP_AREA_PREFIX);
|
||||
};
|
||||
export const processCollectionsDrag = (e: DragEndEvent) => {
|
||||
if (
|
||||
isCollectionsDropArea(e.over?.id) &&
|
||||
String(e.active.id).startsWith('page-list-item-')
|
||||
) {
|
||||
e.over?.data.current?.addToCollection?.(e.active.data.current?.pageId);
|
||||
}
|
||||
};
|
||||
const CollectionOperations = ({
|
||||
view,
|
||||
showUpdateCollection,
|
||||
setting,
|
||||
}: {
|
||||
view: Collection;
|
||||
showUpdateCollection: () => void;
|
||||
setting: ReturnType<typeof useAllPageSetting>;
|
||||
}) => {
|
||||
const actions = useMemo<
|
||||
Array<
|
||||
| {
|
||||
icon: ReactElement;
|
||||
name: string;
|
||||
click: () => void;
|
||||
className?: string;
|
||||
element?: undefined;
|
||||
}
|
||||
| {
|
||||
element: ReactElement;
|
||||
}
|
||||
>
|
||||
>(
|
||||
() => [
|
||||
{
|
||||
icon: <FilterIcon />,
|
||||
name: 'Edit Filter',
|
||||
click: showUpdateCollection,
|
||||
},
|
||||
{
|
||||
icon: <UnpinIcon />,
|
||||
name: 'Unpin',
|
||||
click: () => {
|
||||
return setting.updateCollection({
|
||||
...view,
|
||||
pinned: false,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
element: <div key="divider" className={styles.menuDividerStyle}></div>,
|
||||
},
|
||||
{
|
||||
icon: <DeleteIcon style={{ color: 'var(--affine-warning-color)' }} />,
|
||||
name: 'Delete',
|
||||
click: () => {
|
||||
return setting.deleteCollection(view.id);
|
||||
},
|
||||
className: styles.deleteFolder,
|
||||
},
|
||||
],
|
||||
[setting, showUpdateCollection, view]
|
||||
);
|
||||
return (
|
||||
<div style={{ minWidth: 150 }}>
|
||||
{actions.map(action => {
|
||||
if (action.element) {
|
||||
return action.element;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
data-testid="collection-option"
|
||||
key={action.name}
|
||||
className={action.className}
|
||||
icon={action.icon}
|
||||
onClick={action.click}
|
||||
>
|
||||
{action.name}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const CollectionRenderer = ({
|
||||
collection,
|
||||
pages,
|
||||
workspace,
|
||||
getPageInfo,
|
||||
}: {
|
||||
collection: Collection;
|
||||
pages: PageMeta[];
|
||||
workspace: AllWorkspace;
|
||||
getPageInfo: GetPageInfoById;
|
||||
}) => {
|
||||
const [collapsed, setCollapsed] = React.useState(true);
|
||||
const setting = useAllPageSetting();
|
||||
const router = useRouter();
|
||||
const clickCollection = useCallback(() => {
|
||||
router
|
||||
.push(`/workspace/${workspace.id}/all`)
|
||||
.then(() => {
|
||||
setting.selectCollection(collection.id);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [router, workspace.id, setting, collection.id]);
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `${Collections_DROP_AREA_PREFIX}${collection.id}`,
|
||||
data: {
|
||||
addToCollection: (id: string) => {
|
||||
setting.addPage(collection.id, id).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
const allPagesMeta = useMemo(
|
||||
() => Object.fromEntries(pages.map(v => [v.id, v])),
|
||||
[pages]
|
||||
);
|
||||
const [show, showUpdateCollection] = useState(false);
|
||||
const allowList = useMemo(
|
||||
() => new Set(collection.allowList),
|
||||
[collection.allowList]
|
||||
);
|
||||
const excludeList = useMemo(
|
||||
() => new Set(collection.excludeList),
|
||||
[collection.excludeList]
|
||||
);
|
||||
const removeFromAllowList = useCallback(
|
||||
(id: string) => {
|
||||
return setting.updateCollection({
|
||||
...collection,
|
||||
allowList: collection.allowList?.filter(v => v != id),
|
||||
});
|
||||
},
|
||||
[collection, setting]
|
||||
);
|
||||
const addToExcludeList = useCallback(
|
||||
(id: string) => {
|
||||
return setting.updateCollection({
|
||||
...collection,
|
||||
excludeList: [id, ...(collection.excludeList ?? [])],
|
||||
});
|
||||
},
|
||||
[collection, setting]
|
||||
);
|
||||
const pagesToRender = pages.filter(
|
||||
page => filterPage(collection, page) && !page.trash
|
||||
);
|
||||
return (
|
||||
<Collapsible.Root open={!collapsed}>
|
||||
<EditCollectionModel
|
||||
getPageInfo={getPageInfo}
|
||||
init={collection}
|
||||
onConfirm={setting.saveCollection}
|
||||
open={show}
|
||||
onClose={() => showUpdateCollection(false)}
|
||||
/>
|
||||
<MenuItem
|
||||
data-testid="collection-item"
|
||||
ref={setNodeRef}
|
||||
onCollapsedChange={setCollapsed}
|
||||
active={isOver}
|
||||
icon={<ViewLayersIcon />}
|
||||
postfix={
|
||||
<Menu
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
content={
|
||||
<CollectionOperations
|
||||
view={collection}
|
||||
showUpdateCollection={() => showUpdateCollection(true)}
|
||||
setting={setting}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div data-testid="collection-options" className={styles.more}>
|
||||
<MoreHorizontalIcon></MoreHorizontalIcon>
|
||||
</div>
|
||||
</Menu>
|
||||
}
|
||||
collapsed={pagesToRender.length > 0 ? collapsed : undefined}
|
||||
onClick={clickCollection}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>{collection.name}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<Collapsible.Content>
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
{pagesToRender.map(page => {
|
||||
return (
|
||||
<Page
|
||||
inAllowList={allowList.has(page.id)}
|
||||
removeFromAllowList={removeFromAllowList}
|
||||
inExcludeList={excludeList.has(page.id)}
|
||||
addToExcludeList={addToExcludeList}
|
||||
allPageMeta={allPagesMeta}
|
||||
page={page}
|
||||
key={page.id}
|
||||
workspace={workspace}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
||||
export const CollectionsList = ({ currentWorkspace }: CollectionsListProps) => {
|
||||
const metas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
|
||||
const { savedCollections } = useSavedCollections();
|
||||
const getPageInfo = useGetPageInfoById();
|
||||
return (
|
||||
<div data-testid="collections" className={styles.wrapper}>
|
||||
{savedCollections
|
||||
.filter(v => v.pinned)
|
||||
.map(view => {
|
||||
return (
|
||||
<CollectionRenderer
|
||||
getPageInfo={getPageInfo}
|
||||
key={view.id}
|
||||
collection={view}
|
||||
pages={metas}
|
||||
workspace={currentWorkspace}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
export * from './collections-list';
|
||||
export { Page } from './page';
|
||||
export { PageOperations } from './page';
|
@ -0,0 +1,200 @@
|
||||
import { Menu } from '@affine/component';
|
||||
import { MenuItem } from '@affine/component/app-sidebar';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
DeleteIcon,
|
||||
EdgelessIcon,
|
||||
FilterIcon,
|
||||
MoreHorizontalIcon,
|
||||
PageIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import { useBlockSuitePageReferences } from '@toeverything/hooks/use-block-suite-page-references';
|
||||
import { useAtomValue } from 'jotai/index';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ReactElement } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { pageSettingFamily } from '../../../../atoms';
|
||||
import { useBlockSuiteMetaHelper } from '../../../../hooks/affine/use-block-suite-meta-helper';
|
||||
import type { AllWorkspace } from '../../../../shared';
|
||||
import { ReferencePage } from '../components/reference-page';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const PageOperations = ({
|
||||
page,
|
||||
inAllowList,
|
||||
addToExcludeList,
|
||||
removeFromAllowList,
|
||||
inExcludeList,
|
||||
workspace,
|
||||
}: {
|
||||
workspace: Workspace;
|
||||
page: PageMeta;
|
||||
inAllowList: boolean;
|
||||
removeFromAllowList: (id: string) => void;
|
||||
inExcludeList: boolean;
|
||||
addToExcludeList: (id: string) => void;
|
||||
}) => {
|
||||
const { removeToTrash } = useBlockSuiteMetaHelper(workspace);
|
||||
const actions = useMemo<
|
||||
Array<
|
||||
| {
|
||||
icon: ReactElement;
|
||||
name: string;
|
||||
click: () => void;
|
||||
className?: string;
|
||||
element?: undefined;
|
||||
}
|
||||
| {
|
||||
element: ReactElement;
|
||||
}
|
||||
>
|
||||
>(
|
||||
() => [
|
||||
...(inAllowList
|
||||
? [
|
||||
{
|
||||
icon: <FilterIcon />,
|
||||
name: 'Remove special filter',
|
||||
click: () => removeFromAllowList(page.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!inExcludeList
|
||||
? [
|
||||
{
|
||||
icon: <FilterIcon />,
|
||||
name: 'Exclude from filter',
|
||||
click: () => addToExcludeList(page.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
element: <div key="divider" className={styles.menuDividerStyle}></div>,
|
||||
},
|
||||
{
|
||||
icon: <DeleteIcon style={{ color: 'var(--affine-warning-color)' }} />,
|
||||
name: 'Delete',
|
||||
click: () => {
|
||||
removeToTrash(page.id);
|
||||
},
|
||||
className: styles.deleteFolder,
|
||||
},
|
||||
],
|
||||
[
|
||||
inAllowList,
|
||||
inExcludeList,
|
||||
page.id,
|
||||
removeFromAllowList,
|
||||
addToExcludeList,
|
||||
removeToTrash,
|
||||
]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{actions.map(action => {
|
||||
if (action.element) {
|
||||
return action.element;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
data-testid="collection-page-option"
|
||||
key={action.name}
|
||||
className={action.className}
|
||||
icon={action.icon}
|
||||
onClick={action.click}
|
||||
>
|
||||
{action.name}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export const Page = ({
|
||||
page,
|
||||
workspace,
|
||||
allPageMeta,
|
||||
inAllowList,
|
||||
inExcludeList,
|
||||
removeFromAllowList,
|
||||
addToExcludeList,
|
||||
}: {
|
||||
page: PageMeta;
|
||||
inAllowList: boolean;
|
||||
removeFromAllowList: (id: string) => void;
|
||||
inExcludeList: boolean;
|
||||
addToExcludeList: (id: string) => void;
|
||||
workspace: AllWorkspace;
|
||||
allPageMeta: Record<string, PageMeta>;
|
||||
}) => {
|
||||
const [collapsed, setCollapsed] = React.useState(true);
|
||||
const router = useRouter();
|
||||
const t = useAFFiNEI18N();
|
||||
const pageId = page.id;
|
||||
const active = router.query.pageId === pageId;
|
||||
const setting = useAtomValue(pageSettingFamily(pageId));
|
||||
const icon = setting?.mode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
||||
const references = useBlockSuitePageReferences(
|
||||
workspace.blockSuiteWorkspace,
|
||||
pageId
|
||||
);
|
||||
const clickPage = useCallback(() => {
|
||||
return router.push(`/workspace/${workspace.id}/${page.id}`);
|
||||
}, [page.id, router, workspace.id]);
|
||||
const referencesToRender = references.filter(id => !allPageMeta[id]?.trash);
|
||||
return (
|
||||
<Collapsible.Root open={!collapsed}>
|
||||
<MenuItem
|
||||
data-testid="collection-page"
|
||||
icon={icon}
|
||||
onClick={clickPage}
|
||||
className={styles.title}
|
||||
active={active}
|
||||
collapsed={referencesToRender.length > 0 ? collapsed : undefined}
|
||||
onCollapsedChange={setCollapsed}
|
||||
postfix={
|
||||
<Menu
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
content={
|
||||
<div style={{ width: 220 }}>
|
||||
<PageOperations
|
||||
inAllowList={inAllowList}
|
||||
removeFromAllowList={removeFromAllowList}
|
||||
inExcludeList={inExcludeList}
|
||||
addToExcludeList={addToExcludeList}
|
||||
page={page}
|
||||
workspace={workspace.blockSuiteWorkspace}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div data-testid="collection-page-options" className={styles.more}>
|
||||
<MoreHorizontalIcon></MoreHorizontalIcon>
|
||||
</div>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
{page.title || t['Untitled']()}
|
||||
</MenuItem>
|
||||
<Collapsible.Content>
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
{referencesToRender.map(id => {
|
||||
return (
|
||||
<ReferencePage
|
||||
key={id}
|
||||
workspace={workspace.blockSuiteWorkspace}
|
||||
pageId={id}
|
||||
metaMapping={allPageMeta}
|
||||
parentIds={new Set([pageId])}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
@ -0,0 +1,51 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const wrapper = style({
|
||||
userSelect: 'none',
|
||||
// marginLeft:8,
|
||||
});
|
||||
export const collapsedIcon = style({
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
transform: 'rotate(-90deg)',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const view = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const viewTitle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const title = style({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
export const more = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 4,
|
||||
padding: 4,
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
export const deleteFolder = style({
|
||||
color: 'var(--affine-warning-color)',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-background-warning-color)',
|
||||
},
|
||||
});
|
||||
export const menuDividerStyle = style({
|
||||
marginTop: '2px',
|
||||
marginBottom: '2px',
|
||||
marginLeft: '12px',
|
||||
marginRight: '8px',
|
||||
height: '1px',
|
||||
background: 'var(--affine-border-color)',
|
||||
});
|
@ -0,0 +1,81 @@
|
||||
import { MenuLinkItem } from '@affine/component/app-sidebar';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import { useBlockSuitePageReferences } from '@toeverything/hooks/use-block-suite-page-references';
|
||||
import { useAtomValue } from 'jotai/index';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { pageSettingFamily } from '../../../../atoms';
|
||||
import * as styles from '../favorite/styles.css';
|
||||
interface ReferencePageProps {
|
||||
workspace: Workspace;
|
||||
pageId: string;
|
||||
metaMapping: Record<string, PageMeta>;
|
||||
parentIds: Set<string>;
|
||||
}
|
||||
|
||||
export const ReferencePage = ({
|
||||
workspace,
|
||||
pageId,
|
||||
metaMapping,
|
||||
parentIds,
|
||||
}: ReferencePageProps) => {
|
||||
const router = useRouter();
|
||||
const setting = useAtomValue(pageSettingFamily(pageId));
|
||||
const active = router.query.pageId === pageId;
|
||||
const icon = setting?.mode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
||||
const references = useBlockSuitePageReferences(workspace, pageId);
|
||||
const referencesToShow = useMemo(() => {
|
||||
return [
|
||||
...new Set(
|
||||
references.filter(
|
||||
ref => !parentIds.has(ref) && !metaMapping[ref]?.trash
|
||||
)
|
||||
),
|
||||
];
|
||||
}, [references, parentIds, metaMapping]);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const collapsible = referencesToShow.length > 0;
|
||||
const nestedItem = parentIds.size > 0;
|
||||
const untitled = !metaMapping[pageId]?.title;
|
||||
return (
|
||||
<Collapsible.Root
|
||||
className={styles.favItemWrapper}
|
||||
data-nested={nestedItem}
|
||||
open={!collapsed}
|
||||
>
|
||||
<MenuLinkItem
|
||||
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 className={styles.label} data-untitled={untitled}>
|
||||
{metaMapping[pageId]?.title || 'Untitled'}
|
||||
</span>
|
||||
</MenuLinkItem>
|
||||
{collapsible && (
|
||||
<Collapsible.Content className={styles.collapsibleContent}>
|
||||
<div className={styles.collapsibleContentInner}>
|
||||
{referencesToShow.map(ref => {
|
||||
return (
|
||||
<ReferencePage
|
||||
key={ref}
|
||||
workspace={workspace}
|
||||
pageId={ref}
|
||||
metaMapping={metaMapping}
|
||||
parentIds={new Set([...parentIds, pageId])}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
)}
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
@ -1,88 +1,10 @@
|
||||
import { MenuLinkItem } from '@affine/component/app-sidebar';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import type { PageMeta } 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, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { pageSettingFamily } from '../../../../atoms';
|
||||
import { ReferencePage } from '../components/reference-page';
|
||||
import type { FavoriteListProps } from '../index';
|
||||
import EmptyItem from './empty-item';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
interface FavoriteMenuItemProps {
|
||||
workspace: Workspace;
|
||||
pageId: string;
|
||||
metaMapping: Record<string, PageMeta>;
|
||||
parentIds: Set<string>;
|
||||
}
|
||||
|
||||
function FavoriteMenuItem({
|
||||
workspace,
|
||||
pageId,
|
||||
metaMapping,
|
||||
parentIds,
|
||||
}: FavoriteMenuItemProps) {
|
||||
const router = useRouter();
|
||||
const setting = useAtomValue(pageSettingFamily(pageId));
|
||||
const active = router.query.pageId === pageId;
|
||||
const icon = setting?.mode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
||||
const references = useBlockSuitePageReferences(workspace, pageId);
|
||||
const referencesToShow = useMemo(() => {
|
||||
return [
|
||||
...new Set(
|
||||
references.filter(
|
||||
ref => !parentIds.has(ref) && !metaMapping[ref]?.trash
|
||||
)
|
||||
),
|
||||
];
|
||||
}, [references, parentIds, metaMapping]);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const collapsible = referencesToShow.length > 0;
|
||||
const nestedItem = parentIds.size > 0;
|
||||
const untitled = !metaMapping[pageId]?.title;
|
||||
return (
|
||||
<Collapsible.Root
|
||||
className={styles.favItemWrapper}
|
||||
data-nested={nestedItem}
|
||||
open={!collapsed}
|
||||
>
|
||||
<MenuLinkItem
|
||||
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 className={styles.label} data-untitled={untitled}>
|
||||
{metaMapping[pageId]?.title || 'Untitled'}
|
||||
</span>
|
||||
</MenuLinkItem>
|
||||
{collapsible && (
|
||||
<Collapsible.Content className={styles.collapsibleContent}>
|
||||
<div className={styles.collapsibleContentInner}>
|
||||
{referencesToShow.map(ref => {
|
||||
return (
|
||||
<FavoriteMenuItem
|
||||
key={ref}
|
||||
workspace={workspace}
|
||||
pageId={ref}
|
||||
metaMapping={metaMapping}
|
||||
parentIds={new Set([...parentIds, pageId])}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
)}
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export const FavoriteList = ({ currentWorkspace }: FavoriteListProps) => {
|
||||
const metas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
|
||||
@ -105,7 +27,7 @@ export const FavoriteList = ({ currentWorkspace }: FavoriteListProps) => {
|
||||
<>
|
||||
{favoriteList.map((pageMeta, index) => {
|
||||
return (
|
||||
<FavoriteMenuItem
|
||||
<ReferencePage
|
||||
key={`${pageMeta}-${index}`}
|
||||
metaMapping={metaMapping}
|
||||
pageId={pageMeta.id}
|
||||
|
@ -3,3 +3,7 @@ import type { AllWorkspace } from '../../../shared';
|
||||
export type FavoriteListProps = {
|
||||
currentWorkspace: AllWorkspace;
|
||||
};
|
||||
|
||||
export type CollectionsListProps = {
|
||||
currentWorkspace: AllWorkspace;
|
||||
};
|
||||
|
@ -29,6 +29,7 @@ import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useHistoryAtom } from '../../atoms/history';
|
||||
import { useAppSetting } from '../../atoms/settings';
|
||||
import type { AllWorkspace } from '../../shared';
|
||||
import { CollectionsList } from '../pure/workspace-slider-bar/collections';
|
||||
import FavoriteList from '../pure/workspace-slider-bar/favorite/favorite-list';
|
||||
import { WorkspaceSelector } from '../pure/workspace-slider-bar/WorkspaceSelector';
|
||||
|
||||
@ -225,7 +226,10 @@ export const RootAppSidebar = ({
|
||||
<span data-testid="shared-pages">{t['Shared Pages']()}</span>
|
||||
</RouteMenuLinkItem>
|
||||
))}
|
||||
|
||||
<CategoryDivider label={t['Collections']()} />
|
||||
{blockSuiteWorkspace && (
|
||||
<CollectionsList currentWorkspace={currentWorkspace} />
|
||||
)}
|
||||
<CategoryDivider label={t['others']()} />
|
||||
<RouteMenuLinkItem
|
||||
ref={trashDroppable.setNodeRef}
|
||||
|
@ -1,18 +1,19 @@
|
||||
import { Button } from '@affine/component';
|
||||
import {
|
||||
CollectionList,
|
||||
FilterList,
|
||||
SaveViewButton,
|
||||
SaveCollectionButton,
|
||||
useAllPageSetting,
|
||||
ViewList,
|
||||
} from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import type { WorkspaceHeaderProps } from '@affine/env/workspace';
|
||||
import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { SettingsIcon } from '@blocksuite/icons';
|
||||
import { RESET } from 'jotai/utils';
|
||||
import { uuidv4 } from '@blocksuite/store';
|
||||
import type { ReactElement } from 'react';
|
||||
import { NIL } from 'uuid';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useGetPageInfoById } from '../hooks/use-get-page-info';
|
||||
import { BlockSuiteEditorHeader } from './blocksuite/workspace-header';
|
||||
import { filterContainerStyle } from './filter-container.css';
|
||||
import { WorkspaceModeFilterTab, WorkspaceTitle } from './pure/workspace-title';
|
||||
@ -23,40 +24,51 @@ export function WorkspaceHeader({
|
||||
}: WorkspaceHeaderProps<WorkspaceFlavour>): ReactElement {
|
||||
const setting = useAllPageSetting();
|
||||
const t = useAFFiNEI18N();
|
||||
const saveToCollection = useCallback(
|
||||
async (collection: Collection) => {
|
||||
await setting.saveCollection(collection);
|
||||
setting.selectCollection(collection.id);
|
||||
},
|
||||
[setting]
|
||||
);
|
||||
const getPageInfoById = useGetPageInfoById();
|
||||
if ('subPath' in currentEntry) {
|
||||
if (currentEntry.subPath === WorkspaceSubPath.ALL) {
|
||||
const leftSlot = <ViewList setting={setting}></ViewList>;
|
||||
const filterContainer = setting.currentView.filterList.length > 0 && (
|
||||
<div className={filterContainerStyle}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<FilterList
|
||||
value={setting.currentView.filterList}
|
||||
onChange={filterList => {
|
||||
setting.setCurrentView(view => ({
|
||||
...view,
|
||||
filterList,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{runtimeConfig.enableAllPageSaving && (
|
||||
<div>
|
||||
{setting.currentView.id !== NIL ||
|
||||
(setting.currentView.id === NIL &&
|
||||
setting.currentView.filterList.length > 0) ? (
|
||||
<SaveViewButton
|
||||
init={setting.currentView.filterList}
|
||||
onConfirm={setting.createView}
|
||||
></SaveViewButton>
|
||||
) : (
|
||||
<Button onClick={() => setting.setCurrentView(RESET)}>
|
||||
Back to all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
const leftSlot = (
|
||||
<CollectionList
|
||||
setting={setting}
|
||||
getPageInfo={getPageInfoById}
|
||||
></CollectionList>
|
||||
);
|
||||
const filterContainer =
|
||||
setting.isDefault && setting.currentCollection.filterList.length > 0 ? (
|
||||
<div className={filterContainerStyle}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<FilterList
|
||||
value={setting.currentCollection.filterList}
|
||||
onChange={filterList => {
|
||||
return setting.updateCollection({
|
||||
...setting.currentCollection,
|
||||
filterList,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{setting.currentCollection.filterList.length > 0 ? (
|
||||
<SaveCollectionButton
|
||||
getPageInfo={getPageInfoById}
|
||||
init={{
|
||||
id: uuidv4(),
|
||||
name: '',
|
||||
filterList: setting.currentCollection.filterList,
|
||||
}}
|
||||
onConfirm={saveToCollection}
|
||||
></SaveCollectionButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
return (
|
||||
<>
|
||||
<WorkspaceModeFilterTab
|
||||
|
27
apps/web/src/hooks/use-get-page-info.ts
Normal file
27
apps/web/src/hooks/use-get-page-info.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { pageSettingsAtom } from '../atoms';
|
||||
import { rootCurrentWorkspaceAtom } from '../atoms/root';
|
||||
|
||||
export const useGetPageInfoById = (): GetPageInfoById => {
|
||||
const currentWorkspace = useAtomValue(rootCurrentWorkspaceAtom);
|
||||
const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
|
||||
const pageMap = useMemo(
|
||||
() => Object.fromEntries(pageMetas.map(page => [page.id, page])),
|
||||
[pageMetas]
|
||||
);
|
||||
const pageSettings = useAtomValue(pageSettingsAtom);
|
||||
return (id: string) => {
|
||||
const page = pageMap[id];
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
...page,
|
||||
isEdgeless: pageSettings[id]?.mode === 'edgeless',
|
||||
};
|
||||
};
|
||||
};
|
@ -50,6 +50,7 @@ import {
|
||||
import { AppContainer } from '../components/affine/app-container';
|
||||
import type { IslandItemNames } from '../components/pure/help-island';
|
||||
import { HelpIsland } from '../components/pure/help-island';
|
||||
import { processCollectionsDrag } from '../components/pure/workspace-slider-bar/collections';
|
||||
import {
|
||||
DROPPABLE_SIDEBAR_TRASH,
|
||||
RootAppSidebar,
|
||||
@ -393,6 +394,8 @@ export const WorkspaceLayoutInner: FC<PropsWithChildren> = ({ children }) => {
|
||||
moveToTrash(pageId);
|
||||
toast(t['Successfully deleted']());
|
||||
}
|
||||
// Drag page into Collections
|
||||
processCollectionsDrag(e);
|
||||
},
|
||||
[moveToTrash, t]
|
||||
);
|
||||
|
@ -51,7 +51,7 @@ const AllPage: NextPageWithLayout = () => {
|
||||
}}
|
||||
/>
|
||||
<PageList
|
||||
view={setting.currentView}
|
||||
collection={setting.currentCollection}
|
||||
onOpenPage={onClickPage}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
/>
|
||||
|
17
apps/web/src/utils/filter.ts
Normal file
17
apps/web/src/utils/filter.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { filterByFilterList } from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
|
||||
export const filterPage = (collection: Collection, page: PageMeta) => {
|
||||
if (collection.excludeList?.includes(page.id)) {
|
||||
return false;
|
||||
}
|
||||
if (collection.allowList?.includes(page.id)) {
|
||||
return true;
|
||||
}
|
||||
return filterByFilterList(collection.filterList, {
|
||||
'Is Favourited': !!page.favorite,
|
||||
Created: page.createDate,
|
||||
Updated: page.updatedDate ?? page.createDate,
|
||||
});
|
||||
};
|
@ -57,7 +57,7 @@
|
||||
"@blocksuite/blocks": "0.0.0-20230629103121-76e6587d-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20230629103121-76e6587d-nightly",
|
||||
"@blocksuite/global": "0.0.0-20230629103121-76e6587d-nightly",
|
||||
"@blocksuite/icons": "^2.1.21",
|
||||
"@blocksuite/icons": "^2.1.23",
|
||||
"@blocksuite/lit": "0.0.0-20230629103121-76e6587d-nightly",
|
||||
"@blocksuite/store": "0.0.0-20230629103121-76e6587d-nightly",
|
||||
"@types/react": "^18.2.14",
|
||||
|
@ -10,6 +10,7 @@ export const root = style({
|
||||
cursor: 'pointer',
|
||||
padding: '0 8px 0 12px',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
margin: '2px 0',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
@ -22,11 +23,12 @@ export const root = style({
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&[data-active="true"]:hover': {
|
||||
background:
|
||||
// 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);',
|
||||
},
|
||||
// this is not visible in dark mode
|
||||
// '&[data-active="true"]:hover': {
|
||||
// background:
|
||||
// // 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)',
|
||||
@ -39,6 +41,7 @@ export const content = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const icon = style({
|
||||
|
@ -12,6 +12,7 @@ export interface MenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
disabled?: boolean;
|
||||
collapsed?: boolean; // true, false, undefined. undefined means no collapse
|
||||
onCollapsedChange?: (collapsed: boolean) => void;
|
||||
postfix?: React.ReactElement;
|
||||
}
|
||||
|
||||
export interface MenuLinkItemProps
|
||||
@ -28,6 +29,7 @@ export const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
|
||||
disabled,
|
||||
collapsed,
|
||||
onCollapsedChange,
|
||||
postfix,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
@ -43,7 +45,6 @@ export const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={clsx([styles.root, props.className])}
|
||||
onClick={onClick}
|
||||
data-active={active}
|
||||
data-disabled={disabled}
|
||||
data-collapsible={collapsible}
|
||||
@ -68,11 +69,15 @@ export const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
|
||||
)}
|
||||
{React.cloneElement(icon, {
|
||||
className: clsx([styles.icon, icon.props.className]),
|
||||
onClick: onClick,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.content}>{children}</div>
|
||||
<div onClick={onClick} className={styles.content}>
|
||||
{children}
|
||||
</div>
|
||||
{postfix}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -4,7 +4,6 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { RESET } from 'jotai/utils';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { createDefaultFilter, vars } from '../filter/vars';
|
||||
@ -12,22 +11,22 @@ import { useAllPageSetting } from '../use-all-page-setting';
|
||||
|
||||
test('useAllPageSetting', async () => {
|
||||
const settingHook = renderHook(() => useAllPageSetting());
|
||||
const prevView = settingHook.result.current.currentView;
|
||||
expect(settingHook.result.current.savedViews).toEqual([]);
|
||||
settingHook.result.current.setCurrentView(view => ({
|
||||
...view,
|
||||
const prevCollection = settingHook.result.current.currentCollection;
|
||||
expect(settingHook.result.current.savedCollections).toEqual([]);
|
||||
await settingHook.result.current.updateCollection({
|
||||
...settingHook.result.current.currentCollection,
|
||||
filterList: [createDefaultFilter(vars[0])],
|
||||
}));
|
||||
});
|
||||
settingHook.rerender();
|
||||
const nextView = settingHook.result.current.currentView;
|
||||
expect(nextView).not.toBe(prevView);
|
||||
expect(nextView.filterList).toEqual([createDefaultFilter(vars[0])]);
|
||||
settingHook.result.current.setCurrentView(RESET);
|
||||
await settingHook.result.current.createView({
|
||||
...settingHook.result.current.currentView,
|
||||
const nextCollection = settingHook.result.current.currentCollection;
|
||||
expect(nextCollection).not.toBe(prevCollection);
|
||||
expect(nextCollection.filterList).toEqual([createDefaultFilter(vars[0])]);
|
||||
settingHook.result.current.backToAll();
|
||||
await settingHook.result.current.saveCollection({
|
||||
...settingHook.result.current.currentCollection,
|
||||
id: '1',
|
||||
});
|
||||
settingHook.rerender();
|
||||
expect(settingHook.result.current.savedViews.length).toBe(1);
|
||||
expect(settingHook.result.current.savedViews[0].id).toBe('1');
|
||||
expect(settingHook.result.current.savedCollections.length).toBe(1);
|
||||
expect(settingHook.result.current.savedCollections[0].id).toBe('1');
|
||||
});
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { CollectionBar } from '@affine/component/page-list';
|
||||
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ArrowDownBigIcon, ArrowUpBigIcon } from '@blocksuite/icons';
|
||||
import { useMediaQuery, useTheme } from '@mui/material';
|
||||
@ -31,12 +33,14 @@ const AllPagesHead = ({
|
||||
createNewPage,
|
||||
createNewEdgeless,
|
||||
importFile,
|
||||
getPageInfo,
|
||||
}: {
|
||||
isPublicWorkspace: boolean;
|
||||
sorter: ReturnType<typeof useSorter<ListData>>;
|
||||
createNewPage: () => void;
|
||||
createNewEdgeless: () => void;
|
||||
importFile: () => void;
|
||||
getPageInfo: GetPageInfoById;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const titleList = [
|
||||
@ -72,7 +76,6 @@ const AllPagesHead = ({
|
||||
} satisfies CSSProperties,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<TableHead>
|
||||
<TableHeadRow>
|
||||
@ -107,6 +110,7 @@ const AllPagesHead = ({
|
||||
</TableCell>
|
||||
))}
|
||||
</TableHeadRow>
|
||||
<CollectionBar getPageInfo={getPageInfo} />
|
||||
</TableHead>
|
||||
);
|
||||
};
|
||||
@ -118,6 +122,7 @@ export const PageList = ({
|
||||
onCreateNewEdgeless,
|
||||
onImportFile,
|
||||
fallback,
|
||||
getPageInfo,
|
||||
}: PageListProps) => {
|
||||
const sorter = useSorter<ListData>({
|
||||
data: list,
|
||||
@ -160,6 +165,7 @@ export const PageList = ({
|
||||
createNewPage={onCreateNewPage}
|
||||
createNewEdgeless={onCreateNewEdgeless}
|
||||
importFile={onImportFile}
|
||||
getPageInfo={getPageInfo}
|
||||
/>
|
||||
<AllPagesBody
|
||||
isPublicWorkspace={isPublicWorkspace}
|
||||
|
@ -5,6 +5,7 @@ import { Menu } from '../../..';
|
||||
import { Condition } from './condition';
|
||||
import * as styles from './index.css';
|
||||
import { CreateFilterMenu } from './vars';
|
||||
|
||||
export const FilterList = ({
|
||||
value,
|
||||
onChange,
|
||||
@ -13,7 +14,13 @@ export const FilterList = ({
|
||||
onChange: (value: Filter[]) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{value.map((filter, i) => {
|
||||
return (
|
||||
<div className={styles.filterItemStyle} key={i}>
|
||||
|
@ -28,7 +28,6 @@ export const filterItemStyle = style({
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--affine-white)',
|
||||
margin: '4px',
|
||||
padding: '4px 8px',
|
||||
});
|
||||
|
||||
|
@ -1,3 +1,5 @@
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
|
||||
/**
|
||||
* Get the keys of an object type whose values are of a given type
|
||||
*
|
||||
@ -45,6 +47,7 @@ export type PageListProps = {
|
||||
onCreateNewPage: () => void;
|
||||
onCreateNewEdgeless: () => void;
|
||||
onImportFile: () => void;
|
||||
getPageInfo: GetPageInfoById;
|
||||
};
|
||||
|
||||
export type DraggableTitleCellData = {
|
||||
|
@ -1,29 +1,29 @@
|
||||
import type { Filter, VariableMap, View } from '@affine/env/filter';
|
||||
import type { Collection, Filter, VariableMap } from '@affine/env/filter';
|
||||
import type { DBSchema } from 'idb';
|
||||
import { openDB } from 'idb';
|
||||
import type { IDBPDatabase } from 'idb/build/entry';
|
||||
import { useAtom } from 'jotai';
|
||||
import { atomWithReset } from 'jotai/utils';
|
||||
import { atomWithReset, RESET } from 'jotai/utils';
|
||||
import { useCallback } from 'react';
|
||||
import useSWRImmutable from 'swr/immutable';
|
||||
import { NIL } from 'uuid';
|
||||
|
||||
import { evalFilterList } from './filter';
|
||||
|
||||
type PersistenceView = View;
|
||||
type PersistenceCollection = Collection;
|
||||
|
||||
export interface PageViewDBV1 extends DBSchema {
|
||||
export interface PageCollectionDBV1 extends DBSchema {
|
||||
view: {
|
||||
key: PersistenceView['id'];
|
||||
value: PersistenceView;
|
||||
key: PersistenceCollection['id'];
|
||||
value: PersistenceCollection;
|
||||
};
|
||||
}
|
||||
|
||||
const pageViewDBPromise: Promise<IDBPDatabase<PageViewDBV1>> =
|
||||
const pageCollectionDBPromise: Promise<IDBPDatabase<PageCollectionDBV1>> =
|
||||
typeof window === 'undefined'
|
||||
? // never resolve in SSR
|
||||
new Promise<any>(() => {})
|
||||
: openDB<PageViewDBV1>('page-view', 1, {
|
||||
: openDB<PageCollectionDBV1>('page-view', 1, {
|
||||
upgrade(database) {
|
||||
database.createObjectStore('view', {
|
||||
keyPath: 'id',
|
||||
@ -31,18 +31,24 @@ const pageViewDBPromise: Promise<IDBPDatabase<PageViewDBV1>> =
|
||||
},
|
||||
});
|
||||
|
||||
const currentViewAtom = atomWithReset<View>({
|
||||
name: 'default',
|
||||
id: NIL,
|
||||
filterList: [],
|
||||
const collectionAtom = atomWithReset<{
|
||||
currentId: string;
|
||||
defaultCollection: Collection;
|
||||
}>({
|
||||
currentId: NIL,
|
||||
defaultCollection: {
|
||||
id: NIL,
|
||||
name: 'All',
|
||||
filterList: [],
|
||||
},
|
||||
});
|
||||
|
||||
export const useAllPageSetting = () => {
|
||||
const { data: savedViews, mutate } = useSWRImmutable(
|
||||
['affine', 'page-view'],
|
||||
export const useSavedCollections = () => {
|
||||
const { data: savedCollections, mutate } = useSWRImmutable<Collection[]>(
|
||||
['affine', 'page-collection'],
|
||||
{
|
||||
fetcher: async () => {
|
||||
const db = await pageViewDBPromise;
|
||||
const db = await pageCollectionDBPromise;
|
||||
const t = db.transaction('view').objectStore('view');
|
||||
return await t.getAll();
|
||||
},
|
||||
@ -51,29 +57,98 @@ export const useAllPageSetting = () => {
|
||||
revalidateOnMount: true,
|
||||
}
|
||||
);
|
||||
|
||||
const [currentView, setCurrentView] = useAtom(currentViewAtom);
|
||||
|
||||
const createView = useCallback(
|
||||
async (view: View) => {
|
||||
if (view.id === NIL) {
|
||||
const saveCollection = useCallback(
|
||||
async (collection: Collection) => {
|
||||
if (collection.id === NIL) {
|
||||
return;
|
||||
}
|
||||
const db = await pageViewDBPromise;
|
||||
const db = await pageCollectionDBPromise;
|
||||
const t = db.transaction('view', 'readwrite').objectStore('view');
|
||||
await t.put(view);
|
||||
await t.put(collection);
|
||||
await mutate();
|
||||
},
|
||||
[mutate]
|
||||
);
|
||||
|
||||
const deleteCollection = useCallback(
|
||||
async (id: string) => {
|
||||
if (id === NIL) {
|
||||
return;
|
||||
}
|
||||
const db = await pageCollectionDBPromise;
|
||||
const t = db.transaction('view', 'readwrite').objectStore('view');
|
||||
await t.delete(id);
|
||||
await mutate();
|
||||
},
|
||||
[mutate]
|
||||
);
|
||||
const addPage = useCallback(
|
||||
async (collectionId: string, pageId: string) => {
|
||||
const collection = savedCollections?.find(v => v.id === collectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
await saveCollection({
|
||||
...collection,
|
||||
allowList: [pageId, ...(collection.allowList ?? [])],
|
||||
});
|
||||
},
|
||||
[saveCollection, savedCollections]
|
||||
);
|
||||
return {
|
||||
currentView,
|
||||
savedViews: savedViews as View[],
|
||||
savedCollections: savedCollections ?? [],
|
||||
saveCollection,
|
||||
deleteCollection,
|
||||
addPage,
|
||||
};
|
||||
};
|
||||
|
||||
export const useAllPageSetting = () => {
|
||||
const { savedCollections, saveCollection, deleteCollection, addPage } =
|
||||
useSavedCollections();
|
||||
const [collectionData, setCollectionData] = useAtom(collectionAtom);
|
||||
|
||||
const updateCollection = useCallback(
|
||||
async (collection: Collection) => {
|
||||
if (collection.id === NIL) {
|
||||
setCollectionData({
|
||||
...collectionData,
|
||||
defaultCollection: collection,
|
||||
});
|
||||
} else {
|
||||
await saveCollection(collection);
|
||||
}
|
||||
},
|
||||
[collectionData, saveCollection, setCollectionData]
|
||||
);
|
||||
const selectCollection = useCallback(
|
||||
(id: string) => {
|
||||
setCollectionData({
|
||||
...collectionData,
|
||||
currentId: id,
|
||||
});
|
||||
},
|
||||
[collectionData, setCollectionData]
|
||||
);
|
||||
const backToAll = useCallback(() => {
|
||||
setCollectionData(RESET);
|
||||
}, [setCollectionData]);
|
||||
const currentCollection =
|
||||
collectionData.currentId === NIL
|
||||
? collectionData.defaultCollection
|
||||
: savedCollections.find(v => v.id === collectionData.currentId) ??
|
||||
collectionData.defaultCollection;
|
||||
return {
|
||||
currentCollection: currentCollection,
|
||||
savedCollections,
|
||||
isDefault: currentCollection.id === NIL,
|
||||
|
||||
// actions
|
||||
createView,
|
||||
setCurrentView,
|
||||
saveCollection,
|
||||
updateCollection,
|
||||
selectCollection,
|
||||
backToAll,
|
||||
deleteCollection,
|
||||
addPage,
|
||||
};
|
||||
};
|
||||
export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) =>
|
||||
|
@ -0,0 +1,48 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const view = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
height: '100%',
|
||||
paddingLeft: 16,
|
||||
});
|
||||
|
||||
export const option = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 4,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
[`${view}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const pin = style({
|
||||
opacity: 1,
|
||||
});
|
||||
export const pinedIcon = style({
|
||||
display: 'block',
|
||||
selectors: {
|
||||
[`${option}:hover &`]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const pinIcon = style({
|
||||
display: 'none',
|
||||
selectors: {
|
||||
[`${option}:hover &`]: {
|
||||
display: 'block',
|
||||
},
|
||||
},
|
||||
});
|
@ -0,0 +1,126 @@
|
||||
import { EditCollectionModel } from '@affine/component/page-list';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import {
|
||||
DeleteIcon,
|
||||
FilterIcon,
|
||||
PinedIcon,
|
||||
PinIcon,
|
||||
UnpinIcon,
|
||||
ViewLayersIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import clsx from 'clsx';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { Button } from '../../../ui/button/button';
|
||||
import { useAllPageSetting } from '../use-all-page-setting';
|
||||
import * as styles from './collection-bar.css';
|
||||
|
||||
export const CollectionBar = ({
|
||||
getPageInfo,
|
||||
}: {
|
||||
getPageInfo: GetPageInfoById;
|
||||
}) => {
|
||||
const setting = useAllPageSetting();
|
||||
const collection = setting.currentCollection;
|
||||
const [open, setOpen] = useState(false);
|
||||
const actions: {
|
||||
icon: ReactNode;
|
||||
click: () => void;
|
||||
className?: string;
|
||||
name: string;
|
||||
}[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: (
|
||||
<>
|
||||
{collection.pinned ? (
|
||||
<PinedIcon className={styles.pinedIcon}></PinedIcon>
|
||||
) : (
|
||||
<PinIcon className={styles.pinedIcon}></PinIcon>
|
||||
)}
|
||||
{collection.pinned ? (
|
||||
<UnpinIcon className={styles.pinIcon}></UnpinIcon>
|
||||
) : (
|
||||
<PinIcon className={styles.pinIcon}></PinIcon>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
name: 'pin',
|
||||
className: styles.pin,
|
||||
click: () => {
|
||||
return setting.updateCollection({
|
||||
...collection,
|
||||
pinned: !collection.pinned,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <FilterIcon />,
|
||||
name: 'edit',
|
||||
click: () => {
|
||||
setOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <DeleteIcon style={{ color: 'red' }} />,
|
||||
name: 'delete',
|
||||
click: () => {
|
||||
setting.deleteCollection(collection.id).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
[setting, collection]
|
||||
);
|
||||
const onClose = useCallback(() => setOpen(false), []);
|
||||
return !setting.isDefault ? (
|
||||
<tr style={{ userSelect: 'none' }}>
|
||||
<td>
|
||||
<div className={styles.view}>
|
||||
<EditCollectionModel
|
||||
getPageInfo={getPageInfo}
|
||||
init={collection}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onConfirm={setting.updateCollection}
|
||||
></EditCollectionModel>
|
||||
<ViewLayersIcon
|
||||
style={{
|
||||
height: 20,
|
||||
width: 20,
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginRight: 10 }}>
|
||||
{setting.currentCollection.name}
|
||||
</div>
|
||||
{actions.map(action => {
|
||||
return (
|
||||
<div
|
||||
key={action.name}
|
||||
data-testid={`collection-bar-option-${action.name}`}
|
||||
onClick={action.click}
|
||||
className={clsx(styles.option, action.className)}
|
||||
>
|
||||
{action.icon}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'end',
|
||||
}}
|
||||
>
|
||||
<Button style={{ border: 'none' }} onClick={() => setting.backToAll()}>
|
||||
Back to all
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
) : null;
|
||||
};
|
@ -0,0 +1,207 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const menuTitleStyle = style({
|
||||
marginLeft: '12px',
|
||||
marginTop: '10px',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
export const menuDividerStyle = style({
|
||||
marginTop: '2px',
|
||||
marginBottom: '2px',
|
||||
marginLeft: '12px',
|
||||
marginRight: '8px',
|
||||
height: '1px',
|
||||
background: 'var(--affine-border-color)',
|
||||
});
|
||||
export const viewButton = style({
|
||||
borderRadius: '8px',
|
||||
height: '100%',
|
||||
padding: '4px 8px',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
background: 'var(--affine-white)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
transition: 'margin-left 0.2s ease-in-out',
|
||||
':hover': {
|
||||
borderColor: 'var(--affine-border-color)',
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
marginRight: '20px',
|
||||
});
|
||||
export const viewMenu = style({});
|
||||
export const viewOption = style({
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 6,
|
||||
width: 24,
|
||||
height: 24,
|
||||
opacity: 0,
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
selectors: {
|
||||
[`${viewMenu}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const deleteOption = style({
|
||||
':hover': {
|
||||
backgroundColor: '#FFEFE9',
|
||||
},
|
||||
});
|
||||
export const filterButton = style({
|
||||
borderRadius: '8px',
|
||||
height: '100%',
|
||||
padding: '4px 8px',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
background: 'var(--affine-white)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
transition: 'margin-left 0.2s ease-in-out',
|
||||
':hover': {
|
||||
borderColor: 'var(--affine-border-color)',
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
export const filterButtonCollapse = style({
|
||||
marginLeft: '20px',
|
||||
});
|
||||
export const viewDivider = style({
|
||||
'::after': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
background: 'var(--affine-border-color)',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
margin: '0 1px',
|
||||
},
|
||||
});
|
||||
export const saveButton = style({
|
||||
marginTop: '4px',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 0',
|
||||
':hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
},
|
||||
});
|
||||
export const saveButtonContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '8px',
|
||||
});
|
||||
export const saveIcon = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
marginRight: '8px',
|
||||
});
|
||||
export const saveText = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
});
|
||||
export const cancelButton = style({
|
||||
background: 'var(--affine-hover-color)',
|
||||
borderRadius: '8px',
|
||||
':hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
},
|
||||
});
|
||||
export const saveTitle = style({
|
||||
fontSize: 'var(--affine-font-h-6)',
|
||||
fontWeight: '600',
|
||||
lineHeight: '24px',
|
||||
paddingBottom: 20,
|
||||
});
|
||||
export const allowList = style({});
|
||||
|
||||
export const allowTitle = style({
|
||||
fontSize: 12,
|
||||
margin: '20px 0',
|
||||
});
|
||||
|
||||
export const allowListContent = style({
|
||||
margin: '8px 0',
|
||||
});
|
||||
|
||||
export const excludeList = style({
|
||||
backgroundColor: 'var(--affine-background-warning-color)',
|
||||
padding: 18,
|
||||
borderRadius: 8,
|
||||
});
|
||||
|
||||
export const excludeListContent = style({
|
||||
margin: '8px 0',
|
||||
});
|
||||
|
||||
export const filterTitle = style({
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
marginBottom: 10,
|
||||
});
|
||||
|
||||
export const excludeTitle = style({
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const excludeTip = style({
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 12,
|
||||
});
|
||||
|
||||
export const scrollContainer = style({
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
export const container = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
export const pageContainer = style({
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 8,
|
||||
paddingRight: 5,
|
||||
});
|
||||
|
||||
export const pageIcon = style({
|
||||
marginRight: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const pageTitle = style({
|
||||
flex: 1,
|
||||
});
|
||||
export const deleteIcon = style({
|
||||
marginLeft: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
padding: 4,
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
@ -0,0 +1,232 @@
|
||||
import { EditCollectionModel } from '@affine/component/page-list';
|
||||
import type { Collection, Filter } from '@affine/env/filter';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
DeleteIcon,
|
||||
FilteredIcon,
|
||||
FilterIcon,
|
||||
FolderIcon,
|
||||
PinIcon,
|
||||
ViewLayersIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom } from 'jotai';
|
||||
import type { MouseEvent, ReactNode } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { Button, MenuItem } from '../../..';
|
||||
import Menu from '../../../ui/menu/menu';
|
||||
import { appSidebarOpenAtom } from '../../app-sidebar';
|
||||
import { CreateFilterMenu } from '../filter/vars';
|
||||
import type { useAllPageSetting } from '../use-all-page-setting';
|
||||
import * as styles from './collection-list.css';
|
||||
|
||||
const CollectionOption = ({
|
||||
collection,
|
||||
setting,
|
||||
updateCollection,
|
||||
}: {
|
||||
collection: Collection;
|
||||
setting: ReturnType<typeof useAllPageSetting>;
|
||||
updateCollection: (view: Collection) => void;
|
||||
}) => {
|
||||
const actions: {
|
||||
icon: ReactNode;
|
||||
click: () => void;
|
||||
className?: string;
|
||||
name: string;
|
||||
}[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: <PinIcon />,
|
||||
name: 'pin',
|
||||
click: () => {
|
||||
return setting.updateCollection({
|
||||
...collection,
|
||||
pinned: !collection.pinned,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <FilterIcon />,
|
||||
name: 'edit',
|
||||
click: () => {
|
||||
updateCollection(collection);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <DeleteIcon style={{ color: 'red' }} />,
|
||||
name: 'delete',
|
||||
click: () => {
|
||||
setting.deleteCollection(collection.id).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
[setting, updateCollection, collection]
|
||||
);
|
||||
const selectCollection = useCallback(
|
||||
() => setting.selectCollection(collection.id),
|
||||
[setting, collection.id]
|
||||
);
|
||||
return (
|
||||
<MenuItem
|
||||
data-testid="collection-select-option"
|
||||
icon={<ViewLayersIcon></ViewLayersIcon>}
|
||||
onClick={selectCollection}
|
||||
key={collection.id}
|
||||
className={styles.viewMenu}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>{collection.name}</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{actions.map((v, i) => {
|
||||
const onClick = (e: MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
v.click();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
data-testid={`collection-select-option-${v.name}`}
|
||||
key={i}
|
||||
onClick={onClick}
|
||||
style={{ marginLeft: i === 0 ? 28 : undefined }}
|
||||
className={clsx(styles.viewOption, v.className)}
|
||||
>
|
||||
{v.icon}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
export const CollectionList = ({
|
||||
setting,
|
||||
getPageInfo,
|
||||
}: {
|
||||
setting: ReturnType<typeof useAllPageSetting>;
|
||||
getPageInfo: GetPageInfoById;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [open] = useAtom(appSidebarOpenAtom);
|
||||
const [collection, setCollection] = useState<Collection>();
|
||||
const onChange = useCallback(
|
||||
(filterList: Filter[]) => {
|
||||
return setting.updateCollection({
|
||||
...setting.currentCollection,
|
||||
filterList,
|
||||
});
|
||||
},
|
||||
[setting]
|
||||
);
|
||||
const closeUpdateCollectionModal = useCallback(
|
||||
() => setCollection(undefined),
|
||||
[]
|
||||
);
|
||||
const onConfirm = useCallback(
|
||||
(view: Collection) => {
|
||||
return setting.updateCollection(view).then(() => {
|
||||
closeUpdateCollectionModal();
|
||||
});
|
||||
},
|
||||
[closeUpdateCollectionModal, setting]
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={clsx({
|
||||
[styles.filterButtonCollapse]: !open,
|
||||
})}
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{setting.savedCollections.length > 0 && (
|
||||
<Menu
|
||||
trigger="click"
|
||||
content={
|
||||
<div style={{ minWidth: 150 }}>
|
||||
<MenuItem
|
||||
icon={<FolderIcon></FolderIcon>}
|
||||
onClick={setting.backToAll}
|
||||
className={styles.viewMenu}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>All</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<div className={styles.menuTitleStyle}>Saved Collection</div>
|
||||
<div className={styles.menuDividerStyle}></div>
|
||||
{setting.savedCollections.map(view => (
|
||||
<CollectionOption
|
||||
key={view.id}
|
||||
collection={view}
|
||||
setting={setting}
|
||||
updateCollection={setCollection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
className={clsx(styles.viewButton)}
|
||||
hoverColor="var(--affine-icon-color)"
|
||||
data-testid="collection-select"
|
||||
>
|
||||
{setting.currentCollection.name}
|
||||
</Button>
|
||||
</Menu>
|
||||
)}
|
||||
<Menu
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
content={
|
||||
<CreateFilterMenu
|
||||
value={setting.currentCollection.filterList}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
icon={<FilteredIcon />}
|
||||
className={clsx(styles.filterButton)}
|
||||
size="small"
|
||||
hoverColor="var(--affine-icon-color)"
|
||||
data-testid="create-first-filter"
|
||||
>
|
||||
{t['com.affine.filter']()}
|
||||
</Button>
|
||||
</Menu>
|
||||
<EditCollectionModel
|
||||
getPageInfo={getPageInfo}
|
||||
init={collection}
|
||||
open={!!collection}
|
||||
onClose={closeUpdateCollectionModal}
|
||||
onConfirm={onConfirm}
|
||||
></EditCollectionModel>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,291 @@
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import {
|
||||
EdgelessIcon,
|
||||
PageIcon,
|
||||
RemoveIcon,
|
||||
SaveIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalCloseButton,
|
||||
ModalWrapper,
|
||||
ScrollableContainer,
|
||||
} from '../../..';
|
||||
import { FilterList } from '../filter';
|
||||
import * as styles from './collection-list.css';
|
||||
|
||||
type CreateCollectionProps = {
|
||||
title?: string;
|
||||
init: Collection;
|
||||
onConfirm: (collection: Collection) => void;
|
||||
onConfirmText?: string;
|
||||
getPageInfo: GetPageInfoById;
|
||||
};
|
||||
export const EditCollectionModel = ({
|
||||
init,
|
||||
onConfirm,
|
||||
open,
|
||||
onClose,
|
||||
getPageInfo,
|
||||
}: {
|
||||
init?: Collection;
|
||||
onConfirm: (view: Collection) => void;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
getPageInfo: GetPageInfoById;
|
||||
}) => {
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<ModalWrapper
|
||||
width={600}
|
||||
style={{
|
||||
padding: '40px',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
}}
|
||||
>
|
||||
<ModalCloseButton
|
||||
top={12}
|
||||
right={12}
|
||||
onClick={onClose}
|
||||
hoverColor="var(--affine-icon-color)"
|
||||
/>
|
||||
{init ? (
|
||||
<EditCollection
|
||||
title="Update Collection"
|
||||
onConfirmText="Save"
|
||||
init={init}
|
||||
getPageInfo={getPageInfo}
|
||||
onCancel={onClose}
|
||||
onConfirm={view => {
|
||||
onConfirm(view);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</ModalWrapper>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const Page = ({
|
||||
id,
|
||||
onClick,
|
||||
getPageInfo,
|
||||
}: {
|
||||
id: string;
|
||||
onClick: (id: string) => void;
|
||||
getPageInfo: GetPageInfoById;
|
||||
}) => {
|
||||
const page = getPageInfo(id);
|
||||
if (!page) {
|
||||
return null;
|
||||
}
|
||||
const icon = page.isEdgeless ? (
|
||||
<EdgelessIcon
|
||||
style={{
|
||||
width: 17.5,
|
||||
height: 17.5,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PageIcon
|
||||
style={{
|
||||
width: 17.5,
|
||||
height: 17.5,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const click = () => {
|
||||
onClick(id);
|
||||
};
|
||||
return (
|
||||
<div className={styles.pageContainer}>
|
||||
<div className={styles.pageIcon}>{icon}</div>
|
||||
<div className={styles.pageTitle}>{page.title}</div>
|
||||
<div onClick={click} className={styles.deleteIcon}>
|
||||
<RemoveIcon />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const EditCollection = ({
|
||||
title,
|
||||
init,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onConfirmText,
|
||||
getPageInfo,
|
||||
}: CreateCollectionProps & {
|
||||
onCancel: () => void;
|
||||
}) => {
|
||||
const [value, onChange] = useState<Collection>(init);
|
||||
const removeFromExcludeList = useCallback(
|
||||
(id: string) => {
|
||||
onChange({
|
||||
...value,
|
||||
excludeList: value.excludeList?.filter(v => v !== id),
|
||||
});
|
||||
},
|
||||
[value]
|
||||
);
|
||||
const removeFromAllowList = useCallback(
|
||||
(id: string) => {
|
||||
onChange({
|
||||
...value,
|
||||
allowList: value.allowList?.filter(v => v !== id),
|
||||
});
|
||||
},
|
||||
[value]
|
||||
);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div className={styles.saveTitle}>
|
||||
{title ?? 'Save As New Collection'}
|
||||
</div>
|
||||
<ScrollableContainer
|
||||
className={styles.scrollContainer}
|
||||
viewPortClassName={styles.container}
|
||||
>
|
||||
<div className={styles.excludeList}>
|
||||
<div className={styles.excludeTitle}>
|
||||
Exclude from this collection
|
||||
</div>
|
||||
{value.excludeList ? (
|
||||
<div className={styles.excludeListContent}>
|
||||
{value.excludeList.map(id => {
|
||||
return (
|
||||
<Page
|
||||
id={id}
|
||||
getPageInfo={getPageInfo}
|
||||
key={id}
|
||||
onClick={removeFromExcludeList}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.excludeTip}>
|
||||
These pages will never appear in the current collection
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
borderRadius: 8,
|
||||
padding: 18,
|
||||
marginTop: 20,
|
||||
}}
|
||||
>
|
||||
<div className={styles.filterTitle}>Filters</div>
|
||||
<FilterList
|
||||
value={value.filterList}
|
||||
onChange={list =>
|
||||
onChange({
|
||||
...value,
|
||||
filterList: list,
|
||||
})
|
||||
}
|
||||
></FilterList>
|
||||
{value.allowList ? (
|
||||
<div className={styles.allowList}>
|
||||
<div className={styles.allowTitle}>With follow pages:</div>
|
||||
<div className={styles.allowListContent}>
|
||||
{value.allowList.map(id => {
|
||||
return (
|
||||
<Page
|
||||
key={id}
|
||||
id={id}
|
||||
getPageInfo={getPageInfo}
|
||||
onClick={removeFromAllowList}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Input
|
||||
data-testid="input-collection-title"
|
||||
placeholder="Untitled Collection"
|
||||
value={value.name}
|
||||
onChange={text =>
|
||||
onChange({
|
||||
...value,
|
||||
name: text,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 40,
|
||||
}}
|
||||
>
|
||||
<Button className={styles.cancelButton} onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
style={{
|
||||
marginLeft: 20,
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
data-testid="save-collection"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
if (value.name.trim().length > 0) {
|
||||
onConfirm(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{onConfirmText ?? 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const SaveCollectionButton = ({
|
||||
init,
|
||||
onConfirm,
|
||||
getPageInfo,
|
||||
}: CreateCollectionProps) => {
|
||||
const [show, changeShow] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className={styles.saveButton}
|
||||
onClick={() => changeShow(true)}
|
||||
size="middle"
|
||||
data-testid="save-as-collection"
|
||||
>
|
||||
<div className={styles.saveButtonContainer}>
|
||||
<div className={styles.saveIcon}>
|
||||
<SaveIcon />
|
||||
</div>
|
||||
<div className={styles.saveText}>Save As Collection</div>
|
||||
</div>
|
||||
</Button>
|
||||
<EditCollectionModel
|
||||
init={init}
|
||||
onConfirm={onConfirm}
|
||||
open={show}
|
||||
getPageInfo={getPageInfo}
|
||||
onClose={() => changeShow(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,112 +0,0 @@
|
||||
import type { Filter, View } from '@affine/env/filter';
|
||||
import { SaveIcon } from '@blocksuite/icons';
|
||||
import { uuidv4 } from '@blocksuite/store';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button, Input, Modal, ModalCloseButton, ModalWrapper } from '../../..';
|
||||
import { FilterList } from '../filter';
|
||||
import * as styles from './view-list.css';
|
||||
|
||||
type CreateViewProps = {
|
||||
init: Filter[];
|
||||
onConfirm: (view: View) => void;
|
||||
};
|
||||
|
||||
const CreateView = ({
|
||||
init,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: CreateViewProps & { onCancel: () => void }) => {
|
||||
const [value, onChange] = useState<View>({
|
||||
name: '',
|
||||
filterList: init,
|
||||
id: uuidv4(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.saveTitle}>Save As New View</div>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
borderRadius: 8,
|
||||
padding: 20,
|
||||
marginTop: 20,
|
||||
}}
|
||||
>
|
||||
<FilterList
|
||||
value={value.filterList}
|
||||
onChange={list => onChange({ ...value, filterList: list })}
|
||||
></FilterList>
|
||||
</div>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Input
|
||||
placeholder="Untitled View"
|
||||
value={value.name}
|
||||
onChange={text => onChange({ ...value, name: text })}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 40 }}
|
||||
>
|
||||
<Button className={styles.cancelButton} onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
style={{ marginLeft: 20, borderRadius: '8px' }}
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
if (value.name.trim().length > 0) {
|
||||
onConfirm(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const SaveViewButton = ({ init, onConfirm }: CreateViewProps) => {
|
||||
const [show, changeShow] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className={styles.saveButton}
|
||||
onClick={() => changeShow(true)}
|
||||
size="middle"
|
||||
>
|
||||
<div className={styles.saveButtonContainer}>
|
||||
<div className={styles.saveIcon}>
|
||||
<SaveIcon />
|
||||
</div>
|
||||
<div className={styles.saveText}>Save View</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Modal open={show} onClose={() => changeShow(false)}>
|
||||
<ModalWrapper
|
||||
width={560}
|
||||
style={{
|
||||
padding: '40px',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
}}
|
||||
>
|
||||
<ModalCloseButton
|
||||
top={12}
|
||||
right={12}
|
||||
onClick={() => changeShow(false)}
|
||||
hoverColor="var(--affine-icon-color)"
|
||||
/>
|
||||
<CreateView
|
||||
init={init}
|
||||
onCancel={() => changeShow(false)}
|
||||
onConfirm={view => {
|
||||
onConfirm(view);
|
||||
changeShow(false);
|
||||
}}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,2 +1,3 @@
|
||||
export * from './create-view';
|
||||
export * from './view-list';
|
||||
export * from './collection-bar';
|
||||
export * from './collection-list';
|
||||
export * from './create-collection';
|
||||
|
@ -1,76 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const filterButton = style({
|
||||
borderRadius: '8px',
|
||||
height: '100%',
|
||||
padding: '4px 8px',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
background: 'var(--affine-white)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
transition: 'margin-left 0.2s ease-in-out',
|
||||
':hover': {
|
||||
borderColor: 'var(--affine-border-color)',
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
export const filterButtonCollapse = style({
|
||||
marginLeft: '20px',
|
||||
});
|
||||
export const viewDivider = style({
|
||||
'::after': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
background: 'var(--affine-border-color)',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
margin: '0 1px',
|
||||
},
|
||||
});
|
||||
export const saveButton = style({
|
||||
marginTop: '4px',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 0',
|
||||
':hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
},
|
||||
});
|
||||
export const saveButtonContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '8px',
|
||||
});
|
||||
export const saveIcon = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
marginRight: '8px',
|
||||
});
|
||||
export const saveText = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
});
|
||||
export const cancelButton = style({
|
||||
background: 'var(--affine-hover-color)',
|
||||
borderRadius: '8px',
|
||||
':hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
},
|
||||
});
|
||||
export const saveTitle = style({
|
||||
fontSize: 'var(--affine-font-h-6)',
|
||||
fontWeight: '600',
|
||||
lineHeight: '24px',
|
||||
});
|
@ -1,72 +0,0 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { FilteredIcon } from '@blocksuite/icons';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
import { Button, MenuItem } from '../../..';
|
||||
import Menu from '../../../ui/menu/menu';
|
||||
import { appSidebarOpenAtom } from '../../app-sidebar';
|
||||
import { CreateFilterMenu } from '../filter/vars';
|
||||
import type { useAllPageSetting } from '../use-all-page-setting';
|
||||
import * as styles from './view-list.css';
|
||||
export const ViewList = ({
|
||||
setting,
|
||||
}: {
|
||||
setting: ReturnType<typeof useAllPageSetting>;
|
||||
}) => {
|
||||
const [open] = useAtom(appSidebarOpenAtom);
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div style={{ marginLeft: 4, display: 'flex', alignItems: 'center' }}>
|
||||
{setting.savedViews.length > 0 && (
|
||||
<Menu
|
||||
trigger="click"
|
||||
content={
|
||||
<div>
|
||||
{setting.savedViews.map(view => {
|
||||
return (
|
||||
<MenuItem
|
||||
onClick={() => setting.setCurrentView(view)}
|
||||
key={view.id}
|
||||
>
|
||||
{view.name}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button style={{ marginRight: 12, cursor: 'pointer' }}>
|
||||
{setting.currentView.name}
|
||||
</Button>
|
||||
</Menu>
|
||||
)}
|
||||
<Menu
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
content={
|
||||
<CreateFilterMenu
|
||||
value={setting.currentView.filterList}
|
||||
onChange={filterList => {
|
||||
setting.setCurrentView(view => ({
|
||||
...view,
|
||||
filterList,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
icon={<FilteredIcon />}
|
||||
className={clsx(styles.filterButton, {
|
||||
[styles.filterButtonCollapse]: !open,
|
||||
})}
|
||||
size="small"
|
||||
hoverColor="var(--affine-icon-color)"
|
||||
>
|
||||
{t['com.affine.filter']()}
|
||||
</Button>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -8,22 +8,28 @@ import * as styles from './index.css';
|
||||
export type ScrollableContainerProps = {
|
||||
showScrollTopBorder?: boolean;
|
||||
inTableView?: boolean;
|
||||
className?: string;
|
||||
viewPortClassName?: string;
|
||||
};
|
||||
|
||||
export const ScrollableContainer = ({
|
||||
children,
|
||||
showScrollTopBorder = false,
|
||||
inTableView = false,
|
||||
className,
|
||||
viewPortClassName,
|
||||
}: PropsWithChildren<ScrollableContainerProps>) => {
|
||||
const [hasScrollTop, ref] = useHasScrollTop();
|
||||
return (
|
||||
<ScrollArea.Root className={styles.scrollableContainerRoot}>
|
||||
<ScrollArea.Root
|
||||
className={clsx(styles.scrollableContainerRoot, className)}
|
||||
>
|
||||
<div
|
||||
data-has-scroll-top={hasScrollTop}
|
||||
className={clsx({ [styles.scrollTopBorder]: showScrollTopBorder })}
|
||||
/>
|
||||
<ScrollArea.Viewport
|
||||
className={clsx([styles.scrollableViewport])}
|
||||
className={clsx([styles.scrollableViewport, viewPortClassName])}
|
||||
ref={ref}
|
||||
>
|
||||
<div className={styles.scrollableContainer}>{children}</div>
|
||||
|
5
packages/env/src/filter.ts
vendored
5
packages/env/src/filter.ts
vendored
@ -25,8 +25,11 @@ export type Filter = {
|
||||
args: Literal[];
|
||||
};
|
||||
|
||||
export type View = {
|
||||
export type Collection = {
|
||||
id: string;
|
||||
name: string;
|
||||
pinned?: boolean;
|
||||
filterList: Filter[];
|
||||
allowList?: string[];
|
||||
excludeList?: string[];
|
||||
};
|
||||
|
7
packages/env/src/page-info.ts
vendored
Normal file
7
packages/env/src/page-info.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
export type PageInfo = {
|
||||
isEdgeless: boolean;
|
||||
title: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type GetPageInfoById = (id: string) => PageInfo | undefined;
|
4
packages/env/src/workspace.ts
vendored
4
packages/env/src/workspace.ts
vendored
@ -7,7 +7,7 @@ import type {
|
||||
} from '@blocksuite/store';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
|
||||
import type { View } from './filter';
|
||||
import type { Collection } from './filter';
|
||||
import type { Workspace as RemoteWorkspace } from './workspace/legacy-cloud';
|
||||
|
||||
export enum WorkspaceVersion {
|
||||
@ -185,7 +185,7 @@ type PageDetailProps<Flavour extends keyof WorkspaceRegistry> =
|
||||
type PageListProps<_Flavour extends keyof WorkspaceRegistry> = {
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
onOpenPage: (pageId: string, newTab?: boolean) => void;
|
||||
view: View;
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
export interface WorkspaceUISchema<Flavour extends keyof WorkspaceRegistry> {
|
||||
|
@ -193,6 +193,7 @@
|
||||
"Check Our Docs": "Check Our Docs",
|
||||
"Get in touch! Join our communities": "Get in touch! Join our communities.",
|
||||
"Favorites": "Favourites",
|
||||
"Collections": "Collections",
|
||||
"Download data": "Download {{CoreOrAll}} data",
|
||||
"Back Home": "Back Home",
|
||||
"Set a Workspace name": "Set a Workspace name",
|
||||
|
@ -49,3 +49,9 @@ export async function clickPageMoreActions(page: Page) {
|
||||
.getByTestId('editor-option-menu')
|
||||
.click();
|
||||
}
|
||||
|
||||
export const closeDownloadTip = async (page: Page) => {
|
||||
await page
|
||||
.locator('[data-testid="download-client-tip-close-button"]')
|
||||
.click();
|
||||
};
|
||||
|
@ -3,7 +3,11 @@ import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { openHomePage } from '../libs/load-page';
|
||||
import { getBlockSuiteEditorTitle, waitEditorLoad } from '../libs/page-logic';
|
||||
import {
|
||||
closeDownloadTip,
|
||||
getBlockSuiteEditorTitle,
|
||||
waitEditorLoad,
|
||||
} from '../libs/page-logic';
|
||||
import { clickSideBarAllPageButton } from '../libs/sidebar';
|
||||
|
||||
function getAllPage(page: Page) {
|
||||
@ -52,12 +56,6 @@ test('all page can create new edgeless page', async ({ page }) => {
|
||||
await expect(page.locator('affine-edgeless-page')).toBeVisible();
|
||||
});
|
||||
|
||||
const closeDownloadTip = async (page: Page) => {
|
||||
await page
|
||||
.locator('[data-testid="download-client-tip-close-button"]')
|
||||
.click();
|
||||
};
|
||||
|
||||
const createFirstFilter = async (page: Page, name: string) => {
|
||||
await page
|
||||
.locator('[data-testid="editor-header-items"]')
|
||||
|
103
tests/parallels/local-first-collections-items.spec.ts
Normal file
103
tests/parallels/local-first-collections-items.spec.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { test } from '@affine-test/kit/playwright';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { openHomePage } from '../libs/load-page';
|
||||
import {
|
||||
closeDownloadTip,
|
||||
getBlockSuiteEditorTitle,
|
||||
newPage,
|
||||
waitEditorLoad,
|
||||
} from '../libs/page-logic';
|
||||
|
||||
const createAndPinCollection = async (
|
||||
page: Page,
|
||||
options?: {
|
||||
collectionName?: string;
|
||||
}
|
||||
) => {
|
||||
await openHomePage(page);
|
||||
await waitEditorLoad(page);
|
||||
await newPage(page);
|
||||
await getBlockSuiteEditorTitle(page).click();
|
||||
await getBlockSuiteEditorTitle(page).fill('test page');
|
||||
await page.getByTestId('all-pages').click();
|
||||
const cell = page.getByRole('cell', {
|
||||
name: 'test page',
|
||||
});
|
||||
await expect(cell).toBeVisible();
|
||||
await closeDownloadTip(page);
|
||||
await page.getByTestId('create-first-filter').click();
|
||||
await page
|
||||
.getByTestId('variable-select')
|
||||
.locator('button', { hasText: 'Created' })
|
||||
.click();
|
||||
await page.getByTestId('save-as-collection').click();
|
||||
const title = page.getByTestId('input-collection-title');
|
||||
await title.isVisible();
|
||||
await title.fill(options?.collectionName ?? 'test collection');
|
||||
await page.getByTestId('save-collection').click();
|
||||
await page.getByTestId('collection-bar-option-pin').click();
|
||||
};
|
||||
test('Show collections items in sidebar', async ({ page }) => {
|
||||
await createAndPinCollection(page);
|
||||
const collections = page.getByTestId('collections');
|
||||
const items = collections.getByTestId('collection-item');
|
||||
expect(await items.count()).toBe(1);
|
||||
const first = items.first();
|
||||
expect(await first.textContent()).toBe('test collection');
|
||||
await first.getByTestId('fav-collapsed-button').click();
|
||||
const collectionPage = collections.getByTestId('collection-page').nth(1);
|
||||
expect(await collectionPage.textContent()).toBe('test page');
|
||||
await collectionPage.getByTestId('collection-page-options').click();
|
||||
const deletePage = page
|
||||
.getByTestId('collection-page-option')
|
||||
.getByText('Delete');
|
||||
await deletePage.click();
|
||||
expect(await collections.getByTestId('collection-page').count()).toBe(1);
|
||||
await first.getByTestId('collection-options').click();
|
||||
const deleteCollection = page
|
||||
.getByTestId('collection-option')
|
||||
.getByText('Delete');
|
||||
await deleteCollection.click();
|
||||
expect(await items.count()).toBe(0);
|
||||
});
|
||||
|
||||
test('pin and unpin collection', async ({ page }) => {
|
||||
const name = 'asd';
|
||||
await createAndPinCollection(page, { collectionName: name });
|
||||
const collections = page.getByTestId('collections');
|
||||
const items = collections.getByTestId('collection-item');
|
||||
expect(await items.count()).toBe(1);
|
||||
const first = items.first();
|
||||
await first.getByTestId('collection-options').click();
|
||||
const deleteCollection = page
|
||||
.getByTestId('collection-option')
|
||||
.getByText('Unpin');
|
||||
await deleteCollection.click();
|
||||
expect(await items.count()).toBe(0);
|
||||
await page.getByTestId('collection-select').click();
|
||||
const option = page.locator('[data-testid=collection-select-option]', {
|
||||
hasText: name,
|
||||
});
|
||||
await option.hover();
|
||||
await option.getByTestId('collection-select-option-pin').click();
|
||||
expect(await items.count()).toBe(1);
|
||||
});
|
||||
|
||||
test('edit collection', async ({ page }) => {
|
||||
await createAndPinCollection(page);
|
||||
const collections = page.getByTestId('collections');
|
||||
const items = collections.getByTestId('collection-item');
|
||||
expect(await items.count()).toBe(1);
|
||||
const first = items.first();
|
||||
await first.getByTestId('collection-options').click();
|
||||
const editCollection = page
|
||||
.getByTestId('collection-option')
|
||||
.getByText('Edit Filter');
|
||||
await editCollection.click();
|
||||
const title = page.getByTestId('input-collection-title');
|
||||
await title.fill('123');
|
||||
await page.getByTestId('save-collection').click();
|
||||
expect(await first.textContent()).toBe('123');
|
||||
});
|
14
yarn.lock
14
yarn.lock
@ -89,7 +89,7 @@ __metadata:
|
||||
"@blocksuite/blocks": 0.0.0-20230629103121-76e6587d-nightly
|
||||
"@blocksuite/editor": 0.0.0-20230629103121-76e6587d-nightly
|
||||
"@blocksuite/global": 0.0.0-20230629103121-76e6587d-nightly
|
||||
"@blocksuite/icons": ^2.1.21
|
||||
"@blocksuite/icons": ^2.1.23
|
||||
"@blocksuite/lit": 0.0.0-20230629103121-76e6587d-nightly
|
||||
"@blocksuite/store": 0.0.0-20230629103121-76e6587d-nightly
|
||||
"@dnd-kit/core": ^6.0.8
|
||||
@ -455,7 +455,7 @@ __metadata:
|
||||
"@blocksuite/blocks": 0.0.0-20230629103121-76e6587d-nightly
|
||||
"@blocksuite/editor": 0.0.0-20230629103121-76e6587d-nightly
|
||||
"@blocksuite/global": 0.0.0-20230629103121-76e6587d-nightly
|
||||
"@blocksuite/icons": ^2.1.21
|
||||
"@blocksuite/icons": ^2.1.23
|
||||
"@blocksuite/lit": 0.0.0-20230629103121-76e6587d-nightly
|
||||
"@blocksuite/store": 0.0.0-20230629103121-76e6587d-nightly
|
||||
"@storybook/addon-actions": ^7.0.23
|
||||
@ -513,7 +513,7 @@ __metadata:
|
||||
"@blocksuite/blocks": 0.0.0-20230629103121-76e6587d-nightly
|
||||
"@blocksuite/editor": 0.0.0-20230629103121-76e6587d-nightly
|
||||
"@blocksuite/global": 0.0.0-20230629103121-76e6587d-nightly
|
||||
"@blocksuite/icons": ^2.1.21
|
||||
"@blocksuite/icons": ^2.1.23
|
||||
"@blocksuite/lit": 0.0.0-20230629103121-76e6587d-nightly
|
||||
"@blocksuite/store": 0.0.0-20230629103121-76e6587d-nightly
|
||||
"@dnd-kit/core": ^6.0.8
|
||||
@ -3967,13 +3967,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@blocksuite/icons@npm:^2.1.21":
|
||||
version: 2.1.21
|
||||
resolution: "@blocksuite/icons@npm:2.1.21"
|
||||
"@blocksuite/icons@npm:^2.1.23":
|
||||
version: 2.1.24
|
||||
resolution: "@blocksuite/icons@npm:2.1.24"
|
||||
peerDependencies:
|
||||
"@types/react": ^18.0.25
|
||||
react: ^18.2.0
|
||||
checksum: ade86c53243691da1aae2bf2abca88b0d9594590a59cf30ec361cba8cb4268737e7129fc0a61ad87e610d709e3eb3d10c8fea3bb76beeeebb334dd14f1001ea1
|
||||
checksum: 170d060e194a923edc5733563fee54475b088235a344073c57608883c48c54d647bbcb33635dd3d816c4a498468e30acc16c9bb5dc5d515050a5636ead5f7679
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user