refactor(core): refactor collection to use new filter system (#12228)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced a new Collection entity and store with reactive management and real-time updates.
  - Added reactive favorite and shared filters with expanded filtering options.
- **Refactor**
  - Overhauled collection and filtering logic for better performance and maintainability.
  - Replaced legacy filtering UI and logic with a streamlined, service-driven rules system.
  - Updated collection components to use reactive data streams and simplified props.
  - Simplified collection creation by delegating ID generation and instantiation to the service layer.
  - Removed deprecated hooks and replaced state-based filtering with observable-driven filtering.
- **Bug Fixes**
  - Improved accuracy and consistency of tag and favorite filtering in collections.
- **Chores**
  - Removed deprecated and unused filter-related files, types, components, and styles to reduce complexity.
  - Cleaned up imports and removed unused code across multiple components.
- **Documentation**
  - Corrected inline documentation for improved clarity.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
EYHN
2025-05-13 08:28:02 +00:00
parent 5dbe6ff68b
commit 13d882d6d8
96 changed files with 1274 additions and 3161 deletions
@@ -1,5 +1,5 @@
import type { Collection } from '@affine/core/modules/collection';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { AllDocsIcon, FilterIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
@@ -5,10 +5,8 @@ import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback } from 'react';
import { createEmptyCollection } from '../../page-list';
import { ActionButton } from './action-button';
import collectionListDark from './assets/collection-list.dark.png';
import collectionListLight from './assets/collection-list.light.png';
@@ -39,8 +37,7 @@ export const EmptyCollections = (props: UniversalEmptyProps) => {
variant: 'primary',
},
onConfirm(name) {
const id = nanoid();
collectionService.addCollection(createEmptyCollection(id, { name }));
const id = collectionService.createCollection({ name });
navigateHelper.jumpToCollection(currentWorkspace.id, id);
},
});
@@ -2,7 +2,7 @@ import { IconButton, Menu, MenuItem, MenuSeparator } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules';
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc';
import { FavoriteIcon, PlusIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { WorkspacePropertyIcon, WorkspacePropertyName } from '../properties';
@@ -24,6 +24,36 @@ export const AddFilterMenu = ({
{t['com.affine.filter']()}
</div>
<MenuSeparator />
<MenuItem
prefixIcon={<FavoriteIcon className={styles.filterTypeItemIcon} />}
key={'favorite'}
onClick={() => {
onAdd({
type: 'system',
key: 'favorite',
method: 'is',
value: 'true',
});
}}
>
<span className={styles.filterTypeItemName}>{t['Favorited']()}</span>
</MenuItem>
<MenuItem
prefixIcon={<FavoriteIcon className={styles.filterTypeItemIcon} />}
key={'shared'}
onClick={() => {
onAdd({
type: 'system',
key: 'shared',
method: 'is',
value: 'true',
});
}}
>
<span className={styles.filterTypeItemName}>
{t['com.affine.filter.is-public']()}
</span>
</MenuItem>
{workspaceProperties.map(property => {
const type = WorkspacePropertyTypes[property.type];
const defaultFilter = type?.defaultFilter;
@@ -81,13 +81,13 @@ export function useAIChatConfig() {
return tag$.value?.pageIds$.value ?? [];
},
getCollections: () => {
const collections$ = collectionService.collections$;
return createSignalFromObservable(collections$, []);
const collectionMetas$ = collectionService.collectionMetas$;
return createSignalFromObservable(collectionMetas$, []);
},
getCollectionPageIds: (collectionId: string) => {
const collection$ = collectionService.collection$(collectionId);
// TODO: lack of documents that meet the collection rules
return collection$?.value?.allowList ?? [];
return collection$?.value?.info$.value.allowList ?? [];
},
};
@@ -1,15 +0,0 @@
import { AuthService } from '@affine/core/modules/cloud';
import type { DeleteCollectionInfo } from '@affine/env/filter';
import { useLiveData, useService } from '@toeverything/infra';
import { useMemo } from 'react';
export const useDeleteCollectionInfo = () => {
const authService = useService(AuthService);
const user = useLiveData(authService.session.account$);
return useMemo<DeleteCollectionInfo | null>(
() => (user ? { userName: user.label, userId: user.id } : null),
[user]
);
};
@@ -1,201 +0,0 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import type {
Filter,
LiteralValue,
PropertiesMeta,
Ref,
VariableMap,
} from '@affine/env/filter';
import { getOrCreateI18n, I18nextProvider } from '@affine/i18n';
import { render } from '@testing-library/react';
import type { ReactElement } from 'react';
import { useState } from 'react';
import { describe, expect, test } from 'vitest';
import { Condition } from '../filter/condition';
import { tBoolean, tDate } from '../filter/logical/custom-type';
import { toLiteral } from '../filter/shared-types';
import type { FilterMatcherDataType } from '../filter/vars';
import { filterMatcher } from '../filter/vars';
import { filterByFilterList } from '../use-collection-manager';
const ref = (name: keyof VariableMap): Ref => {
return {
type: 'ref',
name,
};
};
const mockVariableMap = (vars: Partial<VariableMap>): VariableMap => {
return {
Created: 0,
Updated: 0,
'Is Favourited': false,
'Is Public': false,
Tags: [],
...vars,
};
};
const mockPropertiesMeta = (meta: Partial<PropertiesMeta>): PropertiesMeta => {
return {
tags: {
options: [],
},
...meta,
};
};
const filter = (
matcherData: FilterMatcherDataType,
left: Ref,
args: LiteralValue[]
): Filter => {
return {
type: 'filter',
left,
funcName: matcherData.name,
args: args.map(toLiteral),
};
};
describe('match filter', () => {
test('boolean variable will match `is` filter', () => {
const is = filterMatcher
.allMatchedData(tBoolean.create())
.find(v => v.name === 'is');
expect(is?.name).toBe('is');
});
test('Date variable will match `before` filter', () => {
const before = filterMatcher
.allMatchedData(tDate.create())
.find(v => v.name === 'before');
expect(before?.name).toBe('before');
});
});
describe('eval filter', () => {
test('before', async () => {
const before = filterMatcher.findData(v => v.name === 'before');
if (!before) {
throw new Error('before is not found');
}
const filter1 = filter(before, ref('Created'), [
new Date(2023, 5, 28).getTime(),
]);
const filter2 = filter(before, ref('Created'), [
new Date(2023, 5, 30).getTime(),
]);
const filter3 = filter(before, ref('Created'), [
new Date(2023, 5, 29).getTime(),
]);
const varMap = mockVariableMap({
Created: new Date(2023, 5, 29).getTime(),
});
expect(filterByFilterList([filter1], varMap)).toBe(false);
expect(filterByFilterList([filter2], varMap)).toBe(true);
expect(filterByFilterList([filter3], varMap)).toBe(false);
});
test('after', async () => {
const after = filterMatcher.findData(v => v.name === 'after');
if (!after) {
throw new Error('after is not found');
}
const filter1 = filter(after, ref('Created'), [
new Date(2023, 5, 28).getTime(),
]);
const filter2 = filter(after, ref('Created'), [
new Date(2023, 5, 30).getTime(),
]);
const filter3 = filter(after, ref('Created'), [
new Date(2023, 5, 29).getTime(),
]);
const varMap = mockVariableMap({
Created: new Date(2023, 5, 29).getTime(),
});
expect(filterByFilterList([filter1], varMap)).toBe(true);
expect(filterByFilterList([filter2], varMap)).toBe(false);
expect(filterByFilterList([filter3], varMap)).toBe(false);
});
test('is', async () => {
const is = filterMatcher.findData(v => v.name === 'is');
if (!is) {
throw new Error('is is not found');
}
const filter1 = filter(is, ref('Is Favourited'), [false]);
const filter2 = filter(is, ref('Is Favourited'), [true]);
const varMap = mockVariableMap({
'Is Favourited': true,
});
expect(filterByFilterList([filter1], varMap)).toBe(false);
expect(filterByFilterList([filter2], varMap)).toBe(true);
});
});
describe('render filter', () => {
test('boolean condition value change', async () => {
const is = filterMatcher.match(tBoolean.create());
const i18n = getOrCreateI18n();
if (!is) {
throw new Error('is is not found');
}
const Wrapper = () => {
const [value, onChange] = useState(
filter(is, ref('Is Favourited'), [true])
);
return (
<I18nextProvider i18n={i18n}>
<Condition
propertiesMeta={mockPropertiesMeta({})}
value={value}
onChange={onChange}
/>
</I18nextProvider>
);
};
const result = render(<Wrapper />);
const dom = await result.findByText('true');
dom.click();
await result.findByText('false');
result.unmount();
});
const WrapperCreator = (fn: FilterMatcherDataType) =>
function Wrapper(): ReactElement {
const [value, onChange] = useState(
filter(fn, ref('Created'), [new Date(2023, 5, 29).getTime()])
);
return (
<Condition
propertiesMeta={mockPropertiesMeta({})}
value={value}
onChange={onChange}
/>
);
};
test('date condition function change', async () => {
const dateFunction = filterMatcher.match(tDate.create());
if (!dateFunction) {
throw new Error('dateFunction is not found');
}
const Wrapper = WrapperCreator(dateFunction);
const result = render(<Wrapper />);
const dom = await result.findByTestId('filter-name');
dom.click();
await result.findByTestId('filter-name');
result.unmount();
});
test('date condition variable change', async () => {
const dateFunction = filterMatcher.match(tDate.create());
if (!dateFunction) {
throw new Error('dateFunction is not found');
}
const Wrapper = WrapperCreator(dateFunction);
const result = render(<Wrapper />);
const dom = await result.findByTestId('variable-name');
dom.click();
await result.findByTestId('variable-name');
result.unmount();
});
});
@@ -1,51 +1,25 @@
import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/use-delete-collection-info';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
import { Trans } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo, useRef, useState } from 'react';
import { CollectionService } from '../../../modules/collection';
import {
type CollectionMeta,
CollectionService,
} from '../../../modules/collection';
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
import { collectionHeaderColsDef } from '../header-col-def';
import { CollectionOperationCell } from '../operation-cell';
import { CollectionListItemRenderer } from '../page-group';
import { ListTableHeader } from '../page-header';
import type { CollectionMeta, ItemListHandle, ListItem } from '../types';
import type { ItemListHandle, ListItem } from '../types';
import { VirtualizedList } from '../virtualized-list';
import { CollectionListHeader } from './collection-list-header';
const useCollectionOperationsRenderer = ({
info,
service,
}: {
info: DeleteCollectionInfo;
service: CollectionService;
}) => {
const collectionOperationsRenderer = useCallback(
(collection: Collection) => {
return (
<CollectionOperationCell
info={info}
collection={collection}
service={service}
/>
);
},
[info, service]
);
return collectionOperationsRenderer;
};
export const VirtualizedCollectionList = ({
collections,
collectionMetas,
setHideHeaderCreateNewCollection,
handleCreateCollection,
}: {
collections: Collection[];
collectionMetas: CollectionMeta[];
handleCreateCollection: () => void;
setHideHeaderCreateNewCollection: (hide: boolean) => void;
}) => {
@@ -55,30 +29,24 @@ export const VirtualizedCollectionList = ({
[]
);
const collectionService = useService(CollectionService);
const collectionMetas = useLiveData(collectionService.collectionMetas$);
const currentWorkspace = useService(WorkspaceService).workspace;
const info = useDeleteCollectionInfo();
const collectionOperations = useCollectionOperationsRenderer({
info,
service: collectionService,
});
const filteredSelectedCollectionIds = useMemo(() => {
const ids = new Set(collections.map(collection => collection.id));
const ids = new Set(collectionMetas.map(collection => collection.id));
return selectedCollectionIds.filter(id => ids.has(id));
}, [collections, selectedCollectionIds]);
}, [collectionMetas, selectedCollectionIds]);
const hideFloatingToolbar = useCallback(() => {
listRef.current?.toggleSelectable();
}, []);
const collectionOperationRenderer = useCallback(
(item: ListItem) => {
const collection = item as CollectionMeta;
return collectionOperations(collection);
},
[collectionOperations]
);
const collectionOperationRenderer = useCallback((item: ListItem) => {
const collection = item;
return (
<CollectionOperationCell collectionMeta={collection as CollectionMeta} />
);
}, []);
const collectionHeaderRenderer = useCallback(() => {
return <ListTableHeader headerCols={collectionHeaderColsDef} />;
@@ -92,9 +60,11 @@ export const VirtualizedCollectionList = ({
if (selectedCollectionIds.length === 0) {
return;
}
collectionService.deleteCollection(info, ...selectedCollectionIds);
for (const collectionId of selectedCollectionIds) {
collectionService.deleteCollection(collectionId);
}
hideFloatingToolbar();
}, [collectionService, hideFloatingToolbar, info, selectedCollectionIds]);
}, [collectionService, hideFloatingToolbar, selectedCollectionIds]);
return (
<>
@@ -14,7 +14,6 @@ import { TagService } from '@affine/core/modules/tag';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { inferOpenMode } from '@affine/core/utils';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import type { DocMode } from '@blocksuite/affine/model';
@@ -29,8 +28,10 @@ import { useCallback, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
import { CollectionService } from '../../../modules/collection';
import { createTagFilter } from '../filter/utils';
import {
type Collection,
CollectionService,
} from '../../../modules/collection';
import { SaveAsCollectionButton } from '../view';
import * as styles from './page-list-header.css';
import { PageListNewPageButton } from './page-list-new-page-button';
@@ -133,11 +134,12 @@ export const CollectionPageListHeader = ({
const workspace = workspaceService.workspace;
const { createEdgeless, createPage } = usePageHelper(workspace.docCollection);
const { openConfirmModal } = useConfirmModal();
const name = useLiveData(collection.name$);
const createAndAddDocument = useCallback(
(createDocumentFn: () => DocRecord) => {
const newDoc = createDocumentFn();
collectionService.addPageToCollection(collection.id, newDoc.id);
collectionService.addDocToCollection(collection.id, newDoc.id);
},
[collection.id, collectionService]
);
@@ -183,7 +185,7 @@ export const CollectionPageListHeader = ({
<div className={styles.titleIcon}>
<ViewLayersIcon />
</div>
<div className={styles.titleCollectionName}>{collection.name}</div>
<div className={styles.titleCollectionName}>{name}</div>
</div>
<div className={styles.rightButtonGroup}>
<Button onClick={handleEdit}>{t['Edit']()}</Button>
@@ -221,12 +223,21 @@ export const TagPageListHeader = ({
}, [jumpToTags, workspaceId]);
const saveToCollection = useCallback(
(collection: Collection) => {
collectionService.addCollection({
...collection,
filterList: [createTagFilter(tag.id)],
(collectionName: string) => {
const id = collectionService.createCollection({
name: collectionName,
rules: {
filters: [
{
type: 'system',
key: 'tags',
method: 'include-all',
value: tag.id,
},
],
},
});
jumpToCollection(workspaceId, collection.id);
jumpToCollection(workspaceId, id);
},
[collectionService, tag.id, jumpToCollection, workspaceId]
);
@@ -1,18 +1,27 @@
import { IconButton, Menu, toast } from '@affine/component';
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import {
CollectionRulesService,
type FilterParams,
} from '@affine/core/modules/collection-rules';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { ShareDocsListService } from '@affine/core/modules/share-doc';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { PublicDocMode } from '@affine/graphql';
import { Trans, useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
import { FilterIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { type ReactNode, useCallback, useEffect, useState } from 'react';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { Filters } from '../../filter';
import { AddFilterMenu } from '../../filter/add-filter';
import { AffineShapeIcon, FavoriteTag } from '..';
import { FilterList } from '../filter';
import { VariableSelect } from '../filter/vars';
import { usePageHeaderColsDef } from '../header-col-def';
import { PageListItemRenderer } from '../page-group';
import { ListTableHeader } from '../page-header';
@@ -20,7 +29,6 @@ import { SelectorLayout } from '../selector/selector-layout';
import type { ListItem } from '../types';
import { VirtualizedList } from '../virtualized-list';
import * as styles from './select-page.css';
import { useFilter } from './use-filter';
import { useSearch } from './use-search';
export const SelectPage = ({
@@ -58,12 +66,13 @@ export const SelectPage = ({
workspaceService,
compatibleFavoriteItemsAdapter,
shareDocsListService,
collectionRulesService,
} = useServices({
ShareDocsListService,
WorkspaceService,
CompatibleFavoriteItemsAdapter,
CollectionRulesService,
});
const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$);
const workspace = workspaceService.workspace;
const docCollection = workspace.docCollection;
const pageMetas = useBlockSuiteDocMeta(docCollection);
@@ -73,20 +82,6 @@ export const SelectPage = ({
shareDocsListService.shareDocs?.revalidate();
}, [shareDocsListService.shareDocs]);
const getPublicMode = useCallback(
(id: string) => {
const mode = shareDocs?.find(shareDoc => shareDoc.id === id)?.mode;
if (mode === PublicDocMode.Edgeless) {
return 'edgeless';
} else if (mode === PublicDocMode.Page) {
return 'page';
} else {
return undefined;
}
},
[shareDocs]
);
const isFavorite = useCallback(
(meta: DocMeta) => favourites.some(fav => fav.id === meta.id),
[favourites]
@@ -106,22 +101,41 @@ export const SelectPage = ({
);
const pageHeaderColsDef = usePageHeaderColsDef();
const {
clickFilter,
createFilter,
filters,
showFilter,
updateFilters,
filteredList,
} = useFilter(
pageMetas.map(meta => ({
meta,
publicMode: getPublicMode(meta.id),
favorite: isFavorite(meta),
}))
);
const [filters, setFilters] = useState<FilterParams[]>([]);
const [filteredDocIds, setFilteredDocIds] = useState<string[]>([]);
const filteredPageMetas = useMemo(() => {
const idSet = new Set(filteredDocIds);
return pageMetas.filter(page => idSet.has(page.id));
}, [pageMetas, filteredDocIds]);
const { searchText, updateSearchText, searchedList } =
useSearch(filteredList);
useSearch(filteredPageMetas);
useEffect(() => {
const subscription = collectionRulesService
.watch([
...filters,
{
type: 'system',
key: 'empty-journal',
method: 'is',
value: 'false',
},
{
type: 'system',
key: 'trash',
method: 'is',
value: 'false',
},
])
.subscribe(result => {
setFilteredDocIds(result.groups.flatMap(group => group.items));
});
return () => {
subscription.unsubscribe();
};
}, [collectionRulesService, filters]);
const operationsRenderer = useCallback(
(item: ListItem) => {
@@ -162,29 +176,21 @@ export const SelectPage = ({
{t['com.affine.selectPage.title']()}
</div>
)}
{!showFilter && filters.length === 0 ? (
{filters.length === 0 ? (
<Menu
items={
<VariableSelect
propertiesMeta={docCollection.meta.properties}
selected={filters}
onSelect={createFilter}
<AddFilterMenu
onAdd={params => setFilters([...filters, params])}
/>
}
>
<IconButton icon={<FilterIcon />} onClick={clickFilter} />
<IconButton icon={<FilterIcon />} />
</Menu>
) : (
<IconButton icon={<FilterIcon />} onClick={clickFilter} />
)}
) : null}
</div>
{showFilter ? (
{filters.length !== 0 ? (
<div style={{ padding: '12px 16px 16px' }}>
<FilterList
propertiesMeta={docCollection.meta.properties}
value={filters}
onChange={updateFilters}
/>
<Filters filters={filters} onChange={setFilters} />
</div>
) : null}
{searchedList.length ? (
@@ -1,45 +0,0 @@
import type { Filter } from '@affine/env/filter';
import type { MouseEvent } from 'react';
import { useCallback, useState } from 'react';
import {
filterPageByRules,
type PageDataForFilter,
} from '../use-collection-manager';
export const useFilter = (list: PageDataForFilter[]) => {
const [filters, changeFilters] = useState<Filter[]>([]);
const [showFilter, setShowFilter] = useState(false);
const clickFilter = useCallback(
(e: MouseEvent) => {
if (showFilter || filters.length !== 0) {
e.stopPropagation();
e.preventDefault();
setShowFilter(!showFilter);
}
},
[filters.length, showFilter]
);
const onCreateFilter = useCallback(
(filter: Filter) => {
changeFilters([...filters, filter]);
setShowFilter(true);
},
[filters]
);
return {
showFilter,
filters,
updateFilters: changeFilters,
clickFilter,
createFilter: onCreateFilter,
filteredList: list
.filter(pageData => {
if (pageData.meta.trash) {
return false;
}
return filterPageByRules(filters, [], pageData);
})
.map(pageData => pageData.meta),
};
};
@@ -1,14 +1,13 @@
import { toast, useConfirmModal } from '@affine/component';
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { CollectionService } from '@affine/core/modules/collection';
import { type Collection } from '@affine/core/modules/collection';
import { DocsService } from '@affine/core/modules/doc';
import type { Tag } from '@affine/core/modules/tag';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Collection, Filter } from '@affine/env/filter';
import { Trans, useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
import { useService } from '@toeverything/infra';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useLiveData, useService } from '@toeverything/infra';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
import { usePageItemGroupDefinitions } from '../group-definitions';
@@ -17,7 +16,6 @@ import { PageOperationCell } from '../operation-cell';
import { PageListItemRenderer } from '../page-group';
import { ListTableHeader } from '../page-header';
import type { ItemListHandle, ListItem } from '../types';
import { useFilteredPageMetas } from '../use-filtered-page-metas';
import { VirtualizedList } from '../virtualized-list';
import {
CollectionPageListHeader,
@@ -25,15 +23,14 @@ import {
TagPageListHeader,
} from './page-list-header';
const usePageOperationsRenderer = () => {
const usePageOperationsRenderer = (collection?: Collection) => {
const t = useI18n();
const collectionService = useService(CollectionService);
const removeFromAllowList = useCallback(
(id: string) => {
collectionService.deletePagesFromCollections([id]);
collection?.removeDoc(id);
toast(t['com.affine.collection.removePage.success']());
},
[collectionService, t]
[collection, t]
);
const pageOperationsRenderer = useCallback(
(page: DocMeta, isInAllowList?: boolean) => {
@@ -53,14 +50,12 @@ const usePageOperationsRenderer = () => {
export const VirtualizedPageList = memo(function VirtualizedPageList({
tag,
collection,
filters,
listItem,
setHideHeaderCreateNewPage,
disableMultiDelete,
}: {
tag?: Tag;
collection?: Collection;
filters?: Filter[];
listItem?: DocMeta[];
setHideHeaderCreateNewPage?: (hide: boolean) => void;
disableMultiDelete?: boolean;
@@ -72,19 +67,28 @@ export const VirtualizedPageList = memo(function VirtualizedPageList({
const currentWorkspace = useService(WorkspaceService).workspace;
const docsService = useService(DocsService);
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
const pageOperations = usePageOperationsRenderer();
const pageOperations = usePageOperationsRenderer(collection);
const pageHeaderColsDef = usePageHeaderColsDef();
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
filters,
collection,
});
const [filteredPageIds, setFilteredPageIds] = useState<string[]>([]);
useEffect(() => {
const subscription = collection?.watch().subscribe(docIds => {
setFilteredPageIds(docIds);
});
return () => subscription?.unsubscribe();
}, [collection]);
const allowList = useLiveData(collection?.info$.map(info => info.allowList));
const pageMetasToRender = useMemo(() => {
if (listItem) {
return listItem;
}
return filteredPageMetas;
}, [filteredPageMetas, listItem]);
if (collection) {
return pageMetas.filter(
page => filteredPageIds.includes(page.id) && !page.trash
);
}
return pageMetas.filter(page => !page.trash);
}, [collection, filteredPageIds, listItem, pageMetas]);
const filteredSelectedPageIds = useMemo(() => {
const ids = new Set(pageMetasToRender.map(page => page.id));
@@ -98,10 +102,10 @@ export const VirtualizedPageList = memo(function VirtualizedPageList({
const pageOperationRenderer = useCallback(
(item: ListItem) => {
const page = item as DocMeta;
const isInAllowList = collection?.allowList?.includes(page.id);
const isInAllowList = allowList?.includes(page.id);
return pageOperations(page, isInAllowList);
},
[collection, pageOperations]
[allowList, pageOperations]
);
const pageHeaderRenderer = useCallback(() => {
@@ -1,181 +0,0 @@
import { Menu, MenuItem, Tooltip } from '@affine/component';
import type { Filter, Literal, PropertiesMeta } from '@affine/env/filter';
import clsx from 'clsx';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import { FilterTag } from './filter-tag-translation';
import * as styles from './index.css';
import { literalMatcher } from './literal-matcher';
import { tBoolean } from './logical/custom-type';
import type { TFunction, TType } from './logical/typesystem';
import { typesystem } from './logical/typesystem';
import { variableDefineMap } from './shared-types';
import { filterMatcher, VariableSelect, vars } from './vars';
export const Condition = ({
value,
onChange,
propertiesMeta,
}: {
value: Filter;
onChange: (filter: Filter) => void;
propertiesMeta: PropertiesMeta;
}) => {
const data = useMemo(() => {
const data = filterMatcher.find(v => v.data.name === value.funcName);
if (!data) {
return;
}
const instance = typesystem.instance(
{},
[variableDefineMap[value.left.name].type(propertiesMeta)],
tBoolean.create(),
data.type
);
return {
render: data.data.render,
type: instance,
};
}, [propertiesMeta, value.funcName, value.left.name]);
if (!data) {
return null;
}
const render =
data.render ??
(({ ast }) => {
const args = renderArgs(value, onChange, data.type);
return (
<div className={styles.filterContainerStyle}>
<Menu
items={
<VariableSelect
propertiesMeta={propertiesMeta}
selected={[]}
onSelect={onChange}
/>
}
>
<div
data-testid="variable-name"
className={clsx(styles.filterTypeStyle, styles.ellipsisTextStyle)}
>
<Tooltip content={ast.left.name}>
<div className={styles.filterTypeIconStyle}>
{variableDefineMap[ast.left.name].icon}
</div>
</Tooltip>
<FilterTag name={ast.left.name} />
</div>
</Menu>
<Menu
items={
<FunctionSelect
propertiesMeta={propertiesMeta}
value={value}
onChange={onChange}
/>
}
>
<div
className={clsx(styles.switchStyle, styles.ellipsisTextStyle)}
data-testid="filter-name"
>
<FilterTag name={ast.funcName} />
</div>
</Menu>
{args}
</div>
);
});
return <>{render({ ast: value })}</>;
};
const FunctionSelect = ({
value,
onChange,
propertiesMeta,
}: {
value: Filter;
onChange: (value: Filter) => void;
propertiesMeta: PropertiesMeta;
}) => {
const list = useMemo(() => {
const type = vars.find(v => v.name === value.left.name)?.type;
if (!type) {
return [];
}
return filterMatcher.allMatchedData(type(propertiesMeta));
}, [propertiesMeta, value.left.name]);
return (
<div data-testid="filter-name-select">
{list.map(v => (
<MenuItem
onClick={() => {
onChange({
...value,
funcName: v.name,
args: v.defaultArgs().map(v => ({ type: 'literal', value: v })),
});
}}
key={v.name}
>
<FilterTag 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
data-testid="filter-arg"
className={clsx(styles.argStyle, styles.ellipsisTextStyle)}
>
{data.render({
type,
value: value?.value,
onChange: v => onChange({ type: 'literal', value: v }),
})}
</div>
);
};
export const renderArgs = (
filter: Filter,
onChange: (value: Filter) => void,
type: TFunction
): ReactNode => {
const rest = type.args.slice(1);
return rest.map((argType, i) => {
const value = filter.args[i];
return (
<Arg
key={`${argType.type}-${i}`}
type={argType}
value={value}
onChange={value => {
const args = type.args.map((_, index) =>
i === index ? value : filter.args[index]
);
onChange({
...filter,
args,
});
}}
></Arg>
);
});
};
@@ -1,17 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const datePickerTriggerInput = style({
fontSize: cssVar('fontXs'),
width: '50px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '22px',
textAlign: 'center',
':hover': {
background: cssVar('hoverColor'),
borderRadius: '4px',
},
});
@@ -1,52 +0,0 @@
import type { PopoverProps } from '@affine/component';
import { DatePicker, Popover } from '@affine/component';
import { useI18n } from '@affine/i18n';
import dayjs from 'dayjs';
import { useCallback, useState } from 'react';
import { datePickerTriggerInput } from './date-select.css';
const datePickerPopperContentOptions: PopoverProps['contentOptions'] = {
style: { padding: 20, marginTop: 10 },
};
export const DateSelect = ({
value,
onChange,
}: {
value: number;
onChange: (value: number) => void;
}) => {
const t = useI18n();
const [open, setOpen] = useState(false);
const onDateChange = useCallback(
(e: string) => {
setOpen(false);
onChange(dayjs(e, 'YYYY-MM-DD').valueOf());
},
[onChange]
);
return (
<Popover
open={open}
onOpenChange={setOpen}
contentOptions={datePickerPopperContentOptions}
content={
<DatePicker
weekDays={t['com.affine.calendar-date-picker.week-days']()}
monthNames={t['com.affine.calendar-date-picker.month-names']()}
todayLabel={t['com.affine.calendar-date-picker.today']()}
value={dayjs(value as number).format('YYYY-MM-DD')}
onChange={onDateChange}
/>
}
>
<input
value={dayjs(value as number).format('MMM DD')}
className={datePickerTriggerInput}
/>
</Popover>
);
};
@@ -1,25 +0,0 @@
import type { Filter, Literal, Ref, VariableMap } from '@affine/env/filter';
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));
};
@@ -1,74 +0,0 @@
import { Button, IconButton, Menu } from '@affine/component';
import type { Filter, PropertiesMeta } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { CloseIcon, PlusIcon } from '@blocksuite/icons/rc';
import { Condition } from './condition';
import * as styles from './index.css';
import { CreateFilterMenu } from './vars';
export const FilterList = ({
value,
onChange,
propertiesMeta,
}: {
value: Filter[];
onChange: (value: Filter[]) => void;
propertiesMeta: PropertiesMeta;
}) => {
const t = useI18n();
return (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 10,
alignItems: 'center',
}}
>
{value.map((filter, i) => {
return (
<div className={styles.filterItemStyle} key={i}>
<Condition
propertiesMeta={propertiesMeta}
value={filter}
onChange={filter => {
onChange(
value.map((old, oldIndex) => (oldIndex === i ? filter : old))
);
}}
/>
<div
className={styles.filterItemCloseStyle}
onClick={() => {
onChange(value.filter((_, index) => i !== index));
}}
>
<CloseIcon />
</div>
</div>
);
})}
<Menu
key={value.length} // hack to force menu to rerender (disable unmount animation)
items={
<CreateFilterMenu
value={value}
onChange={onChange}
propertiesMeta={propertiesMeta}
/>
}
>
{value.length === 0 ? (
<Button suffix={<PlusIcon />}>
{t['com.affine.filterList.button.add']()}
</Button>
) : (
<IconButton size="16">
<PlusIcon />
</IconButton>
)}
</Menu>
</div>
);
};
@@ -1,61 +0,0 @@
import { Tooltip } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { ellipsisTextStyle } from './index.css';
type FilterTagProps = {
name: string;
};
const useFilterTag = ({ name }: FilterTagProps) => {
const t = useI18n();
switch (name) {
case 'Created':
return t['Created']();
case 'Updated':
return t['Updated']();
case 'Tags':
return t['Tags']();
case 'Is Favourited':
return t['com.affine.filter.is-favourited']();
case 'Is Public':
return t['com.affine.filter.is-public']();
case 'after':
return t['com.affine.filter.after']();
case 'before':
return t['com.affine.filter.before']();
case 'last':
return t['com.affine.filter.last']();
case 'is':
return t['com.affine.filter.is']();
case 'is not empty':
return t['com.affine.filter.is not empty']();
case 'is empty':
return t['com.affine.filter.is empty']();
case 'contains all':
return t['com.affine.filter.contains all']();
case 'contains one of':
return t['com.affine.filter.contains one of']();
case 'does not contains all':
return t['com.affine.filter.does not contains all']();
case 'does not contains one of':
return t['com.affine.filter.does not contains one of']();
case 'true':
return t['com.affine.filter.true']();
case 'false':
return t['com.affine.filter.false']();
default:
return name;
}
};
export const FilterTag = ({ name }: FilterTagProps) => {
const tag = useFilterTag({ name });
return (
<Tooltip content={tag}>
<span className={ellipsisTextStyle} data-testid={`filler-tag-${tag}`}>
{tag}
</span>
</Tooltip>
);
};
@@ -1,106 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const filterContainerStyle = style({
display: 'flex',
userSelect: 'none',
alignItems: 'center',
overflow: 'hidden',
});
export const menuItemStyle = style({
fontSize: cssVar('fontXs'),
});
export const variableSelectTitleStyle = style({
margin: '7px 16px',
fontWeight: 500,
lineHeight: '20px',
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
});
export const variableSelectDividerStyle = style({
marginTop: '2px',
marginBottom: '2px',
marginLeft: '12px',
marginRight: '8px',
height: '1px',
background: cssVar('borderColor'),
});
export const menuItemTextStyle = style({
fontSize: cssVar('fontXs'),
});
export const filterItemStyle = style({
display: 'flex',
border: `1px solid ${cssVar('borderColor')}`,
borderRadius: '8px',
background: cssVar('white'),
padding: '4px 8px',
overflow: 'hidden',
justifyContent: 'space-between',
});
export const filterItemCloseStyle = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
marginLeft: '4px',
});
export const inputStyle = style({
fontSize: cssVar('fontXs'),
padding: '2px 4px',
transition: 'all 0.15s ease-in-out',
':hover': {
cursor: 'pointer',
background: cssVar('hoverColor'),
borderRadius: '4px',
},
});
export const switchStyle = style({
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
padding: '2px 4px',
transition: 'all 0.15s ease-in-out',
display: 'flex',
alignItems: 'center',
flex: '3 1 auto',
minWidth: '28px',
':hover': {
cursor: 'pointer',
background: cssVar('hoverColor'),
borderRadius: '4px',
},
});
export const filterTypeStyle = style({
fontSize: cssVar('fontSm'),
display: 'flex',
alignItems: 'center',
padding: '2px 4px',
transition: 'all 0.15s ease-in-out',
marginRight: '6px',
flex: '1 0 auto',
':hover': {
cursor: 'pointer',
background: cssVar('hoverColor'),
borderRadius: '4px',
},
});
export const filterTypeIconStyle = style({
fontSize: cssVar('fontBase'),
marginRight: '6px',
padding: '1px 0',
display: 'flex',
alignItems: 'center',
color: cssVar('iconColor'),
});
export const argStyle = style({
marginLeft: 4,
fontWeight: 600,
flex: '1 0 auto',
});
export const ellipsisTextStyle = style({
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
});
@@ -1,3 +0,0 @@
export * from './eval';
export * from './filter-list';
export * from './utils';
@@ -1,97 +0,0 @@
import { Input, Menu, MenuItem } from '@affine/component';
import type { LiteralValue } from '@affine/env/filter';
import type { ReactNode } from 'react';
import type { TagMeta } from '../types';
import { DateSelect } from './date-select';
import { FilterTag } from './filter-tag-translation';
import { inputStyle } from './index.css';
import { tBoolean, tDate, tDateRange, tTag } from './logical/custom-type';
import { Matcher } from './logical/matcher';
import type { TType } from './logical/typesystem';
import { tArray, typesystem } from './logical/typesystem';
import { MultiSelect } from './multi-select';
export const literalMatcher = new Matcher<{
render: (props: {
type: TType;
value: LiteralValue;
onChange: (lit: LiteralValue) => void;
}) => ReactNode;
}>((type, target) => {
return typesystem.isSubtype(type, target);
});
literalMatcher.register(tDateRange.create(), {
render: ({ value, onChange }) => (
<Menu
items={
<div>
<Input
type="number"
// Handle the input change and update the value accordingly
onChange={i => (i ? onChange(parseInt(i)) : onChange(0))}
/>
{[1, 2, 3, 7, 14, 30].map(i => (
<MenuItem
key={i}
onClick={() => {
// Handle the menu item click and update the value accordingly
onChange(i);
}}
>
{i} {i > 1 ? 'days' : 'day'}
</MenuItem>
))}
</div>
}
>
<div>
<span>{value.toString()}</span> {(value as number) > 1 ? 'days' : 'day'}
</div>
</Menu>
),
});
literalMatcher.register(tBoolean.create(), {
render: ({ value, onChange }) => (
<div
className={inputStyle}
style={{ cursor: 'pointer' }}
onClick={() => {
onChange(!value);
}}
>
<FilterTag name={value?.toString()} />
</div>
),
});
literalMatcher.register(tDate.create(), {
render: ({ value, onChange }) => (
<DateSelect value={value as number} onChange={onChange} />
),
});
const getTagsOfArrayTag = (type: TType): TagMeta[] => {
if (type.type === 'array') {
if (tTag.is(type.ele)) {
return type.ele.data?.tags ?? [];
}
return [];
} else {
return [];
}
};
literalMatcher.register(tArray(tTag.create()), {
render: ({ type, value, onChange }) => {
return (
<MultiSelect
value={(value ?? []) as string[]}
onChange={value => onChange(value)}
options={getTagsOfArrayTag(type).map((v: any) => ({
label: v.name,
value: v.id,
}))}
></MultiSelect>
);
},
});
@@ -1,24 +0,0 @@
import type { TagMeta } from '../../types';
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')
);
export const tTag = typesystem.defineData<{ tags: TagMeta[] }>({
name: 'Tag',
supers: [],
});
export const tDateRange = typesystem.defineData(
DataHelper.create<{ value: number }>('DateRange')
);
@@ -1,55 +0,0 @@
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 readonly list: MatcherData<Data, Type>[] = [];
constructor(
private readonly _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[] {
return this.allMatched(type).map(v => v.data);
}
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;
}
}
@@ -1,282 +0,0 @@
/**
* 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 readonly config: DataDefineConfig<Data>,
private readonly 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[];
_phantom?: T;
}
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 };
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'));
@@ -1,62 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const content = style({
fontSize: 12,
color: cssVar('textPrimaryColor'),
borderRadius: 8,
padding: '3px 4px',
cursor: 'pointer',
overflow: 'hidden',
':hover': {
backgroundColor: cssVar('hoverColor'),
},
});
export const text = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 350,
selectors: {
'&.empty': {
color: 'var(--affine-text-secondary-color)',
},
},
});
export const optionList = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
padding: '0 4px',
maxHeight: '220px',
});
export const scrollbar = style({
vars: {
'--scrollbar-width': '4px',
},
});
export const selectOption = style({
display: 'flex',
alignItems: 'center',
fontSize: 14,
height: 26,
borderRadius: 5,
maxWidth: 240,
minWidth: 100,
padding: '0 12px',
cursor: 'pointer',
':hover': {
backgroundColor: cssVar('hoverColor'),
},
});
export const optionLabel = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
});
export const done = style({
display: 'flex',
alignItems: 'center',
color: cssVar('primaryColor'),
marginLeft: 8,
});
@@ -1,92 +0,0 @@
import { Menu, MenuItem, Scrollable, Tooltip } from '@affine/component';
import { useI18n } from '@affine/i18n';
import clsx from 'clsx';
import type { MouseEvent } from 'react';
import { useMemo } from 'react';
import * as styles from './multi-select.css';
export const MultiSelect = ({
value,
onChange,
options,
}: {
value: string[];
onChange: (value: string[]) => void;
options: {
label: string;
value: string;
}[];
}) => {
const t = useI18n();
const optionMap = useMemo(
() => Object.fromEntries(options.map(v => [v.value, v])),
[options]
);
const content = useMemo(
() => value.map(id => optionMap[id]?.label).join(', '),
[optionMap, value]
);
const items = useMemo(() => {
return (
<Scrollable.Root>
<Scrollable.Viewport
data-testid="multi-select"
className={styles.optionList}
>
{options.length === 0 ? (
<MenuItem checked={true}>
{t['com.affine.filter.empty-tag']()}
</MenuItem>
) : (
options.map(option => {
const selected = value.includes(option.value);
const click = (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
e.preventDefault();
if (selected) {
onChange(value.filter(v => v !== option.value));
} else {
onChange([...value, option.value]);
}
};
return (
<MenuItem
data-testid={`multi-select-${option.label}`}
checked={selected}
onClick={click}
key={option.value}
>
{option.label}
</MenuItem>
);
})
)}
</Scrollable.Viewport>
<Scrollable.Scrollbar className={styles.scrollbar} />
</Scrollable.Root>
);
}, [onChange, options, t, value]);
return (
<Menu items={items}>
<div className={styles.content}>
<Tooltip
content={
content.length ? content : t['com.affine.filter.empty-tag']()
}
>
{value.length ? (
<div className={styles.text}>{content}</div>
) : (
<div className={clsx(styles.text, 'empty')}>
{t['com.affine.filter.empty-tag']()}
</div>
)}
</Tooltip>
</div>
</Menu>
);
};
@@ -1,77 +0,0 @@
import type {
Literal,
LiteralValue,
PropertiesMeta,
VariableMap,
} from '@affine/env/filter';
import {
CloudWorkspaceIcon,
CreatedIcon,
FavoriteIcon,
TagsIcon,
UpdatedIcon,
} from '@blocksuite/icons/rc';
import type { ReactElement } from 'react';
import { tBoolean, tDate, tTag } from './logical/custom-type';
import type { TType } from './logical/typesystem';
import { tArray } from './logical/typesystem';
export const toLiteral = (value: LiteralValue): Literal => ({
type: 'literal',
value,
});
export type FilterVariable = {
name: keyof VariableMap;
type: (propertiesMeta: PropertiesMeta) => TType;
icon: ReactElement;
};
export const variableDefineMap = {
Created: {
type: () => tDate.create(),
icon: <CreatedIcon />,
},
Updated: {
type: () => tDate.create(),
icon: <UpdatedIcon />,
},
'Is Favourited': {
type: () => tBoolean.create(),
icon: <FavoriteIcon />,
},
Tags: {
type: meta =>
tArray(
tTag.create({
tags:
meta.tags?.options.map(t => ({
id: t.id,
name: t.value,
color: t.color,
})) ?? [],
})
),
icon: <TagsIcon />,
},
'Is Public': {
type: () => tBoolean.create(),
icon: <CloudWorkspaceIcon />,
},
// Imported: {
// type: tBoolean.create(),
// },
// 'Daily Note': {
// type: tBoolean.create(),
// },
} satisfies Record<string, Omit<FilterVariable, 'name'>>;
export type InternalVariableMap = {
[K in keyof typeof variableDefineMap]: LiteralValue;
};
declare module '@affine/env/filter' {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface VariableMap extends InternalVariableMap {}
}
@@ -1,10 +0,0 @@
import type { Filter } from '@affine/env/filter';
export const createTagFilter = (id: string): Filter => {
return {
type: 'filter',
left: { type: 'ref', name: 'Tags' },
funcName: 'contains all',
args: [{ type: 'literal', value: [id] }],
};
};
@@ -1,305 +0,0 @@
import { MenuItem, MenuSeparator } from '@affine/component';
import type {
Filter,
LiteralValue,
PropertiesMeta,
VariableMap,
} from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import dayjs from 'dayjs';
import type { ReactNode } from 'react';
import { FilterTag } from './filter-tag-translation';
import * as styles from './index.css';
import { tBoolean, tDate, tDateRange, tTag } from './logical/custom-type';
import { Matcher } from './logical/matcher';
import type { TFunction } from './logical/typesystem';
import {
tArray,
tFunction,
tTypeRef,
tTypeVar,
typesystem,
} from './logical/typesystem';
import type { FilterVariable } from './shared-types';
import { variableDefineMap } from './shared-types';
export const vars: FilterVariable[] = Object.entries(variableDefineMap).map(
([key, value]) => ({
name: key as keyof VariableMap,
type: value.type,
icon: value.icon,
})
);
export const createDefaultFilter = (
variable: FilterVariable,
propertiesMeta: PropertiesMeta
): Filter => {
const data = filterMatcher.match(variable.type(propertiesMeta));
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,
propertiesMeta,
}: {
value: Filter[];
onChange: (value: Filter[]) => void;
propertiesMeta: PropertiesMeta;
}) => {
return (
<VariableSelect
propertiesMeta={propertiesMeta}
selected={value}
onSelect={filter => {
onChange([...value, filter]);
}}
/>
);
};
export const VariableSelect = ({
onSelect,
propertiesMeta,
}: {
selected: Filter[];
onSelect: (value: Filter) => void;
propertiesMeta: PropertiesMeta;
}) => {
const t = useI18n();
return (
<div data-testid="variable-select">
<div className={styles.variableSelectTitleStyle}>
{t['com.affine.filter']()}
</div>
<MenuSeparator />
{vars
// .filter(v => !selected.find(filter => filter.left.name === v.name))
.map(v => (
<MenuItem
prefixIcon={variableDefineMap[v.name].icon}
key={v.name}
onClick={() => {
onSelect(createDefaultFilter(v, propertiesMeta));
}}
className={styles.menuItemStyle}
>
<div
data-testid="variable-select-item"
className={styles.menuItemTextStyle}
>
<FilterTag name={v.name} />
</div>
</MenuItem>
))}
</div>
);
};
export type FilterMatcherDataType = {
name: string;
defaultArgs: () => LiteralValue[];
render?: (props: { ast: Filter }) => ReactNode;
impl: (...args: (LiteralValue | undefined)[]) => boolean;
};
export const filterMatcher = new Matcher<FilterMatcherDataType, TFunction>(
(type, target) => {
const staticType = typesystem.subst(
Object.fromEntries(type.typeVars?.map(v => [v.name, v.bound]) ?? []),
type
);
const firstArg = staticType.args[0];
return firstArg && typesystem.isSubtype(firstArg, target);
}
);
filterMatcher.register(
tFunction({
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').valueOf()];
},
impl: (date, target) => {
if (typeof date !== 'number' || typeof target !== 'number') {
throw new Error('argument type error');
}
return dayjs(date).isAfter(dayjs(target).endOf('day'));
},
}
);
filterMatcher.register(
tFunction({
args: [tDate.create(), tDateRange.create()],
rt: tBoolean.create(),
}),
{
name: 'last',
defaultArgs: () => [30], // Default to the last 30 days
impl: (date, n) => {
if (typeof date !== 'number' || typeof n !== 'number') {
throw new Error('Argument type error: date and n must be numbers');
}
const startDate = dayjs().subtract(n, 'day').startOf('day').valueOf();
return date > startDate;
},
}
);
filterMatcher.register(
tFunction({
args: [tDate.create(), tDate.create()],
rt: tBoolean.create(),
}),
{
name: 'before',
defaultArgs: () => [dayjs().endOf('day').valueOf()],
impl: (date, target) => {
if (typeof date !== 'number' || typeof target !== 'number') {
throw new Error('argument type error');
}
return dayjs(date).isBefore(dayjs(target).startOf('day'));
},
}
);
const safeArray = (arr: unknown): LiteralValue[] => {
return Array.isArray(arr) ? arr : [];
};
filterMatcher.register(
tFunction({
args: [tArray(tTag.create())],
rt: tBoolean.create(),
}),
{
name: 'is not empty',
defaultArgs: () => [],
impl: tags => {
const safeTags = safeArray(tags);
return safeTags.length > 0;
},
}
);
filterMatcher.register(
tFunction({
args: [tArray(tTag.create())],
rt: tBoolean.create(),
}),
{
name: 'is empty',
defaultArgs: () => [],
impl: tags => {
const safeTags = safeArray(tags);
return safeTags.length === 0;
},
}
);
filterMatcher.register(
tFunction({
typeVars: [tTypeVar('T', tTag.create())],
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
rt: tBoolean.create(),
}),
{
name: 'contains all',
defaultArgs: () => [],
impl: (tags, target) => {
if (!Array.isArray(target)) {
return true;
}
const safeTags = safeArray(tags);
return target.every(id => safeTags.includes(id));
},
}
);
filterMatcher.register(
tFunction({
typeVars: [tTypeVar('T', tTag.create())],
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
rt: tBoolean.create(),
}),
{
name: 'contains one of',
defaultArgs: () => [],
impl: (tags, target) => {
if (!Array.isArray(target)) {
return true;
}
const safeTags = safeArray(tags);
return target.some(id => safeTags.includes(id));
},
}
);
filterMatcher.register(
tFunction({
typeVars: [tTypeVar('T', tTag.create())],
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
rt: tBoolean.create(),
}),
{
name: 'does not contains all',
defaultArgs: () => [],
impl: (tags, target) => {
if (!Array.isArray(target)) {
return true;
}
const safeTags = safeArray(tags);
return !target.every(id => safeTags.includes(id));
},
}
);
filterMatcher.register(
tFunction({
typeVars: [tTypeVar('T', tTag.create())],
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
rt: tBoolean.create(),
}),
{
name: 'does not contains one of',
defaultArgs: () => [],
impl: (tags, target) => {
if (!Array.isArray(target)) {
return true;
}
const safeTags = safeArray(tags);
return !target.some(id => safeTags.includes(id));
},
}
);
@@ -6,7 +6,6 @@ export * from './components/page-display-menu';
export * from './docs';
export * from './docs/page-list-item';
export * from './docs/page-tags';
export * from './filter';
export * from './group-definitions';
export * from './header-col-def';
export * from './list';
@@ -17,8 +16,6 @@ export * from './page-header';
export * from './tags';
export * from './types';
export * from './use-all-doc-display-properties';
export * from './use-collection-manager';
export * from './use-filtered-page-metas';
export * from './utils';
export * from './view';
export * from './virtualized-list';
@@ -16,7 +16,6 @@ import {
} from '@affine/core/modules/favorite';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import type { DocMeta } from '@blocksuite/affine/store';
@@ -38,8 +37,10 @@ import { useLiveData, useService, useServices } from '@toeverything/infra';
import type { MouseEvent } from 'react';
import { useCallback, useState } from 'react';
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
import type { CollectionService } from '../../modules/collection';
import {
type CollectionMeta,
CollectionService,
} from '../../modules/collection';
import { useGuard } from '../guard';
import { IsFavoriteIcon } from '../pure/icons';
import { FavoriteTag } from './components/favorite-tag';
@@ -328,31 +329,28 @@ export const TrashOperationCell = ({
};
export interface CollectionOperationCellProps {
collection: Collection;
info: DeleteCollectionInfo;
service: CollectionService;
collectionMeta: CollectionMeta;
}
export const CollectionOperationCell = ({
collection,
service,
info,
collectionMeta,
}: CollectionOperationCellProps) => {
const t = useI18n();
const {
compatibleFavoriteItemsAdapter: favAdapter,
workspaceService,
workspaceDialogService,
collectionService,
docsService,
} = useServices({
CompatibleFavoriteItemsAdapter,
WorkspaceService,
WorkspaceDialogService,
CollectionService,
DocsService,
});
const docCollection = workspaceService.workspace.docCollection;
const { createPage } = usePageHelper(docCollection);
const collectionId = collectionMeta.id;
const { openConfirmModal } = useConfirmModal();
const favourite = useLiveData(
favAdapter.isFavorite$(collection.id, 'collection')
favAdapter.isFavorite$(collectionId, 'collection')
);
const { openPromptModal } = usePromptModal();
@@ -377,44 +375,43 @@ export const CollectionOperationCell = ({
variant: 'primary',
},
onConfirm(name) {
service.updateCollection(collection.id, () => ({
...collection,
collectionService.updateCollection(collectionId, {
name,
}));
});
},
});
},
[collection, handlePropagation, openPromptModal, service, t]
[collectionId, collectionService, handlePropagation, openPromptModal, t]
);
const handleEdit = useCallback(
(event: MouseEvent) => {
handlePropagation(event);
workspaceDialogService.open('collection-editor', {
collectionId: collection.id,
collectionId: collectionId,
});
},
[handlePropagation, workspaceDialogService, collection.id]
[handlePropagation, workspaceDialogService, collectionId]
);
const handleDelete = useCallback(() => {
return service.deleteCollection(info, collection.id);
}, [service, info, collection]);
return collectionService.deleteCollection(collectionId);
}, [collectionId, collectionService]);
const onToggleFavoriteCollection = useCallback(() => {
const status = favAdapter.isFavorite(collection.id, 'collection');
favAdapter.toggle(collection.id, 'collection');
const status = favAdapter.isFavorite(collectionId, 'collection');
favAdapter.toggle(collectionId, 'collection');
toast(
status
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
}, [favAdapter, collection.id, t]);
}, [favAdapter, collectionId, t]);
const createAndAddDocument = useCallback(() => {
const newDoc = createPage();
service.addPageToCollection(collection.id, newDoc.id);
}, [collection.id, createPage, service]);
const newDoc = docsService.createDoc();
collectionService.addDocToCollection(collectionId, newDoc.id);
}, [docsService, collectionService, collectionId]);
const onConfirmAddDocToCollection = useCallback(() => {
openConfirmModal({
@@ -1,4 +1,5 @@
import { shallowEqual } from '@affine/component';
import type { CollectionMeta } from '@affine/core/modules/collection';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
@@ -25,7 +26,6 @@ import {
import { TagListItem } from './tags/tag-list-item';
import type {
CollectionListItemProps,
CollectionMeta,
ItemGroupProps,
ListItem,
ListProps,
@@ -1,15 +1,15 @@
import type { Collection } from '@affine/env/filter';
import type { CollectionMeta } from '@affine/core/modules/collection';
import type { DocMeta, Workspace } from '@blocksuite/affine/store';
import type { JSX, PropsWithChildren, ReactNode } from 'react';
import type { To } from 'react-router-dom';
export type ListItem = DocMeta | CollectionMeta | TagMeta;
export interface CollectionMeta extends Collection {
title: string;
createDate?: Date | number;
updatedDate?: Date | number;
}
export type ListItem =
| DocMeta
| (CollectionMeta & {
createDate?: Date | number;
updatedDate?: Date | number;
})
| TagMeta;
export type TagMeta = {
id: string;
@@ -1,49 +0,0 @@
import type { Collection, Filter, VariableMap } from '@affine/env/filter';
import type { DocMeta } from '@blocksuite/affine/store';
import { evalFilterList } from './filter';
export const createEmptyCollection = (
id: string,
data?: Partial<Omit<Collection, 'id'>>
): Collection => {
return {
id,
name: '',
filterList: [],
allowList: [],
...data,
};
};
export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) =>
evalFilterList(filterList, varMap);
export type PageDataForFilter = {
meta: DocMeta;
favorite: boolean;
publicMode: undefined | 'page' | 'edgeless';
};
export const filterPage = (collection: Collection, page: PageDataForFilter) => {
if (collection.filterList.length === 0) {
return collection.allowList.includes(page.meta.id);
}
return filterPageByRules(collection.filterList, collection.allowList, page);
};
export const filterPageByRules = (
rules: Filter[],
allowList: string[],
{ meta, publicMode, favorite }: PageDataForFilter
) => {
if (allowList?.includes(meta.id)) {
return true;
}
return filterByFilterList(rules, {
'Is Favourited': !!favorite,
'Is Public': !!publicMode,
Created: meta.createDate,
Updated: meta.updatedDate ?? meta.createDate,
Tags: meta.tags,
});
};
@@ -1,81 +0,0 @@
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { ShareDocsListService } from '@affine/core/modules/share-doc';
import type { Collection, Filter } from '@affine/env/filter';
import { PublicDocMode } from '@affine/graphql';
import type { DocMeta } from '@blocksuite/affine/store';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useMemo } from 'react';
import { filterPage, filterPageByRules } from './use-collection-manager';
export const useFilteredPageMetas = (
pageMetas: DocMeta[],
options: {
trash?: boolean;
filters?: Filter[];
collection?: Collection;
} = {}
) => {
const shareDocsListService = useService(ShareDocsListService);
const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$);
const getPublicMode = useCallback(
(id: string) => {
const mode = shareDocs?.find(shareDoc => shareDoc.id === id)?.mode;
return mode
? mode === PublicDocMode.Edgeless
? ('edgeless' as const)
: ('page' as const)
: undefined;
},
[shareDocs]
);
useEffect(() => {
// TODO(@eyhn): loading & error UI
shareDocsListService.shareDocs?.revalidate();
}, [shareDocsListService]);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const favoriteItems = useLiveData(favAdapter.favorites$);
const filteredPageMetas = useMemo(
() =>
pageMetas.filter(pageMeta => {
if (options.trash) {
if (!pageMeta.trash) {
return false;
}
} else if (pageMeta.trash) {
return false;
}
const pageData = {
meta: pageMeta,
favorite: favoriteItems.some(fav => fav.id === pageMeta.id),
publicMode: getPublicMode(pageMeta.id),
};
if (
options.filters &&
!filterPageByRules(options.filters, [], pageData)
) {
return false;
}
if (options.collection && !filterPage(options.collection, pageData)) {
return false;
}
return true;
}),
[
pageMetas,
options.trash,
options.filters,
options.collection,
favoriteItems,
getPublicMode,
]
);
return filteredPageMetas;
};
@@ -1,43 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const menuTitleStyle = style({
marginLeft: '12px',
marginTop: '10px',
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
});
export const menuDividerStyle = style({
marginTop: '2px',
marginBottom: '2px',
marginLeft: '12px',
marginRight: '8px',
height: '1px',
background: cssVar('borderColor'),
});
export const viewMenu = style({});
export const viewOption = style({
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 6,
width: 24,
height: 24,
opacity: 0,
':hover': {
backgroundColor: cssVar('hoverColor'),
},
selectors: {
[`${viewMenu}:hover &`]: {
opacity: 1,
},
},
});
export const filterMenuTrigger = style({
padding: '6px 8px',
selectors: {
[`&[data-is-hidden="true"]`]: {
display: 'none',
},
},
});
@@ -1,41 +0,0 @@
import { Button, FlexWrapper, Menu } from '@affine/component';
import type { Filter, PropertiesMeta } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { FilterIcon } from '@blocksuite/icons/rc';
import { CreateFilterMenu } from '../filter/vars';
import * as styles from './collection-list.css';
export const AllPageListOperationsMenu = ({
propertiesMeta,
filterList,
onChangeFilterList,
}: {
propertiesMeta: PropertiesMeta;
filterList: Filter[];
onChangeFilterList: (filterList: Filter[]) => void;
}) => {
const t = useI18n();
return (
<FlexWrapper alignItems="center">
<Menu
items={
<CreateFilterMenu
propertiesMeta={propertiesMeta}
value={filterList}
onChange={onChangeFilterList}
/>
}
>
<Button
className={styles.filterMenuTrigger}
prefix={<FilterIcon />}
data-testid="create-first-filter"
>
{t['com.affine.filter']()}
</Button>
</Menu>
</FlexWrapper>
);
};
@@ -1,10 +1,8 @@
import type { MenuItemProps } from '@affine/component';
import { Menu, MenuItem, usePromptModal } from '@affine/component';
import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/use-delete-collection-info';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { WorkbenchService } from '@affine/core/modules/workbench';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import {
DeleteIcon,
@@ -18,7 +16,10 @@ import { useLiveData, useService, useServices } from '@toeverything/infra';
import type { PropsWithChildren, ReactElement } from 'react';
import { useCallback, useMemo } from 'react';
import { CollectionService } from '../../../modules/collection';
import {
type Collection,
CollectionService,
} from '../../../modules/collection';
import { IsFavoriteIcon } from '../../pure/icons';
import * as styles from './collection-operations.css';
@@ -41,7 +42,6 @@ export const CollectionOperations = ({
WorkbenchService,
WorkspaceDialogService,
});
const deleteInfo = useDeleteCollectionInfo();
const workbench = workbenchService.workbench;
const t = useI18n();
const { openPromptModal } = usePromptModal();
@@ -63,10 +63,9 @@ export const CollectionOperations = ({
variant: 'primary',
},
onConfirm(name) {
service.updateCollection(collection.id, () => ({
...collection,
service.updateCollection(collection.id, {
name,
}));
});
},
});
}, [openRenameModal, openPromptModal, t, service, collection]);
@@ -160,7 +159,7 @@ export const CollectionOperations = ({
icon: <DeleteIcon />,
name: t['Delete'](),
click: () => {
service.deleteCollection(deleteInfo, collection.id);
service.deleteCollection(collection.id);
},
type: 'danger',
},
@@ -175,7 +174,6 @@ export const CollectionOperations = ({
openCollectionNewTab,
openCollectionSplitView,
service,
deleteInfo,
collection.id,
]
);
@@ -1,5 +1,4 @@
export * from './affine-shape';
export * from './collection-list';
export * from './collection-operations';
export * from './create-collection';
export * from './save-as-collection-button';
@@ -1,15 +1,12 @@
import { Button, usePromptModal } from '@affine/component';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { SaveIcon } from '@blocksuite/icons/rc';
import { nanoid } from 'nanoid';
import { useCallback } from 'react';
import { createEmptyCollection } from '../use-collection-manager';
import * as styles from './save-as-collection-button.css';
interface SaveAsCollectionButtonProps {
onConfirm: (collection: Collection) => void;
onConfirm: (collectionName: string) => void;
}
export const SaveAsCollectionButton = ({
@@ -35,7 +32,7 @@ export const SaveAsCollectionButton = ({
variant: 'primary',
},
onConfirm(name) {
onConfirm(createEmptyCollection(nanoid(), { name }));
onConfirm(name);
},
});
}, [openPromptModal, t, onConfirm]);
@@ -1,4 +1,4 @@
import type { Collection } from '@affine/env/filter';
import type { Collection } from '@affine/core/modules/collection';
import { useI18n } from '@affine/i18n';
import { DeleteIcon, FilterIcon } from '@blocksuite/icons/rc';
import type { ReactNode } from 'react';
@@ -1,11 +1,12 @@
import { toast, useConfirmModal } from '@affine/component';
import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper';
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { DocsService } from '@affine/core/modules/doc';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { Trans, useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
import { useService } from '@toeverything/infra';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo, useRef, useState } from 'react';
import { ListFloatingToolbar } from './components/list-floating-toolbar';
@@ -14,7 +15,6 @@ import { TrashOperationCell } from './operation-cell';
import { PageListItemRenderer } from './page-group';
import { ListTableHeader } from './page-header';
import type { ItemListHandle, ListItem } from './types';
import { useFilteredPageMetas } from './use-filtered-page-metas';
import { VirtualizedList } from './virtualized-list';
export const VirtualizedTrashList = ({
@@ -25,13 +25,17 @@ export const VirtualizedTrashList = ({
disableMultiRestore?: boolean;
}) => {
const currentWorkspace = useService(WorkspaceService).workspace;
const docsService = useService(DocsService);
const guardService = useService(GuardService);
const docCollection = currentWorkspace.docCollection;
const { restoreFromTrash, permanentlyDeletePage } = useBlockSuiteMetaHelper();
const allTrashPageIds = useLiveData(
LiveData.from(docsService.allTrashDocIds$(), [])
);
const pageMetas = useBlockSuiteDocMeta(docCollection);
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
trash: true,
});
const filteredPageMetas = useMemo(() => {
return pageMetas.filter(page => allTrashPageIds.includes(page.id));
}, [pageMetas, allTrashPageIds]);
const listRef = useRef<ItemListHandle>(null);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
@@ -0,0 +1,43 @@
import { Menu, MenuItem } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules';
export const FavoriteFilterValue = ({
filter,
onChange,
}: {
filter: FilterParams;
onChange: (filter: FilterParams) => void;
}) => {
return (
<Menu
items={
<>
<MenuItem
onClick={() => {
onChange({
...filter,
value: 'true',
});
}}
selected={filter.value === 'true'}
>
{'True'}
</MenuItem>
<MenuItem
onClick={() => {
onChange({
...filter,
value: 'false',
});
}}
selected={filter.value !== 'true'}
>
{'False'}
</MenuItem>
</>
}
>
<span>{filter.value === 'true' ? 'True' : 'False'}</span>
</Menu>
);
};
@@ -1,7 +1,9 @@
import type { FilterParams } from '@affine/core/modules/collection-rules';
import type { I18nString } from '@affine/i18n';
import { TagIcon } from '@blocksuite/icons/rc';
import { FavoriteIcon, ShareIcon, TagIcon } from '@blocksuite/icons/rc';
import { FavoriteFilterValue } from './favorite';
import { SharedFilterValue } from './shared';
import { TagsFilterValue } from './tags';
export const SystemPropertyTypes = {
@@ -15,6 +17,22 @@ export const SystemPropertyTypes = {
},
filterValue: TagsFilterValue,
},
favorite: {
icon: FavoriteIcon,
name: 'Favorited',
filterMethod: {
is: 'com.affine.filter.is',
},
filterValue: FavoriteFilterValue,
},
shared: {
icon: ShareIcon,
name: 'Shared',
filterMethod: {
is: 'com.affine.filter.is',
},
filterValue: SharedFilterValue,
},
} satisfies {
[type: string]: {
icon: React.FC<React.SVGProps<SVGSVGElement>>;
@@ -0,0 +1,43 @@
import { Menu, MenuItem } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules';
export const SharedFilterValue = ({
filter,
onChange,
}: {
filter: FilterParams;
onChange: (filter: FilterParams) => void;
}) => {
return (
<Menu
items={
<>
<MenuItem
onClick={() => {
onChange({
...filter,
value: 'true',
});
}}
selected={filter.value === 'true'}
>
{'True'}
</MenuItem>
<MenuItem
onClick={() => {
onChange({
...filter,
value: 'false',
});
}}
selected={filter.value !== 'true'}
>
{'False'}
</MenuItem>
</>
}
>
<span>{filter.value === 'true' ? 'True' : 'False'}</span>
</Menu>
);
};
@@ -102,7 +102,10 @@ export const WorkspacePropertyTypes = {
renameable: false,
description: 'com.affine.page-properties.property.tags.tooltips',
filterMethod: {
include: 'com.affine.filter.contains all',
'include-all': 'com.affine.filter.contains all',
'include-any-of': 'com.affine.filter.contains one of',
'not-include-all': 'com.affine.filter.does not contains all',
'not-include-any-of': 'com.affine.filter.does not contains one of',
'is-not-empty': 'com.affine.filter.is not empty',
'is-empty': 'com.affine.filter.is empty',
},