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,19 +1,16 @@
import { MenuItem, notify } from '@affine/component';
import { filterPage } from '@affine/core/components/page-list';
import type { NodeOperation } from '@affine/core/desktop/components/navigation-panel';
import { CollectionService } from '@affine/core/modules/collection';
import {
type Collection,
CollectionService,
} from '@affine/core/modules/collection';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { DocsService } from '@affine/core/modules/doc';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { ShareDocsListService } from '@affine/core/modules/share-doc';
import type { Collection } from '@affine/env/filter';
import { PublicDocMode } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import type { DocMeta } from '@blocksuite/affine/store';
import { FilterMinusIcon, ViewLayersIcon } from '@blocksuite/icons/rc';
import { LiveData, useLiveData, useServices } from '@toeverything/infra';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
@@ -46,6 +43,7 @@ export const NavigationPanelCollectionNode = ({
const [collapsed, setCollapsed] = useState(true);
const collection = useLiveData(collectionService.collection$(collectionId));
const name = useLiveData(collection?.name$);
const handleOpenCollapsed = useCallback(() => {
setCollapsed(false);
@@ -86,7 +84,7 @@ export const NavigationPanelCollectionNode = ({
return (
<NavigationPanelTreeNode
icon={CollectionIcon}
name={collection.name || t['Untitled']()}
name={name || t['Untitled']()}
collapsed={collapsed}
setCollapsed={setCollapsed}
to={`/collection/${collection.id}`}
@@ -110,14 +108,7 @@ const NavigationPanelCollectionNodeChildren = ({
onAddDoc?: () => void;
}) => {
const t = useI18n();
const {
docsService,
compatibleFavoriteItemsAdapter,
shareDocsListService,
collectionService,
} = useServices({
DocsService,
CompatibleFavoriteItemsAdapter,
const { shareDocsListService, collectionService } = useServices({
ShareDocsListService,
CollectionService,
});
@@ -127,28 +118,12 @@ const NavigationPanelCollectionNodeChildren = ({
shareDocsListService.shareDocs?.revalidate();
}, [shareDocsListService]);
const docMetas = useLiveData(
useMemo(
() =>
LiveData.computed(get => {
return get(docsService.list.docs$).map(
doc => get(doc.meta$) as DocMeta
);
}),
[docsService]
)
);
const favourites = useLiveData(compatibleFavoriteItemsAdapter.favorites$);
const allowList = useMemo(
() => new Set(collection.allowList),
[collection.allowList]
);
const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$);
const allowList = useLiveData(collection.allowList$);
const handleRemoveFromAllowList = useCallback(
(id: string) => {
track.$.navigationPanel.collections.removeOrganizeItem({ type: 'doc' });
collectionService.deletePageFromCollection(collection.id, id);
collectionService.removeDocFromCollection(collection.id, id);
notify.success({
message: t['com.affine.collection.removePage.success'](),
});
@@ -156,28 +131,22 @@ const NavigationPanelCollectionNodeChildren = ({
[collection.id, collectionService, t]
);
const filtered = docMetas.filter(meta => {
if (meta.trash) return false;
const publicMode = shareDocs?.find(d => d.id === meta.id)?.mode;
const pageData = {
meta: meta as DocMeta,
publicMode:
publicMode === PublicDocMode.Edgeless
? ('edgeless' as const)
: publicMode === PublicDocMode.Page
? ('page' as const)
: undefined,
favorite: favourites.some(fav => fav.id === meta.id),
};
return filterPage(collection, pageData);
});
const [filteredDocIds, setFilteredDocIds] = useState<string[]>([]);
useEffect(() => {
const subscription = collection.watch().subscribe(docIds => {
setFilteredDocIds(docIds);
});
return () => subscription.unsubscribe();
}, [collection]);
return (
<>
{filtered.map(doc => (
{filteredDocIds.map(docId => (
<NavigationPanelDocNode
key={doc.id}
docId={doc.id}
key={docId}
docId={docId}
operations={
allowList
? [
@@ -186,7 +155,7 @@ const NavigationPanelCollectionNodeChildren = ({
view: (
<MenuItem
prefixIcon={<FilterMinusIcon />}
onClick={() => handleRemoveFromAllowList(doc.id)}
onClick={() => handleRemoveFromAllowList(docId)}
>
{t['Remove special filter']()}
</MenuItem>
@@ -6,7 +6,6 @@ import {
useConfirmModal,
} from '@affine/component';
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils';
import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/use-delete-collection-info';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import type { NodeOperation } from '@affine/core/desktop/components/navigation-panel';
import { CollectionService } from '@affine/core/modules/collection';
@@ -44,7 +43,6 @@ export const useNavigationPanelCollectionNodeOperations = (
CollectionService,
CompatibleFavoriteItemsAdapter,
});
const deleteInfo = useDeleteCollectionInfo();
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
@@ -61,7 +59,7 @@ export const useNavigationPanelCollectionNodeOperations = (
const createAndAddDocument = useCallback(() => {
const newDoc = createPage();
collectionService.addPageToCollection(collectionId, newDoc.id);
collectionService.addDocToCollection(collectionId, newDoc.id);
track.$.navigationPanel.collections.createDoc();
track.$.navigationPanel.collections.addDocToCollection({
control: 'button',
@@ -102,11 +100,11 @@ export const useNavigationPanelCollectionNodeOperations = (
}, [collectionId, workbenchService.workbench]);
const handleDeleteCollection = useCallback(() => {
collectionService.deleteCollection(deleteInfo, collectionId);
collectionService.deleteCollection(collectionId);
track.$.navigationPanel.organize.deleteOrganizeItem({
type: 'collection',
});
}, [collectionId, collectionService, deleteInfo]);
}, [collectionId, collectionService]);
const handleShowEdit = useCallback(() => {
onOpenEdit();
@@ -115,11 +113,10 @@ export const useNavigationPanelCollectionNodeOperations = (
const handleRename = useCallback(
(name: string) => {
const collection = collectionService.collection$(collectionId).value;
if (collection && collection.name !== name) {
collectionService.updateCollection(collectionId, () => ({
...collection,
if (collection && collection.name$.value !== name) {
collectionService.updateCollection(collectionId, {
name,
}));
});
track.$.navigationPanel.organize.renameOrganizeItem({
type: 'collection',
@@ -1,5 +1,4 @@
import { usePromptModal } from '@affine/component';
import { createEmptyCollection } from '@affine/core/components/page-list/use-collection-manager';
import { NavigationPanelTreeRoot } from '@affine/core/desktop/components/navigation-panel';
import { CollectionService } from '@affine/core/modules/collection';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
@@ -8,7 +7,6 @@ import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { AddCollectionIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
@@ -25,7 +23,7 @@ export const NavigationPanelCollections = () => {
NavigationPanelService,
});
const navigationPanelSection = navigationPanelService.sections.collections;
const collections = useLiveData(collectionService.collections$);
const collectionMetas = useLiveData(collectionService.collectionMetas$);
const { openPromptModal } = usePromptModal();
const handleCreateCollection = useCallback(() => {
@@ -46,8 +44,7 @@ export const NavigationPanelCollections = () => {
variant: 'primary',
},
onConfirm(name) {
const id = nanoid();
collectionService.addCollection(createEmptyCollection(id, { name }));
const id = collectionService.createCollection({ name });
track.$.navigationPanel.organize.createOrganizeItem({
type: 'collection',
});
@@ -70,7 +67,7 @@ export const NavigationPanelCollections = () => {
title={t['com.affine.rootAppSidebar.collections']()}
>
<NavigationPanelTreeRoot>
{collections.map(collection => (
{collectionMetas.map(collection => (
<NavigationPanelCollectionNode
key={collection.id}
collectionId={collection.id}
@@ -19,7 +19,7 @@ export const CollectionSelectorDialog = ({
}: DialogComponentProps<WORKSPACE_DIALOG_SCHEMA['collection-selector']>) => {
const t = useI18n();
const collectionService = useService(CollectionService);
const collections = useLiveData(collectionService.collections$);
const collections = useLiveData(collectionService.collectionMetas$);
const list = useMemo(() => {
return collections.map(collection => ({
@@ -1,29 +1,26 @@
import { notify, useThemeColorV2 } from '@affine/component';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { useThemeColorV2 } from '@affine/component';
import { CollectionService } from '@affine/core/modules/collection';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback, useEffect } from 'react';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { CollectionDetail } from '../../../views';
export const Component = () => {
useThemeColorV2('layer/background/mobile/primary');
const { collectionService, globalContextService, workspaceService } =
useServices({
WorkspaceService,
CollectionService,
GlobalContextService,
});
const { collectionService, globalContextService } = useServices({
CollectionService,
GlobalContextService,
});
const globalContext = globalContextService.globalContext;
const collections = useLiveData(collectionService.collections$);
const params = useParams();
const navigate = useNavigateHelper();
const workspace = workspaceService.workspace;
const collection = collections.find(v => v.id === params.collectionId);
const collection = useLiveData(
params.collectionId
? collectionService.collection$(params.collectionId)
: null
);
useEffect(() => {
if (collection) {
@@ -38,30 +35,9 @@ export const Component = () => {
return;
}, [collection, globalContext]);
const notifyCollectionDeleted = useCallback(() => {
navigate.jumpToPage(workspace.id, 'home');
const collection = collectionService.collectionsTrash$.value.find(
v => v.collection.id === params.collectionId
);
let text = 'Collection does not exist';
if (collection) {
if (collection.userId) {
text = `${collection.collection.name} has been deleted by ${collection.userName}`;
} else {
text = `${collection.collection.name} has been deleted`;
}
}
return notify.error({ title: text });
}, [collectionService, navigate, params.collectionId, workspace.id]);
useEffect(() => {
if (!collection) {
notifyCollectionDeleted();
}
}, [collection, notifyCollectionDeleted]);
if (!collection) {
return null;
// TODO: implement 404 page
return <div></div>;
}
return <CollectionDetail collection={collection} />;
@@ -41,7 +41,7 @@ const RecentList = () => {
TagService,
});
const recentDocsList = useLiveData(mobileSearchService.recentDocs.items$);
const collections = useLiveData(collectionService.collections$);
const collectionMetas = useLiveData(collectionService.collectionMetas$);
const tags = useLiveData(
LiveData.computed(get =>
get(tagService.tagList.tags$).map(tag => ({
@@ -63,7 +63,7 @@ const RecentList = () => {
);
const collectionList = useMemo(() => {
return collections.slice(0, 3).map(item => {
return collectionMetas.slice(0, 3).map(item => {
return {
id: 'collection:' + item.id,
source: 'collection',
@@ -72,7 +72,7 @@ const RecentList = () => {
payload: { collectionId: item.id },
} satisfies QuickSearchItem<'collection', { collectionId: string }>;
});
}, [collections]);
}, [collectionMetas]);
const tagList = useMemo(() => {
return tags
@@ -1,19 +1,20 @@
import { EmptyCollectionDetail } from '@affine/core/components/affine/empty';
import { isEmptyCollection } from '@affine/core/desktop/pages/workspace/collection';
import { AppTabs, PageHeader } from '@affine/core/mobile/components';
import { PageHeader } from '@affine/core/mobile/components';
import { Page } from '@affine/core/mobile/components/page';
import type { Collection } from '@affine/env/filter';
import type { Collection } from '@affine/core/modules/collection';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import { useLiveData } from '@toeverything/infra';
import { AllDocList } from '../doc/list';
import * as styles from './detail.css';
export const DetailHeader = ({ collection }: { collection: Collection }) => {
const name = useLiveData(collection.name$);
return (
<PageHeader className={styles.header} back>
<div className={styles.headerContent}>
<ViewLayersIcon className={styles.headerIcon} />
{collection.name}
{name}
</div>
</PageHeader>
);
@@ -24,13 +25,14 @@ export const CollectionDetail = ({
}: {
collection: Collection;
}) => {
if (isEmptyCollection(collection)) {
const info = useLiveData(collection.info$);
if (info.allowList.length === 0 && info.rules.filters.length === 0) {
return (
<>
<DetailHeader collection={collection} />
<EmptyCollectionDetail collection={collection} absoluteCenter />
<AppTabs />
</>
<Page header={<DetailHeader collection={collection} />}>
<div style={{ flexGrow: 1 }}>
<EmptyCollectionDetail collection={collection} absoluteCenter />
</div>
</Page>
);
}
@@ -1,4 +1,4 @@
import type { Collection } from '@affine/env/filter';
import type { Collection } from '@affine/core/modules/collection';
import { DetailHeader } from './detail';
@@ -1,6 +1,6 @@
import { IconButton } from '@affine/component';
import type { CollectionMeta } from '@affine/core/components/page-list';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import type { CollectionMeta } from '@affine/core/modules/collection';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
@@ -1,24 +1,13 @@
import { EmptyCollections } from '@affine/core/components/affine/empty';
import type { CollectionMeta } from '@affine/core/components/page-list';
import { CollectionService } from '@affine/core/modules/collection';
import { useLiveData, useService } from '@toeverything/infra';
import { useMemo } from 'react';
import { CollectionListItem } from './item';
import { list } from './styles.css';
export const CollectionList = () => {
const collectionService = useService(CollectionService);
const collections = useLiveData(collectionService.collections$);
const collectionMetas = useMemo(
() =>
collections.map(
collection =>
({ ...collection, title: collection.name }) satisfies CollectionMeta
),
[collections]
);
const collectionMetas = useLiveData(collectionService.collectionMetas$);
if (!collectionMetas.length) {
return <EmptyCollections absoluteCenter />;
@@ -3,16 +3,16 @@ import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-su
import {
type ItemGroupProps,
useAllDocDisplayProperties,
useFilteredPageMetas,
} from '@affine/core/components/page-list';
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 type { DocMeta } from '@blocksuite/affine/store';
import { ToggleDownIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
import { useLiveData, useService } from '@toeverything/infra';
import { useMemo } from 'react';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { useEffect, useMemo, useState } from 'react';
import * as styles from './list.css';
import { MasonryDocs } from './masonry';
@@ -41,42 +41,53 @@ export const DocGroup = ({ group }: { group: ItemGroupProps<DocMeta> }) => {
export interface AllDocListProps {
collection?: Collection;
tag?: Tag;
filters?: Filter[];
trash?: boolean;
}
export const AllDocList = ({
trash,
collection,
tag,
filters = [],
}: AllDocListProps) => {
export const AllDocList = ({ trash, collection, tag }: AllDocListProps) => {
const [properties] = useAllDocDisplayProperties();
const workspace = useService(WorkspaceService).workspace;
const allPageMetas = useBlockSuiteDocMeta(workspace.docCollection);
const docsService = useService(DocsService);
const allTrashPageIds = useLiveData(
LiveData.from(docsService.allTrashDocIds$(), [])
);
const tagPageIds = useLiveData(tag?.pageIds$);
const filteredPageMetas = useFilteredPageMetas(allPageMetas, {
trash,
filters,
collection,
});
const [filteredPageIds, setFilteredPageIds] = useState<string[]>([]);
useEffect(() => {
const subscription = collection?.watch().subscribe(docIds => {
setFilteredPageIds(docIds);
});
return () => subscription?.unsubscribe();
}, [collection]);
const finalPageMetas = useMemo(() => {
const collectionFilteredPageMetas = collection
? allPageMetas.filter(page => filteredPageIds.includes(page.id))
: allPageMetas;
const filteredPageMetas = collectionFilteredPageMetas.filter(
page => allTrashPageIds.includes(page.id) === !!trash
);
if (tag) {
const pageIdsSet = new Set(tagPageIds);
return filteredPageMetas.filter(page => pageIdsSet.has(page.id));
}
return filteredPageMetas;
}, [filteredPageMetas, tag, tagPageIds]);
// const groupDefs =
// usePageItemGroupDefinitions() as ItemGroupDefinition<DocMeta>[];
// const groups = useMemo(() => {
// return itemsToItemGroups(finalPageMetas ?? [], groupDefs);
// }, [finalPageMetas, groupDefs]);
}, [
allPageMetas,
allTrashPageIds,
collection,
filteredPageIds,
tag,
tagPageIds,
trash,
]);
if (!finalPageMetas.length) {
return (
@@ -87,14 +98,6 @@ export const AllDocList = ({
);
}
// return (
// <div className={styles.groups}>
// {groups.map(group => (
// <DocGroup key={group.id} group={group} />
// ))}
// </div>
// );
return (
<MasonryDocs
items={finalPageMetas}