mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-23 09:22:38 +03:00
feat: add tags support (#2988)
Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
parent
e871ffcba0
commit
000f802baa
@ -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'),
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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(),
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
@ -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 () => {
|
||||
|
@ -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,
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -0,0 +1,30 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const tagList = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'nowrap',
|
||||
gap: 10,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
export const tagListFull = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
maxWidth: 300,
|
||||
padding: 10,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const tag = style({
|
||||
flexShrink: 0,
|
||||
padding: '2px 10px',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
lineHeight: '16px',
|
||||
fontWeight: 400,
|
||||
maxWidth: '100%',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
@ -0,0 +1,26 @@
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
|
||||
import Menu from '../../../ui/menu/menu';
|
||||
import * as styles from './tags.css';
|
||||
|
||||
export const Tags = ({ value }: { value: Tag[] }) => {
|
||||
const list = value.map(tag => {
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={styles.tag}
|
||||
style={{ backgroundColor: tag.color }}
|
||||
>
|
||||
{tag.value}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Menu
|
||||
pointerEnterDelay={500}
|
||||
content={<div className={styles.tagListFull}>{list}</div>}
|
||||
>
|
||||
<div className={styles.tagList}>{list}</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
@ -1,4 +1,5 @@
|
||||
import type { Filter, Literal } from '@affine/env/filter';
|
||||
import type { 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,
|
||||
|
@ -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;
|
||||
|
@ -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={{
|
||||
|
@ -1,2 +1,3 @@
|
||||
export * from './eval';
|
||||
export * from './filter-list';
|
||||
export * from './utils';
|
||||
|
@ -1,20 +1,21 @@
|
||||
import type { Literal } from '@affine/env/filter';
|
||||
import type { LiteralValue, Tag } from '@affine/env/filter';
|
||||
import dayjs from 'dayjs';
|
||||
import 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
@ -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: [],
|
||||
});
|
||||
|
@ -0,0 +1,50 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const content = style({
|
||||
fontSize: 12,
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
borderRadius: 8,
|
||||
padding: '3px 4px',
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
export const text = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 350,
|
||||
});
|
||||
export const optionList = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
padding: '0 4px',
|
||||
});
|
||||
export const selectOption = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 14,
|
||||
height: 26,
|
||||
borderRadius: 5,
|
||||
maxWidth: 240,
|
||||
minWidth: 100,
|
||||
padding: '0 12px',
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
export const optionLabel = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
});
|
||||
export const done = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'var(--affine-primary-color)',
|
||||
marginLeft: 8,
|
||||
});
|
@ -0,0 +1,77 @@
|
||||
import { DoneIcon } from '@blocksuite/icons';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Menu from '../../../ui/menu/menu';
|
||||
import * as styles from './multi-select.css';
|
||||
|
||||
export const MultiSelect = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
options: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
}) => {
|
||||
const optionMap = useMemo(
|
||||
() => Object.fromEntries(options.map(v => [v.value, v])),
|
||||
[options]
|
||||
);
|
||||
return (
|
||||
<Menu
|
||||
trigger="click"
|
||||
content={
|
||||
<div data-testid="multi-select" className={styles.optionList}>
|
||||
{options.map(option => {
|
||||
const selected = value.includes(option.value);
|
||||
const click = (e: MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
if (selected) {
|
||||
onChange(value.filter(v => v !== option.value));
|
||||
} else {
|
||||
onChange([...value, option.value]);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={styles.selectOption}
|
||||
data-testid="select-option"
|
||||
style={{
|
||||
backgroundColor: selected
|
||||
? 'var(--affine-hover-color)'
|
||||
: undefined,
|
||||
}}
|
||||
onClick={click}
|
||||
key={option.value}
|
||||
>
|
||||
<div className={styles.optionLabel}>{option.label}</div>
|
||||
<div
|
||||
style={{ opacity: selected ? 1 : 0 }}
|
||||
className={styles.done}
|
||||
>
|
||||
<DoneIcon />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
{value.length ? (
|
||||
<div className={styles.text}>
|
||||
{value.map(id => optionMap[id]?.label).join(', ')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--affine-text-secondary-color)' }}>
|
||||
Empty
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
@ -1,8 +1,19 @@
|
||||
import type { Literal, LiteralValue, VariableMap } from '@affine/env/filter';
|
||||
import { 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;
|
||||
|
10
packages/component/src/components/page-list/filter/utils.ts
Normal file
10
packages/component/src/components/page-list/filter/utils.ts
Normal 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] }],
|
||||
};
|
||||
};
|
@ -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;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
@ -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';
|
||||
|
@ -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 = {
|
||||
|
@ -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) =>
|
@ -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',
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
10
packages/env/src/filter.ts
vendored
10
packages/env/src/filter.ts
vendored
@ -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'];
|
||||
|
@ -72,6 +72,7 @@
|
||||
"Delete": "Delete",
|
||||
"Title": "Title",
|
||||
"Untitled": "Untitled",
|
||||
"Tags": "Tags",
|
||||
"Created": "Created",
|
||||
"Updated": "Updated",
|
||||
"Open in new tab": "Open in new tab",
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user