mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
test: add some tests for page filter (#2593)
This commit is contained in:
@@ -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<BlockSuitePageListProps> = ({
|
||||
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]
|
||||
|
||||
@@ -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>): 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 <Condition value={value} onChange={onChange} />;
|
||||
};
|
||||
const result = render(<Wrapper />);
|
||||
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 <Condition value={value} onChange={onChange} />;
|
||||
};
|
||||
const result = render(<Wrapper />);
|
||||
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 <Condition value={value} onChange={onChange} />;
|
||||
};
|
||||
const result = render(<Wrapper />);
|
||||
const dom = await result.findByTestId('variable-name');
|
||||
dom.click();
|
||||
await result.findByTestId('variable-name');
|
||||
result.unmount();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ({
|
||||
>
|
||||
<Menu
|
||||
trigger="click"
|
||||
content={<ChangeFilterMenu selected={[]} onSelect={onChange} />}
|
||||
content={<VariableSelect selected={[]} onSelect={onChange} />}
|
||||
>
|
||||
<div>{ast.left.name}</div>
|
||||
<div data-testid="variable-name">{ast.left.name}</div>
|
||||
</Menu>
|
||||
<Menu
|
||||
trigger="click"
|
||||
content={<FunctionSelect value={value} onChange={onChange} />}
|
||||
>
|
||||
<div style={{ marginLeft: 4, color: 'gray' }}>{ast.funcName}</div>
|
||||
<div
|
||||
style={{ marginLeft: 4, color: 'gray' }}
|
||||
data-testid="filter-name"
|
||||
>
|
||||
{ast.funcName}
|
||||
</div>
|
||||
</Menu>
|
||||
{args}
|
||||
</div>
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -34,14 +34,7 @@ export class Matcher<Data, Type extends TType = TType> {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -18,10 +18,19 @@ export type Filter = {
|
||||
funcName: string;
|
||||
args: Literal[];
|
||||
};
|
||||
|
||||
export type LiteralValue =
|
||||
| number
|
||||
| string
|
||||
| boolean
|
||||
| { [K: string]: LiteralValue }
|
||||
| Array<LiteralValue>;
|
||||
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 (
|
||||
<ChangeFilterMenu
|
||||
<VariableSelect
|
||||
selected={value}
|
||||
onSelect={filter => {
|
||||
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<FilterMatcherDataType, 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);
|
||||
}
|
||||
);
|
||||
|
||||
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'));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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<View, 'filterList'> & {
|
||||
filterList: (Omit<Filter, 'args'> & {
|
||||
args: (Omit<Literal, 'value'> & {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user