feat: headless filter in all pages tab (#2566)

Co-authored-by: himself65 <himself65@outlook.com>
This commit is contained in:
3720
2023-05-29 12:06:40 +08:00
committed by Himself65
parent 6d362f77ca
commit 921f4c97d1
23 changed files with 1098 additions and 15 deletions

View File

@@ -0,0 +1,127 @@
import type { ReactNode } from 'react';
import { useMemo } from 'react';
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';
export const Condition = ({
value,
onChange,
}: {
value: Filter;
onChange: (filter: Filter) => void;
}) => {
const data = useMemo(
() => filterMatcher.find(v => v.data.name === value.funcName),
[value.funcName]
);
if (!data) {
return null;
}
const render =
data.data.render ??
(({ ast }) => {
const args = renderArgs(value, onChange, data.type);
return (
<div
style={{ display: 'flex', userSelect: 'none', alignItems: 'center' }}
>
<Menu
trigger="click"
content={<ChangeFilterMenu selected={[]} onSelect={onChange} />}
>
<div>{ast.left.name}</div>
</Menu>
<Menu
trigger="click"
content={<FunctionSelect value={value} onChange={onChange} />}
>
<div style={{ marginLeft: 4, color: 'gray' }}>{ast.funcName}</div>
</Menu>
{args}
</div>
);
});
return <>{render({ ast: value })}</>;
};
const FunctionSelect = ({
value,
onChange,
}: {
value: Filter;
onChange: (value: Filter) => void;
}) => {
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 (
<div>
{list.map(v => (
<MenuItem
onClick={() => {
onChange({
...value,
funcName: v.name,
args: v.defaultArgs().map(v => ({ type: 'literal', value: v })),
});
}}
key={v.name}
>
{v.name}
</MenuItem>
))}
</div>
);
};
export const Arg = ({
type,
value,
onChange,
}: {
type: TType;
value: Literal;
onChange: (lit: Literal) => void;
}) => {
const data = useMemo(() => literalMatcher.match(type), [type]);
if (!data) {
return null;
}
return (
<div style={{ marginLeft: 4 }}>
{data.render({ type, value, onChange })}
</div>
);
};
export const renderArgs = (
filter: Filter,
onChange: (value: Filter) => void,
type: TFunction
): ReactNode => {
const rest = type.args.slice(1);
return rest.map((type, i) => {
const value = filter.args[i];
return (
<Arg
key={i}
type={type}
value={value}
onChange={value => {
const args = filter.args.map((v, index) => (i === index ? value : v));
onChange({
...filter,
args,
});
}}
></Arg>
);
});
};

View File

@@ -0,0 +1,25 @@
import type { Filter, Literal, Ref } from './vars';
import type { VariableMap } from './vars';
import { filterMatcher } from './vars';
const evalRef = (ref: Ref, variableMap: VariableMap) => {
return variableMap[ref.name];
};
const evalLiteral = (lit: Literal) => {
return lit.value;
};
const evalFilter = (filter: Filter, variableMap: VariableMap): boolean => {
const impl = filterMatcher.findData(v => v.name === filter.funcName)?.impl;
if (!impl) {
throw new Error('No function implementation found');
}
const leftValue = evalRef(filter.left, variableMap);
const args = filter.args.map(evalLiteral);
return impl(leftValue, ...args);
};
export const evalFilterList = (
filterList: Filter[],
variableMap: VariableMap
) => {
return filterList.every(filter => evalFilter(filter, variableMap));
};

View File

@@ -0,0 +1,66 @@
import { Menu } from '@affine/component';
import { Condition } from './condition';
import type { Filter } from './vars';
import { CreateFilterMenu } from './vars';
export const FilterList = ({
value,
onChange,
}: {
value: Filter[];
onChange: (value: Filter[]) => void;
}) => {
return (
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
{value.map((filter, i) => {
return (
<div
style={{
display: 'flex',
border: '1px solid gray',
borderRadius: 4,
padding: '2px 6px',
margin: 4,
backgroundColor: 'white',
}}
key={i}
>
<Condition
value={filter}
onChange={filter => {
onChange(
value.map((old, oldIndex) => (oldIndex === i ? filter : old))
);
}}
/>
<div
style={{ marginLeft: 8, cursor: 'pointer' }}
onClick={() => {
onChange(value.filter((_, index) => i !== index));
}}
>
x
</div>
</div>
);
})}
<Menu
trigger={'click'}
content={<CreateFilterMenu value={value} onChange={onChange} />}
>
<div
style={{
cursor: 'pointer',
padding: 4,
marginLeft: 4,
display: 'flex',
alignItems: 'center',
}}
>
+
</div>
</Menu>
</div>
);
};

View File

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

View File

@@ -0,0 +1,45 @@
import dayjs from 'dayjs';
import type { ReactNode } from 'react';
import { tBoolean, tDate } from './logical/custom-type';
import { Matcher } from './logical/matcher';
import type { TType } from './logical/typesystem';
import { typesystem } from './logical/typesystem';
import type { Literal } from './vars';
export const literalMatcher = new Matcher<{
render: (props: {
type: TType;
value: Literal;
onChange: (lit: Literal) => void;
}) => ReactNode;
}>((type, target) => {
return typesystem.isSubtype(type, target);
});
literalMatcher.register(tBoolean.create(), {
render: ({ value, onChange }) => (
<div
style={{ cursor: 'pointer' }}
onClick={() => {
onChange({ type: 'literal', value: !value.value });
}}
>
{value.value?.toString()}
</div>
),
});
literalMatcher.register(tDate.create(), {
render: ({ value, onChange }) => (
<input
value={dayjs(value.value as number).format('YYYY-MM-DD')}
type="date"
onChange={e => {
onChange({
type: 'literal',
value: dayjs(e.target.value, 'YYYY-MM-DD'),
});
}}
/>
),
});

View File

@@ -0,0 +1,14 @@
import { DataHelper, typesystem } from './typesystem';
export const tNumber = typesystem.defineData(
DataHelper.create<{ value: number }>('Number')
);
export const tString = typesystem.defineData(
DataHelper.create<{ value: string }>('String')
);
export const tBoolean = typesystem.defineData(
DataHelper.create<{ value: boolean }>('Boolean')
);
export const tDate = typesystem.defineData(
DataHelper.create<{ value: number }>('Date')
);

View File

@@ -0,0 +1,60 @@
import type { TType } from './typesystem';
import { typesystem } from './typesystem';
type MatcherData<Data, Type extends TType = TType> = { type: Type; data: Data };
export class Matcher<Data, Type extends TType = TType> {
private list: MatcherData<Data, Type>[] = [];
constructor(private _match?: (type: Type, target: TType) => boolean) {}
register(type: Type, data: Data) {
this.list.push({ type, data });
}
match(type: TType) {
const match = this._match ?? typesystem.isSubtype.bind(typesystem);
for (const t of this.list) {
if (match(t.type, type)) {
return t.data;
}
}
return;
}
allMatched(type: TType): MatcherData<Data>[] {
const match = this._match ?? typesystem.isSubtype.bind(typesystem);
const result: MatcherData<Data>[] = [];
for (const t of this.list) {
if (match(t.type, type)) {
result.push(t);
}
}
return result;
}
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;
}
findData(f: (data: Data) => boolean): Data | undefined {
return this.list.find(data => f(data.data))?.data;
}
find(
f: (data: MatcherData<Data, Type>) => boolean
): MatcherData<Data, Type> | undefined {
return this.list.find(f);
}
all(): MatcherData<Data, Type>[] {
return this.list;
}
}

