feat: add tags support (#2988)

Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
3720
2023-07-04 15:32:11 +08:00
committed by Alex Yang
parent 65c6479ee4
commit 34ad5cdaef
34 changed files with 706 additions and 78 deletions
@@ -71,6 +71,7 @@ AffineAllPageList.args = {
icon: <PageIcon />, icon: <PageIcon />,
isPublicPage: true, isPublicPage: true,
title: 'Today Page', title: 'Today Page',
tags: [],
preview: 'this is page preview', preview: 'this is page preview',
createDate: new Date(), createDate: new Date(),
updatedDate: new Date(), updatedDate: new Date(),
@@ -87,6 +88,7 @@ AffineAllPageList.args = {
isPublicPage: true, isPublicPage: true,
title: title:
'1 Example Public Page with long title that will be truncated because it is too too long', '1 Example Public Page with long title that will be truncated because it is too too long',
tags: [],
preview: preview:
'this is page preview and it is very long and will be truncated because it is too long and it is very long and will be truncated because it is too long', 'this is page preview and it is very long and will be truncated because it is too long and it is very long and will be truncated because it is too long',
createDate: new Date('2021-01-01'), createDate: new Date('2021-01-01'),
@@ -103,6 +105,7 @@ AffineAllPageList.args = {
isPublicPage: false, isPublicPage: false,
icon: <PageIcon />, icon: <PageIcon />,
title: '2 Favorited Page 2021', title: '2 Favorited Page 2021',
tags: [],
createDate: new Date('2021-01-02'), createDate: new Date('2021-01-02'),
updatedDate: new Date('2021-01-01'), updatedDate: new Date('2021-01-01'),
bookmarkPage: () => toast('Bookmark page'), bookmarkPage: () => toast('Bookmark page'),
@@ -117,6 +120,7 @@ AffineAllPageList.args = {
isPublicPage: false, isPublicPage: false,
icon: <PageIcon />, icon: <PageIcon />,
title: 'page created in 2023-04-01', title: 'page created in 2023-04-01',
tags: [],
createDate: new Date('2023-04-01'), createDate: new Date('2023-04-01'),
updatedDate: new Date('2023-04-01'), updatedDate: new Date('2023-04-01'),
bookmarkPage: () => toast('Bookmark page'), bookmarkPage: () => toast('Bookmark page'),
@@ -110,6 +110,13 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
usePageHelper(blockSuiteWorkspace); usePageHelper(blockSuiteWorkspace);
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const getPageInfo = useGetPageInfoById(); const getPageInfo = useGetPageInfoById();
const tagOptionMap = useMemo(
() =>
Object.fromEntries(
blockSuiteWorkspace.meta.properties.tags.options.map(v => [v.id, v])
),
[blockSuiteWorkspace.meta.properties.tags.options]
);
const list = useMemo( const list = useMemo(
() => () =>
pageMetas pageMetas
@@ -180,11 +187,15 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
const pageList: ListData[] = list.map(pageMeta => { const pageList: ListData[] = list.map(pageMeta => {
const page = blockSuiteWorkspace.getPage(pageMeta.id); const page = blockSuiteWorkspace.getPage(pageMeta.id);
const preview = page ? getPagePreviewText(page) : undefined; const preview = page ? getPagePreviewText(page) : undefined;
return { return {
icon: isPreferredEdgeless(pageMeta.id) ? <EdgelessIcon /> : <PageIcon />, icon: isPreferredEdgeless(pageMeta.id) ? <EdgelessIcon /> : <PageIcon />,
pageId: pageMeta.id, pageId: pageMeta.id,
title: pageMeta.title, title: pageMeta.title,
preview, preview,
tags:
page?.meta.tags.map(id => tagOptionMap[id]).filter(v => v != null) ??
[],
favorite: !!pageMeta.favorite, favorite: !!pageMeta.favorite,
isPublicPage: !!pageMeta.isPublic, isPublicPage: !!pageMeta.isPublic,
createDate: new Date(pageMeta.createDate), createDate: new Date(pageMeta.createDate),
@@ -219,6 +230,7 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
}); });
return ( return (
<PageList <PageList
propertiesMeta={blockSuiteWorkspace.meta.properties}
getPageInfo={getPageInfo} getPageInfo={getPageInfo}
onCreateNewPage={createPage} onCreateNewPage={createPage}
onCreateNewEdgeless={createEdgeless} onCreateNewEdgeless={createEdgeless}
@@ -2,7 +2,7 @@ import { Menu } from '@affine/component';
import { MenuItem } from '@affine/component/app-sidebar'; import { MenuItem } from '@affine/component/app-sidebar';
import { import {
EditCollectionModel, EditCollectionModel,
useAllPageSetting, useCollectionManager,
useSavedCollections, useSavedCollections,
} from '@affine/component/page-list'; } from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter'; import type { Collection } from '@affine/env/filter';
@@ -49,7 +49,7 @@ const CollectionOperations = ({
}: { }: {
view: Collection; view: Collection;
showUpdateCollection: () => void; showUpdateCollection: () => void;
setting: ReturnType<typeof useAllPageSetting>; setting: ReturnType<typeof useCollectionManager>;
}) => { }) => {
const actions = useMemo< const actions = useMemo<
Array< Array<
@@ -128,7 +128,7 @@ const CollectionRenderer = ({
getPageInfo: GetPageInfoById; getPageInfo: GetPageInfoById;
}) => { }) => {
const [collapsed, setCollapsed] = React.useState(true); const [collapsed, setCollapsed] = React.useState(true);
const setting = useAllPageSetting(); const setting = useCollectionManager();
const router = useRouter(); const router = useRouter();
const clickCollection = useCallback(() => { const clickCollection = useCallback(() => {
router router
@@ -187,6 +187,7 @@ const CollectionRenderer = ({
return ( return (
<Collapsible.Root open={!collapsed}> <Collapsible.Root open={!collapsed}>
<EditCollectionModel <EditCollectionModel
propertiesMeta={workspace.blockSuiteWorkspace.meta.properties}
getPageInfo={getPageInfo} getPageInfo={getPageInfo}
init={collection} init={collection}
onConfirm={setting.saveCollection} onConfirm={setting.saveCollection}
+9 -2
View File
@@ -2,7 +2,7 @@ import {
CollectionList, CollectionList,
FilterList, FilterList,
SaveCollectionButton, SaveCollectionButton,
useAllPageSetting, useCollectionManager,
} from '@affine/component/page-list'; } from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter'; import type { Collection } from '@affine/env/filter';
import type { WorkspaceHeaderProps } from '@affine/env/workspace'; import type { WorkspaceHeaderProps } from '@affine/env/workspace';
@@ -22,7 +22,7 @@ export function WorkspaceHeader({
currentWorkspace, currentWorkspace,
currentEntry, currentEntry,
}: WorkspaceHeaderProps<WorkspaceFlavour>): ReactElement { }: WorkspaceHeaderProps<WorkspaceFlavour>): ReactElement {
const setting = useAllPageSetting(); const setting = useCollectionManager();
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const saveToCollection = useCallback( const saveToCollection = useCallback(
async (collection: Collection) => { async (collection: Collection) => {
@@ -38,6 +38,7 @@ export function WorkspaceHeader({
<CollectionList <CollectionList
setting={setting} setting={setting}
getPageInfo={getPageInfoById} getPageInfo={getPageInfoById}
propertiesMeta={currentWorkspace.blockSuiteWorkspace.meta.properties}
></CollectionList> ></CollectionList>
); );
const filterContainer = const filterContainer =
@@ -45,6 +46,9 @@ export function WorkspaceHeader({
<div className={filterContainerStyle}> <div className={filterContainerStyle}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<FilterList <FilterList
propertiesMeta={
currentWorkspace.blockSuiteWorkspace.meta.properties
}
value={setting.currentCollection.filterList} value={setting.currentCollection.filterList}
onChange={filterList => { onChange={filterList => {
return setting.updateCollection({ return setting.updateCollection({
@@ -57,6 +61,9 @@ export function WorkspaceHeader({
<div> <div>
{setting.currentCollection.filterList.length > 0 ? ( {setting.currentCollection.filterList.length > 0 ? (
<SaveCollectionButton <SaveCollectionButton
propertiesMeta={
currentWorkspace.blockSuiteWorkspace.meta.properties
}
getPageInfo={getPageInfoById} getPageInfo={getPageInfoById}
init={{ init={{
id: uuidv4(), id: uuidv4(),
@@ -1,4 +1,9 @@
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton'; import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import {
createTagFilter,
useCollectionManager,
} from '@affine/component/page-list';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { rootCurrentPageIdAtom } from '@affine/workspace/atom'; import { rootCurrentPageIdAtom } from '@affine/workspace/atom';
import type { EditorContainer } from '@blocksuite/editor'; import type { EditorContainer } from '@blocksuite/editor';
import type { Page } from '@blocksuite/store'; import type { Page } from '@blocksuite/store';
@@ -19,29 +24,41 @@ import type { NextPageWithLayout } from '../../../shared';
const WorkspaceDetail: React.FC = () => { const WorkspaceDetail: React.FC = () => {
const router = useRouter(); const router = useRouter();
const { openPage } = useRouterHelper(router); const { openPage, jumpToSubPath } = useRouterHelper(router);
const currentPageId = useAtomValue(rootCurrentPageIdAtom); const currentPageId = useAtomValue(rootCurrentPageIdAtom);
const [currentWorkspace] = useCurrentWorkspace(); const [currentWorkspace] = useCurrentWorkspace();
assertExists(currentWorkspace); assertExists(currentWorkspace);
assertExists(currentPageId); assertExists(currentPageId);
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace; const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
const [setting, setSetting] = useAtom(pageSettingFamily(currentPageId)); const [setting, setSetting] = useAtom(pageSettingFamily(currentPageId));
const collectionManager = useCollectionManager();
if (!setting) { if (!setting) {
setSetting({ setSetting({
mode: 'page', mode: 'page',
}); });
} }
const onLoad = useCallback( const onLoad = useCallback(
(page: Page, editor: EditorContainer) => { (page: Page, editor: EditorContainer) => {
const dispose = editor.slots.pageLinkClicked.on(({ pageId }) => { const dispose = editor.slots.pageLinkClicked.on(({ pageId }) => {
return openPage(blockSuiteWorkspace.id, pageId); return openPage(blockSuiteWorkspace.id, pageId);
}); });
const disposeTagClick = editor.slots.tagClicked.on(async ({ tagId }) => {
await jumpToSubPath(currentWorkspace.id, WorkspaceSubPath.ALL);
collectionManager.backToAll();
collectionManager.setTemporaryFilter([createTagFilter(tagId)]);
});
return () => { return () => {
dispose.dispose(); dispose.dispose();
disposeTagClick.dispose();
}; };
}, },
[blockSuiteWorkspace.id, openPage] [
blockSuiteWorkspace.id,
collectionManager,
currentWorkspace.id,
jumpToSubPath,
openPage,
]
); );
const { PageDetail, Header } = getUIAdapter(currentWorkspace.flavour); const { PageDetail, Header } = getUIAdapter(currentWorkspace.flavour);
@@ -1,4 +1,4 @@
import { useAllPageSetting } from '@affine/component/page-list'; import { useCollectionManager } from '@affine/component/page-list';
import { QueryParamError } from '@affine/env/constant'; import { QueryParamError } from '@affine/env/constant';
import { WorkspaceSubPath } from '@affine/env/workspace'; import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
@@ -16,7 +16,7 @@ import type { NextPageWithLayout } from '../../../shared';
const AllPage: NextPageWithLayout = () => { const AllPage: NextPageWithLayout = () => {
const router = useRouter(); const router = useRouter();
const setting = useAllPageSetting(); const setting = useCollectionManager();
const { jumpToPage } = useRouterHelper(router); const { jumpToPage } = useRouterHelper(router);
const [currentWorkspace] = useCurrentWorkspace(); const [currentWorkspace] = useCurrentWorkspace();
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
+1
View File
@@ -13,5 +13,6 @@ export const filterPage = (collection: Collection, page: PageMeta) => {
'Is Favourited': !!page.favorite, 'Is Favourited': !!page.favorite,
Created: page.createDate, Created: page.createDate,
Updated: page.updatedDate ?? page.createDate, Updated: page.updatedDate ?? page.createDate,
Tags: page.tags,
}); });
}; };
@@ -6,6 +6,7 @@ import 'fake-indexeddb/auto';
import type { import type {
Filter, Filter,
LiteralValue, LiteralValue,
PropertiesMeta,
Ref, Ref,
VariableMap, VariableMap,
} from '@affine/env/filter'; } from '@affine/env/filter';
@@ -21,7 +22,7 @@ import { tBoolean, tDate } from '../filter/logical/custom-type';
import { toLiteral } from '../filter/shared-types'; import { toLiteral } from '../filter/shared-types';
import type { FilterMatcherDataType } from '../filter/vars'; import type { FilterMatcherDataType } from '../filter/vars';
import { filterMatcher } from '../filter/vars'; import { filterMatcher } from '../filter/vars';
import { filterByFilterList } from '../use-all-page-setting'; import { filterByFilterList } from '../use-collection-manager';
const ref = (name: keyof VariableMap): Ref => { const ref = (name: keyof VariableMap): Ref => {
return { return {
type: 'ref', type: 'ref',
@@ -33,9 +34,18 @@ const mockVariableMap = (vars: Partial<VariableMap>): VariableMap => {
Created: 0, Created: 0,
Updated: 0, Updated: 0,
'Is Favourited': false, 'Is Favourited': false,
Tags: [],
...vars, ...vars,
}; };
}; };
const mockPropertiesMeta = (meta: Partial<PropertiesMeta>): PropertiesMeta => {
return {
tags: {
options: [],
},
...meta,
};
};
const filter = ( const filter = (
matcherData: FilterMatcherDataType, matcherData: FilterMatcherDataType,
left: Ref, left: Ref,
@@ -127,7 +137,11 @@ describe('render filter', () => {
return ( return (
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<Condition value={value} onChange={onChange} /> <Condition
propertiesMeta={mockPropertiesMeta({})}
value={value}
onChange={onChange}
/>
</I18nextProvider> </I18nextProvider>
); );
}; };
@@ -143,7 +157,13 @@ describe('render filter', () => {
const [value, onChange] = useState( const [value, onChange] = useState(
filter(fn, ref('Created'), [new Date(2023, 5, 29).getTime()]) filter(fn, ref('Created'), [new Date(2023, 5, 29).getTime()])
); );
return <Condition value={value} onChange={onChange} />; return (
<Condition
propertiesMeta={mockPropertiesMeta({})}
value={value}
onChange={onChange}
/>
);
}; };
test('date condition function change', async () => { test('date condition function change', async () => {
@@ -7,20 +7,24 @@ import { renderHook } from '@testing-library/react';
import { expect, test } from 'vitest'; import { expect, test } from 'vitest';
import { createDefaultFilter, vars } from '../filter/vars'; import { createDefaultFilter, vars } from '../filter/vars';
import { useAllPageSetting } from '../use-all-page-setting'; import { useCollectionManager } from '../use-collection-manager';
const defaultMeta = { tags: { options: [] } };
test('useAllPageSetting', async () => { test('useAllPageSetting', async () => {
const settingHook = renderHook(() => useAllPageSetting()); const settingHook = renderHook(() => useCollectionManager());
const prevCollection = settingHook.result.current.currentCollection; const prevCollection = settingHook.result.current.currentCollection;
expect(settingHook.result.current.savedCollections).toEqual([]); expect(settingHook.result.current.savedCollections).toEqual([]);
await settingHook.result.current.updateCollection({ await settingHook.result.current.updateCollection({
...settingHook.result.current.currentCollection, ...settingHook.result.current.currentCollection,
filterList: [createDefaultFilter(vars[0])], filterList: [createDefaultFilter(vars[0], defaultMeta)],
}); });
settingHook.rerender(); settingHook.rerender();
const nextCollection = settingHook.result.current.currentCollection; const nextCollection = settingHook.result.current.currentCollection;
expect(nextCollection).not.toBe(prevCollection); expect(nextCollection).not.toBe(prevCollection);
expect(nextCollection.filterList).toEqual([createDefaultFilter(vars[0])]); expect(nextCollection.filterList).toEqual([
createDefaultFilter(vars[0], defaultMeta),
]);
settingHook.result.current.backToAll(); settingHook.result.current.backToAll();
await settingHook.result.current.saveCollection({ await settingHook.result.current.saveCollection({
...settingHook.result.current.currentCollection, ...settingHook.result.current.currentCollection,
@@ -1,5 +1,6 @@
import { CollectionBar } from '@affine/component/page-list'; import { CollectionBar } from '@affine/component/page-list';
import { DEFAULT_SORT_KEY } from '@affine/env/constant'; import { DEFAULT_SORT_KEY } from '@affine/env/constant';
import type { PropertiesMeta } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info'; import type { GetPageInfoById } from '@affine/env/page-info';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowDownBigIcon, ArrowUpBigIcon } from '@blocksuite/icons'; import { ArrowDownBigIcon, ArrowUpBigIcon } from '@blocksuite/icons';
@@ -34,6 +35,7 @@ const AllPagesHead = ({
createNewEdgeless, createNewEdgeless,
importFile, importFile,
getPageInfo, getPageInfo,
propertiesMeta,
}: { }: {
isPublicWorkspace: boolean; isPublicWorkspace: boolean;
sorter: ReturnType<typeof useSorter<ListData>>; sorter: ReturnType<typeof useSorter<ListData>>;
@@ -41,6 +43,7 @@ const AllPagesHead = ({
createNewEdgeless: () => void; createNewEdgeless: () => void;
importFile: () => void; importFile: () => void;
getPageInfo: GetPageInfoById; getPageInfo: GetPageInfoById;
propertiesMeta: PropertiesMeta;
}) => { }) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const titleList = [ const titleList = [
@@ -49,17 +52,21 @@ const AllPagesHead = ({
content: t['Title'](), content: t['Title'](),
proportion: 0.5, proportion: 0.5,
}, },
{
key: 'tags',
content: t['Tags'](),
proportion: 0.2,
},
{ {
key: 'createDate', key: 'createDate',
content: t['Created'](), content: t['Created'](),
proportion: 0.2, proportion: 0.1,
}, },
{ {
key: 'updatedDate', key: 'updatedDate',
content: t['Updated'](), content: t['Updated'](),
proportion: 0.2, proportion: 0.1,
}, },
{ {
key: 'unsortable_action', key: 'unsortable_action',
content: ( content: (
@@ -110,7 +117,11 @@ const AllPagesHead = ({
</TableCell> </TableCell>
))} ))}
</TableHeadRow> </TableHeadRow>
<CollectionBar getPageInfo={getPageInfo} /> <CollectionBar
columnsCount={titleList.length}
getPageInfo={getPageInfo}
propertiesMeta={propertiesMeta}
/>
</TableHead> </TableHead>
); );
}; };
@@ -123,6 +134,7 @@ export const PageList = ({
onImportFile, onImportFile,
fallback, fallback,
getPageInfo, getPageInfo,
propertiesMeta,
}: PageListProps) => { }: PageListProps) => {
const sorter = useSorter<ListData>({ const sorter = useSorter<ListData>({
data: list, data: list,
@@ -160,6 +172,7 @@ export const PageList = ({
<StyledTableContainer ref={ref}> <StyledTableContainer ref={ref}>
<Table showBorder={hasScrollTop} style={{ maxHeight: '100%' }}> <Table showBorder={hasScrollTop} style={{ maxHeight: '100%' }}>
<AllPagesHead <AllPagesHead
propertiesMeta={propertiesMeta}
isPublicWorkspace={isPublicWorkspace} isPublicWorkspace={isPublicWorkspace}
sorter={sorter} sorter={sorter}
createNewPage={onCreateNewPage} createNewPage={onCreateNewPage}
@@ -6,6 +6,7 @@ import { Fragment } from 'react';
import { styled } from '../../styles'; import { styled } from '../../styles';
import { TableBody, TableCell } from '../../ui/table'; import { TableBody, TableCell } from '../../ui/table';
import { FavoriteTag } from './components/favorite-tag'; import { FavoriteTag } from './components/favorite-tag';
import { Tags } from './components/tags';
import { TitleCell } from './components/title-cell'; import { TitleCell } from './components/title-cell';
import { OperationCell } from './operation-cell'; import { OperationCell } from './operation-cell';
import { StyledTableBodyRow } from './styles'; import { StyledTableBodyRow } from './styles';
@@ -51,6 +52,7 @@ export const AllPagesBody = ({
pageId, pageId,
title, title,
preview, preview,
tags,
icon, icon,
isPublicPage, isPublicPage,
favorite, favorite,
@@ -86,6 +88,14 @@ export const AllPagesBody = ({
data-testid="title" data-testid="title"
onClick={onClickPage} onClick={onClickPage}
/> />
<TableCell
data-testid="tags"
hidden={isSmallDevices}
onClick={onClickPage}
style={{ fontSize: 'var(--affine-font-xs)' }}
>
<Tags value={tags}></Tags>
</TableCell>
<TableCell <TableCell
data-testid="created-date" data-testid="created-date"
ellipsis={true} ellipsis={true}
@@ -0,0 +1,30 @@
import { style } from '@vanilla-extract/css';
export const tagList = style({
display: 'flex',
flexWrap: 'nowrap',
gap: 10,
overflow: 'hidden',
});
export const tagListFull = style({
display: 'flex',
flexWrap: 'wrap',
gap: 10,
maxWidth: 300,
padding: 10,
overflow: 'hidden',
});
export const tag = style({
flexShrink: 0,
padding: '2px 10px',
borderRadius: 6,
fontSize: 12,
lineHeight: '16px',
fontWeight: 400,
maxWidth: '100%',
color: 'var(--affine-text-primary-color)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
@@ -0,0 +1,26 @@
import type { Tag } from '@affine/env/filter';
import Menu from '../../../ui/menu/menu';
import * as styles from './tags.css';
export const Tags = ({ value }: { value: Tag[] }) => {
const list = value.map(tag => {
return (
<div
key={tag.id}
className={styles.tag}
style={{ backgroundColor: tag.color }}
>
{tag.value}
</div>
);
});
return (
<Menu
pointerEnterDelay={500}
content={<div className={styles.tagListFull}>{list}</div>}
>
<div className={styles.tagList}>{list}</div>
</Menu>
);
};
@@ -1,4 +1,5 @@
import type { Filter, Literal } from '@affine/env/filter'; import type { Filter, Literal } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
@@ -6,26 +7,42 @@ import { Menu, MenuItem } from '../../../ui/menu';
import { FilterTag } from './filter-tag-translation'; import { FilterTag } from './filter-tag-translation';
import * as styles from './index.css'; import * as styles from './index.css';
import { literalMatcher } from './literal-matcher'; import { literalMatcher } from './literal-matcher';
import { tBoolean } from './logical/custom-type';
import type { TFunction, TType } from './logical/typesystem'; import type { TFunction, TType } from './logical/typesystem';
import { typesystem } from './logical/typesystem';
import { variableDefineMap } from './shared-types'; import { variableDefineMap } from './shared-types';
import { filterMatcher, VariableSelect, vars } from './vars'; import { filterMatcher, VariableSelect, vars } from './vars';
export const Condition = ({ export const Condition = ({
value, value,
onChange, onChange,
propertiesMeta,
}: { }: {
value: Filter; value: Filter;
onChange: (filter: Filter) => void; onChange: (filter: Filter) => void;
propertiesMeta: PropertiesMeta;
}) => { }) => {
const data = useMemo( const data = useMemo(() => {
() => filterMatcher.find(v => v.data.name === value.funcName), const data = filterMatcher.find(v => v.data.name === value.funcName);
[value.funcName] if (!data) {
); return;
}
const instance = typesystem.instance(
{},
[variableDefineMap[value.left.name].type(propertiesMeta)],
tBoolean.create(),
data.type
);
return {
render: data.data.render,
type: instance,
};
}, [propertiesMeta, value.funcName, value.left.name]);
if (!data) { if (!data) {
return null; return null;
} }
const render = const render =
data.data.render ?? data.render ??
(({ ast }) => { (({ ast }) => {
const args = renderArgs(value, onChange, data.type); const args = renderArgs(value, onChange, data.type);
return ( return (
@@ -34,7 +51,13 @@ export const Condition = ({
> >
<Menu <Menu
trigger="click" trigger="click"
content={<VariableSelect selected={[]} onSelect={onChange} />} content={
<VariableSelect
propertiesMeta={propertiesMeta}
selected={[]}
onSelect={onChange}
/>
}
> >
<div data-testid="variable-name" className={styles.filterTypeStyle}> <div data-testid="variable-name" className={styles.filterTypeStyle}>
<div className={styles.filterTypeIconStyle}> <div className={styles.filterTypeIconStyle}>
@@ -47,7 +70,13 @@ export const Condition = ({
</Menu> </Menu>
<Menu <Menu
trigger="click" trigger="click"
content={<FunctionSelect value={value} onChange={onChange} />} content={
<FunctionSelect
propertiesMeta={propertiesMeta}
value={value}
onChange={onChange}
/>
}
> >
<div className={styles.switchStyle} data-testid="filter-name"> <div className={styles.switchStyle} data-testid="filter-name">
<FilterTag name={ast.funcName} /> <FilterTag name={ast.funcName} />
@@ -63,17 +92,19 @@ export const Condition = ({
const FunctionSelect = ({ const FunctionSelect = ({
value, value,
onChange, onChange,
propertiesMeta,
}: { }: {
value: Filter; value: Filter;
onChange: (value: Filter) => void; onChange: (value: Filter) => void;
propertiesMeta: PropertiesMeta;
}) => { }) => {
const list = useMemo(() => { const list = useMemo(() => {
const type = vars.find(v => v.name === value.left.name)?.type; const type = vars.find(v => v.name === value.left.name)?.type;
if (!type) { if (!type) {
return []; return [];
} }
return filterMatcher.allMatchedData(type); return filterMatcher.allMatchedData(type(propertiesMeta));
}, [value.left.name]); }, [propertiesMeta, value.left.name]);
return ( return (
<div data-testid="filter-name-select"> <div data-testid="filter-name-select">
{list.map(v => ( {list.map(v => (
@@ -109,7 +140,11 @@ export const Arg = ({
} }
return ( return (
<div data-testid="filter-arg" style={{ marginLeft: 4, fontWeight: 600 }}> <div data-testid="filter-arg" style={{ marginLeft: 4, fontWeight: 600 }}>
{data.render({ type, value, onChange })} {data.render({
type,
value: value?.value,
onChange: v => onChange({ type: 'literal', value: v }),
})}
</div> </div>
); );
}; };
@@ -119,15 +154,17 @@ export const renderArgs = (
type: TFunction type: TFunction
): ReactNode => { ): ReactNode => {
const rest = type.args.slice(1); const rest = type.args.slice(1);
return rest.map((type, i) => { return rest.map((argType, i) => {
const value = filter.args[i]; const value = filter.args[i];
return ( return (
<Arg <Arg
key={i} key={i}
type={type} type={argType}
value={value} value={value}
onChange={value => { onChange={value => {
const args = filter.args.map((v, index) => (i === index ? value : v)); const args = type.args.map((_, index) =>
i === index ? value : filter.args[index]
);
onChange({ onChange({
...filter, ...filter,
args, args,
@@ -5,8 +5,8 @@ import { filterMatcher } from './vars';
const evalRef = (ref: Ref, variableMap: VariableMap) => { const evalRef = (ref: Ref, variableMap: VariableMap) => {
return variableMap[ref.name]; return variableMap[ref.name];
}; };
const evalLiteral = (lit: Literal) => { const evalLiteral = (lit?: Literal) => {
return lit.value; return lit?.value;
}; };
const evalFilter = (filter: Filter, variableMap: VariableMap): boolean => { const evalFilter = (filter: Filter, variableMap: VariableMap): boolean => {
const impl = filterMatcher.findData(v => v.name === filter.funcName)?.impl; const impl = filterMatcher.findData(v => v.name === filter.funcName)?.impl;
@@ -1,4 +1,5 @@
import type { Filter } from '@affine/env/filter'; import type { Filter } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import { CloseIcon, PlusIcon } from '@blocksuite/icons'; import { CloseIcon, PlusIcon } from '@blocksuite/icons';
import { Menu } from '../../..'; import { Menu } from '../../..';
@@ -9,9 +10,11 @@ import { CreateFilterMenu } from './vars';
export const FilterList = ({ export const FilterList = ({
value, value,
onChange, onChange,
propertiesMeta,
}: { }: {
value: Filter[]; value: Filter[];
onChange: (value: Filter[]) => void; onChange: (value: Filter[]) => void;
propertiesMeta: PropertiesMeta;
}) => { }) => {
return ( return (
<div <div
@@ -25,6 +28,7 @@ export const FilterList = ({
return ( return (
<div className={styles.filterItemStyle} key={i}> <div className={styles.filterItemStyle} key={i}>
<Condition <Condition
propertiesMeta={propertiesMeta}
value={filter} value={filter}
onChange={filter => { onChange={filter => {
onChange( onChange(
@@ -45,7 +49,13 @@ export const FilterList = ({
})} })}
<Menu <Menu
trigger={'click'} trigger={'click'}
content={<CreateFilterMenu value={value} onChange={onChange} />} content={
<CreateFilterMenu
value={value}
onChange={onChange}
propertiesMeta={propertiesMeta}
/>
}
> >
<div <div
style={{ style={{
@@ -1,2 +1,3 @@
export * from './eval'; export * from './eval';
export * from './filter-list'; export * from './filter-list';
export * from './utils';
@@ -1,20 +1,21 @@
import type { Literal } from '@affine/env/filter'; import type { LiteralValue, Tag } from '@affine/env/filter';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { AFFiNEDatePicker } from '../../date-picker'; import { AFFiNEDatePicker } from '../../date-picker';
import { FilterTag } from './filter-tag-translation'; import { FilterTag } from './filter-tag-translation';
import { inputStyle } from './index.css'; import { inputStyle } from './index.css';
import { tBoolean, tDate } from './logical/custom-type'; import { tBoolean, tDate, tTag } from './logical/custom-type';
import { Matcher } from './logical/matcher'; import { Matcher } from './logical/matcher';
import type { TType } from './logical/typesystem'; import type { TType } from './logical/typesystem';
import { typesystem } from './logical/typesystem'; import { tArray, typesystem } from './logical/typesystem';
import { MultiSelect } from './multi-select';
export const literalMatcher = new Matcher<{ export const literalMatcher = new Matcher<{
render: (props: { render: (props: {
type: TType; type: TType;
value: Literal; value: LiteralValue;
onChange: (lit: Literal) => void; onChange: (lit: LiteralValue) => void;
}) => ReactNode; }) => ReactNode;
}>((type, target) => { }>((type, target) => {
return typesystem.isSubtype(type, target); return typesystem.isSubtype(type, target);
@@ -26,23 +27,44 @@ literalMatcher.register(tBoolean.create(), {
className={inputStyle} className={inputStyle}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => { onClick={() => {
onChange({ type: 'literal', value: !value.value }); onChange(!value);
}} }}
> >
<FilterTag name={value.value?.toString()} /> <FilterTag name={value?.toString()} />
</div> </div>
), ),
}); });
literalMatcher.register(tDate.create(), { literalMatcher.register(tDate.create(), {
render: ({ value, onChange }) => ( render: ({ value, onChange }) => (
<AFFiNEDatePicker <AFFiNEDatePicker
value={dayjs(value.value as number).format('YYYY-MM-DD')} value={dayjs(value as number).format('YYYY-MM-DD')}
onChange={e => { onChange={e => {
onChange({ onChange(dayjs(e, 'YYYY-MM-DD').valueOf());
type: 'literal',
value: dayjs(e, 'YYYY-MM-DD').valueOf(),
});
}} }}
/> />
), ),
}); });
const getTagsOfArrayTag = (type: TType): Tag[] => {
if (type.type === 'array') {
if (tTag.is(type.ele)) {
return type.ele.data?.tags ?? [];
}
return [];
} else {
return [];
}
};
literalMatcher.register(tArray(tTag.create()), {
render: ({ type, value, onChange }) => {
return (
<MultiSelect
value={(value ?? []) as string[]}
onChange={value => onChange(value)}
options={getTagsOfArrayTag(type).map(v => ({
label: v.value,
value: v.id,
}))}
></MultiSelect>
);
},
});
@@ -1,3 +1,5 @@
import type { Tag } from '@affine/env/filter';
import { DataHelper, typesystem } from './typesystem'; import { DataHelper, typesystem } from './typesystem';
export const tNumber = typesystem.defineData( export const tNumber = typesystem.defineData(
@@ -12,3 +14,8 @@ export const tBoolean = typesystem.defineData(
export const tDate = typesystem.defineData( export const tDate = typesystem.defineData(
DataHelper.create<{ value: number }>('Date') DataHelper.create<{ value: number }>('Date')
); );
export const tTag = typesystem.defineData<{ tags: Tag[] }>({
name: 'Tag',
supers: [],
});
@@ -0,0 +1,50 @@
import { style } from '@vanilla-extract/css';
export const content = style({
fontSize: 12,
color: 'var(--affine-text-primary-color)',
borderRadius: 8,
padding: '3px 4px',
cursor: 'pointer',
':hover': {
backgroundColor: 'var(--affine-hover-color)',
},
});
export const text = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 350,
});
export const optionList = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
padding: '0 4px',
});
export const selectOption = style({
display: 'flex',
alignItems: 'center',
fontSize: 14,
height: 26,
borderRadius: 5,
maxWidth: 240,
minWidth: 100,
padding: '0 12px',
cursor: 'pointer',
':hover': {
backgroundColor: 'var(--affine-hover-color)',
},
});
export const optionLabel = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
});
export const done = style({
display: 'flex',
alignItems: 'center',
color: 'var(--affine-primary-color)',
marginLeft: 8,
});
@@ -0,0 +1,77 @@
import { DoneIcon } from '@blocksuite/icons';
import type { MouseEvent } from 'react';
import { useMemo } from 'react';
import Menu from '../../../ui/menu/menu';
import * as styles from './multi-select.css';
export const MultiSelect = ({
value,
onChange,
options,
}: {
value: string[];
onChange: (value: string[]) => void;
options: {
label: string;
value: string;
}[];
}) => {
const optionMap = useMemo(
() => Object.fromEntries(options.map(v => [v.value, v])),
[options]
);
return (
<Menu
trigger="click"
content={
<div data-testid="multi-select" className={styles.optionList}>
{options.map(option => {
const selected = value.includes(option.value);
const click = (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (selected) {
onChange(value.filter(v => v !== option.value));
} else {
onChange([...value, option.value]);
}
};
return (
<div
className={styles.selectOption}
data-testid="select-option"
style={{
backgroundColor: selected
? 'var(--affine-hover-color)'
: undefined,
}}
onClick={click}
key={option.value}
>
<div className={styles.optionLabel}>{option.label}</div>
<div
style={{ opacity: selected ? 1 : 0 }}
className={styles.done}
>
<DoneIcon />
</div>
</div>
);
})}
</div>
}
>
<div className={styles.content}>
{value.length ? (
<div className={styles.text}>
{value.map(id => optionMap[id]?.label).join(', ')}
</div>
) : (
<div style={{ color: 'var(--affine-text-secondary-color)' }}>
Empty
</div>
)}
</div>
</Menu>
);
};
@@ -1,8 +1,19 @@
import type { Literal, LiteralValue, VariableMap } from '@affine/env/filter'; import type {
import { DateTimeIcon, FavoritedIcon } from '@blocksuite/icons'; Literal,
LiteralValue,
PropertiesMeta,
VariableMap,
} from '@affine/env/filter';
import {
DateTimeIcon,
FavoritedIcon,
MultiSelectIcon,
} from '@blocksuite/icons';
import type { ReactElement } from 'react';
import { tBoolean, tDate } from './logical/custom-type'; import { tBoolean, tDate, tTag } from './logical/custom-type';
import type { TType } from './logical/typesystem'; import type { TType } from './logical/typesystem';
import { tArray } from './logical/typesystem';
export const toLiteral = (value: LiteralValue): Literal => ({ export const toLiteral = (value: LiteralValue): Literal => ({
type: 'literal', type: 'literal',
@@ -11,29 +22,34 @@ export const toLiteral = (value: LiteralValue): Literal => ({
export type FilterVariable = { export type FilterVariable = {
name: keyof VariableMap; name: keyof VariableMap;
type: TType; type: (propertiesMeta: PropertiesMeta) => TType;
icon: ReactElement;
}; };
export const variableDefineMap = { export const variableDefineMap = {
Created: { Created: {
type: tDate.create(), type: () => tDate.create(),
icon: <DateTimeIcon />, icon: <DateTimeIcon />,
}, },
Updated: { Updated: {
type: tDate.create(), type: () => tDate.create(),
icon: <DateTimeIcon />, icon: <DateTimeIcon />,
}, },
'Is Favourited': { 'Is Favourited': {
type: tBoolean.create(), type: () => tBoolean.create(),
icon: <FavoritedIcon />, icon: <FavoritedIcon />,
}, },
Tags: {
type: meta => tArray(tTag.create({ tags: meta.tags.options })),
icon: <MultiSelectIcon />,
},
// Imported: { // Imported: {
// type: tBoolean.create(), // type: tBoolean.create(),
// }, // },
// 'Daily Note': { // 'Daily Note': {
// type: tBoolean.create(), // type: tBoolean.create(),
// }, // },
} as const; } satisfies Record<string, Omit<FilterVariable, 'name'>>;
export type InternalVariableMap = { export type InternalVariableMap = {
[K in keyof typeof variableDefineMap]: LiteralValue; [K in keyof typeof variableDefineMap]: LiteralValue;
@@ -0,0 +1,10 @@
import type { Filter } from '@affine/env/filter';
export const createTagFilter = (id: string): Filter => {
return {
type: 'filter',
left: { type: 'ref', name: 'Tags' },
funcName: 'contains all',
args: [{ type: 'literal', value: [id] }],
};
};
@@ -1,4 +1,9 @@
import type { Filter, LiteralValue, VariableMap } from '@affine/env/filter'; import type {
Filter,
LiteralValue,
PropertiesMeta,
VariableMap,
} from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@@ -6,10 +11,16 @@ import type { ReactNode } from 'react';
import { MenuItem } from '../../../ui/menu'; import { MenuItem } from '../../../ui/menu';
import { FilterTag } from './filter-tag-translation'; import { FilterTag } from './filter-tag-translation';
import * as styles from './index.css'; import * as styles from './index.css';
import { tBoolean, tDate } from './logical/custom-type'; import { tBoolean, tDate, tTag } from './logical/custom-type';
import { Matcher } from './logical/matcher'; import { Matcher } from './logical/matcher';
import type { TFunction } from './logical/typesystem'; import type { TFunction } from './logical/typesystem';
import { tFunction, typesystem } from './logical/typesystem'; import {
tArray,
tFunction,
tTypeRef,
tTypeVar,
typesystem,
} from './logical/typesystem';
import type { FilterVariable } from './shared-types'; import type { FilterVariable } from './shared-types';
import { variableDefineMap } from './shared-types'; import { variableDefineMap } from './shared-types';
@@ -21,8 +32,11 @@ export const vars: FilterVariable[] = Object.entries(variableDefineMap).map(
}) })
); );
export const createDefaultFilter = (variable: FilterVariable): Filter => { export const createDefaultFilter = (
const data = filterMatcher.match(variable.type); variable: FilterVariable,
propertiesMeta: PropertiesMeta
): Filter => {
const data = filterMatcher.match(variable.type(propertiesMeta));
if (!data) { if (!data) {
throw new Error('No matching function found'); throw new Error('No matching function found');
} }
@@ -37,12 +51,15 @@ export const createDefaultFilter = (variable: FilterVariable): Filter => {
export const CreateFilterMenu = ({ export const CreateFilterMenu = ({
value, value,
onChange, onChange,
propertiesMeta,
}: { }: {
value: Filter[]; value: Filter[];
onChange: (value: Filter[]) => void; onChange: (value: Filter[]) => void;
propertiesMeta: PropertiesMeta;
}) => { }) => {
return ( return (
<VariableSelect <VariableSelect
propertiesMeta={propertiesMeta}
selected={value} selected={value}
onSelect={filter => { onSelect={filter => {
onChange([...value, filter]); onChange([...value, filter]);
@@ -52,9 +69,11 @@ export const CreateFilterMenu = ({
}; };
export const VariableSelect = ({ export const VariableSelect = ({
onSelect, onSelect,
propertiesMeta,
}: { }: {
selected: Filter[]; selected: Filter[];
onSelect: (value: Filter) => void; onSelect: (value: Filter) => void;
propertiesMeta: PropertiesMeta;
}) => { }) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
return ( return (
@@ -70,7 +89,7 @@ export const VariableSelect = ({
icon={variableDefineMap[v.name].icon} icon={variableDefineMap[v.name].icon}
key={v.name} key={v.name}
onClick={() => { onClick={() => {
onSelect(createDefaultFilter(v)); onSelect(createDefaultFilter(v, propertiesMeta));
}} }}
className={styles.menuItemStyle} className={styles.menuItemStyle}
> >
@@ -90,7 +109,7 @@ export type FilterMatcherDataType = {
name: string; name: string;
defaultArgs: () => LiteralValue[]; defaultArgs: () => LiteralValue[];
render?: (props: { ast: Filter }) => ReactNode; render?: (props: { ast: Filter }) => ReactNode;
impl: (...args: LiteralValue[]) => boolean; impl: (...args: (LiteralValue | undefined)[]) => boolean;
}; };
export const filterMatcher = new Matcher<FilterMatcherDataType, TFunction>( export const filterMatcher = new Matcher<FilterMatcherDataType, TFunction>(
(type, target) => { (type, target) => {
@@ -146,3 +165,103 @@ filterMatcher.register(
}, },
} }
); );
filterMatcher.register(
tFunction({ args: [tArray(tTag.create())], rt: tBoolean.create() }),
{
name: 'is not empty',
defaultArgs: () => [],
impl: tags => {
if (Array.isArray(tags)) {
return tags.length > 0;
}
return true;
},
}
);
filterMatcher.register(
tFunction({ args: [tArray(tTag.create())], rt: tBoolean.create() }),
{
name: 'is empty',
defaultArgs: () => [],
impl: tags => {
if (Array.isArray(tags)) {
return tags.length == 0;
}
return true;
},
}
);
filterMatcher.register(
tFunction({
typeVars: [tTypeVar('T', tTag.create())],
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
rt: tBoolean.create(),
}),
{
name: 'contains all',
defaultArgs: () => [],
impl: (tags, target) => {
if (Array.isArray(tags) && Array.isArray(target)) {
return target.every(id => tags.includes(id));
}
return true;
},
}
);
filterMatcher.register(
tFunction({
typeVars: [tTypeVar('T', tTag.create())],
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
rt: tBoolean.create(),
}),
{
name: 'contains one of',
defaultArgs: () => [],
impl: (tags, target) => {
if (Array.isArray(tags) && Array.isArray(target)) {
return target.some(id => tags.includes(id));
}
return true;
},
}
);
filterMatcher.register(
tFunction({
typeVars: [tTypeVar('T', tTag.create())],
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
rt: tBoolean.create(),
}),
{
name: 'does not contains all',
defaultArgs: () => [],
impl: (tags, target) => {
if (Array.isArray(tags) && Array.isArray(target)) {
return !target.every(id => tags.includes(id));
}
return true;
},
}
);
filterMatcher.register(
tFunction({
typeVars: [tTypeVar('T', tTag.create())],
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
rt: tBoolean.create(),
}),
{
name: 'does not contains one of',
defaultArgs: () => [],
impl: (tags, target) => {
if (Array.isArray(tags) && Array.isArray(target)) {
return !target.some(id => tags.includes(id));
}
return true;
},
}
);
@@ -7,5 +7,5 @@ export * from './operation-cell';
export * from './operation-menu-items'; export * from './operation-menu-items';
export * from './styles'; export * from './styles';
export * from './type'; export * from './type';
export * from './use-all-page-setting'; export * from './use-collection-manager';
export * from './view'; export * from './view';
@@ -1,3 +1,5 @@
import type { Tag } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info'; import type { GetPageInfoById } from '@affine/env/page-info';
/** /**
@@ -14,6 +16,7 @@ export type ListData = {
icon: JSX.Element; icon: JSX.Element;
title: string; title: string;
preview?: string; preview?: string;
tags: Tag[];
favorite: boolean; favorite: boolean;
createDate: Date; createDate: Date;
updatedDate: Date; updatedDate: Date;
@@ -48,6 +51,7 @@ export type PageListProps = {
onCreateNewEdgeless: () => void; onCreateNewEdgeless: () => void;
onImportFile: () => void; onImportFile: () => void;
getPageInfo: GetPageInfoById; getPageInfo: GetPageInfoById;
propertiesMeta: PropertiesMeta;
}; };
export type DraggableTitleCellData = { export type DraggableTitleCellData = {
@@ -31,16 +31,17 @@ const pageCollectionDBPromise: Promise<IDBPDatabase<PageCollectionDBV1>> =
}, },
}); });
const defaultCollection = {
id: NIL,
name: 'All',
filterList: [],
};
const collectionAtom = atomWithReset<{ const collectionAtom = atomWithReset<{
currentId: string; currentId: string;
defaultCollection: Collection; defaultCollection: Collection;
}>({ }>({
currentId: NIL, currentId: NIL,
defaultCollection: { defaultCollection: defaultCollection,
id: NIL,
name: 'All',
filterList: [],
},
}); });
export const useSavedCollections = () => { export const useSavedCollections = () => {
@@ -102,7 +103,7 @@ export const useSavedCollections = () => {
}; };
}; };
export const useAllPageSetting = () => { export const useCollectionManager = () => {
const { savedCollections, saveCollection, deleteCollection, addPage } = const { savedCollections, saveCollection, deleteCollection, addPage } =
useSavedCollections(); useSavedCollections();
const [collectionData, setCollectionData] = useAtom(collectionAtom); const [collectionData, setCollectionData] = useAtom(collectionAtom);
@@ -132,6 +133,18 @@ export const useAllPageSetting = () => {
const backToAll = useCallback(() => { const backToAll = useCallback(() => {
setCollectionData(RESET); setCollectionData(RESET);
}, [setCollectionData]); }, [setCollectionData]);
const setTemporaryFilter = useCallback(
(filterList: Filter[]) => {
setCollectionData({
currentId: NIL,
defaultCollection: {
...defaultCollection,
filterList: filterList,
},
});
},
[setCollectionData]
);
const currentCollection = const currentCollection =
collectionData.currentId === NIL collectionData.currentId === NIL
? collectionData.defaultCollection ? collectionData.defaultCollection
@@ -149,6 +162,7 @@ export const useAllPageSetting = () => {
backToAll, backToAll,
deleteCollection, deleteCollection,
addPage, addPage,
setTemporaryFilter,
}; };
}; };
export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) => export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) =>
@@ -1,4 +1,5 @@
import { EditCollectionModel } from '@affine/component/page-list'; import { EditCollectionModel } from '@affine/component/page-list';
import type { PropertiesMeta } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info'; import type { GetPageInfoById } from '@affine/env/page-info';
import { import {
DeleteIcon, DeleteIcon,
@@ -13,15 +14,19 @@ import type { ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { Button } from '../../../ui/button/button'; import { Button } from '../../../ui/button/button';
import { useAllPageSetting } from '../use-all-page-setting'; import { useCollectionManager } from '../use-collection-manager';
import * as styles from './collection-bar.css'; import * as styles from './collection-bar.css';
export const CollectionBar = ({ export const CollectionBar = ({
getPageInfo, getPageInfo,
propertiesMeta,
columnsCount,
}: { }: {
getPageInfo: GetPageInfoById; getPageInfo: GetPageInfoById;
propertiesMeta: PropertiesMeta;
columnsCount: number;
}) => { }) => {
const setting = useAllPageSetting(); const setting = useCollectionManager();
const collection = setting.currentCollection; const collection = setting.currentCollection;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const actions: { const actions: {
@@ -80,6 +85,7 @@ export const CollectionBar = ({
<td> <td>
<div className={styles.view}> <div className={styles.view}>
<EditCollectionModel <EditCollectionModel
propertiesMeta={propertiesMeta}
getPageInfo={getPageInfo} getPageInfo={getPageInfo}
init={collection} init={collection}
open={open} open={open}
@@ -109,8 +115,9 @@ export const CollectionBar = ({
})} })}
</div> </div>
</td> </td>
<td></td> {Array.from({ length: columnsCount - 2 }).map((_, i) => (
<td></td> <td key={i}></td>
))}
<td <td
style={{ style={{
display: 'flex', display: 'flex',
@@ -1,5 +1,6 @@
import { EditCollectionModel } from '@affine/component/page-list'; import { EditCollectionModel } from '@affine/component/page-list';
import type { Collection, Filter } from '@affine/env/filter'; import type { Collection, Filter } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info'; import type { GetPageInfoById } from '@affine/env/page-info';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { import {
@@ -19,7 +20,7 @@ import { Button, MenuItem } from '../../..';
import Menu from '../../../ui/menu/menu'; import Menu from '../../../ui/menu/menu';
import { appSidebarOpenAtom } from '../../app-sidebar'; import { appSidebarOpenAtom } from '../../app-sidebar';
import { CreateFilterMenu } from '../filter/vars'; import { CreateFilterMenu } from '../filter/vars';
import type { useAllPageSetting } from '../use-all-page-setting'; import type { useCollectionManager } from '../use-collection-manager';
import * as styles from './collection-list.css'; import * as styles from './collection-list.css';
const CollectionOption = ({ const CollectionOption = ({
@@ -28,7 +29,7 @@ const CollectionOption = ({
updateCollection, updateCollection,
}: { }: {
collection: Collection; collection: Collection;
setting: ReturnType<typeof useAllPageSetting>; setting: ReturnType<typeof useCollectionManager>;
updateCollection: (view: Collection) => void; updateCollection: (view: Collection) => void;
}) => { }) => {
const actions: { const actions: {
@@ -118,9 +119,11 @@ const CollectionOption = ({
export const CollectionList = ({ export const CollectionList = ({
setting, setting,
getPageInfo, getPageInfo,
propertiesMeta,
}: { }: {
setting: ReturnType<typeof useAllPageSetting>; setting: ReturnType<typeof useCollectionManager>;
getPageInfo: GetPageInfoById; getPageInfo: GetPageInfoById;
propertiesMeta: PropertiesMeta;
}) => { }) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const [open] = useAtom(appSidebarOpenAtom); const [open] = useAtom(appSidebarOpenAtom);
@@ -205,6 +208,7 @@ export const CollectionList = ({
placement="bottom-start" placement="bottom-start"
content={ content={
<CreateFilterMenu <CreateFilterMenu
propertiesMeta={propertiesMeta}
value={setting.currentCollection.filterList} value={setting.currentCollection.filterList}
onChange={onChange} onChange={onChange}
/> />
@@ -221,6 +225,7 @@ export const CollectionList = ({
</Button> </Button>
</Menu> </Menu>
<EditCollectionModel <EditCollectionModel
propertiesMeta={propertiesMeta}
getPageInfo={getPageInfo} getPageInfo={getPageInfo}
init={collection} init={collection}
open={!!collection} open={!!collection}
@@ -1,4 +1,5 @@
import type { Collection } from '@affine/env/filter'; import type { Collection } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info'; import type { GetPageInfoById } from '@affine/env/page-info';
import { import {
EdgelessIcon, EdgelessIcon,
@@ -25,6 +26,7 @@ type CreateCollectionProps = {
onConfirm: (collection: Collection) => void; onConfirm: (collection: Collection) => void;
onConfirmText?: string; onConfirmText?: string;
getPageInfo: GetPageInfoById; getPageInfo: GetPageInfoById;
propertiesMeta: PropertiesMeta;
}; };
export const EditCollectionModel = ({ export const EditCollectionModel = ({
init, init,
@@ -32,12 +34,14 @@ export const EditCollectionModel = ({
open, open,
onClose, onClose,
getPageInfo, getPageInfo,
propertiesMeta,
}: { }: {
init?: Collection; init?: Collection;
onConfirm: (view: Collection) => void; onConfirm: (view: Collection) => void;
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
getPageInfo: GetPageInfoById; getPageInfo: GetPageInfoById;
propertiesMeta: PropertiesMeta;
}) => { }) => {
return ( return (
<Modal open={open} onClose={onClose}> <Modal open={open} onClose={onClose}>
@@ -56,6 +60,7 @@ export const EditCollectionModel = ({
/> />
{init ? ( {init ? (
<EditCollection <EditCollection
propertiesMeta={propertiesMeta}
title="Update Collection" title="Update Collection"
onConfirmText="Save" onConfirmText="Save"
init={init} init={init}
@@ -120,6 +125,7 @@ export const EditCollection = ({
onCancel, onCancel,
onConfirmText, onConfirmText,
getPageInfo, getPageInfo,
propertiesMeta,
}: CreateCollectionProps & { }: CreateCollectionProps & {
onCancel: () => void; onCancel: () => void;
}) => { }) => {
@@ -189,6 +195,7 @@ export const EditCollection = ({
> >
<div className={styles.filterTitle}>Filters</div> <div className={styles.filterTitle}>Filters</div>
<FilterList <FilterList
propertiesMeta={propertiesMeta}
value={value.filterList} value={value.filterList}
onChange={list => onChange={list =>
onChange({ onChange({
@@ -262,6 +269,7 @@ export const SaveCollectionButton = ({
init, init,
onConfirm, onConfirm,
getPageInfo, getPageInfo,
propertiesMeta,
}: CreateCollectionProps) => { }: CreateCollectionProps) => {
const [show, changeShow] = useState(false); const [show, changeShow] = useState(false);
return ( return (
@@ -280,6 +288,7 @@ export const SaveCollectionButton = ({
</div> </div>
</Button> </Button>
<EditCollectionModel <EditCollectionModel
propertiesMeta={propertiesMeta}
init={init} init={init}
onConfirm={onConfirm} onConfirm={onConfirm}
open={show} open={show}
+10
View File
@@ -1,3 +1,5 @@
import type { Workspace } from '@blocksuite/store';
export type LiteralValue = export type LiteralValue =
| number | number
| string | string
@@ -33,3 +35,11 @@ export type Collection = {
allowList?: string[]; allowList?: string[];
excludeList?: string[]; excludeList?: string[];
}; };
export type Tag = {
id: string;
value: string;
color: string;
parentId?: string;
};
export type PropertiesMeta = Workspace['meta']['properties'];
+1
View File
@@ -72,6 +72,7 @@
"Delete": "Delete", "Delete": "Delete",
"Title": "Title", "Title": "Title",
"Untitled": "Untitled", "Untitled": "Untitled",
"Tags": "Tags",
"Created": "Created", "Created": "Created",
"Updated": "Updated", "Updated": "Updated",
"Open in new tab": "Open in new tab", "Open in new tab": "Open in new tab",
+61
View File
@@ -6,6 +6,7 @@ import { openHomePage } from '../libs/load-page';
import { import {
closeDownloadTip, closeDownloadTip,
getBlockSuiteEditorTitle, getBlockSuiteEditorTitle,
newPage,
waitEditorLoad, waitEditorLoad,
} from '../libs/page-logic'; } from '../libs/page-logic';
import { clickSideBarAllPageButton } from '../libs/sidebar'; import { clickSideBarAllPageButton } from '../libs/sidebar';
@@ -25,6 +26,7 @@ function getAllPage(page: Page) {
await newPageDropdown.click(); await newPageDropdown.click();
await edgelessBlockCard.click(); await edgelessBlockCard.click();
} }
return { clickNewPageButton, clickNewEdgelessDropdown }; return { clickNewPageButton, clickNewEdgelessDropdown };
} }
@@ -302,3 +304,62 @@ test('use monthpicker to modify the month of datepicker', async ({ page }) => {
await selectMonthFromMonthPicker(page, nextMonth); await selectMonthFromMonthPicker(page, nextMonth);
await checkDatePickerMonth(page, nextMonth); await checkDatePickerMonth(page, nextMonth);
}); });
const createTag = async (page: Page, name: string) => {
await page.keyboard.type(name);
await page.keyboard.press('ArrowUp');
await page.keyboard.press('Enter');
};
const createPageWithTag = async (
page: Page,
options: {
title: string;
tags: string[];
}
) => {
await page.getByTestId('all-pages').click();
await newPage(page);
await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('test page');
await page.locator('affine-page-meta-data').click();
await page.locator('.add-tag').click();
for (const name of options.tags) {
await createTag(page, name);
}
await page.keyboard.press('Escape');
};
const changeFilter = async (page: Page, to: string | RegExp) => {
await page.getByTestId('filter-name').click();
await page
.getByTestId('filter-name-select')
.locator('button', { hasText: to })
.click();
};
async function selectTag(page: Page, name: string | RegExp) {
await page.getByTestId('filter-arg').click();
await page
.getByTestId('multi-select')
.getByTestId('select-option')
.getByText(name)
.click();
await page.getByTestId('filter-arg').click();
}
test('allow creation of filters by tags', async ({ page }) => {
await openHomePage(page);
await waitEditorLoad(page);
await closeDownloadTip(page);
await createPageWithTag(page, { title: 'Page A', tags: ['A'] });
await createPageWithTag(page, { title: 'Page B', tags: ['B'] });
await clickSideBarAllPageButton(page);
await createFirstFilter(page, 'Tags');
await checkFilterName(page, 'is not empty');
await checkPagesCount(page, 2);
await changeFilter(page, /^contains all/);
await checkPagesCount(page, 3);
await selectTag(page, 'A');
await checkPagesCount(page, 1);
await changeFilter(page, /^does not contains all/);
await selectTag(page, 'B');
await checkPagesCount(page, 2);
});
@@ -101,3 +101,26 @@ test('edit collection', async ({ page }) => {
await page.getByTestId('save-collection').click(); await page.getByTestId('save-collection').click();
expect(await first.textContent()).toBe('123'); expect(await first.textContent()).toBe('123');
}); });
test('create temporary filter by click tag', async ({ page }) => {
await openHomePage(page);
await waitEditorLoad(page);
await newPage(page);
await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('test page');
await page.locator('affine-page-meta-data').click();
await page.locator('.add-tag').click();
await page.keyboard.type('TODO Tag');
await page.keyboard.press('Enter');
await page.keyboard.press('Escape');
await page.locator('.tag', { hasText: 'TODO Tag' }).click();
await closeDownloadTip(page);
const cell = page.getByRole('cell', {
name: 'test page',
});
await expect(cell).toBeVisible();
expect(await page.getByTestId('title').count()).toBe(1);
await page.getByTestId('filter-arg').click();
await page.getByRole('tooltip').getByText('TODO Tag').click();
expect(await page.getByTestId('title').count()).toBe(2);
});