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
+5 -3
View File
@@ -18,9 +18,11 @@ export const blockSuiteFeatureFlags = {
* @type {import('@affine/env').BuildFlags}
*/
export const buildFlags = {
enableAllPageFilter: process.env.ENABLE_ALL_PAGE_FILTER
? process.env.ENABLE_ALL_PAGE_FILTER === 'true'
: false,
enableAllPageFilter:
!!process.env.VERCEL ||
(process.env.ENABLE_ALL_PAGE_FILTER
? process.env.ENABLE_ALL_PAGE_FILTER === 'true'
: false),
enableImagePreviewModal: process.env.ENABLE_IMAGE_PREVIEW_MODAL
? process.env.ENABLE_IMAGE_PREVIEW_MODAL === 'true'
: true,
+2 -1
View File
@@ -330,9 +330,10 @@ export const AffinePlugin: WorkspaceAdapter<WorkspaceFlavour.AFFINE> = {
</>
);
},
PageList: ({ blockSuiteWorkspace, onOpenPage }) => {
PageList: ({ blockSuiteWorkspace, onOpenPage, view }) => {
return (
<BlockSuitePageList
view={view}
listType="all"
onOpenPage={onOpenPage}
blockSuiteWorkspace={blockSuiteWorkspace}
+2 -1
View File
@@ -100,10 +100,11 @@ export const LocalPlugin: WorkspaceAdapter<WorkspaceFlavour.LOCAL> = {
</>
);
},
PageList: ({ blockSuiteWorkspace, onOpenPage }) => {
PageList: ({ blockSuiteWorkspace, onOpenPage, view }) => {
return (
<BlockSuitePageList
listType="all"
view={view}
onOpenPage={onOpenPage}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
@@ -1,6 +1,14 @@
import { Empty } from '@affine/component';
import type { ListData, TrashListData } from '@affine/component/page-list';
import { PageList, PageListTrashView } from '@affine/component/page-list';
import type {
ListData,
TrashListData,
View,
} from '@affine/component/page-list';
import {
filterByView,
PageList,
PageListTrashView,
} from '@affine/component/page-list';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
@@ -19,6 +27,7 @@ export type BlockSuitePageListProps = {
listType: 'all' | 'trash' | 'shared' | 'public';
isPublic?: true;
onOpenPage: (pageId: string, newTab?: boolean) => void;
view?: View;
};
const filter = {
@@ -61,6 +70,7 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
onOpenPage,
listType,
isPublic = false,
view,
}) => {
const pageMetas = useBlockSuitePageMeta(blockSuiteWorkspace);
const {
@@ -74,8 +84,22 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
usePageHelper(blockSuiteWorkspace);
const t = useAFFiNEI18N();
const list = useMemo(
() => pageMetas.filter(pageMeta => filter[listType](pageMeta, pageMetas)),
[pageMetas, listType]
() =>
pageMetas.filter(pageMeta => {
if (!filter[listType](pageMeta, pageMetas)) {
return false;
}
console.log(view);
if (!view) {
return true;
}
return filterByView(view, {
Favorite: !!pageMeta.favorite,
Created: pageMeta.createDate,
Updated: pageMeta.updatedDate,
});
}),
[pageMetas, listType, view]
);
if (list.length === 0) {
return <PageListEmpty listType={listType} />;
@@ -8,7 +8,7 @@ import { WorkspaceFlavour } from '@affine/workspace/type';
import { CloseIcon, MinusIcon, RoundedRectangleIcon } from '@blocksuite/icons';
import type { Page } from '@blocksuite/store';
import { useAtom, useAtomValue } from 'jotai';
import type { FC, HTMLAttributes, PropsWithChildren } from 'react';
import type { FC, HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import {
forwardRef,
lazy,
@@ -44,6 +44,7 @@ export type BaseHeaderProps<
currentPage: Page | null;
isPublic: boolean;
isPreview: boolean;
leftSlot?: ReactNode;
};
export const enum HeaderRightItemName {
@@ -198,11 +199,14 @@ export const Header = forwardRef<
data-is-edgeless={mode === 'edgeless'}
>
<Suspense>
<SidebarSwitch
visible={!open}
tooltipContent={t['Expand sidebar']()}
data-testid="sliderBar-arrowButton-expand"
/>
<div style={{ display: 'flex', alignItems: 'center' }}>
<SidebarSwitch
visible={!open}
tooltipContent={t['Expand sidebar']()}
data-testid="sliderBar-arrowButton-expand"
/>
{props.leftSlot}
</div>
</Suspense>
{props.children}
@@ -1,3 +1,11 @@
import { Button } from '@affine/component';
import {
FilterList,
SaveViewButton,
useAllPageSetting,
ViewList,
} from '@affine/component/page-list';
import { config } from '@affine/env';
import { QueryParamError, Unreachable } from '@affine/env/constant';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { WorkspaceFlavour } from '@affine/workspace/type';
@@ -17,6 +25,7 @@ import type { NextPageWithLayout } from '../../../shared';
const AllPage: NextPageWithLayout = () => {
const router = useRouter();
const setting = useAllPageSetting();
const { jumpToPage } = useRouterHelper(router);
const [currentWorkspace] = useCurrentWorkspace();
const t = useAFFiNEI18N();
@@ -37,6 +46,38 @@ const AllPage: NextPageWithLayout = () => {
if (typeof router.query.workspaceId !== 'string') {
throw new QueryParamError('workspaceId', router.query.workspaceId);
}
const leftSlot = config.enableAllPageFilter && (
<ViewList setting={setting}></ViewList>
);
const filterContainer = config.enableAllPageFilter &&
setting.currentView.filterList.length > 0 && (
<div style={{ padding: 12, display: 'flex' }}>
<div style={{ flex: 1 }}>
<FilterList
value={setting.currentView.filterList}
onChange={filterList =>
setting.changeView(
{
...setting.currentView,
filterList,
},
setting.currentViewIndex
)
}
/>
</div>
<div>
{setting.currentViewIndex == null ? (
<SaveViewButton
init={setting.currentView.filterList}
onConfirm={setting.createView}
></SaveViewButton>
) : (
<Button onClick={() => setting.selectView()}>Back to all</Button>
)}
</div>
</div>
);
if (currentWorkspace.flavour === WorkspaceFlavour.AFFINE) {
const PageList = WorkspaceAdapters[currentWorkspace.flavour].UI.PageList;
return (
@@ -50,10 +91,13 @@ const AllPage: NextPageWithLayout = () => {
isPreview={false}
isPublic={false}
icon={<FolderIcon />}
leftSlot={leftSlot}
>
{t['All pages']()}
</WorkspaceTitle>
{filterContainer}
<PageList
view={setting.currentView}
onOpenPage={onClickPage}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
@@ -72,10 +116,13 @@ const AllPage: NextPageWithLayout = () => {
isPreview={false}
isPublic={false}
icon={<FolderIcon />}
leftSlot={leftSlot}
>
{t['All pages']()}
</WorkspaceTitle>
{filterContainer}
<PageList
view={setting.currentView}
onOpenPage={onClickPage}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
+1
View File
@@ -42,6 +42,7 @@
"@toeverything/theme": "^0.5.9",
"@vanilla-extract/dynamic": "^2.0.3",
"clsx": "^1.2.1",
"dayjs": "^1.11.7",
"jotai": "^2.1.0",
"lit": "^2.7.4",
"lottie-web": "^5.11.0",
@@ -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>
);
});
};
@@ -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'));
@@ -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>
);
};
+2
View File
@@ -1,5 +1,6 @@
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
/// <reference path='../../../apps/electron/layers/preload/preload.d.ts' />
import type { View } from '@affine/component/page-list';
import type { Workspace as RemoteWorkspace } from '@affine/workspace/affine/api';
import type { EditorContainer } from '@blocksuite/editor';
import type { Page } from '@blocksuite/store';
@@ -191,6 +192,7 @@ type PageDetailProps<Flavour extends keyof WorkspaceRegistry> =
type PageListProps<_Flavour extends keyof WorkspaceRegistry> = {
blockSuiteWorkspace: BlockSuiteWorkspace;
onOpenPage: (pageId: string, newTab?: boolean) => void;
view: View;
};
export interface WorkspaceUISchema<Flavour extends keyof WorkspaceRegistry> {
+8
View File
@@ -92,6 +92,7 @@ __metadata:
"@vitejs/plugin-react": ^4.0.0
clsx: ^1.2.1
concurrently: ^8.0.1
dayjs: ^1.11.7
jest-mock: ^29.5.0
jotai: ^2.1.0
lit: ^2.7.4
@@ -13232,6 +13233,13 @@ __metadata:
languageName: node
linkType: hard
"dayjs@npm:^1.11.7":
version: 1.11.7
resolution: "dayjs@npm:1.11.7"
checksum: 5003a7c1dd9ed51385beb658231c3548700b82d3548c0cfbe549d85f2d08e90e972510282b7506941452c58d32136d6362f009c77ca55381a09c704e9f177ebb
languageName: node
linkType: hard
"debounce@npm:^1.2.0":
version: 1.2.1
resolution: "debounce@npm:1.2.1"