From 8c5a1e2de38dcdce5122ee8715f14a4d647180ac Mon Sep 17 00:00:00 2001 From: 3720 Date: Tue, 30 May 2023 16:39:14 +0800 Subject: [PATCH] test: add some tests for page filter (#2593) --- .../block-suite-page-list/index.tsx | 6 +- .../page-list/__tests__/filter.spec.tsx | 160 ++++++++++++++++++ .../__tests__/use-all-page-setting.spec.ts | 25 +-- .../components/page-list/filter/condition.tsx | 13 +- .../page-list/filter/literal-matcher.tsx | 2 +- .../page-list/filter/logical/matcher.ts | 9 +- .../page-list/filter/logical/typesystem.ts | 1 - .../src/components/page-list/filter/vars.tsx | 67 +++++--- .../page-list/use-all-page-setting.ts | 43 +---- 9 files changed, 221 insertions(+), 105 deletions(-) create mode 100644 packages/component/src/components/page-list/__tests__/filter.spec.tsx diff --git a/apps/web/src/components/blocksuite/block-suite-page-list/index.tsx b/apps/web/src/components/blocksuite/block-suite-page-list/index.tsx index 851debabaa..91568c8ab3 100644 --- a/apps/web/src/components/blocksuite/block-suite-page-list/index.tsx +++ b/apps/web/src/components/blocksuite/block-suite-page-list/index.tsx @@ -5,7 +5,7 @@ import type { View, } from '@affine/component/page-list'; import { - filterByView, + filterByFilterList, PageList, PageListTrashView, } from '@affine/component/page-list'; @@ -92,10 +92,10 @@ export const BlockSuitePageList: React.FC = ({ if (!view) { return true; } - return filterByView(view, { + return filterByFilterList(view.filterList, { Favorite: !!pageMeta.favorite, Created: pageMeta.createDate, - Updated: pageMeta.updatedDate, + Updated: pageMeta.updatedDate ?? pageMeta.createDate, }); }), [pageMetas, listType, view] diff --git a/packages/component/src/components/page-list/__tests__/filter.spec.tsx b/packages/component/src/components/page-list/__tests__/filter.spec.tsx new file mode 100644 index 0000000000..17d24940ff --- /dev/null +++ b/packages/component/src/components/page-list/__tests__/filter.spec.tsx @@ -0,0 +1,160 @@ +/** + * @vitest-environment happy-dom + */ +import 'fake-indexeddb/auto'; + +import { assertExists } from '@blocksuite/global/utils'; +import { render } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, test } from 'vitest'; + +import { Condition } from '../filter/condition'; +import { tBoolean, tDate } from '../filter/logical/custom-type'; +import type { + Filter, + FilterMatcherDataType, + LiteralValue, + Ref, + VariableMap, +} from '../filter/vars'; +import { filterMatcher, toLiteral } from '../filter/vars'; +import { filterByFilterList } from '../use-all-page-setting'; + +const ref = (name: keyof VariableMap): Ref => { + return { + type: 'ref', + name, + }; +}; +const mockVariableMap = (vars: Partial): VariableMap => { + return { + Created: 0, + Updated: 0, + Favorite: false, + ...vars, + }; +}; +const filter = ( + matcherData: FilterMatcherDataType, + left: Ref, + args: LiteralValue[] +): Filter => { + return { + type: 'filter', + left, + funcName: matcherData.name, + args: args.map(toLiteral), + }; +}; +describe('match filter', () => { + test('boolean variable will match `is` filter', () => { + const is = filterMatcher + .allMatchedData(tBoolean.create()) + .find(v => v.name === 'is'); + expect(is?.name).toBe('is'); + }); + test('Date variable will match `before` filter', () => { + const before = filterMatcher + .allMatchedData(tDate.create()) + .find(v => v.name === 'before'); + expect(before?.name).toBe('before'); + }); +}); + +describe('eval filter', () => { + test('before', async () => { + const before = filterMatcher.findData(v => v.name === 'before'); + assertExists(before); + const filter1 = filter(before, ref('Created'), [ + new Date(2023, 5, 28).getTime(), + ]); + const filter2 = filter(before, ref('Created'), [ + new Date(2023, 5, 30).getTime(), + ]); + const filter3 = filter(before, ref('Created'), [ + new Date(2023, 5, 29).getTime(), + ]); + const varMap = mockVariableMap({ + Created: new Date(2023, 5, 29).getTime(), + }); + expect(filterByFilterList([filter1], varMap)).toBe(false); + expect(filterByFilterList([filter2], varMap)).toBe(true); + expect(filterByFilterList([filter3], varMap)).toBe(false); + }); + test('after', async () => { + const after = filterMatcher.findData(v => v.name === 'after'); + assertExists(after); + const filter1 = filter(after, ref('Created'), [ + new Date(2023, 5, 28).getTime(), + ]); + const filter2 = filter(after, ref('Created'), [ + new Date(2023, 5, 30).getTime(), + ]); + const filter3 = filter(after, ref('Created'), [ + new Date(2023, 5, 29).getTime(), + ]); + const varMap = mockVariableMap({ + Created: new Date(2023, 5, 29).getTime(), + }); + expect(filterByFilterList([filter1], varMap)).toBe(true); + expect(filterByFilterList([filter2], varMap)).toBe(false); + expect(filterByFilterList([filter3], varMap)).toBe(false); + }); + test('is', async () => { + const is = filterMatcher.findData(v => v.name === 'is'); + assertExists(is); + const filter1 = filter(is, ref('Favorite'), [false]); + const filter2 = filter(is, ref('Favorite'), [true]); + const varMap = mockVariableMap({ + Favorite: true, + }); + expect(filterByFilterList([filter1], varMap)).toBe(false); + expect(filterByFilterList([filter2], varMap)).toBe(true); + }); +}); + +describe('render filter', () => { + test('boolean condition value change', async () => { + const is = filterMatcher.match(tBoolean.create()); + assertExists(is); + const Wrapper = () => { + const [value, onChange] = useState(filter(is, ref('Favorite'), [true])); + return ; + }; + const result = render(); + const dom = await result.findByText('true'); + dom.click(); + await result.findByText('false'); + result.unmount(); + }); + test('date condition function change', async () => { + const dateFunction = filterMatcher.match(tDate.create()); + assertExists(dateFunction); + const Wrapper = () => { + const [value, onChange] = useState( + filter(dateFunction, ref('Created'), [new Date(2023, 5, 29).getTime()]) + ); + return ; + }; + const result = render(); + const dom = await result.findByTestId('filter-name'); + dom.click(); + await result.findByTestId('filter-name'); + result.unmount(); + }); + test('date condition variable change', async () => { + const dateFunction = filterMatcher.match(tDate.create()); + assertExists(dateFunction); + const Wrapper = () => { + const [value, onChange] = useState( + filter(dateFunction, ref('Created'), [new Date(2023, 5, 29).getTime()]) + ); + return ; + }; + const result = render(); + const dom = await result.findByTestId('variable-name'); + dom.click(); + await result.findByTestId('variable-name'); + result.unmount(); + }); +}); diff --git a/packages/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts b/packages/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts index 65b0e6b94f..ee027a49a9 100644 --- a/packages/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts +++ b/packages/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts @@ -7,6 +7,7 @@ import { renderHook } from '@testing-library/react'; import { RESET } from 'jotai/utils'; import { expect, test } from 'vitest'; +import { createDefaultFilter, vars } from '../filter/vars'; import { useAllPageSetting } from '../use-all-page-setting'; test('useAllPageSetting', async () => { @@ -15,32 +16,12 @@ test('useAllPageSetting', async () => { expect(settingHook.result.current.savedViews).toEqual([]); settingHook.result.current.setCurrentView(view => ({ ...view, - filterList: [ - { - type: 'filter', - left: { - type: 'ref', - name: 'Created', - }, - funcName: 'Create', - args: [], - }, - ], + filterList: [createDefaultFilter(vars[0])], })); settingHook.rerender(); const nextView = settingHook.result.current.currentView; expect(nextView).not.toBe(prevView); - expect(nextView.filterList).toEqual([ - { - type: 'filter', - left: { - type: 'ref', - name: 'Created', - }, - funcName: 'Create', - args: [], - }, - ]); + expect(nextView.filterList).toEqual([createDefaultFilter(vars[0])]); settingHook.result.current.setCurrentView(RESET); await settingHook.result.current.createView({ ...settingHook.result.current.currentView, diff --git a/packages/component/src/components/page-list/filter/condition.tsx b/packages/component/src/components/page-list/filter/condition.tsx index b39ce63514..039aa761ba 100644 --- a/packages/component/src/components/page-list/filter/condition.tsx +++ b/packages/component/src/components/page-list/filter/condition.tsx @@ -5,7 +5,7 @@ import { Menu, MenuItem } from '../../../ui/menu'; import { literalMatcher } from './literal-matcher'; import type { TFunction, TType } from './logical/typesystem'; import type { Filter, Literal } from './vars'; -import { ChangeFilterMenu, filterMatcher, vars } from './vars'; +import { filterMatcher, VariableSelect, vars } from './vars'; export const Condition = ({ value, @@ -31,15 +31,20 @@ export const Condition = ({ > } + content={} > -
{ast.left.name}
+
{ast.left.name}
} > -
{ast.funcName}
+
+ {ast.funcName} +
{args} diff --git a/packages/component/src/components/page-list/filter/literal-matcher.tsx b/packages/component/src/components/page-list/filter/literal-matcher.tsx index 5f68ca0cf6..f0d472b640 100644 --- a/packages/component/src/components/page-list/filter/literal-matcher.tsx +++ b/packages/component/src/components/page-list/filter/literal-matcher.tsx @@ -37,7 +37,7 @@ literalMatcher.register(tDate.create(), { onChange={e => { onChange({ type: 'literal', - value: dayjs(e.target.value, 'YYYY-MM-DD'), + value: dayjs(e.target.value, 'YYYY-MM-DD').millisecond(), }); }} /> diff --git a/packages/component/src/components/page-list/filter/logical/matcher.ts b/packages/component/src/components/page-list/filter/logical/matcher.ts index 7cd22f1919..d7ffc5763c 100644 --- a/packages/component/src/components/page-list/filter/logical/matcher.ts +++ b/packages/component/src/components/page-list/filter/logical/matcher.ts @@ -34,14 +34,7 @@ export class Matcher { } allMatchedData(type: TType): Data[] { - const match = this._match ?? typesystem.isSubtype.bind(typesystem); - const result: Data[] = []; - for (const t of this.list) { - if (match(t.type, type)) { - result.push(t.data); - } - } - return result; + return this.allMatched(type).map(v => v.data); } findData(f: (data: Data) => boolean): Data | undefined { diff --git a/packages/component/src/components/page-list/filter/logical/typesystem.ts b/packages/component/src/components/page-list/filter/logical/typesystem.ts index f981a9ad63..1aac7e8166 100644 --- a/packages/component/src/components/page-list/filter/logical/typesystem.ts +++ b/packages/component/src/components/page-list/filter/logical/typesystem.ts @@ -267,7 +267,6 @@ export class Typesystem { template: TFunction ): TFunction { const ctx = { ...context }; - console.log(template); template.args.forEach((arg, i) => { const realArg = realArgs[i]; if (realArg) { diff --git a/packages/component/src/components/page-list/filter/vars.tsx b/packages/component/src/components/page-list/filter/vars.tsx index 5c61b2c413..78ef49d05b 100644 --- a/packages/component/src/components/page-list/filter/vars.tsx +++ b/packages/component/src/components/page-list/filter/vars.tsx @@ -18,10 +18,19 @@ export type Filter = { funcName: string; args: Literal[]; }; - +export type LiteralValue = + | number + | string + | boolean + | { [K: string]: LiteralValue } + | Array; +export const toLiteral = (value: LiteralValue): Literal => ({ + type: 'literal', + value, +}); export type Literal = { type: 'literal'; - value: unknown; + value: LiteralValue; }; export type FilterVariable = { @@ -45,7 +54,9 @@ export const variableDefineMap = { // type: tBoolean.create(), // }, } as const; -export type VariableMap = { [K in keyof typeof variableDefineMap]: unknown }; +export type VariableMap = { + [K in keyof typeof variableDefineMap]: LiteralValue; +}; export const vars: FilterVariable[] = Object.entries(variableDefineMap).map( ([key, value]) => ({ name: key as keyof VariableMap, @@ -53,7 +64,7 @@ export const vars: FilterVariable[] = Object.entries(variableDefineMap).map( }) ); -const createDefaultFilter = (variable: FilterVariable): Filter => { +export const createDefaultFilter = (variable: FilterVariable): Filter => { const data = filterMatcher.match(variable.type); if (!data) { throw new Error('No matching function found'); @@ -74,7 +85,7 @@ export const CreateFilterMenu = ({ onChange: (value: Filter[]) => void; }) => { return ( - { onChange([...value, filter]); @@ -83,7 +94,7 @@ export const CreateFilterMenu = ({ ); }; -export const ChangeFilterMenu = ({ +export const VariableSelect = ({ onSelect, }: { selected: Filter[]; @@ -107,22 +118,22 @@ export const ChangeFilterMenu = ({ ); }; -export const filterMatcher = new Matcher< - { - name: string; - defaultArgs: () => unknown[]; - render?: (props: { ast: Filter }) => ReactNode; - impl: (...args: unknown[]) => boolean; - }, - TFunction ->((type, target) => { - const staticType = typesystem.subst( - Object.fromEntries(type.typeVars?.map(v => [v.name, v.bound]) ?? []), - type - ); - const firstArg = staticType.args[0]; - return firstArg && typesystem.isSubtype(firstArg, target); -}); +export type FilterMatcherDataType = { + name: string; + defaultArgs: () => LiteralValue[]; + render?: (props: { ast: Filter }) => ReactNode; + impl: (...args: LiteralValue[]) => boolean; +}; +export const filterMatcher = new Matcher( + (type, target) => { + const staticType = typesystem.subst( + Object.fromEntries(type.typeVars?.map(v => [v.name, v.bound]) ?? []), + type + ); + const firstArg = staticType.args[0]; + return firstArg && typesystem.isSubtype(firstArg, target); + } +); filterMatcher.register( tFunction({ @@ -143,13 +154,13 @@ filterMatcher.register( { name: 'after', defaultArgs: () => { - return [dayjs().subtract(1, 'day').endOf('day')]; + return [dayjs().subtract(1, 'day').endOf('day').millisecond()]; }, impl: (date, target) => { - if (typeof date !== 'number' || !dayjs.isDayjs(target)) { + if (typeof date !== 'number' || typeof target !== 'number') { throw new Error('argument type error'); } - return dayjs(date).isAfter(target.endOf('day')); + return dayjs(date).isAfter(dayjs(target).endOf('day')); }, } ); @@ -158,12 +169,12 @@ filterMatcher.register( tFunction({ args: [tDate.create(), tDate.create()], rt: tBoolean.create() }), { name: 'before', - defaultArgs: () => [dayjs().endOf('day')], + defaultArgs: () => [dayjs().endOf('day').millisecond()], impl: (date, target) => { - if (typeof date !== 'number' || !dayjs.isDayjs(target)) { + if (typeof date !== 'number' || typeof target !== 'number') { throw new Error('argument type error'); } - return dayjs(date).isBefore(target.startOf('day')); + return dayjs(date).isBefore(dayjs(target).startOf('day')); }, } ); diff --git a/packages/component/src/components/page-list/use-all-page-setting.ts b/packages/component/src/components/page-list/use-all-page-setting.ts index bea49ba2fe..4abf06e46b 100644 --- a/packages/component/src/components/page-list/use-all-page-setting.ts +++ b/packages/component/src/components/page-list/use-all-page-setting.ts @@ -1,4 +1,3 @@ -import dayjs from 'dayjs'; import type { DBSchema } from 'idb'; import { openDB } from 'idb'; import type { IDBPDatabase } from 'idb/build/entry'; @@ -10,7 +9,6 @@ import { NIL } from 'uuid'; import { evalFilterList } from './filter'; import type { Filter, VariableMap } from './filter/vars'; -import type { Literal } from './filter/vars'; export type View = { id: string; @@ -18,13 +16,7 @@ export type View = { filterList: Filter[]; }; -type PersistenceView = Omit & { - filterList: (Omit & { - args: (Omit & { - value: string; - })[]; - })[]; -}; +type PersistenceView = View; export interface PageViewDBV1 extends DBSchema { view: { @@ -58,17 +50,7 @@ export const useAllPageSetting = () => { fetcher: async () => { const db = await pageViewDBPromise; const t = db.transaction('view').objectStore('view'); - const all = await t.getAll(); - return all.map(view => ({ - ...view, - filterList: view.filterList.map(filter => ({ - ...filter, - args: filter.args.map(arg => ({ - ...arg, - value: dayjs(arg.value), - })), - })), - })); + return await t.getAll(); }, suspense: true, fallbackData: [], @@ -85,22 +67,7 @@ export const useAllPageSetting = () => { } const db = await pageViewDBPromise; const t = db.transaction('view', 'readwrite').objectStore('view'); - const persistenceView: PersistenceView = { - ...view, - filterList: view.filterList.map(filter => { - return { - ...filter, - args: filter.args.map(arg => { - return { - type: arg.type, - // @ts-expect-error - value: arg.value.toString(), - }; - }), - }; - }), - }; - await t.put(persistenceView); + await t.put(view); await mutate(); }, [mutate] @@ -115,5 +82,5 @@ export const useAllPageSetting = () => { setCurrentView, }; }; -export const filterByView = (view: View, varMap: VariableMap) => - evalFilterList(view.filterList, varMap); +export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) => + evalFilterList(filterList, varMap);