mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 04:48:53 +00:00
feat: headless filter in all pages tab (#2566)
Co-authored-by: himself65 <himself65@outlook.com>
This commit is contained in:
127
packages/component/src/components/page-list/filter/condition.tsx
Normal file
127
packages/component/src/components/page-list/filter/condition.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
};
|
||||
25
packages/component/src/components/page-list/filter/eval.ts
Normal file
25
packages/component/src/components/page-list/filter/eval.ts
Normal 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));
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './eval';
|
||||
export * from './filter-list';
|
||||
@@ -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'),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -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')
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
169
packages/component/src/components/page-list/filter/vars.tsx
Normal file
169
packages/component/src/components/page-list/filter/vars.tsx
Normal 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'));
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './create-view';
|
||||
export * from './view-list';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user