View File

@@ -0,0 +1,283 @@
/**
* This file will be moved to a separate package soon.
*/
export interface TUnion {
type: 'union';
title: 'union';
list: TType[];
}
export const tUnion = (list: TType[]): TUnion => ({
type: 'union',
title: 'union',
list,
});
// TODO treat as data type
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export interface TArray<Ele extends TType = TType> {
type: 'array';
ele: Ele;
title: 'array';
}
export const tArray = <const T extends TType>(ele: T): TArray<T> => {
return {
type: 'array',
title: 'array',
ele,
};
};
export type TTypeVar = {
type: 'typeVar';
title: 'typeVar';
name: string;
bound: TType;
};
export const tTypeVar = (name: string, bound: TType): TTypeVar => {
return {
type: 'typeVar',
title: 'typeVar',
name,
bound,
};
};
export type TTypeRef = {
type: 'typeRef';
title: 'typeRef';
name: string;
};
export const tTypeRef = (name: string): TTypeRef => {
return {
type: 'typeRef',
title: 'typeRef',
name,
};
};
export type TFunction = {
type: 'function';
title: 'function';
typeVars: TTypeVar[];
args: TType[];
rt: TType;
};
export const tFunction = (fn: {
typeVars?: TTypeVar[];
args: TType[];
rt: TType;
}): TFunction => {
return {
type: 'function',
title: 'function',
typeVars: fn.typeVars ?? [],
args: fn.args,
rt: fn.rt,
};
};
export type TType = TDataType | TArray | TUnion | TTypeRef | TFunction;
export type DataTypeShape = Record<string, unknown>;
export type TDataType<Data extends DataTypeShape = Record<string, unknown>> = {
type: 'data';
name: string;
data?: Data;
};
export type ValueOfData<T extends DataDefine> = T extends DataDefine<infer R>
? R
: never;
export class DataDefine<Data extends DataTypeShape = Record<string, unknown>> {
constructor(
private config: DataDefineConfig<Data>,
private dataMap: Map<string, DataDefine>
) {}
create(data?: Data): TDataType<Data> {
return {
type: 'data',
name: this.config.name,
data,
};
}
is(data: TType): data is TDataType<Data> {
if (data.type !== 'data') {
return false;
}
return data.name === this.config.name;
}
private isByName(name: string): boolean {
return name === this.config.name;
}
isSubOf(superType: TDataType): boolean {
if (this.is(superType)) {
return true;
}
return this.config.supers.some(sup => sup.isSubOf(superType));
}
private isSubOfByName(superType: string): boolean {
if (this.isByName(superType)) {
return true;
}
return this.config.supers.some(sup => sup.isSubOfByName(superType));
}
isSuperOf(subType: TDataType): boolean {
const dataDefine = this.dataMap.get(subType.name);
if (!dataDefine) {
throw new Error('bug');
}
return dataDefine.isSubOfByName(this.config.name);
}
}
// type DataTypeVar = {};
// TODO support generic data type
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface DataDefineConfig<T extends DataTypeShape> {
name: string;
supers: DataDefine[];
}
interface DataHelper<T extends DataTypeShape> {
create<V = Record<string, unknown>>(name: string): DataDefineConfig<T & V>;
extends<V extends DataTypeShape>(
dataDefine: DataDefine<V>
): DataHelper<T & V>;
}
const createDataHelper = <T extends DataTypeShape = Record<string, unknown>>(
...supers: DataDefine[]
): DataHelper<T> => {
return {
create(name: string) {
return {
name,
supers,
};
},
extends(dataDefine) {
return createDataHelper(...supers, dataDefine);
},
};
};
export const DataHelper = createDataHelper();
export class Typesystem {
dataMap = new Map<string, DataDefine<any>>();
defineData<T extends DataTypeShape>(
config: DataDefineConfig<T>
): DataDefine<T> {
const result = new DataDefine(config, this.dataMap);
this.dataMap.set(config.name, result);
return result;
}
isDataType(t: TType): t is TDataType {
return t.type === 'data';
}
isSubtype(
superType: TType,
sub: TType,
context?: Record<string, TType>
): boolean {
if (superType.type === 'typeRef') {
// TODO both are ref
if (context && sub.type != 'typeRef') {
context[superType.name] = sub;
}
// TODO bound
return true;
}
if (sub.type === 'typeRef') {
// TODO both are ref
if (context) {
context[sub.name] = superType;
}
return true;
}
if (tUnknown.is(superType)) {
return true;
}
if (superType.type === 'union') {
return superType.list.some(type => this.isSubtype(type, sub, context));
}
if (sub.type === 'union') {
return sub.list.every(type => this.isSubtype(superType, type, context));
}
if (this.isDataType(sub)) {
const dataDefine = this.dataMap.get(sub.name);
if (!dataDefine) {
throw new Error('bug');
}
if (!this.isDataType(superType)) {
return false;
}
return dataDefine.isSubOf(superType);
}
if (superType.type === 'array' || sub.type === 'array') {
if (superType.type !== 'array' || sub.type !== 'array') {
return false;
}
return this.isSubtype(superType.ele, sub.ele, context);
}
return false;
}
subst(context: Record<string, TType>, template: TFunction): TFunction {
const subst = (type: TType): TType => {
if (this.isDataType(type)) {
return type;
}
switch (type.type) {
case 'typeRef':
return { ...context[type.name] };
case 'union':
return tUnion(type.list.map(type => subst(type)));
case 'array':
return tArray(subst(type.ele));
case 'function':
throw new Error('TODO');
}
};
const result = tFunction({
args: template.args.map(type => subst(type)),
rt: subst(template.rt),
});
return result;
}
instance(
context: Record<string, TType>,
realArgs: TType[],
realRt: TType,
template: TFunction
): TFunction {
const ctx = { ...context };
console.log(template);
template.args.forEach((arg, i) => {
const realArg = realArgs[i];
if (realArg) {
this.isSubtype(arg, realArg, ctx);
}
});
this.isSubtype(realRt, template.rt);
return this.subst(ctx, template);
}
}
export const typesystem = new Typesystem();
export const tUnknown = typesystem.defineData(DataHelper.create('Unknown'));

