diff --git a/apps/web/preset.config.mjs b/apps/web/preset.config.mjs index 7a7a94408d..1e9e985f4a 100644 --- a/apps/web/preset.config.mjs +++ b/apps/web/preset.config.mjs @@ -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, diff --git a/apps/web/src/adapters/affine/index.tsx b/apps/web/src/adapters/affine/index.tsx index 49795e1954..7865ca908d 100644 --- a/apps/web/src/adapters/affine/index.tsx +++ b/apps/web/src/adapters/affine/index.tsx @@ -330,9 +330,10 @@ export const AffinePlugin: WorkspaceAdapter = { ); }, - PageList: ({ blockSuiteWorkspace, onOpenPage }) => { + PageList: ({ blockSuiteWorkspace, onOpenPage, view }) => { return ( = { ); }, - PageList: ({ blockSuiteWorkspace, onOpenPage }) => { + PageList: ({ blockSuiteWorkspace, onOpenPage, view }) => { return ( diff --git a/apps/web/src/components/blocksuite/block-suite-page-list/index.tsx b/apps/web/src/components/blocksuite/block-suite-page-list/index.tsx index e833e44173..05582ef00e 100644 --- a/apps/web/src/components/blocksuite/block-suite-page-list/index.tsx +++ b/apps/web/src/components/blocksuite/block-suite-page-list/index.tsx @@ -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 = ({ onOpenPage, listType, isPublic = false, + view, }) => { const pageMetas = useBlockSuitePageMeta(blockSuiteWorkspace); const { @@ -74,8 +84,22 @@ export const BlockSuitePageList: React.FC = ({ 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 ; diff --git a/apps/web/src/components/blocksuite/workspace-header/header.tsx b/apps/web/src/components/blocksuite/workspace-header/header.tsx index c7bd5a5d1a..a19514a4aa 100644 --- a/apps/web/src/components/blocksuite/workspace-header/header.tsx +++ b/apps/web/src/components/blocksuite/workspace-header/header.tsx @@ -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'} > - +
+ + {props.leftSlot} +
{props.children} diff --git a/apps/web/src/pages/workspace/[workspaceId]/all.tsx b/apps/web/src/pages/workspace/[workspaceId]/all.tsx index 32d355f14a..236955b59f 100644 --- a/apps/web/src/pages/workspace/[workspaceId]/all.tsx +++ b/apps/web/src/pages/workspace/[workspaceId]/all.tsx @@ -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 && ( + + ); + const filterContainer = config.enableAllPageFilter && + setting.currentView.filterList.length > 0 && ( +
+
+ + setting.changeView( + { + ...setting.currentView, + filterList, + }, + setting.currentViewIndex + ) + } + /> +
+
+ {setting.currentViewIndex == null ? ( + + ) : ( + + )} +
+
+ ); 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={} + leftSlot={leftSlot} > {t['All pages']()} + {filterContainer} @@ -72,10 +116,13 @@ const AllPage: NextPageWithLayout = () => { isPreview={false} isPublic={false} icon={} + leftSlot={leftSlot} > {t['All pages']()} + {filterContainer} diff --git a/packages/component/package.json b/packages/component/package.json index e14e0f2fd7..888c93006d 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -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", diff --git a/packages/component/src/components/page-list/filter/condition.tsx b/packages/component/src/components/page-list/filter/condition.tsx new file mode 100644 index 0000000000..b39ce63514 --- /dev/null +++ b/packages/component/src/components/page-list/filter/condition.tsx @@ -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 ( +
+ } + > +
{ast.left.name}
+
+ } + > +
{ast.funcName}
+
+ {args} +
+ ); + }); + 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 ( +
+ {list.map(v => ( + { + onChange({ + ...value, + funcName: v.name, + args: v.defaultArgs().map(v => ({ type: 'literal', value: v })), + }); + }} + key={v.name} + > + {v.name} + + ))} +
+ ); +}; + +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 ( +
+ {data.render({ type, value, onChange })} +
+ ); +}; +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 ( + { + const args = filter.args.map((v, index) => (i === index ? value : v)); + onChange({ + ...filter, + args, + }); + }} + > + ); + }); +}; diff --git a/packages/component/src/components/page-list/filter/eval.ts b/packages/component/src/components/page-list/filter/eval.ts new file mode 100644 index 0000000000..8b2d333aa6 --- /dev/null +++ b/packages/component/src/components/page-list/filter/eval.ts @@ -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)); +}; diff --git a/packages/component/src/components/page-list/filter/filter-list.tsx b/packages/component/src/components/page-list/filter/filter-list.tsx new file mode 100644 index 0000000000..181ee607b3 --- /dev/null +++ b/packages/component/src/components/page-list/filter/filter-list.tsx @@ -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 ( +
+ {value.map((filter, i) => { + return ( +
+ { + onChange( + value.map((old, oldIndex) => (oldIndex === i ? filter : old)) + ); + }} + /> +
{ + onChange(value.filter((_, index) => i !== index)); + }} + > + x +
+
+ ); + })} + } + > +
+ + +
+
+
+ ); +}; diff --git a/packages/component/src/components/page-list/filter/index.ts b/packages/component/src/components/page-list/filter/index.ts new file mode 100644 index 0000000000..a34117ba25 --- /dev/null +++ b/packages/component/src/components/page-list/filter/index.ts @@ -0,0 +1,2 @@ +export * from './eval'; +export * from './filter-list'; diff --git a/packages/component/src/components/page-list/filter/literal-matcher.tsx b/packages/component/src/components/page-list/filter/literal-matcher.tsx new file mode 100644 index 0000000000..5f68ca0cf6 --- /dev/null +++ b/packages/component/src/components/page-list/filter/literal-matcher.tsx @@ -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 }) => ( +
{ + onChange({ type: 'literal', value: !value.value }); + }} + > + {value.value?.toString()} +
+ ), +}); +literalMatcher.register(tDate.create(), { + render: ({ value, onChange }) => ( + { + onChange({ + type: 'literal', + value: dayjs(e.target.value, 'YYYY-MM-DD'), + }); + }} + /> + ), +}); diff --git a/packages/component/src/components/page-list/filter/logical/custom-type.ts b/packages/component/src/components/page-list/filter/logical/custom-type.ts new file mode 100644 index 0000000000..5d20a5ee6d --- /dev/null +++ b/packages/component/src/components/page-list/filter/logical/custom-type.ts @@ -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') +); diff --git a/packages/component/src/components/page-list/filter/logical/matcher.ts b/packages/component/src/components/page-list/filter/logical/matcher.ts new file mode 100644 index 0000000000..7cd22f1919 --- /dev/null +++ b/packages/component/src/components/page-list/filter/logical/matcher.ts @@ -0,0 +1,60 @@ +import type { TType } from './typesystem'; +import { typesystem } from './typesystem'; + +type MatcherData = { type: Type; data: Data }; + +export class Matcher { + private list: MatcherData[] = []; + + 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[] { + const match = this._match ?? typesystem.isSubtype.bind(typesystem); + const result: MatcherData[] = []; + 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) => boolean + ): MatcherData | undefined { + return this.list.find(f); + } + + all(): MatcherData[] { + return this.list; + } +} diff --git a/packages/component/src/components/page-list/filter/logical/typesystem.ts b/packages/component/src/components/page-list/filter/logical/typesystem.ts new file mode 100644 index 0000000000..f981a9ad63 --- /dev/null +++ b/packages/component/src/components/page-list/filter/logical/typesystem.ts @@ -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 { + type: 'array'; + ele: Ele; + title: 'array'; +} + +export const tArray = (ele: T): TArray => { + 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; +export type TDataType> = { + type: 'data'; + name: string; + data?: Data; +}; +export type ValueOfData = T extends DataDefine + ? R + : never; + +export class DataDefine> { + constructor( + private config: DataDefineConfig, + private dataMap: Map + ) {} + + create(data?: Data): TDataType { + return { + type: 'data', + name: this.config.name, + data, + }; + } + + is(data: TType): data is TDataType { + 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 { + name: string; + supers: DataDefine[]; +} + +interface DataHelper { + create>(name: string): DataDefineConfig; + + extends( + dataDefine: DataDefine + ): DataHelper; +} + +const createDataHelper = >( + ...supers: DataDefine[] +): DataHelper => { + return { + create(name: string) { + return { + name, + supers, + }; + }, + extends(dataDefine) { + return createDataHelper(...supers, dataDefine); + }, + }; +}; +export const DataHelper = createDataHelper(); + +export class Typesystem { + dataMap = new Map>(); + + defineData( + config: DataDefineConfig + ): DataDefine { + 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 + ): 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, 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, + 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')); diff --git a/packages/component/src/components/page-list/filter/vars.tsx b/packages/component/src/components/page-list/filter/vars.tsx new file mode 100644 index 0000000000..5c61b2c413 --- /dev/null +++ b/packages/component/src/components/page-list/filter/vars.tsx @@ -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 ( + { + onChange([...value, filter]); + }} + /> + ); +}; + +export const ChangeFilterMenu = ({ + onSelect, +}: { + selected: Filter[]; + onSelect: (value: Filter) => void; +}) => { + return ( +
+ {vars + // .filter(v => !selected.find(filter => filter.left.name === v.name)) + .map(v => ( + { + onSelect(createDefaultFilter(v)); + }} + > + {v.name} + + ))} +
+ ); +}; + +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')); + }, + } +); diff --git a/packages/component/src/components/page-list/index.tsx b/packages/component/src/components/page-list/index.tsx index 44387bb1a1..2dbea157a7 100644 --- a/packages/component/src/components/page-list/index.tsx +++ b/packages/component/src/components/page-list/index.tsx @@ -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'; diff --git a/packages/component/src/components/page-list/use-all-page-setting.ts b/packages/component/src/components/page-list/use-all-page-setting.ts new file mode 100644 index 0000000000..176df30ebd --- /dev/null +++ b/packages/component/src/components/page-list/use-all-page-setting.ts @@ -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({ + 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); diff --git a/packages/component/src/components/page-list/view/create-view.tsx b/packages/component/src/components/page-list/view/create-view.tsx new file mode 100644 index 0000000000..4b2c3b3aca --- /dev/null +++ b/packages/component/src/components/page-list/view/create-view.tsx @@ -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({ name: '', filterList: init }); + + return ( +
+

Save As New View

+
+ onChange({ ...value, filterList: list })} + > +
+
+ onChange({ ...value, name: text })} + /> +
+
+ + +
+
+ ); +}; +export const SaveViewButton = ({ init, onConfirm }: CreateViewProps) => { + const [show, changeShow] = useState(false); + return ( + <> + + changeShow(false)}> + + changeShow(false)} + onConfirm={view => { + onConfirm(view); + changeShow(false); + }} + /> + + + + ); +}; diff --git a/packages/component/src/components/page-list/view/index.ts b/packages/component/src/components/page-list/view/index.ts new file mode 100644 index 0000000000..a047ac03d3 --- /dev/null +++ b/packages/component/src/components/page-list/view/index.ts @@ -0,0 +1,2 @@ +export * from './create-view'; +export * from './view-list'; diff --git a/packages/component/src/components/page-list/view/view-list.tsx b/packages/component/src/components/page-list/view/view-list.tsx new file mode 100644 index 0000000000..dbdfd04d5f --- /dev/null +++ b/packages/component/src/components/page-list/view/view-list.tsx @@ -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; +}) => { + return ( +
+ {setting.currentViewIndex != null && ( + + {setting.viewList.map((v, i) => { + return ( + setting.selectView(i)} key={i}> + {v.name} + + ); + })} +
+ } + > + + {setting.currentView.name} + + + )} + { + setting.changeView( + { ...setting.currentView, filterList }, + setting.currentViewIndex + ); + }} + /> + } + > + Filter + + + ); +}; diff --git a/packages/workspace/src/type.ts b/packages/workspace/src/type.ts index 77de6ce6d2..6a983844e0 100644 --- a/packages/workspace/src/type.ts +++ b/packages/workspace/src/type.ts @@ -1,5 +1,6 @@ // eslint-disable-next-line @typescript-eslint/triple-slash-reference /// +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 = type PageListProps<_Flavour extends keyof WorkspaceRegistry> = { blockSuiteWorkspace: BlockSuiteWorkspace; onOpenPage: (pageId: string, newTab?: boolean) => void; + view: View; }; export interface WorkspaceUISchema { diff --git a/yarn.lock b/yarn.lock index 5e9934a9df..5ec66717fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"