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 bb6c40dce..5a4f1ddf5 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 9f9684cf1..356864126 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 9cf7e2204..a313db2f6 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 c663de2a3..c50acfe16 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 d8885f540..4f6c210f8 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 61dfe12b8..b9ccc2154 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 aec0d7be0..199d938b8 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 bb20c3874..b6f74d30a 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 6fddff8de..f07cf7ec5 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 5984a55de..134690ea3 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 5d20a5ee6..8f1c5cd28 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 000000000..543102468 --- /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 000000000..013f96c08 --- /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 541d45aef..8b63eb50d 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 000000000..ca1bd9a10 --- /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 8e51f6c30..553cb4bc9 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 e9d450e56..ab5fa4d31 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 c09418a83..d019f54a5 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 c02187f86..79480ce3d 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 f26b50c8b..2634ef984 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 d696eacb5..b1118fa8c 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); +});