View File

@@ -0,0 +1,169 @@
import dayjs from 'dayjs';
import type { ReactNode } from 'react';
import { MenuItem } from '../../../ui/menu';
import { tBoolean, tDate } from './logical/custom-type';
import { Matcher } from './logical/matcher';
import type { TFunction, TType } from './logical/typesystem';
import { tFunction, typesystem } from './logical/typesystem';
export type Ref = {
type: 'ref';
name: keyof VariableMap;
};
export type Filter = {
type: 'filter';
left: Ref;
funcName: string;
args: Literal[];
};
export type Literal = {
type: 'literal';
value: unknown;
};
export type FilterVariable = {
name: keyof VariableMap;
type: TType;
};
export const variableDefineMap = {
Created: {
type: tDate.create(),
},
Updated: {
type: tDate.create(),
},
Favorite: {
type: tBoolean.create(),
},
// Imported: {
// type: tBoolean.create(),
// },
// 'Daily Note': {
// type: tBoolean.create(),
// },
} as const;
export type VariableMap = { [K in keyof typeof variableDefineMap]: unknown };
export const vars: FilterVariable[] = Object.entries(variableDefineMap).map(
([key, value]) => ({
name: key as keyof VariableMap,
type: value.type,
})
);
const createDefaultFilter = (variable: FilterVariable): Filter => {
const data = filterMatcher.match(variable.type);
if (!data) {
throw new Error('No matching function found');
}
return {
type: 'filter',
left: { type: 'ref', name: variable.name },
funcName: data.name,
args: data.defaultArgs().map(value => ({ type: 'literal', value })),
};
};
export const CreateFilterMenu = ({
value,
onChange,
}: {
value: Filter[];
onChange: (value: Filter[]) => void;
}) => {
return (
<ChangeFilterMenu
selected={value}
onSelect={filter => {
onChange([...value, filter]);
}}
/>
);
};
export const ChangeFilterMenu = ({
onSelect,
}: {
selected: Filter[];
onSelect: (value: Filter) => void;
}) => {
return (
<div>
{vars
// .filter(v => !selected.find(filter => filter.left.name === v.name))
.map(v => (
<MenuItem
key={v.name}
onClick={() => {
onSelect(createDefaultFilter(v));
}}
>
{v.name}
</MenuItem>
))}
</div>
);
};
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);
});
filterMatcher.register(
tFunction({
args: [tBoolean.create(), tBoolean.create()],
rt: tBoolean.create(),
}),
{
name: 'is',
defaultArgs: () => [true],
impl: (value, target) => {
return value == target;
},
}
);
filterMatcher.register(
tFunction({ args: [tDate.create(), tDate.create()], rt: tBoolean.create() }),
{
name: 'after',
defaultArgs: () => {
return [dayjs().subtract(1, 'day').endOf('day')];
},
impl: (date, target) => {
if (typeof date !== 'number' || !dayjs.isDayjs(target)) {
throw new Error('argument type error');
}
return dayjs(date).isAfter(target.endOf('day'));
},
}
);
filterMatcher.register(
tFunction({ args: [tDate.create(), tDate.create()], rt: tBoolean.create() }),
{
name: 'before',
defaultArgs: () => [dayjs().endOf('day')],
impl: (date, target) => {
if (typeof date !== 'number' || !dayjs.isDayjs(target)) {
throw new Error('argument type error');
}
return dayjs(date).isBefore(target.startOf('day'));
},
}
);

