From 000f802baa98c9e5d71c48ec24e82545f9a60114 Mon Sep 17 00:00:00 2001 From: 3720 Date: Tue, 4 Jul 2023 15:32:11 +0800 Subject: [PATCH] feat: add tags support (#2988) Co-authored-by: Alex Yang --- .../src/stories/page-list.stories.tsx | 4 + .../block-suite-page-list/index.tsx | 12 ++ .../collections/collections-list.tsx | 7 +- apps/web/src/components/workspace-header.tsx | 11 +- .../workspace/[workspaceId]/[pageId].tsx | 23 ++- .../src/pages/workspace/[workspaceId]/all.tsx | 4 +- apps/web/src/utils/filter.ts | 1 + .../page-list/__tests__/filter.spec.tsx | 26 +++- .../__tests__/use-all-page-setting.spec.ts | 12 +- .../src/components/page-list/all-page.tsx | 21 ++- .../components/page-list/all-pages-body.tsx | 10 ++ .../page-list/components/tags.css.ts | 30 ++++ .../components/page-list/components/tags.tsx | 26 ++++ .../components/page-list/filter/condition.tsx | 63 +++++++-- .../src/components/page-list/filter/eval.ts | 4 +- .../page-list/filter/filter-list.tsx | 12 +- .../src/components/page-list/filter/index.ts | 1 + .../page-list/filter/literal-matcher.tsx | 46 ++++-- .../page-list/filter/logical/custom-type.ts | 7 + .../page-list/filter/multi-select.css.ts | 50 +++++++ .../page-list/filter/multi-select.tsx | 77 ++++++++++ .../page-list/filter/shared-types.tsx | 32 +++-- .../src/components/page-list/filter/utils.ts | 10 ++ .../src/components/page-list/filter/vars.tsx | 133 +++++++++++++++++- .../src/components/page-list/index.tsx | 2 +- .../src/components/page-list/type.ts | 4 + ...e-setting.ts => use-collection-manager.ts} | 26 +++- .../page-list/view/collection-bar.tsx | 15 +- .../page-list/view/collection-list.tsx | 11 +- .../page-list/view/create-collection.tsx | 9 ++ packages/env/src/filter.ts | 10 ++ packages/i18n/src/resources/en.json | 1 + tests/parallels/all-page.spec.ts | 61 ++++++++ .../local-first-collections-items.spec.ts | 23 +++ 34 files changed, 706 insertions(+), 78 deletions(-) create mode 100644 packages/component/src/components/page-list/components/tags.css.ts create mode 100644 packages/component/src/components/page-list/components/tags.tsx create mode 100644 packages/component/src/components/page-list/filter/multi-select.css.ts create mode 100644 packages/component/src/components/page-list/filter/multi-select.tsx create mode 100644 packages/component/src/components/page-list/filter/utils.ts rename packages/component/src/components/page-list/{use-all-page-setting.ts => use-collection-manager.ts} (89%) diff --git a/apps/storybook/src/stories/page-list.stories.tsx b/apps/storybook/src/stories/page-list.stories.tsx index bb6c40dcec..5a4f1ddf54 100644 --- a/apps/storybook/src/stories/page-list.stories.tsx +++ b/apps/storybook/src/stories/page-list.stories.tsx @@ -71,6 +71,7 @@ AffineAllPageList.args = { icon: , isPublicPage: true, title: 'Today Page', + tags: [], preview: 'this is page preview', createDate: new Date(), updatedDate: new Date(), @@ -87,6 +88,7 @@ AffineAllPageList.args = { isPublicPage: true, title: '1 Example Public Page with long title that will be truncated because it is too too long', + tags: [], 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', createDate: new Date('2021-01-01'), @@ -103,6 +105,7 @@ AffineAllPageList.args = { isPublicPage: false, icon: , title: '2 Favorited Page 2021', + tags: [], createDate: new Date('2021-01-02'), updatedDate: new Date('2021-01-01'), bookmarkPage: () => toast('Bookmark page'), @@ -117,6 +120,7 @@ AffineAllPageList.args = { isPublicPage: false, icon: , title: 'page created in 2023-04-01', + tags: [], createDate: new Date('2023-04-01'), updatedDate: new Date('2023-04-01'), bookmarkPage: () => toast('Bookmark page'), diff --git a/apps/web/src/components/blocksuite/block-suite-page-list/index.tsx b/apps/web/src/components/blocksuite/block-suite-page-list/index.tsx index 9f9684cf1d..3568641266 100644 --- a/apps/web/src/components/blocksuite/block-suite-page-list/index.tsx +++ b/apps/web/src/components/blocksuite/block-suite-page-list/index.tsx @@ -110,6 +110,13 @@ export const BlockSuitePageList: React.FC = ({ usePageHelper(blockSuiteWorkspace); const t = useAFFiNEI18N(); 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( () => pageMetas @@ -180,11 +187,15 @@ export const BlockSuitePageList: React.FC = ({ const pageList: ListData[] = list.map(pageMeta => { const page = blockSuiteWorkspace.getPage(pageMeta.id); const preview = page ? getPagePreviewText(page) : undefined; + return { icon: isPreferredEdgeless(pageMeta.id) ? : , pageId: pageMeta.id, title: pageMeta.title, preview, + tags: + page?.meta.tags.map(id => tagOptionMap[id]).filter(v => v != null) ?? + [], favorite: !!pageMeta.favorite, isPublicPage: !!pageMeta.isPublic, createDate: new Date(pageMeta.createDate), @@ -219,6 +230,7 @@ export const BlockSuitePageList: React.FC = ({ }); return ( void; - setting: ReturnType; + setting: ReturnType; }) => { const actions = useMemo< Array< @@ -128,7 +128,7 @@ const CollectionRenderer = ({ getPageInfo: GetPageInfoById; }) => { const [collapsed, setCollapsed] = React.useState(true); - const setting = useAllPageSetting(); + const setting = useCollectionManager(); const router = useRouter(); const clickCollection = useCallback(() => { router @@ -187,6 +187,7 @@ const CollectionRenderer = ({ return ( ): ReactElement { - const setting = useAllPageSetting(); + const setting = useCollectionManager(); const t = useAFFiNEI18N(); const saveToCollection = useCallback( async (collection: Collection) => { @@ -38,6 +38,7 @@ export function WorkspaceHeader({ ); const filterContainer = @@ -45,6 +46,9 @@ export function WorkspaceHeader({
{ return setting.updateCollection({ @@ -57,6 +61,9 @@ export function WorkspaceHeader({
{setting.currentCollection.filterList.length > 0 ? ( { const router = useRouter(); - const { openPage } = useRouterHelper(router); + const { openPage, jumpToSubPath } = useRouterHelper(router); const currentPageId = useAtomValue(rootCurrentPageIdAtom); const [currentWorkspace] = useCurrentWorkspace(); assertExists(currentWorkspace); assertExists(currentPageId); const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace; const [setting, setSetting] = useAtom(pageSettingFamily(currentPageId)); + const collectionManager = useCollectionManager(); if (!setting) { setSetting({ mode: 'page', }); } - const onLoad = useCallback( (page: Page, editor: EditorContainer) => { const dispose = editor.slots.pageLinkClicked.on(({ 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 () => { dispose.dispose(); + disposeTagClick.dispose(); }; }, - [blockSuiteWorkspace.id, openPage] + [ + blockSuiteWorkspace.id, + collectionManager, + currentWorkspace.id, + jumpToSubPath, + openPage, + ] ); const { PageDetail, Header } = getUIAdapter(currentWorkspace.flavour); diff --git a/apps/web/src/pages/workspace/[workspaceId]/all.tsx b/apps/web/src/pages/workspace/[workspaceId]/all.tsx index 9cf7e2204f..a313db2f6d 100644 --- a/apps/web/src/pages/workspace/[workspaceId]/all.tsx +++ b/apps/web/src/pages/workspace/[workspaceId]/all.tsx @@ -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 { WorkspaceSubPath } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; @@ -16,7 +16,7 @@ import type { NextPageWithLayout } from '../../../shared'; const AllPage: NextPageWithLayout = () => { const router = useRouter(); - const setting = useAllPageSetting(); + const setting = useCollectionManager(); const { jumpToPage } = useRouterHelper(router); const [currentWorkspace] = useCurrentWorkspace(); const t = useAFFiNEI18N(); diff --git a/apps/web/src/utils/filter.ts b/apps/web/src/utils/filter.ts index c663de2a3e..c50acfe167 100644 --- a/apps/web/src/utils/filter.ts +++ b/apps/web/src/utils/filter.ts @@ -13,5 +13,6 @@ export const filterPage = (collection: Collection, page: PageMeta) => { 'Is Favourited': !!page.favorite, Created: page.createDate, Updated: page.updatedDate ?? page.createDate, + Tags: page.tags, }); }; diff --git a/packages/component/src/components/page-list/__tests__/filter.spec.tsx b/packages/component/src/components/page-list/__tests__/filter.spec.tsx index d8885f5409..4f6c210f87 100644 --- a/packages/component/src/components/page-list/__tests__/filter.spec.tsx +++ b/packages/component/src/components/page-list/__tests__/filter.spec.tsx @@ -6,6 +6,7 @@ import 'fake-indexeddb/auto'; import type { Filter, LiteralValue, + PropertiesMeta, Ref, VariableMap, } from '@affine/env/filter'; @@ -21,7 +22,7 @@ import { tBoolean, tDate } from '../filter/logical/custom-type'; import { toLiteral } from '../filter/shared-types'; import type { FilterMatcherDataType } 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 => { return { type: 'ref', @@ -33,9 +34,18 @@ const mockVariableMap = (vars: Partial): VariableMap => { Created: 0, Updated: 0, 'Is Favourited': false, + Tags: [], ...vars, }; }; +const mockPropertiesMeta = (meta: Partial): PropertiesMeta => { + return { + tags: { + options: [], + }, + ...meta, + }; +}; const filter = ( matcherData: FilterMatcherDataType, left: Ref, @@ -127,7 +137,11 @@ describe('render filter', () => { return ( - + ); }; @@ -143,7 +157,13 @@ describe('render filter', () => { const [value, onChange] = useState( filter(fn, ref('Created'), [new Date(2023, 5, 29).getTime()]) ); - return ; + return ( + + ); }; test('date condition function change', async () => { diff --git a/packages/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts b/packages/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts index 61dfe12b81..b9ccc2154f 100644 --- a/packages/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts +++ b/packages/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts @@ -7,20 +7,24 @@ import { renderHook } from '@testing-library/react'; import { expect, test } from 'vitest'; 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 () => { - const settingHook = renderHook(() => useAllPageSetting()); + const settingHook = renderHook(() => useCollectionManager()); 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])], + filterList: [createDefaultFilter(vars[0], defaultMeta)], }); settingHook.rerender(); const nextCollection = settingHook.result.current.currentCollection; expect(nextCollection).not.toBe(prevCollection); - expect(nextCollection.filterList).toEqual([createDefaultFilter(vars[0])]); + expect(nextCollection.filterList).toEqual([ + createDefaultFilter(vars[0], defaultMeta), + ]); settingHook.result.current.backToAll(); await settingHook.result.current.saveCollection({ ...settingHook.result.current.currentCollection, diff --git a/packages/component/src/components/page-list/all-page.tsx b/packages/component/src/components/page-list/all-page.tsx index aec0d7be0c..199d938b8d 100644 --- a/packages/component/src/components/page-list/all-page.tsx +++ b/packages/component/src/components/page-list/all-page.tsx @@ -1,5 +1,6 @@ import { CollectionBar } from '@affine/component/page-list'; import { DEFAULT_SORT_KEY } from '@affine/env/constant'; +import type { PropertiesMeta } from '@affine/env/filter'; import type { GetPageInfoById } from '@affine/env/page-info'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ArrowDownBigIcon, ArrowUpBigIcon } from '@blocksuite/icons'; @@ -34,6 +35,7 @@ const AllPagesHead = ({ createNewEdgeless, importFile, getPageInfo, + propertiesMeta, }: { isPublicWorkspace: boolean; sorter: ReturnType>; @@ -41,6 +43,7 @@ const AllPagesHead = ({ createNewEdgeless: () => void; importFile: () => void; getPageInfo: GetPageInfoById; + propertiesMeta: PropertiesMeta; }) => { const t = useAFFiNEI18N(); const titleList = [ @@ -49,17 +52,21 @@ const AllPagesHead = ({ content: t['Title'](), proportion: 0.5, }, + { + key: 'tags', + content: t['Tags'](), + proportion: 0.2, + }, { key: 'createDate', content: t['Created'](), - proportion: 0.2, + proportion: 0.1, }, { key: 'updatedDate', content: t['Updated'](), - proportion: 0.2, + proportion: 0.1, }, - { key: 'unsortable_action', content: ( @@ -110,7 +117,11 @@ const AllPagesHead = ({ ))} - + ); }; @@ -123,6 +134,7 @@ export const PageList = ({ onImportFile, fallback, getPageInfo, + propertiesMeta, }: PageListProps) => { const sorter = useSorter({ data: list, @@ -160,6 +172,7 @@ export const PageList = ({ + { + const list = value.map(tag => { + return ( +
+ {tag.value} +
+ ); + }); + return ( + {list}} + > +
{list}
+
+ ); +}; diff --git a/packages/component/src/components/page-list/filter/condition.tsx b/packages/component/src/components/page-list/filter/condition.tsx index bb20c38742..b6f74d30aa 100644 --- a/packages/component/src/components/page-list/filter/condition.tsx +++ b/packages/component/src/components/page-list/filter/condition.tsx @@ -1,4 +1,5 @@ import type { Filter, Literal } from '@affine/env/filter'; +import type { PropertiesMeta } from '@affine/env/filter'; import type { ReactNode } from 'react'; import { useMemo } from 'react'; @@ -6,26 +7,42 @@ import { Menu, MenuItem } from '../../../ui/menu'; import { FilterTag } from './filter-tag-translation'; import * as styles from './index.css'; import { literalMatcher } from './literal-matcher'; +import { tBoolean } from './logical/custom-type'; import type { TFunction, TType } from './logical/typesystem'; +import { typesystem } from './logical/typesystem'; import { variableDefineMap } from './shared-types'; import { filterMatcher, VariableSelect, vars } from './vars'; export const Condition = ({ value, onChange, + propertiesMeta, }: { value: Filter; onChange: (filter: Filter) => void; + propertiesMeta: PropertiesMeta; }) => { - const data = useMemo( - () => filterMatcher.find(v => v.data.name === value.funcName), - [value.funcName] - ); + const data = useMemo(() => { + const data = filterMatcher.find(v => v.data.name === 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) { return null; } const render = - data.data.render ?? + data.render ?? (({ ast }) => { const args = renderArgs(value, onChange, data.type); return ( @@ -34,7 +51,13 @@ export const Condition = ({ > } + content={ + + } >
@@ -47,7 +70,13 @@ export const Condition = ({
} + content={ + + } >
@@ -63,17 +92,19 @@ export const Condition = ({ const FunctionSelect = ({ value, onChange, + propertiesMeta, }: { value: Filter; onChange: (value: Filter) => void; + propertiesMeta: PropertiesMeta; }) => { const list = useMemo(() => { const type = vars.find(v => v.name === value.left.name)?.type; if (!type) { return []; } - return filterMatcher.allMatchedData(type); - }, [value.left.name]); + return filterMatcher.allMatchedData(type(propertiesMeta)); + }, [propertiesMeta, value.left.name]); return (
{list.map(v => ( @@ -109,7 +140,11 @@ export const Arg = ({ } return (
- {data.render({ type, value, onChange })} + {data.render({ + type, + value: value?.value, + onChange: v => onChange({ type: 'literal', value: v }), + })}
); }; @@ -119,15 +154,17 @@ export const renderArgs = ( type: TFunction ): ReactNode => { const rest = type.args.slice(1); - return rest.map((type, i) => { + return rest.map((argType, i) => { const value = filter.args[i]; return ( { - const args = filter.args.map((v, index) => (i === index ? value : v)); + const args = type.args.map((_, index) => + i === index ? value : filter.args[index] + ); onChange({ ...filter, args, diff --git a/packages/component/src/components/page-list/filter/eval.ts b/packages/component/src/components/page-list/filter/eval.ts index 6fddff8de3..f07cf7ec5e 100644 --- a/packages/component/src/components/page-list/filter/eval.ts +++ b/packages/component/src/components/page-list/filter/eval.ts @@ -5,8 +5,8 @@ import { filterMatcher } from './vars'; const evalRef = (ref: Ref, variableMap: VariableMap) => { return variableMap[ref.name]; }; -const evalLiteral = (lit: Literal) => { - return lit.value; +const evalLiteral = (lit?: Literal) => { + return lit?.value; }; const evalFilter = (filter: Filter, variableMap: VariableMap): boolean => { const impl = filterMatcher.findData(v => v.name === filter.funcName)?.impl; diff --git a/packages/component/src/components/page-list/filter/filter-list.tsx b/packages/component/src/components/page-list/filter/filter-list.tsx index 5984a55de8..134690ea3a 100644 --- a/packages/component/src/components/page-list/filter/filter-list.tsx +++ b/packages/component/src/components/page-list/filter/filter-list.tsx @@ -1,4 +1,5 @@ import type { Filter } from '@affine/env/filter'; +import type { PropertiesMeta } from '@affine/env/filter'; import { CloseIcon, PlusIcon } from '@blocksuite/icons'; import { Menu } from '../../..'; @@ -9,9 +10,11 @@ import { CreateFilterMenu } from './vars'; export const FilterList = ({ value, onChange, + propertiesMeta, }: { value: Filter[]; onChange: (value: Filter[]) => void; + propertiesMeta: PropertiesMeta; }) => { return (
{ onChange( @@ -45,7 +49,13 @@ export const FilterList = ({ })} } + content={ + + } >
void; + value: LiteralValue; + onChange: (lit: LiteralValue) => void; }) => ReactNode; }>((type, target) => { return typesystem.isSubtype(type, target); @@ -26,23 +27,44 @@ literalMatcher.register(tBoolean.create(), { className={inputStyle} style={{ cursor: 'pointer' }} onClick={() => { - onChange({ type: 'literal', value: !value.value }); + onChange(!value); }} > - +
), }); literalMatcher.register(tDate.create(), { render: ({ value, onChange }) => ( { - onChange({ - type: 'literal', - value: dayjs(e, 'YYYY-MM-DD').valueOf(), - }); + onChange(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 ( + onChange(value)} + options={getTagsOfArrayTag(type).map(v => ({ + label: v.value, + value: v.id, + }))} + > + ); + }, +}); diff --git a/packages/component/src/components/page-list/filter/logical/custom-type.ts b/packages/component/src/components/page-list/filter/logical/custom-type.ts index 5d20a5ee6d..8f1c5cd28c 100644 --- a/packages/component/src/components/page-list/filter/logical/custom-type.ts +++ b/packages/component/src/components/page-list/filter/logical/custom-type.ts @@ -1,3 +1,5 @@ +import type { Tag } from '@affine/env/filter'; + import { DataHelper, typesystem } from './typesystem'; export const tNumber = typesystem.defineData( @@ -12,3 +14,8 @@ export const tBoolean = typesystem.defineData( export const tDate = typesystem.defineData( DataHelper.create<{ value: number }>('Date') ); + +export const tTag = typesystem.defineData<{ tags: Tag[] }>({ + name: 'Tag', + supers: [], +}); diff --git a/packages/component/src/components/page-list/filter/multi-select.css.ts b/packages/component/src/components/page-list/filter/multi-select.css.ts new file mode 100644 index 0000000000..5431024680 --- /dev/null +++ b/packages/component/src/components/page-list/filter/multi-select.css.ts @@ -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, +}); diff --git a/packages/component/src/components/page-list/filter/multi-select.tsx b/packages/component/src/components/page-list/filter/multi-select.tsx new file mode 100644 index 0000000000..013f96c089 --- /dev/null +++ b/packages/component/src/components/page-list/filter/multi-select.tsx @@ -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 ( + + {options.map(option => { + const selected = value.includes(option.value); + const click = (e: MouseEvent) => { + e.stopPropagation(); + if (selected) { + onChange(value.filter(v => v !== option.value)); + } else { + onChange([...value, option.value]); + } + }; + return ( +
+
{option.label}
+
+ +
+
+ ); + })} +
+ } + > +
+ {value.length ? ( +
+ {value.map(id => optionMap[id]?.label).join(', ')} +
+ ) : ( +
+ Empty +
+ )} +
+
+ ); +}; diff --git a/packages/component/src/components/page-list/filter/shared-types.tsx b/packages/component/src/components/page-list/filter/shared-types.tsx index 541d45aef1..8b63eb50d1 100644 --- a/packages/component/src/components/page-list/filter/shared-types.tsx +++ b/packages/component/src/components/page-list/filter/shared-types.tsx @@ -1,8 +1,19 @@ -import type { Literal, LiteralValue, VariableMap } from '@affine/env/filter'; -import { DateTimeIcon, FavoritedIcon } from '@blocksuite/icons'; +import type { + 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 { tArray } from './logical/typesystem'; export const toLiteral = (value: LiteralValue): Literal => ({ type: 'literal', @@ -11,29 +22,34 @@ export const toLiteral = (value: LiteralValue): Literal => ({ export type FilterVariable = { name: keyof VariableMap; - type: TType; + type: (propertiesMeta: PropertiesMeta) => TType; + icon: ReactElement; }; export const variableDefineMap = { Created: { - type: tDate.create(), + type: () => tDate.create(), icon: , }, Updated: { - type: tDate.create(), + type: () => tDate.create(), icon: , }, 'Is Favourited': { - type: tBoolean.create(), + type: () => tBoolean.create(), icon: , }, + Tags: { + type: meta => tArray(tTag.create({ tags: meta.tags.options })), + icon: , + }, // Imported: { // type: tBoolean.create(), // }, // 'Daily Note': { // type: tBoolean.create(), // }, -} as const; +} satisfies Record>; export type InternalVariableMap = { [K in keyof typeof variableDefineMap]: LiteralValue; diff --git a/packages/component/src/components/page-list/filter/utils.ts b/packages/component/src/components/page-list/filter/utils.ts new file mode 100644 index 0000000000..ca1bd9a10e --- /dev/null +++ b/packages/component/src/components/page-list/filter/utils.ts @@ -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] }], + }; +}; diff --git a/packages/component/src/components/page-list/filter/vars.tsx b/packages/component/src/components/page-list/filter/vars.tsx index 8e51f6c30b..553cb4bc9a 100644 --- a/packages/component/src/components/page-list/filter/vars.tsx +++ b/packages/component/src/components/page-list/filter/vars.tsx @@ -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 dayjs from 'dayjs'; import type { ReactNode } from 'react'; @@ -6,10 +11,16 @@ import type { ReactNode } from 'react'; import { MenuItem } from '../../../ui/menu'; import { FilterTag } from './filter-tag-translation'; 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 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 { variableDefineMap } from './shared-types'; @@ -21,8 +32,11 @@ export const vars: FilterVariable[] = Object.entries(variableDefineMap).map( }) ); -export const createDefaultFilter = (variable: FilterVariable): Filter => { - const data = filterMatcher.match(variable.type); +export const createDefaultFilter = ( + variable: FilterVariable, + propertiesMeta: PropertiesMeta +): Filter => { + const data = filterMatcher.match(variable.type(propertiesMeta)); if (!data) { throw new Error('No matching function found'); } @@ -37,12 +51,15 @@ export const createDefaultFilter = (variable: FilterVariable): Filter => { export const CreateFilterMenu = ({ value, onChange, + propertiesMeta, }: { value: Filter[]; onChange: (value: Filter[]) => void; + propertiesMeta: PropertiesMeta; }) => { return ( { onChange([...value, filter]); @@ -52,9 +69,11 @@ export const CreateFilterMenu = ({ }; export const VariableSelect = ({ onSelect, + propertiesMeta, }: { selected: Filter[]; onSelect: (value: Filter) => void; + propertiesMeta: PropertiesMeta; }) => { const t = useAFFiNEI18N(); return ( @@ -70,7 +89,7 @@ export const VariableSelect = ({ icon={variableDefineMap[v.name].icon} key={v.name} onClick={() => { - onSelect(createDefaultFilter(v)); + onSelect(createDefaultFilter(v, propertiesMeta)); }} className={styles.menuItemStyle} > @@ -90,7 +109,7 @@ export type FilterMatcherDataType = { name: string; defaultArgs: () => LiteralValue[]; render?: (props: { ast: Filter }) => ReactNode; - impl: (...args: LiteralValue[]) => boolean; + impl: (...args: (LiteralValue | undefined)[]) => boolean; }; export const filterMatcher = new Matcher( (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; + }, + } +); diff --git a/packages/component/src/components/page-list/index.tsx b/packages/component/src/components/page-list/index.tsx index e9d450e56c..ab5fa4d31c 100644 --- a/packages/component/src/components/page-list/index.tsx +++ b/packages/component/src/components/page-list/index.tsx @@ -7,5 +7,5 @@ export * from './operation-cell'; export * from './operation-menu-items'; export * from './styles'; export * from './type'; -export * from './use-all-page-setting'; +export * from './use-collection-manager'; export * from './view'; diff --git a/packages/component/src/components/page-list/type.ts b/packages/component/src/components/page-list/type.ts index c09418a83b..d019f54a56 100644 --- a/packages/component/src/components/page-list/type.ts +++ b/packages/component/src/components/page-list/type.ts @@ -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'; /** @@ -14,6 +16,7 @@ export type ListData = { icon: JSX.Element; title: string; preview?: string; + tags: Tag[]; favorite: boolean; createDate: Date; updatedDate: Date; @@ -48,6 +51,7 @@ export type PageListProps = { onCreateNewEdgeless: () => void; onImportFile: () => void; getPageInfo: GetPageInfoById; + propertiesMeta: PropertiesMeta; }; export type DraggableTitleCellData = { diff --git a/packages/component/src/components/page-list/use-all-page-setting.ts b/packages/component/src/components/page-list/use-collection-manager.ts similarity index 89% rename from packages/component/src/components/page-list/use-all-page-setting.ts rename to packages/component/src/components/page-list/use-collection-manager.ts index c02187f861..79480ce3d6 100644 --- a/packages/component/src/components/page-list/use-all-page-setting.ts +++ b/packages/component/src/components/page-list/use-collection-manager.ts @@ -31,16 +31,17 @@ const pageCollectionDBPromise: Promise> = }, }); +const defaultCollection = { + id: NIL, + name: 'All', + filterList: [], +}; const collectionAtom = atomWithReset<{ currentId: string; defaultCollection: Collection; }>({ currentId: NIL, - defaultCollection: { - id: NIL, - name: 'All', - filterList: [], - }, + defaultCollection: defaultCollection, }); export const useSavedCollections = () => { @@ -102,7 +103,7 @@ export const useSavedCollections = () => { }; }; -export const useAllPageSetting = () => { +export const useCollectionManager = () => { const { savedCollections, saveCollection, deleteCollection, addPage } = useSavedCollections(); const [collectionData, setCollectionData] = useAtom(collectionAtom); @@ -132,6 +133,18 @@ export const useAllPageSetting = () => { const backToAll = useCallback(() => { setCollectionData(RESET); }, [setCollectionData]); + const setTemporaryFilter = useCallback( + (filterList: Filter[]) => { + setCollectionData({ + currentId: NIL, + defaultCollection: { + ...defaultCollection, + filterList: filterList, + }, + }); + }, + [setCollectionData] + ); const currentCollection = collectionData.currentId === NIL ? collectionData.defaultCollection @@ -149,6 +162,7 @@ export const useAllPageSetting = () => { backToAll, deleteCollection, addPage, + setTemporaryFilter, }; }; export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) => diff --git a/packages/component/src/components/page-list/view/collection-bar.tsx b/packages/component/src/components/page-list/view/collection-bar.tsx index f26b50c8b7..2634ef9844 100644 --- a/packages/component/src/components/page-list/view/collection-bar.tsx +++ b/packages/component/src/components/page-list/view/collection-bar.tsx @@ -1,4 +1,5 @@ import { EditCollectionModel } from '@affine/component/page-list'; +import type { PropertiesMeta } from '@affine/env/filter'; import type { GetPageInfoById } from '@affine/env/page-info'; import { DeleteIcon, @@ -13,15 +14,19 @@ 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 { useCollectionManager } from '../use-collection-manager'; import * as styles from './collection-bar.css'; export const CollectionBar = ({ getPageInfo, + propertiesMeta, + columnsCount, }: { getPageInfo: GetPageInfoById; + propertiesMeta: PropertiesMeta; + columnsCount: number; }) => { - const setting = useAllPageSetting(); + const setting = useCollectionManager(); const collection = setting.currentCollection; const [open, setOpen] = useState(false); const actions: { @@ -80,6 +85,7 @@ export const CollectionBar = ({
- - + {Array.from({ length: columnsCount - 2 }).map((_, i) => ( + + ))}
; + setting: ReturnType; updateCollection: (view: Collection) => void; }) => { const actions: { @@ -118,9 +119,11 @@ const CollectionOption = ({ export const CollectionList = ({ setting, getPageInfo, + propertiesMeta, }: { - setting: ReturnType; + setting: ReturnType; getPageInfo: GetPageInfoById; + propertiesMeta: PropertiesMeta; }) => { const t = useAFFiNEI18N(); const [open] = useAtom(appSidebarOpenAtom); @@ -205,6 +208,7 @@ export const CollectionList = ({ placement="bottom-start" content={ @@ -221,6 +225,7 @@ export const CollectionList = ({ void; onConfirmText?: string; getPageInfo: GetPageInfoById; + propertiesMeta: PropertiesMeta; }; export const EditCollectionModel = ({ init, @@ -32,12 +34,14 @@ export const EditCollectionModel = ({ open, onClose, getPageInfo, + propertiesMeta, }: { init?: Collection; onConfirm: (view: Collection) => void; open: boolean; onClose: () => void; getPageInfo: GetPageInfoById; + propertiesMeta: PropertiesMeta; }) => { return ( @@ -56,6 +60,7 @@ export const EditCollectionModel = ({ /> {init ? ( void; }) => { @@ -189,6 +195,7 @@ export const EditCollection = ({ >
Filters
onChange({ @@ -262,6 +269,7 @@ export const SaveCollectionButton = ({ init, onConfirm, getPageInfo, + propertiesMeta, }: CreateCollectionProps) => { const [show, changeShow] = useState(false); return ( @@ -280,6 +288,7 @@ export const SaveCollectionButton = ({ { await selectMonthFromMonthPicker(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); +}); diff --git a/tests/parallels/local-first-collections-items.spec.ts b/tests/parallels/local-first-collections-items.spec.ts index d696eacb5a..b1118fa8c6 100644 --- a/tests/parallels/local-first-collections-items.spec.ts +++ b/tests/parallels/local-first-collections-items.spec.ts @@ -101,3 +101,26 @@ test('edit collection', async ({ page }) => { await page.getByTestId('save-collection').click(); 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); +});