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 GitHub
parent e871ffcba0
commit 000f802baa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 706 additions and 78 deletions

View File

@ -71,6 +71,7 @@ AffineAllPageList.args = {
icon: <PageIcon />,
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: <PageIcon />,
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: <PageIcon />,
title: 'page created in 2023-04-01',
tags: [],
createDate: new Date('2023-04-01'),
updatedDate: new Date('2023-04-01'),
bookmarkPage: () => toast('Bookmark page'),

View File

@ -110,6 +110,13 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
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<BlockSuitePageListProps> = ({
const pageList: ListData[] = list.map(pageMeta => {
const page = blockSuiteWorkspace.getPage(pageMeta.id);
const preview = page ? getPagePreviewText(page) : undefined;
return {
icon: isPreferredEdgeless(pageMeta.id) ? <EdgelessIcon /> : <PageIcon />,
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<BlockSuitePageListProps> = ({
});
return (
<PageList
propertiesMeta={blockSuiteWorkspace.meta.properties}
getPageInfo={getPageInfo}
onCreateNewPage={createPage}
onCreateNewEdgeless={createEdgeless}

View File

@ -2,7 +2,7 @@ import { Menu } from '@affine/component';
import { MenuItem } from '@affine/component/app-sidebar';
import {
EditCollectionModel,
useAllPageSetting,
useCollectionManager,
useSavedCollections,
} from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter';
@ -49,7 +49,7 @@ const CollectionOperations = ({
}: {
view: Collection;
showUpdateCollection: () => void;
setting: ReturnType<typeof useAllPageSetting>;
setting: ReturnType<typeof useCollectionManager>;
}) => {
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 (
<Collapsible.Root open={!collapsed}>
<EditCollectionModel
propertiesMeta={workspace.blockSuiteWorkspace.meta.properties}
getPageInfo={getPageInfo}
init={collection}
onConfirm={setting.saveCollection}

View File

@ -2,7 +2,7 @@ import {
CollectionList,
FilterList,
SaveCollectionButton,
useAllPageSetting,
useCollectionManager,
} from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter';
import type { WorkspaceHeaderProps } from '@affine/env/workspace';
@ -22,7 +22,7 @@ export function WorkspaceHeader({
currentWorkspace,
currentEntry,
}: WorkspaceHeaderProps<WorkspaceFlavour>): ReactElement {
const setting = useAllPageSetting();
const setting = useCollectionManager();
const t = useAFFiNEI18N();
const saveToCollection = useCallback(
async (collection: Collection) => {
@ -38,6 +38,7 @@ export function WorkspaceHeader({
<CollectionList
setting={setting}
getPageInfo={getPageInfoById}
propertiesMeta={currentWorkspace.blockSuiteWorkspace.meta.properties}
></CollectionList>
);
const filterContainer =
@ -45,6 +46,9 @@ export function WorkspaceHeader({
<div className={filterContainerStyle}>
<div style={{ flex: 1 }}>
<FilterList
propertiesMeta={
currentWorkspace.blockSuiteWorkspace.meta.properties
}
value={setting.currentCollection.filterList}
onChange={filterList => {
return setting.updateCollection({
@ -57,6 +61,9 @@ export function WorkspaceHeader({
<div>
{setting.currentCollection.filterList.length > 0 ? (
<SaveCollectionButton
propertiesMeta={
currentWorkspace.blockSuiteWorkspace.meta.properties
}
getPageInfo={getPageInfoById}
init={{
id: uuidv4(),

View File

@ -1,4 +1,9 @@
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 type { EditorContainer } from '@blocksuite/editor';
import type { Page } from '@blocksuite/store';
@ -19,29 +24,41 @@ import type { NextPageWithLayout } from '../../../shared';
const WorkspaceDetail: React.FC = () => {
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);

View File

@ -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();

View File

@ -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,
});
};

View File

@ -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>): VariableMap => {
Created: 0,
Updated: 0,
'Is Favourited': false,
Tags: [],
...vars,
};
};
const mockPropertiesMeta = (meta: Partial<PropertiesMeta>): PropertiesMeta => {
return {
tags: {
options: [],
},
...meta,
};
};
const filter = (
matcherData: FilterMatcherDataType,
left: Ref,
@ -127,7 +137,11 @@ describe('render filter', () => {
return (
<I18nextProvider i18n={i18n}>
<Condition value={value} onChange={onChange} />
<Condition
propertiesMeta={mockPropertiesMeta({})}
value={value}
onChange={onChange}
/>
</I18nextProvider>
);
};
@ -143,7 +157,13 @@ describe('render filter', () => {
const [value, onChange] = useState(
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 () => {

View File

@ -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,

View File

@ -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<typeof useSorter<ListData>>;
@ -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 = ({
</TableCell>
))}
</TableHeadRow>
<CollectionBar getPageInfo={getPageInfo} />
<CollectionBar
columnsCount={titleList.length}
getPageInfo={getPageInfo}
propertiesMeta={propertiesMeta}
/>
</TableHead>
);
};
@ -123,6 +134,7 @@ export const PageList = ({
onImportFile,
fallback,
getPageInfo,
propertiesMeta,
}: PageListProps) => {
const sorter = useSorter<ListData>({
data: list,
@ -160,6 +172,7 @@ export const PageList = ({
<StyledTableContainer ref={ref}>
<Table showBorder={hasScrollTop} style={{ maxHeight: '100%' }}>
<AllPagesHead
propertiesMeta={propertiesMeta}
isPublicWorkspace={isPublicWorkspace}
sorter={sorter}
createNewPage={onCreateNewPage}

View File

@ -6,6 +6,7 @@ import { Fragment } from 'react';
import { styled } from '../../styles';
import { TableBody, TableCell } from '../../ui/table';
import { FavoriteTag } from './components/favorite-tag';
import { Tags } from './components/tags';
import { TitleCell } from './components/title-cell';
import { OperationCell } from './operation-cell';
import { StyledTableBodyRow } from './styles';
@ -51,6 +52,7 @@ export const AllPagesBody = ({
pageId,
title,
preview,
tags,
icon,
isPublicPage,
favorite,
@ -86,6 +88,14 @@ export const AllPagesBody = ({
data-testid="title"
onClick={onClickPage}
/>
<TableCell
data-testid="tags"
hidden={isSmallDevices}
onClick={onClickPage}
style={{ fontSize: 'var(--affine-font-xs)' }}
>
<Tags value={tags}></Tags>
</TableCell>
<TableCell
data-testid="created-date"
ellipsis={true}

View File

@ -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',
});

View File

@ -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>
);
};

View File

@ -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 = ({
>
<Menu
trigger="click"
content={<VariableSelect selected={[]} onSelect={onChange} />}
content={
<VariableSelect
propertiesMeta={propertiesMeta}
selected={[]}
onSelect={onChange}
/>
}
>
<div data-testid="variable-name" className={styles.filterTypeStyle}>
<div className={styles.filterTypeIconStyle}>
@ -47,7 +70,13 @@ export const Condition = ({
</Menu>
<Menu
trigger="click"
content={<FunctionSelect value={value} onChange={onChange} />}
content={
<FunctionSelect
propertiesMeta={propertiesMeta}
value={value}
onChange={onChange}
/>
}
>
<div className={styles.switchStyle} data-testid="filter-name">
<FilterTag name={ast.funcName} />
@ -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 (
<div data-testid="filter-name-select">
{list.map(v => (
@ -109,7 +140,11 @@ export const Arg = ({
}
return (
<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>
);
};
@ -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 (
<Arg
key={i}
type={type}
type={argType}
value={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({
...filter,
args,

View File

@ -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;

View File

@ -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 (
<div
@ -25,6 +28,7 @@ export const FilterList = ({
return (
<div className={styles.filterItemStyle} key={i}>
<Condition
propertiesMeta={propertiesMeta}
value={filter}
onChange={filter => {
onChange(
@ -45,7 +49,13 @@ export const FilterList = ({
})}
<Menu
trigger={'click'}
content={<CreateFilterMenu value={value} onChange={onChange} />}
content={
<CreateFilterMenu
value={value}
onChange={onChange}
propertiesMeta={propertiesMeta}
/>
}
>
<div
style={{

View File

@ -1,2 +1,3 @@
export * from './eval';
export * from './filter-list';
export * from './utils';

View File

@ -1,20 +1,21 @@
import type { Literal } from '@affine/env/filter';
import type { LiteralValue, Tag } from '@affine/env/filter';
import dayjs from 'dayjs';
import type { ReactNode } from 'react';
import { AFFiNEDatePicker } from '../../date-picker';
import { FilterTag } from './filter-tag-translation';
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 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<{
render: (props: {
type: TType;
value: Literal;
onChange: (lit: Literal) => 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);
}}
>
<FilterTag name={value.value?.toString()} />
<FilterTag name={value?.toString()} />
</div>
),
});
literalMatcher.register(tDate.create(), {
render: ({ value, onChange }) => (
<AFFiNEDatePicker
value={dayjs(value.value as number).format('YYYY-MM-DD')}
value={dayjs(value as number).format('YYYY-MM-DD')}
onChange={e => {
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 (
<MultiSelect
value={(value ?? []) as string[]}
onChange={value => onChange(value)}
options={getTagsOfArrayTag(type).map(v => ({
label: v.value,
value: v.id,
}))}
></MultiSelect>
);
},
});

View File

@ -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: [],
});

View File

@ -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,
});

View File

@ -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>
);
};

View File

@ -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: <DateTimeIcon />,
},
Updated: {
type: tDate.create(),
type: () => tDate.create(),
icon: <DateTimeIcon />,
},
'Is Favourited': {
type: tBoolean.create(),
type: () => tBoolean.create(),
icon: <FavoritedIcon />,
},
Tags: {
type: meta => tArray(tTag.create({ tags: meta.tags.options })),
icon: <MultiSelectIcon />,
},
// Imported: {
// type: tBoolean.create(),
// },
// 'Daily Note': {
// type: tBoolean.create(),
// },
} as const;
} satisfies Record<string, Omit<FilterVariable, 'name'>>;
export type InternalVariableMap = {
[K in keyof typeof variableDefineMap]: LiteralValue;

View File

@ -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] }],
};
};

View File

@ -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 (
<VariableSelect
propertiesMeta={propertiesMeta}
selected={value}
onSelect={filter => {
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<FilterMatcherDataType, TFunction>(
(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;
},
}
);

View File

@ -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';

View File

@ -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 = {

View File

@ -31,16 +31,17 @@ const pageCollectionDBPromise: Promise<IDBPDatabase<PageCollectionDBV1>> =
},
});
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) =>

View File

@ -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 = ({
<td>
<div className={styles.view}>
<EditCollectionModel
propertiesMeta={propertiesMeta}
getPageInfo={getPageInfo}
init={collection}
open={open}
@ -109,8 +115,9 @@ export const CollectionBar = ({
})}
</div>
</td>
<td></td>
<td></td>
{Array.from({ length: columnsCount - 2 }).map((_, i) => (
<td key={i}></td>
))}
<td
style={{
display: 'flex',

View File

@ -1,5 +1,6 @@
import { EditCollectionModel } from '@affine/component/page-list';
import type { Collection, Filter } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
@ -19,7 +20,7 @@ import { Button, MenuItem } from '../../..';
import Menu from '../../../ui/menu/menu';
import { appSidebarOpenAtom } from '../../app-sidebar';
import { CreateFilterMenu } from '../filter/vars';
import type { useAllPageSetting } from '../use-all-page-setting';
import type { useCollectionManager } from '../use-collection-manager';
import * as styles from './collection-list.css';
const CollectionOption = ({
@ -28,7 +29,7 @@ const CollectionOption = ({
updateCollection,
}: {
collection: Collection;
setting: ReturnType<typeof useAllPageSetting>;
setting: ReturnType<typeof useCollectionManager>;
updateCollection: (view: Collection) => void;
}) => {
const actions: {
@ -118,9 +119,11 @@ const CollectionOption = ({
export const CollectionList = ({
setting,
getPageInfo,
propertiesMeta,
}: {
setting: ReturnType<typeof useAllPageSetting>;
setting: ReturnType<typeof useCollectionManager>;
getPageInfo: GetPageInfoById;
propertiesMeta: PropertiesMeta;
}) => {
const t = useAFFiNEI18N();
const [open] = useAtom(appSidebarOpenAtom);
@ -205,6 +208,7 @@ export const CollectionList = ({
placement="bottom-start"
content={
<CreateFilterMenu
propertiesMeta={propertiesMeta}
value={setting.currentCollection.filterList}
onChange={onChange}
/>
@ -221,6 +225,7 @@ export const CollectionList = ({
</Button>
</Menu>
<EditCollectionModel
propertiesMeta={propertiesMeta}
getPageInfo={getPageInfo}
init={collection}
open={!!collection}

View File

@ -1,4 +1,5 @@
import type { Collection } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info';
import {
EdgelessIcon,
@ -25,6 +26,7 @@ type CreateCollectionProps = {
onConfirm: (collection: Collection) => 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 (
<Modal open={open} onClose={onClose}>
@ -56,6 +60,7 @@ export const EditCollectionModel = ({
/>
{init ? (
<EditCollection
propertiesMeta={propertiesMeta}
title="Update Collection"
onConfirmText="Save"
init={init}
@ -120,6 +125,7 @@ export const EditCollection = ({
onCancel,
onConfirmText,
getPageInfo,
propertiesMeta,
}: CreateCollectionProps & {
onCancel: () => void;
}) => {
@ -189,6 +195,7 @@ export const EditCollection = ({
>
<div className={styles.filterTitle}>Filters</div>
<FilterList
propertiesMeta={propertiesMeta}
value={value.filterList}
onChange={list =>
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 = ({
</div>
</Button>
<EditCollectionModel
propertiesMeta={propertiesMeta}
init={init}
onConfirm={onConfirm}
open={show}

View File

@ -1,3 +1,5 @@
import type { Workspace } from '@blocksuite/store';
export type LiteralValue =
| number
| string
@ -33,3 +35,11 @@ export type Collection = {
allowList?: string[];
excludeList?: string[];
};
export type Tag = {
id: string;
value: string;
color: string;
parentId?: string;
};
export type PropertiesMeta = Workspace['meta']['properties'];

View File

@ -72,6 +72,7 @@
"Delete": "Delete",
"Title": "Title",
"Untitled": "Untitled",
"Tags": "Tags",
"Created": "Created",
"Updated": "Updated",
"Open in new tab": "Open in new tab",

View File

@ -6,6 +6,7 @@ import { openHomePage } from '../libs/load-page';
import {
closeDownloadTip,
getBlockSuiteEditorTitle,
newPage,
waitEditorLoad,
} from '../libs/page-logic';
import { clickSideBarAllPageButton } from '../libs/sidebar';
@ -25,6 +26,7 @@ function getAllPage(page: Page) {
await newPageDropdown.click();
await edgelessBlockCard.click();
}
return { clickNewPageButton, clickNewEdgelessDropdown };
}
@ -302,3 +304,62 @@ test('use monthpicker to modify the month of datepicker', async ({ page }) => {
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);
});

View File

@ -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);
});