View File

@@ -1,5 +1,8 @@
export * from './all-page';
export * from './filter';
export * from './operation-cell';
export * from './operation-menu-items';
export * from './styles';
export * from './type';
export * from './use-all-page-setting';
export * from './view';

View File

@@ -0,0 +1,62 @@
import { useState } from 'react';
import { evalFilterList } from './filter';
import type { Filter, VariableMap } from './filter/vars';
export type View = {
name: string;
filterList: Filter[];
};
export type AllPageSetting = {
mainView: View;
currentView?: number;
savedViews: View[];
};
export const useAllPageSetting = () => {
const [setting, changeSetting] = useState<AllPageSetting>({
mainView: {
name: 'default',
filterList: [],
},
savedViews: [],
});
const changeView = (view: View, i?: number) =>
changeSetting(setting => {
if (i != null) {
return {
...setting,
savedViews: setting.savedViews.map((v, index) =>
i === index ? view : v
),
};
} else {
return {
...setting,
mainView: view,
};
}
});
const createView = (view: View) =>
changeSetting(setting => ({
...setting,
currentView: setting.savedViews.length,
savedViews: [...setting.savedViews, view],
}));
const selectView = (i?: number) =>
changeSetting(setting => ({ ...setting, currentView: i }));
const currentView =
setting.currentView != null
? setting.savedViews[setting.currentView]
: setting.mainView;
return {
currentView,
currentViewIndex: setting.currentView,
viewList: setting.savedViews,
selectView,
createView,
changeView,
};
};
export const filterByView = (view: View, varMap: VariableMap) =>
evalFilterList(view.filterList, varMap);

View File

@@ -0,0 +1,80 @@
import { Button, Input, Modal, ModalWrapper } from '@affine/component';
import { useState } from 'react';
import { FilterList } from '../filter';
import type { Filter } from '../filter/vars';
import type { View } from '../use-all-page-setting';
type CreateViewProps = {
init: Filter[];
onConfirm: (view: View) => void;
};
const CreateView = ({
init,
onConfirm,
onCancel,
}: CreateViewProps & { onCancel: () => void }) => {
const [value, onChange] = useState<View>({ name: '', filterList: init });
return (
<div>
<h1>Save As New View</h1>
<div
style={{
backgroundColor: 'rgba(0, 0, 0, 0.04)',
borderRadius: 8,
padding: 20,
marginTop: 20,
}}
>
<FilterList
value={value.filterList}
onChange={list => onChange({ ...value, filterList: list })}
></FilterList>
</div>
<div style={{ marginTop: 20 }}>
<Input
placeholder="Untitled View"
value={value.name}
onChange={text => onChange({ ...value, name: text })}
/>
</div>
<div
style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20 }}
>
<Button onClick={onCancel}>Cancel</Button>
<Button
style={{ marginLeft: 20 }}
type="primary"
onClick={() => {
if (value.name.trim().length > 0) {
onConfirm(value);
}
}}
>
Create
</Button>
</div>
</div>
);
};
export const SaveViewButton = ({ init, onConfirm }: CreateViewProps) => {
const [show, changeShow] = useState(false);
return (
<>
<Button onClick={() => changeShow(true)}>Save view</Button>
<Modal open={show} onClose={() => changeShow(false)}>
<ModalWrapper width={560} style={{ padding: '40px' }}>
<CreateView
init={init}
onCancel={() => changeShow(false)}
onConfirm={view => {
onConfirm(view);
changeShow(false);
}}
/>
</ModalWrapper>
</Modal>
</>
);
};

View File

@@ -0,0 +1,2 @@
export * from './create-view';
export * from './view-list';

View File

@@ -0,0 +1,55 @@
import { MenuItem, styled } from '@affine/component';
import Menu from '../../../ui/menu/menu';
import { CreateFilterMenu } from '../filter/vars';
import type { useAllPageSetting } from '../use-all-page-setting';
const NoDragDiv = styled('div')`
-webkit-app-region: no-drag;
`;
export const ViewList = ({
setting,
}: {
setting: ReturnType<typeof useAllPageSetting>;
}) => {
return (
<div style={{ marginLeft: 4, display: 'flex', alignItems: 'center' }}>
{setting.currentViewIndex != null && (
<Menu
trigger="click"
content={
<div>
{setting.viewList.map((v, i) => {
return (
<MenuItem onClick={() => setting.selectView(i)} key={i}>
{v.name}
</MenuItem>
);
})}
</div>
}
>
<NoDragDiv style={{ marginRight: 12, cursor: 'pointer' }}>
{setting.currentView.name}
</NoDragDiv>
</Menu>
)}
<Menu
trigger="click"
content={
<CreateFilterMenu
value={setting.currentView.filterList}
onChange={filterList => {
setting.changeView(
{ ...setting.currentView, filterList },
setting.currentViewIndex
);
}}
/>
}
>
<NoDragDiv style={{ cursor: 'pointer' }}>Filter</NoDragDiv>
</Menu>
</div>
);
};