diff --git a/packages/common/env/src/filter.ts b/packages/common/env/src/filter.ts index c92ff6cb9d..a8cca4f1f9 100644 --- a/packages/common/env/src/filter.ts +++ b/packages/common/env/src/filter.ts @@ -55,23 +55,6 @@ export const collectionSchema = z.object({ createDate: z.union([z.date(), z.number()]).optional(), updateDate: z.union([z.date(), z.number()]).optional(), }); -export const deletedCollectionSchema = z.object({ - userId: z.string().optional(), - userName: z.string(), - collection: collectionSchema, -}); -export type DeprecatedCollection = { - id: string; - name: string; - workspaceId: string; - filterList: z.infer[]; - allowList?: string[]; -}; export type Collection = z.input; -export type DeleteCollectionInfo = { - userId: string; - userName: string; -} | null; -export type DeletedCollection = z.input; export type PropertiesMeta = DocsPropertiesMeta; diff --git a/packages/common/infra/src/utils/yjs-observable.ts b/packages/common/infra/src/utils/yjs-observable.ts index 3863eee36e..f446a89e43 100644 --- a/packages/common/infra/src/utils/yjs-observable.ts +++ b/packages/common/infra/src/utils/yjs-observable.ts @@ -182,7 +182,7 @@ export function yjsObservePath(yjs?: any, path?: string) { * observable will automatically update when yjs data changed. * * @example - * yjsObserveDeep(yjs) -> emit when any of children changed + * yjsObserve(yjs) -> emit when yjs type changed */ export function yjsObserve(yjs?: any) { return new Observable(subscriber => { diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/add-popover.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/add-popover.ts index c7a82ea08f..cb99bda4c6 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/add-popover.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/add-popover.ts @@ -1,8 +1,6 @@ import { toast } from '@affine/component'; -import type { - CollectionMeta, - TagMeta, -} from '@affine/core/components/page-list'; +import type { TagMeta } from '@affine/core/components/page-list'; +import type { CollectionMeta } from '@affine/core/modules/collection'; import track from '@affine/track'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; import { scrollbarStyle } from '@blocksuite/affine/shared/styles'; diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/chat-panel-chips.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/chat-panel-chips.ts index 58e1894f52..fa3a8d18df 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/chat-panel-chips.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/chat-panel-chips.ts @@ -1,5 +1,4 @@ import type { TagMeta } from '@affine/core/components/page-list'; -import type { Collection } from '@affine/env/filter'; import { createLitPortal } from '@blocksuite/affine/components/portal'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; @@ -128,7 +127,7 @@ export class ChatPanelChips extends SignalWatcher( private _tags: Signal = signal([]); - private _collections: Signal = signal([]); + private _collections: Signal<{ id: string; name: string }[]> = signal([]); private _cleanup: (() => void) | null = null; diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/collection-chip.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/collection-chip.ts index 88af22a66b..ba2150eace 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/collection-chip.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/collection-chip.ts @@ -1,4 +1,3 @@ -import type { Collection } from '@affine/env/filter'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; import { ShadowlessElement } from '@blocksuite/affine/std'; import { CollectionsIcon } from '@blocksuite/icons/lit'; @@ -18,7 +17,7 @@ export class ChatPanelCollectionChip extends SignalWatcher( accessor removeChip!: (chip: CollectionChip) => void; @property({ attribute: false }) - accessor collection!: Collection; + accessor collection!: { id: string; name: string }; override render() { const { state } = this.chip; diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/type.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/type.ts index d75bcd0cab..567b240bcb 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/type.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/type.ts @@ -4,7 +4,6 @@ import type { SearchDocMenuAction, SearchTagMenuAction, } from '@affine/core/modules/search-menu/services'; -import type { Collection } from '@affine/env/filter'; import type { DocMeta, Store } from '@blocksuite/affine/store'; import type { LinkedMenuGroup } from '@blocksuite/affine/widgets/linked-doc'; import type { Signal } from '@preact/signals-core'; @@ -71,7 +70,7 @@ export interface DocDisplayConfig { getTagTitle: (tagId: string) => string; getTagPageIds: (tagId: string) => string[]; getCollections: () => { - signal: Signal; + signal: Signal<{ id: string; name: string }[]>; cleanup: () => void; }; getCollectionPageIds: (collectionId: string) => string[]; diff --git a/packages/frontend/core/src/components/affine/empty/collection-detail.tsx b/packages/frontend/core/src/components/affine/empty/collection-detail.tsx index 041d8e0544..4a2825d53e 100644 --- a/packages/frontend/core/src/components/affine/empty/collection-detail.tsx +++ b/packages/frontend/core/src/components/affine/empty/collection-detail.tsx @@ -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'; diff --git a/packages/frontend/core/src/components/affine/empty/collections.tsx b/packages/frontend/core/src/components/affine/empty/collections.tsx index efd5c8927f..f41231d35e 100644 --- a/packages/frontend/core/src/components/affine/empty/collections.tsx +++ b/packages/frontend/core/src/components/affine/empty/collections.tsx @@ -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); }, }); diff --git a/packages/frontend/core/src/components/filter/add-filter.tsx b/packages/frontend/core/src/components/filter/add-filter.tsx index d9438dd93a..47e2820960 100644 --- a/packages/frontend/core/src/components/filter/add-filter.tsx +++ b/packages/frontend/core/src/components/filter/add-filter.tsx @@ -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']()} + } + key={'favorite'} + onClick={() => { + onAdd({ + type: 'system', + key: 'favorite', + method: 'is', + value: 'true', + }); + }} + > + {t['Favorited']()} + + } + key={'shared'} + onClick={() => { + onAdd({ + type: 'system', + key: 'shared', + method: 'is', + value: 'true', + }); + }} + > + + {t['com.affine.filter.is-public']()} + + {workspaceProperties.map(property => { const type = WorkspacePropertyTypes[property.type]; const defaultFilter = type?.defaultFilter; diff --git a/packages/frontend/core/src/components/hooks/affine/use-ai-chat-config.ts b/packages/frontend/core/src/components/hooks/affine/use-ai-chat-config.ts index 556cc55c90..898dea5972 100644 --- a/packages/frontend/core/src/components/hooks/affine/use-ai-chat-config.ts +++ b/packages/frontend/core/src/components/hooks/affine/use-ai-chat-config.ts @@ -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 ?? []; }, }; diff --git a/packages/frontend/core/src/components/hooks/affine/use-delete-collection-info.ts b/packages/frontend/core/src/components/hooks/affine/use-delete-collection-info.ts deleted file mode 100644 index dc8fea8e1c..0000000000 --- a/packages/frontend/core/src/components/hooks/affine/use-delete-collection-info.ts +++ /dev/null @@ -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( - () => (user ? { userName: user.label, userId: user.id } : null), - [user] - ); -}; diff --git a/packages/frontend/core/src/components/page-list/__tests__/filter.spec.tsx b/packages/frontend/core/src/components/page-list/__tests__/filter.spec.tsx deleted file mode 100644 index 6858378e9c..0000000000 --- a/packages/frontend/core/src/components/page-list/__tests__/filter.spec.tsx +++ /dev/null @@ -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 => { - return { - Created: 0, - Updated: 0, - 'Is Favourited': false, - 'Is Public': false, - Tags: [], - ...vars, - }; -}; -const mockPropertiesMeta = (meta: Partial): 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 ( - - - - ); - }; - const result = render(); - 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 ( - - ); - }; - - 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(); - 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(); - const dom = await result.findByTestId('variable-name'); - dom.click(); - await result.findByTestId('variable-name'); - result.unmount(); - }); -}); diff --git a/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx b/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx index 54709ebc8a..18dff8bdbd 100644 --- a/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx +++ b/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx @@ -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 ( - - ); - }, - [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 ( + + ); + }, []); const collectionHeaderRenderer = useCallback(() => { return ; @@ -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 ( <> diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx b/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx index 81a2989ef2..a7dbf3e069 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx +++ b/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx @@ -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 = ({
-
{collection.name}
+
{name}
@@ -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] ); diff --git a/packages/frontend/core/src/components/page-list/docs/select-page.tsx b/packages/frontend/core/src/components/page-list/docs/select-page.tsx index d30772be08..e67246ae17 100644 --- a/packages/frontend/core/src/components/page-list/docs/select-page.tsx +++ b/packages/frontend/core/src/components/page-list/docs/select-page.tsx @@ -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([]); + + const [filteredDocIds, setFilteredDocIds] = useState([]); + 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']()}
)} - {!showFilter && filters.length === 0 ? ( + {filters.length === 0 ? ( setFilters([...filters, params])} /> } > - } onClick={clickFilter} /> + } /> - ) : ( - } onClick={clickFilter} /> - )} + ) : null} - {showFilter ? ( + {filters.length !== 0 ? (
- +
) : null} {searchedList.length ? ( diff --git a/packages/frontend/core/src/components/page-list/docs/use-filter.tsx b/packages/frontend/core/src/components/page-list/docs/use-filter.tsx deleted file mode 100644 index a0846a20fa..0000000000 --- a/packages/frontend/core/src/components/page-list/docs/use-filter.tsx +++ /dev/null @@ -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([]); - 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), - }; -}; diff --git a/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx b/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx index cb6e7f28c5..fe2a7fed9d 100644 --- a/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx +++ b/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx @@ -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([]); + 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(() => { diff --git a/packages/frontend/core/src/components/page-list/filter/condition.tsx b/packages/frontend/core/src/components/page-list/filter/condition.tsx deleted file mode 100644 index f4c510b122..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/condition.tsx +++ /dev/null @@ -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 ( -
- - } - > -
- -
- {variableDefineMap[ast.left.name].icon} -
-
- -
-
- - } - > -
- -
-
- {args} -
- ); - }); - 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 ( -
- {list.map(v => ( - { - onChange({ - ...value, - funcName: v.name, - args: v.defaultArgs().map(v => ({ type: 'literal', value: v })), - }); - }} - key={v.name} - > - - - ))} -
- ); -}; - -export const Arg = ({ - type, - value, - onChange, -}: { - type: TType; - value: Literal; - onChange: (lit: Literal) => void; -}) => { - const data = useMemo(() => literalMatcher.match(type), [type]); - if (!data) { - return null; - } - return ( -
- {data.render({ - type, - value: value?.value, - onChange: v => onChange({ type: 'literal', value: v }), - })} -
- ); -}; -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 ( - { - const args = type.args.map((_, index) => - i === index ? value : filter.args[index] - ); - onChange({ - ...filter, - args, - }); - }} - > - ); - }); -}; diff --git a/packages/frontend/core/src/components/page-list/filter/date-select.css.ts b/packages/frontend/core/src/components/page-list/filter/date-select.css.ts deleted file mode 100644 index 279eacda34..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/date-select.css.ts +++ /dev/null @@ -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', - }, -}); diff --git a/packages/frontend/core/src/components/page-list/filter/date-select.tsx b/packages/frontend/core/src/components/page-list/filter/date-select.tsx deleted file mode 100644 index 183103a8a7..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/date-select.tsx +++ /dev/null @@ -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 ( - - } - > - - - ); -}; diff --git a/packages/frontend/core/src/components/page-list/filter/eval.ts b/packages/frontend/core/src/components/page-list/filter/eval.ts deleted file mode 100644 index f07cf7ec5e..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/eval.ts +++ /dev/null @@ -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)); -}; diff --git a/packages/frontend/core/src/components/page-list/filter/filter-list.tsx b/packages/frontend/core/src/components/page-list/filter/filter-list.tsx deleted file mode 100644 index 328dec62d1..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/filter-list.tsx +++ /dev/null @@ -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 ( -
- {value.map((filter, i) => { - return ( -
- { - onChange( - value.map((old, oldIndex) => (oldIndex === i ? filter : old)) - ); - }} - /> -
{ - onChange(value.filter((_, index) => i !== index)); - }} - > - -
-
- ); - })} - - } - > - {value.length === 0 ? ( - - ) : ( - - - - )} - -
- ); -}; diff --git a/packages/frontend/core/src/components/page-list/filter/filter-tag-translation.tsx b/packages/frontend/core/src/components/page-list/filter/filter-tag-translation.tsx deleted file mode 100644 index 65e3789140..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/filter-tag-translation.tsx +++ /dev/null @@ -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 ( - - - {tag} - - - ); -}; diff --git a/packages/frontend/core/src/components/page-list/filter/index.css.ts b/packages/frontend/core/src/components/page-list/filter/index.css.ts deleted file mode 100644 index e066412ac2..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/index.css.ts +++ /dev/null @@ -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', -}); diff --git a/packages/frontend/core/src/components/page-list/filter/index.ts b/packages/frontend/core/src/components/page-list/filter/index.ts deleted file mode 100644 index 85fcf7f55b..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './eval'; -export * from './filter-list'; -export * from './utils'; diff --git a/packages/frontend/core/src/components/page-list/filter/literal-matcher.tsx b/packages/frontend/core/src/components/page-list/filter/literal-matcher.tsx deleted file mode 100644 index 14bee1f66d..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/literal-matcher.tsx +++ /dev/null @@ -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 }) => ( - - (i ? onChange(parseInt(i)) : onChange(0))} - /> - {[1, 2, 3, 7, 14, 30].map(i => ( - { - // Handle the menu item click and update the value accordingly - onChange(i); - }} - > - {i} {i > 1 ? 'days' : 'day'} - - ))} - - } - > -
- {value.toString()} {(value as number) > 1 ? 'days' : 'day'} -
-
- ), -}); - -literalMatcher.register(tBoolean.create(), { - render: ({ value, onChange }) => ( -
{ - onChange(!value); - }} - > - -
- ), -}); -literalMatcher.register(tDate.create(), { - render: ({ value, 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 ( - onChange(value)} - options={getTagsOfArrayTag(type).map((v: any) => ({ - label: v.name, - value: v.id, - }))} - > - ); - }, -}); diff --git a/packages/frontend/core/src/components/page-list/filter/logical/custom-type.ts b/packages/frontend/core/src/components/page-list/filter/logical/custom-type.ts deleted file mode 100644 index 97bdad377b..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/logical/custom-type.ts +++ /dev/null @@ -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') -); diff --git a/packages/frontend/core/src/components/page-list/filter/logical/matcher.ts b/packages/frontend/core/src/components/page-list/filter/logical/matcher.ts deleted file mode 100644 index fe3574378e..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/logical/matcher.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { TType } from './typesystem'; -import { typesystem } from './typesystem'; - -type MatcherData = { type: Type; data: Data }; - -export class Matcher { - private readonly list: MatcherData[] = []; - - 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[] { - const match = this._match ?? typesystem.isSubtype.bind(typesystem); - const result: MatcherData[] = []; - for (const t of this.list) { - if (match(t.type, type)) { - result.push(t); - } - } - return result; - } - - allMatchedData(type: TType): Data[] { - 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) => boolean - ): MatcherData | undefined { - return this.list.find(f); - } - - all(): MatcherData[] { - return this.list; - } -} diff --git a/packages/frontend/core/src/components/page-list/filter/logical/typesystem.ts b/packages/frontend/core/src/components/page-list/filter/logical/typesystem.ts deleted file mode 100644 index c4e424c594..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/logical/typesystem.ts +++ /dev/null @@ -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 { - type: 'array'; - ele: Ele; - title: 'array'; -} - -export const tArray = (ele: T): TArray => { - return { - type: 'array', - title: 'array', - ele, - }; -}; -export type TTypeVar = { - type: 'typeVar'; - title: 'typeVar'; - name: string; - bound: TType; -}; -export const tTypeVar = (name: string, bound: TType): TTypeVar => { - return { - type: 'typeVar', - title: 'typeVar', - name, - bound, - }; -}; -export type TTypeRef = { - type: 'typeRef'; - title: 'typeRef'; - name: string; -}; -export const tTypeRef = (name: string): TTypeRef => { - return { - type: 'typeRef', - title: 'typeRef', - name, - }; -}; - -export type TFunction = { - type: 'function'; - title: 'function'; - typeVars: TTypeVar[]; - args: TType[]; - rt: TType; -}; - -export const tFunction = (fn: { - typeVars?: TTypeVar[]; - args: TType[]; - rt: TType; -}): TFunction => { - return { - type: 'function', - title: 'function', - typeVars: fn.typeVars ?? [], - args: fn.args, - rt: fn.rt, - }; -}; - -export type TType = TDataType | TArray | TUnion | TTypeRef | TFunction; - -export type DataTypeShape = Record; -export type TDataType> = { - type: 'data'; - name: string; - data?: Data; -}; -export type ValueOfData = - T extends DataDefine ? R : never; - -export class DataDefine> { - constructor( - private readonly config: DataDefineConfig, - private readonly dataMap: Map - ) {} - - create(data?: Data): TDataType { - return { - type: 'data', - name: this.config.name, - data, - }; - } - - is(data: TType): data is TDataType { - if (data.type !== 'data') { - return false; - } - return data.name === this.config.name; - } - - private isByName(name: string): boolean { - return name === this.config.name; - } - - isSubOf(superType: TDataType): boolean { - if (this.is(superType)) { - return true; - } - return this.config.supers.some(sup => sup.isSubOf(superType)); - } - - private isSubOfByName(superType: string): boolean { - if (this.isByName(superType)) { - return true; - } - return this.config.supers.some(sup => sup.isSubOfByName(superType)); - } - - isSuperOf(subType: TDataType): boolean { - const dataDefine = this.dataMap.get(subType.name); - if (!dataDefine) { - throw new Error('bug'); - } - return dataDefine.isSubOfByName(this.config.name); - } -} - -// type DataTypeVar = {}; - -// TODO support generic data type -// eslint-disable-next-line @typescript-eslint/no-unused-vars -interface DataDefineConfig { - name: string; - supers: DataDefine[]; - _phantom?: T; -} - -interface DataHelper { - create>(name: string): DataDefineConfig; - - extends( - dataDefine: DataDefine - ): DataHelper; -} - -const createDataHelper = >( - ...supers: DataDefine[] -): DataHelper => { - return { - create(name: string) { - return { - name, - supers, - }; - }, - extends(dataDefine) { - return createDataHelper(...supers, dataDefine); - }, - }; -}; -export const DataHelper = createDataHelper(); - -export class Typesystem { - dataMap = new Map>(); - - defineData( - config: DataDefineConfig - ): DataDefine { - const result = new DataDefine(config, this.dataMap); - this.dataMap.set(config.name, result); - return result; - } - - isDataType(t: TType): t is TDataType { - return t.type === 'data'; - } - - isSubtype( - superType: TType, - sub: TType, - context?: Record - ): boolean { - if (superType.type === 'typeRef') { - // TODO both are ref - if (context && sub.type !== 'typeRef') { - context[superType.name] = sub; - } - // TODO bound - return true; - } - if (sub.type === 'typeRef') { - // TODO both are ref - if (context) { - context[sub.name] = superType; - } - return true; - } - if (tUnknown.is(superType)) { - return true; - } - if (superType.type === 'union') { - return superType.list.some(type => this.isSubtype(type, sub, context)); - } - if (sub.type === 'union') { - return sub.list.every(type => this.isSubtype(superType, type, context)); - } - - if (this.isDataType(sub)) { - const dataDefine = this.dataMap.get(sub.name); - if (!dataDefine) { - throw new Error('bug'); - } - if (!this.isDataType(superType)) { - return false; - } - return dataDefine.isSubOf(superType); - } - - if (superType.type === 'array' || sub.type === 'array') { - if (superType.type !== 'array' || sub.type !== 'array') { - return false; - } - return this.isSubtype(superType.ele, sub.ele, context); - } - return false; - } - - subst(context: Record, template: TFunction): TFunction { - const subst = (type: TType): TType => { - if (this.isDataType(type)) { - return type; - } - switch (type.type) { - case 'typeRef': - return { ...context[type.name] }; - case 'union': - return tUnion(type.list.map(type => subst(type))); - case 'array': - return tArray(subst(type.ele)); - case 'function': - throw new Error('TODO'); - } - }; - const result = tFunction({ - args: template.args.map(type => subst(type)), - rt: subst(template.rt), - }); - return result; - } - - instance( - context: Record, - realArgs: TType[], - realRt: TType, - template: TFunction - ): TFunction { - const ctx = { ...context }; - template.args.forEach((arg, i) => { - const realArg = realArgs[i]; - if (realArg) { - this.isSubtype(arg, realArg, ctx); - } - }); - this.isSubtype(realRt, template.rt); - return this.subst(ctx, template); - } -} - -export const typesystem = new Typesystem(); -export const tUnknown = typesystem.defineData(DataHelper.create('Unknown')); diff --git a/packages/frontend/core/src/components/page-list/filter/multi-select.css.ts b/packages/frontend/core/src/components/page-list/filter/multi-select.css.ts deleted file mode 100644 index 565e13069a..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/multi-select.css.ts +++ /dev/null @@ -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, -}); diff --git a/packages/frontend/core/src/components/page-list/filter/multi-select.tsx b/packages/frontend/core/src/components/page-list/filter/multi-select.tsx deleted file mode 100644 index 053e9b8924..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/multi-select.tsx +++ /dev/null @@ -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 ( - - - {options.length === 0 ? ( - - {t['com.affine.filter.empty-tag']()} - - ) : ( - options.map(option => { - const selected = value.includes(option.value); - const click = (e: MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - if (selected) { - onChange(value.filter(v => v !== option.value)); - } else { - onChange([...value, option.value]); - } - }; - return ( - - {option.label} - - ); - }) - )} - - - - ); - }, [onChange, options, t, value]); - - return ( - -
- - {value.length ? ( -
{content}
- ) : ( -
- {t['com.affine.filter.empty-tag']()} -
- )} -
-
-
- ); -}; diff --git a/packages/frontend/core/src/components/page-list/filter/shared-types.tsx b/packages/frontend/core/src/components/page-list/filter/shared-types.tsx deleted file mode 100644 index 4c212c7b8f..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/shared-types.tsx +++ /dev/null @@ -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: , - }, - Updated: { - type: () => tDate.create(), - icon: , - }, - 'Is Favourited': { - type: () => tBoolean.create(), - icon: , - }, - Tags: { - type: meta => - tArray( - tTag.create({ - tags: - meta.tags?.options.map(t => ({ - id: t.id, - name: t.value, - color: t.color, - })) ?? [], - }) - ), - icon: , - }, - 'Is Public': { - type: () => tBoolean.create(), - icon: , - }, - // Imported: { - // type: tBoolean.create(), - // }, - // 'Daily Note': { - // type: tBoolean.create(), - // }, -} satisfies Record>; - -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 {} -} diff --git a/packages/frontend/core/src/components/page-list/filter/utils.ts b/packages/frontend/core/src/components/page-list/filter/utils.ts deleted file mode 100644 index ca1bd9a10e..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/utils.ts +++ /dev/null @@ -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] }], - }; -}; diff --git a/packages/frontend/core/src/components/page-list/filter/vars.tsx b/packages/frontend/core/src/components/page-list/filter/vars.tsx deleted file mode 100644 index 8e534b886d..0000000000 --- a/packages/frontend/core/src/components/page-list/filter/vars.tsx +++ /dev/null @@ -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 ( - { - onChange([...value, filter]); - }} - /> - ); -}; -export const VariableSelect = ({ - onSelect, - propertiesMeta, -}: { - selected: Filter[]; - onSelect: (value: Filter) => void; - propertiesMeta: PropertiesMeta; -}) => { - const t = useI18n(); - return ( -
-
- {t['com.affine.filter']()} -
- - {vars - // .filter(v => !selected.find(filter => filter.left.name === v.name)) - .map(v => ( - { - onSelect(createDefaultFilter(v, propertiesMeta)); - }} - className={styles.menuItemStyle} - > -
- -
-
- ))} -
- ); -}; - -export type FilterMatcherDataType = { - name: string; - defaultArgs: () => LiteralValue[]; - render?: (props: { ast: Filter }) => ReactNode; - impl: (...args: (LiteralValue | undefined)[]) => boolean; -}; -export const filterMatcher = new Matcher( - (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)); - }, - } -); diff --git a/packages/frontend/core/src/components/page-list/index.tsx b/packages/frontend/core/src/components/page-list/index.tsx index 3243f71962..fb9adfaea3 100644 --- a/packages/frontend/core/src/components/page-list/index.tsx +++ b/packages/frontend/core/src/components/page-list/index.tsx @@ -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'; diff --git a/packages/frontend/core/src/components/page-list/operation-cell.tsx b/packages/frontend/core/src/components/page-list/operation-cell.tsx index 323e1840e1..943f552d74 100644 --- a/packages/frontend/core/src/components/page-list/operation-cell.tsx +++ b/packages/frontend/core/src/components/page-list/operation-cell.tsx @@ -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({ diff --git a/packages/frontend/core/src/components/page-list/page-group.tsx b/packages/frontend/core/src/components/page-list/page-group.tsx index 874ca0ec39..4379392a54 100644 --- a/packages/frontend/core/src/components/page-list/page-group.tsx +++ b/packages/frontend/core/src/components/page-list/page-group.tsx @@ -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, diff --git a/packages/frontend/core/src/components/page-list/types.ts b/packages/frontend/core/src/components/page-list/types.ts index 81f1092ef9..2bbdbf9b6e 100644 --- a/packages/frontend/core/src/components/page-list/types.ts +++ b/packages/frontend/core/src/components/page-list/types.ts @@ -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; diff --git a/packages/frontend/core/src/components/page-list/use-collection-manager.ts b/packages/frontend/core/src/components/page-list/use-collection-manager.ts deleted file mode 100644 index 8264c0fb5b..0000000000 --- a/packages/frontend/core/src/components/page-list/use-collection-manager.ts +++ /dev/null @@ -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> -): 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, - }); -}; diff --git a/packages/frontend/core/src/components/page-list/use-filtered-page-metas.tsx b/packages/frontend/core/src/components/page-list/use-filtered-page-metas.tsx deleted file mode 100644 index d40ff56844..0000000000 --- a/packages/frontend/core/src/components/page-list/use-filtered-page-metas.tsx +++ /dev/null @@ -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; -}; diff --git a/packages/frontend/core/src/components/page-list/view/collection-list.css.ts b/packages/frontend/core/src/components/page-list/view/collection-list.css.ts deleted file mode 100644 index 93197bd14a..0000000000 --- a/packages/frontend/core/src/components/page-list/view/collection-list.css.ts +++ /dev/null @@ -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', - }, - }, -}); diff --git a/packages/frontend/core/src/components/page-list/view/collection-list.tsx b/packages/frontend/core/src/components/page-list/view/collection-list.tsx deleted file mode 100644 index 2686bee9e4..0000000000 --- a/packages/frontend/core/src/components/page-list/view/collection-list.tsx +++ /dev/null @@ -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 ( - - - } - > - - - - ); -}; diff --git a/packages/frontend/core/src/components/page-list/view/collection-operations.tsx b/packages/frontend/core/src/components/page-list/view/collection-operations.tsx index 5c14cc0e86..271deaa777 100644 --- a/packages/frontend/core/src/components/page-list/view/collection-operations.tsx +++ b/packages/frontend/core/src/components/page-list/view/collection-operations.tsx @@ -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: , 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, ] ); diff --git a/packages/frontend/core/src/components/page-list/view/index.ts b/packages/frontend/core/src/components/page-list/view/index.ts index 4fc8898b3c..6907b14ad4 100644 --- a/packages/frontend/core/src/components/page-list/view/index.ts +++ b/packages/frontend/core/src/components/page-list/view/index.ts @@ -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'; diff --git a/packages/frontend/core/src/components/page-list/view/save-as-collection-button.tsx b/packages/frontend/core/src/components/page-list/view/save-as-collection-button.tsx index 2f2c11a5f4..c21e3deec1 100644 --- a/packages/frontend/core/src/components/page-list/view/save-as-collection-button.tsx +++ b/packages/frontend/core/src/components/page-list/view/save-as-collection-button.tsx @@ -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]); diff --git a/packages/frontend/core/src/components/page-list/view/use-action.tsx b/packages/frontend/core/src/components/page-list/view/use-action.tsx index a18c172cd7..a10d6b39d4 100644 --- a/packages/frontend/core/src/components/page-list/view/use-action.tsx +++ b/packages/frontend/core/src/components/page-list/view/use-action.tsx @@ -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'; diff --git a/packages/frontend/core/src/components/page-list/virtualized-trash-list.tsx b/packages/frontend/core/src/components/page-list/virtualized-trash-list.tsx index e43098ed40..caa121291b 100644 --- a/packages/frontend/core/src/components/page-list/virtualized-trash-list.tsx +++ b/packages/frontend/core/src/components/page-list/virtualized-trash-list.tsx @@ -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(null); const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); diff --git a/packages/frontend/core/src/components/system-property-types/favorite.tsx b/packages/frontend/core/src/components/system-property-types/favorite.tsx new file mode 100644 index 0000000000..6954c1c375 --- /dev/null +++ b/packages/frontend/core/src/components/system-property-types/favorite.tsx @@ -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 ( + + { + onChange({ + ...filter, + value: 'true', + }); + }} + selected={filter.value === 'true'} + > + {'True'} + + { + onChange({ + ...filter, + value: 'false', + }); + }} + selected={filter.value !== 'true'} + > + {'False'} + + + } + > + {filter.value === 'true' ? 'True' : 'False'} + + ); +}; diff --git a/packages/frontend/core/src/components/system-property-types/index.ts b/packages/frontend/core/src/components/system-property-types/index.ts index 75a717ba9c..bd707ad787 100644 --- a/packages/frontend/core/src/components/system-property-types/index.ts +++ b/packages/frontend/core/src/components/system-property-types/index.ts @@ -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>; diff --git a/packages/frontend/core/src/components/system-property-types/shared.tsx b/packages/frontend/core/src/components/system-property-types/shared.tsx new file mode 100644 index 0000000000..073134a541 --- /dev/null +++ b/packages/frontend/core/src/components/system-property-types/shared.tsx @@ -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 ( + + { + onChange({ + ...filter, + value: 'true', + }); + }} + selected={filter.value === 'true'} + > + {'True'} + + { + onChange({ + ...filter, + value: 'false', + }); + }} + selected={filter.value !== 'true'} + > + {'False'} + + + } + > + {filter.value === 'true' ? 'True' : 'False'} + + ); +}; diff --git a/packages/frontend/core/src/components/workspace-property-types/index.ts b/packages/frontend/core/src/components/workspace-property-types/index.ts index c94c9ec996..4958109ba9 100644 --- a/packages/frontend/core/src/components/workspace-property-types/index.ts +++ b/packages/frontend/core/src/components/workspace-property-types/index.ts @@ -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', }, diff --git a/packages/frontend/core/src/desktop/components/navigation-panel/nodes/collection/index.tsx b/packages/frontend/core/src/desktop/components/navigation-panel/nodes/collection/index.tsx index abc6edebba..94bef235f7 100644 --- a/packages/frontend/core/src/desktop/components/navigation-panel/nodes/collection/index.tsx +++ b/packages/frontend/core/src/desktop/components/navigation-panel/nodes/collection/index.tsx @@ -5,26 +5,17 @@ import { MenuItem, toast, } from '@affine/component'; -import { filterPage } from '@affine/core/components/page-list'; -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 { AffineDNDData } from '@affine/core/types/dnd'; -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 } from '@blocksuite/icons/rc'; -import { - LiveData, - useLiveData, - useService, - useServices, -} from '@toeverything/infra'; +import { useLiveData, useService, useServices } from '@toeverything/infra'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { @@ -71,6 +62,7 @@ export const NavigationPanelCollectionNode = ({ const collectionService = useService(CollectionService); const collection = useLiveData(collectionService.collection$(collectionId)); + const name = useLiveData(collection?.name$); const dndData = useMemo(() => { return { @@ -89,11 +81,10 @@ export const NavigationPanelCollectionNode = ({ const handleRename = useCallback( (name: string) => { - 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', @@ -109,10 +100,10 @@ export const NavigationPanelCollectionNode = ({ if (!collection) { return; } - if (collection.allowList.includes(docId)) { + if (collection.allowList$.value.includes(docId)) { toast(t['com.affine.collection.addPage.alreadyExists']()); } else { - collectionService.addPageToCollection(collection.id, docId); + collectionService.addDocToCollection(collection.id, docId); } }, [collection, collectionService, t] @@ -210,7 +201,7 @@ export const NavigationPanelCollectionNode = ({ return ( { const t = useI18n(); - const { - docsService, - compatibleFavoriteItemsAdapter, - shareDocsListService, - collectionService, - } = useServices({ - DocsService, - CompatibleFavoriteItemsAdapter, - ShareDocsListService, + const { collectionService } = useServices({ CollectionService, }); - useEffect(() => { - // TODO(@eyhn): loading & error UI - 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 allowList = useLiveData( + collection.allowList$.map(list => new Set(list)) ); - const favourites = useLiveData(compatibleFavoriteItemsAdapter.favorites$); - const allowList = useMemo( - () => new Set(collection.allowList), - [collection.allowList] - ); - const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$); const handleRemoveFromAllowList = useCallback( (id: string) => { track.$.navigationPanel.collections.removeOrganizeItem({ type: 'doc' }); - collectionService.deletePageFromCollection(collection.id, id); + collectionService.removeDocFromCollection(collection.id, id); toast(t['com.affine.collection.removePage.success']()); }, [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([]); - return filtered.map(doc => ( + useEffect(() => { + const subscription = collection.watch().subscribe(docIds => { + setFilteredDocIds(docIds); + }); + + return () => subscription.unsubscribe(); + }, [collection]); + + return filteredDocIds.map(docId => ( } - onClick={() => handleRemoveFromAllowList(doc.id)} + onClick={() => handleRemoveFromAllowList(docId)} > {t['Remove special filter']()} diff --git a/packages/frontend/core/src/desktop/components/navigation-panel/nodes/collection/operations.tsx b/packages/frontend/core/src/desktop/components/navigation-panel/nodes/collection/operations.tsx index 87392add31..7d627c262b 100644 --- a/packages/frontend/core/src/desktop/components/navigation-panel/nodes/collection/operations.tsx +++ b/packages/frontend/core/src/desktop/components/navigation-panel/nodes/collection/operations.tsx @@ -5,7 +5,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 { CollectionService } from '@affine/core/modules/collection'; import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; @@ -42,7 +41,6 @@ export const useNavigationPanelCollectionNodeOperations = ( CollectionService, CompatibleFavoriteItemsAdapter, }); - const deleteInfo = useDeleteCollectionInfo(); const { createPage } = usePageHelper( workspaceService.workspace.docCollection @@ -59,7 +57,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', @@ -100,11 +98,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(); diff --git a/packages/frontend/core/src/desktop/components/navigation-panel/sections/collections/index.tsx b/packages/frontend/core/src/desktop/components/navigation-panel/sections/collections/index.tsx index 058ab8cdd4..e10c1ac8c5 100644 --- a/packages/frontend/core/src/desktop/components/navigation-panel/sections/collections/index.tsx +++ b/packages/frontend/core/src/desktop/components/navigation-panel/sections/collections/index.tsx @@ -1,5 +1,4 @@ import { IconButton, usePromptModal } from '@affine/component'; -import { createEmptyCollection } from '@affine/core/components/page-list/use-collection-manager'; import { CollectionService } from '@affine/core/modules/collection'; import { NavigationPanelService } from '@affine/core/modules/navigation-panel'; import { WorkbenchService } from '@affine/core/modules/workbench'; @@ -7,7 +6,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 { CollapsibleSection } from '../../layouts/collapsible-section'; @@ -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', }); @@ -84,7 +81,7 @@ export const NavigationPanelCollections = () => { } > - {collections.map(collection => ( + {Array.from(collections.values()).map(collection => ( void; - onConfirm: (collection: Collection) => void; + onConfirm: (collection: CollectionInfo) => void; } export const EditCollection = ({ @@ -27,9 +27,9 @@ export const EditCollection = ({ }: EditCollectionProps) => { const t = useI18n(); const config = useAllPageListConfig(); - const [value, onChange] = useState(init); + const [value, onChange] = useState(init); const [mode, setMode] = useState<'page' | 'rule'>( - initMode ?? (init.filterList.length === 0 ? 'page' : 'rule') + initMode ?? (init.rules.filters.length === 0 ? 'page' : 'rule') ); const isNameEmpty = useMemo(() => value.name.trim().length === 0, [value]); const onSaveCollection = useCallback(() => { @@ -40,10 +40,10 @@ export const EditCollection = ({ const reset = useCallback(() => { onChange({ ...value, - filterList: init.filterList, + rules: init.rules, allowList: init.allowList, }); - }, [init.allowList, init.filterList, value]); + }, [init, value]); const onIdsChange = useCallback( (ids: string[]) => { onChange({ ...value, allowList: ids }); diff --git a/packages/frontend/core/src/desktop/dialogs/collection-editor/index.tsx b/packages/frontend/core/src/desktop/dialogs/collection-editor/index.tsx index 16e2793b77..9def6783dc 100644 --- a/packages/frontend/core/src/desktop/dialogs/collection-editor/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/collection-editor/index.tsx @@ -1,8 +1,10 @@ import { Modal } from '@affine/component'; -import { CollectionService } from '@affine/core/modules/collection'; +import { + type CollectionInfo, + CollectionService, +} from '@affine/core/modules/collection'; import type { DialogComponentProps } from '@affine/core/modules/dialogs'; import type { WORKSPACE_DIALOG_SCHEMA } from '@affine/core/modules/dialogs/constant'; -import type { Collection } from '@affine/env/filter'; import { useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; import { useCallback } from 'react'; @@ -18,17 +20,18 @@ export const CollectionEditorDialog = ({ const collectionService = useService(CollectionService); const collection = useLiveData(collectionService.collection$(collectionId)); const onConfirmOnCollection = useCallback( - (collection: Collection) => { - collectionService.updateCollection(collection.id, () => collection); + (collection: CollectionInfo) => { + collectionService.updateCollection(collection.id, collection); close(); }, [close, collectionService] ); + const info = useLiveData(collection?.info$); const onCancel = useCallback(() => { close(); }, [close]); - if (!collection) { + if (!collection || !info) { return null; } @@ -50,7 +53,7 @@ export const CollectionEditorDialog = ({ > void; + collection: CollectionInfo; + updateCollection: (collection: CollectionInfo) => void; reset: () => void; buttons: ReactNode; switchMode: ReactNode; @@ -44,30 +43,50 @@ export const RulesMode = ({ }) => { const t = useI18n(); const [showPreview, setShowPreview] = useState(true); - const allowListPages: DocMeta[] = []; - const rulesPages: DocMeta[] = []; const docsService = useService(DocsService); - const favAdapter = useService(CompatibleFavoriteItemsAdapter); - const favorites = useLiveData(favAdapter.favorites$); - allPageListConfig.allPages.forEach(meta => { - if (meta.trash) { - return; - } - const pageData = { - meta, - publicMode: allPageListConfig.getPublicMode(meta.id), - favorite: favorites.some(f => f.id === meta.id), + const collectionRulesService = useService(CollectionRulesService); + const [rulesPageIds, setRulesPageIds] = useState([]); + + useEffect(() => { + const subscription = collectionRulesService + .watch( + collection.rules.filters.length > 0 + ? [ + ...collection.rules.filters, + { + type: 'system', + key: 'trash', + method: 'is', + value: 'false', + }, + ] + : [], + undefined, + undefined + ) + .subscribe(rules => { + setRulesPageIds(rules.groups.flatMap(group => group.items)); + }); + return () => { + subscription.unsubscribe(); }; - if ( - collection.filterList.length && - filterPageByRules(collection.filterList, [], pageData) - ) { - rulesPages.push(meta); - } - if (collection.allowList.includes(meta.id)) { - allowListPages.push(meta); - } - }); + }, [collection, collectionRulesService]); + + const rulesPages = useMemo(() => { + return allPageListConfig.allPages.filter(meta => { + return rulesPageIds.includes(meta.id); + }); + }, [allPageListConfig.allPages, rulesPageIds]); + + const allowListPages = useMemo(() => { + return allPageListConfig.allPages.filter(meta => { + return ( + collection.allowList.includes(meta.id) && + !rulesPageIds.includes(meta.id) + ); + }); + }, [allPageListConfig.allPages, collection.allowList, rulesPageIds]); + const [expandInclude, setExpandInclude] = useState( collection.allowList.length > 0 ); @@ -113,13 +132,17 @@ export const RulesMode = ({ overflowY: 'auto', }} > - updateCollection({ ...collection, filterList }), - [collection, updateCollection] - )} + { + updateCollection({ + ...collection, + rules: { + ...collection.rules, + filters, + }, + }); + }} />
{collection.allowList.length > 0 ? ( @@ -215,7 +238,7 @@ export const RulesMode = ({ > ) : ( )} diff --git a/packages/frontend/core/src/desktop/dialogs/selectors/collection.tsx b/packages/frontend/core/src/desktop/dialogs/selectors/collection.tsx index a73b1c6bf5..ca995b09d5 100644 --- a/packages/frontend/core/src/desktop/dialogs/selectors/collection.tsx +++ b/packages/frontend/core/src/desktop/dialogs/selectors/collection.tsx @@ -2,14 +2,16 @@ import { Modal, toast } from '@affine/component'; import { collectionHeaderColsDef, CollectionListItemRenderer, - type CollectionMeta, FavoriteTag, type ListItem, ListTableHeader, VirtualizedList, } from '@affine/core/components/page-list'; import { SelectorLayout } from '@affine/core/components/page-list/selector/selector-layout'; -import { CollectionService } from '@affine/core/modules/collection'; +import { + type CollectionMeta, + CollectionService, +} from '@affine/core/modules/collection'; import type { DialogComponentProps } from '@affine/core/modules/dialogs'; import type { WORKSPACE_DIALOG_SCHEMA } from '@affine/core/modules/dialogs/constant'; import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; @@ -52,22 +54,15 @@ export const CollectionSelectorDialog = ({ const collectionService = useService(CollectionService); const workspace = useService(WorkspaceService).workspace; - const collections = useLiveData(collectionService.collections$); + const collections = useLiveData(collectionService.collectionMetas$); const [selection, setSelection] = useState(selectedCollectionIds); const [keyword, setKeyword] = useState(''); const collectionMetas = useMemo(() => { - const collectionsList: CollectionMeta[] = collections - .map(collection => { - return { - ...collection, - title: collection.name, - }; - }) - .filter(meta => { - const reg = new RegExp(keyword, 'i'); - return reg.test(meta.title); - }); + const collectionsList: CollectionMeta[] = collections.filter(meta => { + const reg = new RegExp(keyword, 'i'); + return reg.test(meta.title); + }); return collectionsList; }, [collections, keyword]); diff --git a/packages/frontend/core/src/desktop/pages/workspace/all-collection/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/all-collection/index.tsx index 15d57286fc..61a7adc3e1 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/all-collection/index.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/all-collection/index.tsx @@ -1,9 +1,7 @@ import { usePromptModal } from '@affine/component'; import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; -import type { CollectionMeta } from '@affine/core/components/page-list'; import { CollectionListHeader, - createEmptyCollection, VirtualizedCollectionList, } from '@affine/core/components/page-list'; import { @@ -13,8 +11,7 @@ import { import { WorkspaceService } from '@affine/core/modules/workspace'; import { useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; -import { nanoid } from 'nanoid'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useState } from 'react'; import { CollectionService } from '../../../../modules/collection'; import { ViewBody, ViewHeader } from '../../../../modules/workbench'; @@ -31,16 +28,6 @@ export const AllCollection = () => { const collectionService = useService(CollectionService); const collections = useLiveData(collectionService.collections$); - const collectionMetas = useMemo(() => { - const collectionsList: CollectionMeta[] = collections.map(collection => { - return { - ...collection, - title: collection.name, - }; - }); - return collectionsList; - }, [collections]); - const navigateHelper = useNavigateHelper(); const { openPromptModal } = usePromptModal(); @@ -62,8 +49,7 @@ export const AllCollection = () => { variant: 'primary', }, onConfirm(name) { - const id = nanoid(); - collectionService.addCollection(createEmptyCollection(id, { name })); + const id = collectionService.createCollection({ name }); navigateHelper.jumpToCollection(currentWorkspace.id, id); }, }); @@ -87,10 +73,8 @@ export const AllCollection = () => {
- {collectionMetas.length > 0 ? ( + {collections.size > 0 ? ( diff --git a/packages/frontend/core/src/desktop/pages/workspace/all-page-old/all-page-filter.tsx b/packages/frontend/core/src/desktop/pages/workspace/all-page-old/all-page-filter.tsx deleted file mode 100644 index a4c50adc19..0000000000 --- a/packages/frontend/core/src/desktop/pages/workspace/all-page-old/all-page-filter.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { CollectionService } from '@affine/core/modules/collection'; -import { WorkspaceService } from '@affine/core/modules/workspace'; -import type { Collection, Filter } from '@affine/env/filter'; -import { useService } from '@toeverything/infra'; -import { useCallback } from 'react'; - -import { filterContainerStyle } from '../../../../components/filter-container.css'; -import { useNavigateHelper } from '../../../../components/hooks/use-navigate-helper'; -import { - FilterList, - SaveAsCollectionButton, -} from '../../../../components/page-list'; - -export const FilterContainer = ({ - filters, - onChangeFilters, -}: { - filters: Filter[]; - onChangeFilters: (filters: Filter[]) => void; -}) => { - const currentWorkspace = useService(WorkspaceService).workspace; - const navigateHelper = useNavigateHelper(); - const collectionService = useService(CollectionService); - const saveToCollection = useCallback( - (collection: Collection) => { - collectionService.addCollection({ - ...collection, - filterList: filters, - }); - navigateHelper.jumpToCollection(currentWorkspace.id, collection.id); - }, - [collectionService, filters, navigateHelper, currentWorkspace.id] - ); - - if (!filters.length) { - return null; - } - - return ( -
-
- -
-
- {filters.length > 0 ? ( - - ) : null} -
-
- ); -}; diff --git a/packages/frontend/core/src/desktop/pages/workspace/all-page-old/all-page-header.tsx b/packages/frontend/core/src/desktop/pages/workspace/all-page-old/all-page-header.tsx index b6fe8b0aea..4385063be2 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/all-page-old/all-page-header.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/all-page-old/all-page-header.tsx @@ -1,7 +1,6 @@ import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils'; import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation'; import { - AllPageListOperationsMenu, PageDisplayMenu, PageListNewPageButton, } from '@affine/core/components/page-list'; @@ -10,7 +9,6 @@ import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkspaceService } from '@affine/core/modules/workspace'; import { inferOpenMode } from '@affine/core/utils'; -import type { Filter } from '@affine/env/filter'; import { track } from '@affine/track'; import { PlusIcon } from '@blocksuite/icons/rc'; import { useServices } from '@toeverything/infra'; @@ -21,12 +19,8 @@ import * as styles from './all-page.css'; export const AllPageHeader = ({ showCreateNew, - filters, - onChangeFilters, }: { showCreateNew: boolean; - filters: Filter[]; - onChangeFilters: (filters: Filter[]) => void; }) => { const { workspaceService, workspaceDialogService, workbenchService } = useServices({ @@ -90,11 +84,6 @@ export const AllPageHeader = ({ > - } diff --git a/packages/frontend/core/src/desktop/pages/workspace/all-page-old/all-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/all-page-old/all-page.tsx index 315e6c0b44..0d6def4c94 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/all-page-old/all-page.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/all-page-old/all-page.tsx @@ -1,17 +1,15 @@ import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta'; import { PageListHeader, - useFilteredPageMetas, VirtualizedPageList, } from '@affine/core/components/page-list'; import { GlobalContextService } from '@affine/core/modules/global-context'; import { IntegrationService } from '@affine/core/modules/integration'; import { WorkspacePermissionService } from '@affine/core/modules/permissions'; import { WorkspaceService } from '@affine/core/modules/workspace'; -import type { Filter } from '@affine/env/filter'; import { useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useIsActiveView, @@ -23,7 +21,6 @@ import { import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs'; import { EmptyPageList } from '../page-list-empty'; import * as styles from './all-page.css'; -import { FilterContainer } from './all-page-filter'; import { AllPageHeader } from './all-page-header'; export const AllPage = () => { @@ -37,10 +34,10 @@ export const AllPage = () => { const isOwner = useLiveData(permissionService.permission.isOwner$); const importing = useLiveData(integrationService.importing$); - const [filters, setFilters] = useState([]); - const filteredPageMetas = useFilteredPageMetas(pageMetas, { - filters: filters, - }); + const filteredPageMetas = useMemo( + () => pageMetas.filter(page => !page.trash), + [pageMetas] + ); const isActiveView = useIsActiveView(); @@ -66,20 +63,14 @@ export const AllPage = () => { - +
- {filteredPageMetas.length > 0 ? ( ) : ( } /> diff --git a/packages/frontend/core/src/desktop/pages/workspace/collection/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/collection/index.tsx index f494882748..85a4cc31af 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/collection/index.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/collection/index.tsx @@ -1,12 +1,13 @@ -import { notify } from '@affine/component'; import { EmptyCollectionDetail } from '@affine/core/components/affine/empty/collection-detail'; import { VirtualizedPageList } from '@affine/core/components/page-list'; -import { CollectionService } from '@affine/core/modules/collection'; +import { + type Collection, + CollectionService, +} from '@affine/core/modules/collection'; import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import { GlobalContextService } from '@affine/core/modules/global-context'; import { WorkspacePermissionService } from '@affine/core/modules/permissions'; import { WorkspaceService } from '@affine/core/modules/workspace'; -import type { Collection } from '@affine/env/filter'; import { useI18n } from '@affine/i18n'; import { ViewLayersIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService, useServices } from '@toeverything/infra'; @@ -21,6 +22,7 @@ import { ViewIcon, ViewTitle, } from '../../../../modules/workbench'; +import { PageNotFound } from '../../404'; import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs'; import { CollectionDetailHeader } from './header'; @@ -68,30 +70,16 @@ export const Component = function CollectionPage() { GlobalContextService, }); const globalContext = globalContextService.globalContext; - - const collections = useLiveData(collectionService.collections$); - const navigate = useNavigateHelper(); + const t = useI18n(); const params = useParams(); - const workspace = useService(WorkspaceService).workspace; - const collection = collections.find(v => v.id === params.collectionId); + const collection = useLiveData( + params.collectionId + ? collectionService.collection$(params.collectionId) + : null + ); + const name = useLiveData(collection?.name$); const isActiveView = useIsActiveView(); - const notifyCollectionDeleted = useCallback(() => { - navigate.jumpToPage(workspace.id, 'all'); - 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 (isActiveView && collection) { globalContext.collectionId.set(collection.id); @@ -105,25 +93,22 @@ export const Component = function CollectionPage() { return; }, [collection, globalContext, isActiveView]); - useEffect(() => { - if (!collection) { - notifyCollectionDeleted(); - } - }, [collection, notifyCollectionDeleted]); + const info = useLiveData(collection?.info$); if (!collection) { - return null; + return ; } - const inner = isEmptyCollection(collection) ? ( - - ) : ( - - ); + const inner = + info?.allowList.length === 0 && info?.rules.filters.length === 0 ? ( + + ) : ( + + ); return ( <> - + {inner} @@ -134,6 +119,7 @@ const Placeholder = ({ collection }: { collection: Collection }) => { const workspace = useService(WorkspaceService).workspace; const { jumpToCollections } = useNavigateHelper(); const t = useI18n(); + const name = useLiveData(collection?.name$); const handleJumpToCollections = useCallback(() => { jumpToCollections(workspace.id); @@ -176,7 +162,7 @@ const Placeholder = ({ collection }: { collection: Collection }) => { ['WebkitAppRegion' as string]: 'no-drag', }} > - {collection.name} + {name ?? t['Untitled']()}
@@ -190,9 +176,3 @@ const Placeholder = ({ collection }: { collection: Collection }) => { ); }; - -export const isEmptyCollection = (collection: Collection) => { - return ( - collection.allowList.length === 0 && collection.filterList.length === 0 - ); -}; diff --git a/packages/frontend/core/src/desktop/pages/workspace/trash-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/trash-page.tsx index d5ac6947f7..f673711ee4 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/trash-page.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/trash-page.tsx @@ -1,16 +1,14 @@ import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta'; -import { - useFilteredPageMetas, - VirtualizedTrashList, -} from '@affine/core/components/page-list'; +import { VirtualizedTrashList } from '@affine/core/components/page-list'; import { Header } from '@affine/core/components/pure/header'; +import { DocsService } from '@affine/core/modules/doc'; import { GlobalContextService } from '@affine/core/modules/global-context'; import { WorkspacePermissionService } from '@affine/core/modules/permissions'; import { WorkspaceService } from '@affine/core/modules/workspace'; import { useI18n } from '@affine/i18n'; import { DeleteIcon } from '@blocksuite/icons/rc'; -import { useLiveData, useService } from '@toeverything/infra'; -import { useEffect } from 'react'; +import { LiveData, useLiveData, useService } from '@toeverything/infra'; +import { useEffect, useMemo } from 'react'; import { useIsActiveView, @@ -43,11 +41,15 @@ export const TrashPage = () => { const isAdmin = useLiveData(permissionService.permission.isAdmin$); const isOwner = useLiveData(permissionService.permission.isOwner$); const docCollection = currentWorkspace.docCollection; + const docsService = useService(DocsService); + 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 isActiveView = useIsActiveView(); diff --git a/packages/frontend/core/src/mobile/components/navigation/nodes/collection/index.tsx b/packages/frontend/core/src/mobile/components/navigation/nodes/collection/index.tsx index 8fa84f246f..bf2ad59047 100644 --- a/packages/frontend/core/src/mobile/components/navigation/nodes/collection/index.tsx +++ b/packages/frontend/core/src/mobile/components/navigation/nodes/collection/index.tsx @@ -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 ( 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([]); + + useEffect(() => { + const subscription = collection.watch().subscribe(docIds => { + setFilteredDocIds(docIds); + }); + + return () => subscription.unsubscribe(); + }, [collection]); return ( <> - {filtered.map(doc => ( + {filteredDocIds.map(docId => ( } - onClick={() => handleRemoveFromAllowList(doc.id)} + onClick={() => handleRemoveFromAllowList(docId)} > {t['Remove special filter']()} diff --git a/packages/frontend/core/src/mobile/components/navigation/nodes/collection/operations.tsx b/packages/frontend/core/src/mobile/components/navigation/nodes/collection/operations.tsx index 37715a7ad8..242baa3802 100644 --- a/packages/frontend/core/src/mobile/components/navigation/nodes/collection/operations.tsx +++ b/packages/frontend/core/src/mobile/components/navigation/nodes/collection/operations.tsx @@ -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', diff --git a/packages/frontend/core/src/mobile/components/navigation/sections/collections/index.tsx b/packages/frontend/core/src/mobile/components/navigation/sections/collections/index.tsx index d74fd01ecb..436fd3e794 100644 --- a/packages/frontend/core/src/mobile/components/navigation/sections/collections/index.tsx +++ b/packages/frontend/core/src/mobile/components/navigation/sections/collections/index.tsx @@ -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']()} > - {collections.map(collection => ( + {collectionMetas.map(collection => ( ) => { const t = useI18n(); const collectionService = useService(CollectionService); - const collections = useLiveData(collectionService.collections$); + const collections = useLiveData(collectionService.collectionMetas$); const list = useMemo(() => { return collections.map(collection => ({ diff --git a/packages/frontend/core/src/mobile/pages/workspace/collection/detail.tsx b/packages/frontend/core/src/mobile/pages/workspace/collection/detail.tsx index 1259f94c94..72b0da7037 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/collection/detail.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/collection/detail.tsx @@ -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
; } return ; diff --git a/packages/frontend/core/src/mobile/pages/workspace/search.tsx b/packages/frontend/core/src/mobile/pages/workspace/search.tsx index d7c1f0db83..6911440c2d 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/search.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/search.tsx @@ -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 diff --git a/packages/frontend/core/src/mobile/views/all-docs/collection/detail.tsx b/packages/frontend/core/src/mobile/views/all-docs/collection/detail.tsx index c782a54188..eb4182380c 100644 --- a/packages/frontend/core/src/mobile/views/all-docs/collection/detail.tsx +++ b/packages/frontend/core/src/mobile/views/all-docs/collection/detail.tsx @@ -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 (
- {collection.name} + {name}
); @@ -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 ( - <> - - - - + }> +
+ +
+
); } diff --git a/packages/frontend/core/src/mobile/views/all-docs/collection/empty.tsx b/packages/frontend/core/src/mobile/views/all-docs/collection/empty.tsx index a4d66dc203..4565d502dc 100644 --- a/packages/frontend/core/src/mobile/views/all-docs/collection/empty.tsx +++ b/packages/frontend/core/src/mobile/views/all-docs/collection/empty.tsx @@ -1,4 +1,4 @@ -import type { Collection } from '@affine/env/filter'; +import type { Collection } from '@affine/core/modules/collection'; import { DetailHeader } from './detail'; diff --git a/packages/frontend/core/src/mobile/views/all-docs/collection/item.tsx b/packages/frontend/core/src/mobile/views/all-docs/collection/item.tsx index e192724b4f..8e1a5bf011 100644 --- a/packages/frontend/core/src/mobile/views/all-docs/collection/item.tsx +++ b/packages/frontend/core/src/mobile/views/all-docs/collection/item.tsx @@ -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'; diff --git a/packages/frontend/core/src/mobile/views/all-docs/collection/list.tsx b/packages/frontend/core/src/mobile/views/all-docs/collection/list.tsx index 49ccd03b93..0055eab486 100644 --- a/packages/frontend/core/src/mobile/views/all-docs/collection/list.tsx +++ b/packages/frontend/core/src/mobile/views/all-docs/collection/list.tsx @@ -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 ; diff --git a/packages/frontend/core/src/mobile/views/all-docs/doc/list.tsx b/packages/frontend/core/src/mobile/views/all-docs/doc/list.tsx index 12dc8fc4ff..784998fd64 100644 --- a/packages/frontend/core/src/mobile/views/all-docs/doc/list.tsx +++ b/packages/frontend/core/src/mobile/views/all-docs/doc/list.tsx @@ -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 }) => { 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([]); + + 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[]; - - // 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 ( - //
- // {groups.map(group => ( - // - // ))} - //
- // ); - return ( > { const method = params.method as WorkspacePropertyFilter<'createdBy'>; if (method === 'include') { - const userIds = params.value?.split(',') ?? []; + const userIds = params.value?.split(',').filter(Boolean) ?? []; return this.docsService.propertyValues$('createdBy').pipe( map(o => { diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/favorite.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/favorite.ts new file mode 100644 index 0000000000..7b63d98edd --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/favorite.ts @@ -0,0 +1,49 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import type { FavoriteService } from '@affine/core/modules/favorite'; +import { Service } from '@toeverything/infra'; +import { combineLatest, map, type Observable } from 'rxjs'; + +import type { FilterProvider } from '../../provider'; +import type { FilterParams } from '../../types'; + +export class FavoriteFilterProvider extends Service implements FilterProvider { + constructor( + private readonly favoriteService: FavoriteService, + private readonly docsService: DocsService + ) { + super(); + } + + filter$(params: FilterParams): Observable> { + const method = params.method; + if (method === 'is') { + return combineLatest([ + this.favoriteService.favoriteList.list$, + this.docsService.allDocIds$(), + ]).pipe( + map(([favoriteList, allDocIds]) => { + const favoriteDocIds = new Set(); + for (const { id, type } of favoriteList) { + if (type === 'doc') { + favoriteDocIds.add(id); + } + } + if (params.value === 'true') { + return favoriteDocIds; + } else if (params.value === 'false') { + const notFavoriteDocIds = new Set(); + for (const id of allDocIds) { + if (!favoriteDocIds.has(id)) { + notFavoriteDocIds.add(id); + } + } + return notFavoriteDocIds; + } else { + throw new Error(`Unsupported value: ${params.value}`); + } + }) + ); + } + throw new Error(`Unsupported method: ${params.method}`); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/shared.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/shared.ts new file mode 100644 index 0000000000..29ba97a57b --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/shared.ts @@ -0,0 +1,48 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import type { ShareDocsListService } from '@affine/core/modules/share-doc'; +import { onStart, Service } from '@toeverything/infra'; +import { combineLatest, map, type Observable, of } from 'rxjs'; + +import type { FilterProvider } from '../../provider'; +import type { FilterParams } from '../../types'; + +export class SharedFilterProvider extends Service implements FilterProvider { + constructor( + private readonly shareDocsListService: ShareDocsListService, + private readonly docsService: DocsService + ) { + super(); + } + + filter$(params: FilterParams): Observable> { + const method = params.method; + if (method === 'is') { + return combineLatest([ + this.shareDocsListService.shareDocs?.list$ ?? + (of([]) as Observable<{ id: string }[]>), + this.docsService.allDocIds$(), + ]).pipe( + onStart(() => { + this.shareDocsListService.shareDocs?.revalidate(); + }), + map(([shareDocsList, allDocIds]) => { + const shareDocIds = new Set(shareDocsList.map(item => item.id)); + if (params.value === 'true') { + return shareDocIds; + } else if (params.value === 'false') { + const notShareDocIds = new Set(); + for (const id of allDocIds) { + if (!shareDocIds.has(id)) { + notShareDocIds.add(id); + } + } + return notShareDocIds; + } else { + throw new Error(`Unsupported value: ${params.value}`); + } + }) + ); + } + throw new Error(`Unsupported method: ${params.method}`); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/tags.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/tags.ts index 7dc936ef01..3414850a05 100644 --- a/packages/frontend/core/src/modules/collection-rules/impls/filters/tags.ts +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/tags.ts @@ -1,5 +1,6 @@ import type { DocsService } from '@affine/core/modules/doc'; import type { TagService } from '@affine/core/modules/tag'; +import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property'; import { Service } from '@toeverything/infra'; import { combineLatest, map, type Observable, of, switchMap } from 'rxjs'; @@ -15,23 +16,66 @@ export class TagsFilterProvider extends Service implements FilterProvider { } filter$(params: FilterParams): Observable> { - if (params.method === 'include') { - const tagIds = params.value?.split(',') ?? []; - - const tags = tagIds.map(id => this.tagService.tagList.tagByTagId$(id)); - + const method = params.method as WorkspacePropertyFilter<'tags'>; + const tagIds = params.value?.split(',').filter(Boolean) ?? []; + const tags = tagIds.map(id => this.tagService.tagList.tagByTagId$(id)); + if (method === 'include-all' || method === 'not-include-all') { if (tags.length === 0) { return of(new Set()); } - return combineLatest(tags).pipe( + const includeDocIds$ = combineLatest(tags).pipe( + switchMap(tags => + combineLatest( + tags + .filter(tag => tag !== undefined) + .map(tag => tag.pageIds$.map(ids => new Set(ids))) + ).pipe( + map(pageIds => + pageIds.reduce((acc, curr) => acc.intersection(curr)) + ) + ) + ) + ); + if (method === 'include-all') { + return includeDocIds$; + } else { + return combineLatest([ + this.docsService.allDocIds$(), + includeDocIds$, + ]).pipe( + map( + ([docIds, includeDocIds]) => + new Set(docIds.filter(id => !includeDocIds.has(id))) + ) + ); + } + } else if (method === 'include-any-of' || method === 'not-include-any-of') { + if (tags.length === 0) { + return of(new Set()); + } + + const includeAnyOfDocIds$ = combineLatest(tags).pipe( switchMap(tags => combineLatest( tags.filter(tag => tag !== undefined).map(tag => tag.pageIds$) ).pipe(map(pageIds => new Set(pageIds.flat()))) ) ); - } else if (params.method === 'is-not-empty') { + if (method === 'include-any-of') { + return includeAnyOfDocIds$; + } else { + return combineLatest([ + this.docsService.allDocIds$(), + includeAnyOfDocIds$, + ]).pipe( + map( + ([docIds, includeAnyOfDocIds]) => + new Set(docIds.filter(id => !includeAnyOfDocIds.has(id))) + ) + ); + } + } else if (method === 'is-not-empty') { return combineLatest([ this.tagService.tagList.tags$.map(tags => new Set(tags.map(t => t.id))), this.docsService.allDocsTagIds$(), @@ -49,7 +93,7 @@ export class TagsFilterProvider extends Service implements FilterProvider { ) ) ); - } else if (params.method === 'is-empty') { + } else if (method === 'is-empty') { return this.tagService.tagList.tags$ .map(tags => new Set(tags.map(t => t.id))) .pipe( @@ -70,6 +114,6 @@ export class TagsFilterProvider extends Service implements FilterProvider { ) ); } - throw new Error(`Unsupported method: ${params.method}`); + throw new Error(`Unsupported method: ${method}`); } } diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/updated-by.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/updated-by.ts index 502f9685ca..ec60c63444 100644 --- a/packages/frontend/core/src/modules/collection-rules/impls/filters/updated-by.ts +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/updated-by.ts @@ -13,7 +13,7 @@ export class UpdatedByFilterProvider extends Service implements FilterProvider { filter$(params: FilterParams): Observable> { const method = params.method as WorkspacePropertyFilter<'updatedBy'>; if (method === 'include') { - const userIds = params.value?.split(',') ?? []; + const userIds = params.value?.split(',').filter(Boolean) ?? []; return this.docsService.propertyValues$('updatedBy').pipe( map(o => { diff --git a/packages/frontend/core/src/modules/collection-rules/index.ts b/packages/frontend/core/src/modules/collection-rules/index.ts index 210a45464d..ee52f0ad57 100644 --- a/packages/frontend/core/src/modules/collection-rules/index.ts +++ b/packages/frontend/core/src/modules/collection-rules/index.ts @@ -1,6 +1,8 @@ import type { Framework } from '@toeverything/infra'; import { DocsService } from '../doc'; +import { FavoriteService } from '../favorite'; +import { ShareDocsListService } from '../share-doc'; import { TagService } from '../tag'; import { WorkspaceScope } from '../workspace'; import { WorkspacePropertyService } from '../workspace-property'; @@ -10,8 +12,10 @@ import { CreatedByFilterProvider } from './impls/filters/created-by'; import { DatePropertyFilterProvider } from './impls/filters/date'; import { DocPrimaryModeFilterProvider } from './impls/filters/doc-primary-mode'; import { EmptyJournalFilterProvider } from './impls/filters/empty-journal'; +import { FavoriteFilterProvider } from './impls/filters/favorite'; import { JournalFilterProvider } from './impls/filters/journal'; import { PropertyFilterProvider } from './impls/filters/property'; +import { SharedFilterProvider } from './impls/filters/shared'; import { SystemFilterProvider } from './impls/filters/system'; import { TagsFilterProvider } from './impls/filters/tags'; import { TextPropertyFilterProvider } from './impls/filters/text'; @@ -118,6 +122,14 @@ export function configureCollectionRulesModule(framework: Framework) { .impl(FilterProvider('system:empty-journal'), EmptyJournalFilterProvider, [ DocsService, ]) + .impl(FilterProvider('system:favorite'), FavoriteFilterProvider, [ + FavoriteService, + DocsService, + ]) + .impl(FilterProvider('system:shared'), SharedFilterProvider, [ + ShareDocsListService, + DocsService, + ]) // --------------- Group By --------------- .impl(GroupByProvider('system'), SystemGroupByProvider) .impl(GroupByProvider('property'), PropertyGroupByProvider, [ diff --git a/packages/frontend/core/src/modules/collection-rules/services/collection-rules.ts b/packages/frontend/core/src/modules/collection-rules/services/collection-rules.ts index e1d1a6ba62..799ce8dda1 100644 --- a/packages/frontend/core/src/modules/collection-rules/services/collection-rules.ts +++ b/packages/frontend/core/src/modules/collection-rules/services/collection-rules.ts @@ -3,6 +3,7 @@ import { catchError, combineLatest, distinctUntilChanged, + firstValueFrom, map, type Observable, of, @@ -21,7 +22,8 @@ export class CollectionRulesService extends Service { watch( filters: FilterParams[], groupBy?: GroupByParams, - orderBy?: OrderByParams + orderBy?: OrderByParams, + extraAllowList?: string[] ): Observable<{ groups: { key: string; @@ -36,7 +38,10 @@ export class CollectionRulesService extends Service { filterErrors: any[]; // errors from the filter providers }> = filters.length === 0 - ? of({ filtered: new Set(), filterErrors: [] }) + ? of({ + filtered: new Set(extraAllowList ?? []), + filterErrors: [], + }) : combineLatest( filters.map(filter => { const provider = filterProviders.get(filter.type); @@ -57,7 +62,7 @@ export class CollectionRulesService extends Service { }) ).pipe( map(results => { - const finalSet = results.reduce((acc, result) => { + const aggregated = results.reduce((acc, result) => { if ('error' in acc) { return acc; } @@ -67,8 +72,15 @@ export class CollectionRulesService extends Service { return acc.intersection(result); }); + const filtered = + 'error' in aggregated ? new Set() : aggregated; + + const finalSet = filtered.union( + new Set(extraAllowList ?? []) + ); + return { - filtered: 'error' in finalSet ? new Set() : finalSet, + filtered: finalSet, filterErrors: results.map(i => ('error' in i ? i.error : null)), }; }) @@ -204,4 +216,15 @@ export class CollectionRulesService extends Service { return final$; } + + compute( + filters: FilterParams[], + groupBy?: GroupByParams, + orderBy?: OrderByParams, + extraAllowList?: string[] + ) { + return firstValueFrom( + this.watch(filters, groupBy, orderBy, extraAllowList) + ); + } } diff --git a/packages/frontend/core/src/modules/collection/entities/collection.ts b/packages/frontend/core/src/modules/collection/entities/collection.ts new file mode 100644 index 0000000000..b0b67b5f54 --- /dev/null +++ b/packages/frontend/core/src/modules/collection/entities/collection.ts @@ -0,0 +1,88 @@ +import { Entity, LiveData } from '@toeverything/infra'; +import { uniq } from 'lodash-es'; +import { map, switchMap } from 'rxjs'; + +import type { CollectionRulesService } from '../../collection-rules'; +import type { CollectionInfo, CollectionStore } from '../stores/collection'; + +export class Collection extends Entity<{ id: string }> { + constructor( + private readonly store: CollectionStore, + private readonly rulesService: CollectionRulesService + ) { + super(); + } + + id = this.props.id; + + info$ = LiveData.from( + this.store.watchCollectionInfo(this.id).pipe( + map( + info => + ({ + // default fields in case collection info is not found + name: '', + id: this.id, + rules: { + filters: [], + }, + allowList: [], + ...info, + }) as CollectionInfo + ) + ), + {} as CollectionInfo + ); + + name$ = this.info$.map(info => info.name); + allowList$ = this.info$.map(info => info.allowList); + rules$ = this.info$.map(info => info.rules); + + /** + * Returns a list of document IDs that match the collection rules and allow list. + * + * For performance optimization, + * Developers must explicitly call `watch()` to retrieve the result and properly manage the subscription lifecycle. + */ + watch() { + return this.info$.pipe( + switchMap(info => { + return this.rulesService + .watch( + info.rules.filters.length > 0 + ? [ + ...info.rules.filters, + // if we have more than one filter, we need to add a system filter to exclude trash + { + type: 'system', + key: 'trash', + method: 'is', + value: 'false', + }, + ] + : [], // If no filters are provided, an empty filter list will match no documents + undefined, + undefined, + info.allowList + ) + .pipe(map(result => result.groups.map(group => group.items).flat())); + }) + ); + } + + updateInfo(info: Partial) { + this.store.updateCollectionInfo(this.id, info); + } + + addDoc(...docIds: string[]) { + this.store.updateCollectionInfo(this.id, { + allowList: uniq([...this.info$.value.allowList, ...docIds]), + }); + } + + removeDoc(...docIds: string[]) { + this.store.updateCollectionInfo(this.id, { + allowList: this.info$.value.allowList.filter(id => !docIds.includes(id)), + }); + } +} diff --git a/packages/frontend/core/src/modules/collection/index.ts b/packages/frontend/core/src/modules/collection/index.ts index 9fb608c6d2..77ca8cc55a 100644 --- a/packages/frontend/core/src/modules/collection/index.ts +++ b/packages/frontend/core/src/modules/collection/index.ts @@ -1,12 +1,20 @@ +export { Collection } from './entities/collection'; +export type { CollectionMeta } from './services/collection'; export { CollectionService } from './services/collection'; +export type { CollectionInfo } from './stores/collection'; import { type Framework } from '@toeverything/infra'; +import { CollectionRulesService } from '../collection-rules'; import { WorkspaceScope, WorkspaceService } from '../workspace'; +import { Collection } from './entities/collection'; import { CollectionService } from './services/collection'; +import { CollectionStore } from './stores/collection'; export function configureCollectionModule(framework: Framework) { framework .scope(WorkspaceScope) - .service(CollectionService, [WorkspaceService]); + .service(CollectionService, [CollectionStore]) + .store(CollectionStore, [WorkspaceService]) + .entity(Collection, [CollectionStore, CollectionRulesService]); } diff --git a/packages/frontend/core/src/modules/collection/services/collection.ts b/packages/frontend/core/src/modules/collection/services/collection.ts index de434461f0..d511a59adf 100644 --- a/packages/frontend/core/src/modules/collection/services/collection.ts +++ b/packages/frontend/core/src/modules/collection/services/collection.ts @@ -1,193 +1,78 @@ -import type { - Collection, - DeleteCollectionInfo, - DeletedCollection, -} from '@affine/env/filter'; -import { LiveData, Service } from '@toeverything/infra'; -import { Observable } from 'rxjs'; -import { Array as YArray } from 'yjs'; +import { LiveData, ObjectPool, Service } from '@toeverything/infra'; +import { map } from 'rxjs'; -import type { WorkspaceService } from '../../workspace'; +import { Collection } from '../entities/collection'; +import type { CollectionInfo, CollectionStore } from '../stores/collection'; -const SETTING_KEY = 'setting'; - -const COLLECTIONS_KEY = 'collections'; -const COLLECTIONS_TRASH_KEY = 'collections_trash'; +export interface CollectionMeta extends Pick { + title: string; +} export class CollectionService extends Service { - constructor(private readonly workspaceService: WorkspaceService) { + constructor(private readonly store: CollectionStore) { super(); } - private get doc() { - return this.workspaceService.workspace.docCollection.doc; - } + pool = new ObjectPool({ + onDelete(obj) { + obj.dispose(); + }, + }); - private get setting() { - return this.workspaceService.workspace.docCollection.doc.getMap( - SETTING_KEY - ); - } - - private get collectionsYArray(): YArray | undefined { - return this.setting.get(COLLECTIONS_KEY) as YArray; - } - - private get collectionsTrashYArray(): YArray | undefined { - return this.setting.get(COLLECTIONS_TRASH_KEY) as YArray; - } + // collection metas used in collection list, only include `id` and `name`, without `rules` and `allowList` + readonly collectionMetas$ = LiveData.from( + this.store.watchCollectionMetas(), + [] + ); readonly collections$ = LiveData.from( - new Observable(subscriber => { - subscriber.next(this.collectionsYArray?.toArray() ?? []); - const fn = () => { - subscriber.next(this.collectionsYArray?.toArray() ?? []); - }; - this.setting.observeDeep(fn); - return () => { - this.setting.unobserveDeep(fn); - }; - }), - [] + this.store.watchCollectionIds().pipe( + map( + ids => + new Map( + ids.map(id => { + const exists = this.pool.get(id); + if (exists) { + return [id, exists.obj]; + } + const record = this.framework.createEntity(Collection, { id }); + this.pool.put(id, record); + return [id, record] as const; + }) + ) + ) + ), + new Map() ); collection$(id: string) { - return this.collections$.map(collections => { - return collections.find(v => v.id === id); + return this.collections$.selector(collections => { + return collections.get(id); }); } - readonly collectionsTrash$ = LiveData.from( - new Observable(subscriber => { - subscriber.next(this.collectionsTrashYArray?.toArray() ?? []); - const fn = () => { - subscriber.next(this.collectionsTrashYArray?.toArray() ?? []); - }; - this.setting.observeDeep(fn); - return () => { - this.setting.unobserveDeep(fn); - }; - }), - [] - ); - - addCollection(...collections: Collection[]) { - if (!this.setting.has(COLLECTIONS_KEY)) { - this.setting.set(COLLECTIONS_KEY, new YArray()); - } - this.doc.transact(() => { - this.collectionsYArray?.insert(0, collections); - }); + createCollection(collectionInfo: Partial>) { + return this.store.createCollection(collectionInfo); } - updateCollection(id: string, updater: (value: Collection) => Collection) { - if (this.collectionsYArray) { - updateFirstOfYArray( - this.collectionsYArray, - v => v.id === id, - v => { - return updater(v); - } - ); - } - } - - addPageToCollection(collectionId: string, pageId: string) { - this.updateCollection(collectionId, old => { - return { - ...old, - allowList: [pageId, ...(old.allowList ?? [])], - }; - }); - } - - deletePageFromCollection(collectionId: string, pageId: string) { - this.updateCollection(collectionId, old => { - return { - ...old, - allowList: old.allowList?.filter(id => id !== pageId), - }; - }); - } - - deleteCollection(info: DeleteCollectionInfo, ...ids: string[]) { - const collectionsYArray = this.collectionsYArray; - if (!collectionsYArray) { - return; - } - const set = new Set(ids); - this.workspaceService.workspace.docCollection.doc.transact(() => { - const indexList: number[] = []; - const list: Collection[] = []; - collectionsYArray.forEach((collection, i) => { - if (set.has(collection.id)) { - set.delete(collection.id); - indexList.unshift(i); - list.push(JSON.parse(JSON.stringify(collection))); - } - }); - indexList.forEach(i => { - collectionsYArray.delete(i); - }); - if (!this.collectionsTrashYArray) { - this.setting.set(COLLECTIONS_TRASH_KEY, new YArray()); - } - const collectionsTrashYArray = this.collectionsTrashYArray; - if (!collectionsTrashYArray) { - return; - } - collectionsTrashYArray.insert( - 0, - list.map(collection => ({ - userId: info?.userId, - userName: info ? info.userName : 'Local User', - collection, - })) - ); - if (collectionsTrashYArray.length > 10) { - collectionsTrashYArray.delete(10, collectionsTrashYArray.length - 10); - } - }); - } - - private deletePagesFromCollection( - collection: Collection, - idSet: Set + updateCollection( + id: string, + collectionInfo: Partial> ) { - const newAllowList = collection.allowList.filter(id => !idSet.has(id)); - if (newAllowList.length !== collection.allowList.length) { - this.updateCollection(collection.id, old => { - return { - ...old, - allowList: newAllowList, - }; - }); - } + return this.store.updateCollectionInfo(id, collectionInfo); } - deletePagesFromCollections(ids: string[]) { - const idSet = new Set(ids); - this.doc.transact(() => { - this.collections$.value.forEach(collection => { - this.deletePagesFromCollection(collection, idSet); - }); - }); + addDocToCollection(collectionId: string, docId: string) { + const collection = this.collection$(collectionId).value; + collection?.addDoc(docId); + } + + removeDocFromCollection(collectionId: string, docId: string) { + const collection = this.collection$(collectionId).value; + collection?.removeDoc(docId); + } + + deleteCollection(id: string) { + this.store.deleteCollection(id); } } - -const updateFirstOfYArray = ( - array: YArray, - p: (value: T) => boolean, - update: (value: T) => T -) => { - array.doc?.transact(() => { - for (let i = 0; i < array.length; i++) { - const ele = array.get(i); - if (p(ele)) { - array.delete(i); - array.insert(i, [update(ele)]); - return; - } - } - }); -}; diff --git a/packages/frontend/core/src/modules/collection/stores/collection.ts b/packages/frontend/core/src/modules/collection/stores/collection.ts new file mode 100644 index 0000000000..650928bae6 --- /dev/null +++ b/packages/frontend/core/src/modules/collection/stores/collection.ts @@ -0,0 +1,291 @@ +import type { Collection as LegacyCollectionInfo } from '@affine/env/filter'; +import { + Store, + yjsGetPath, + yjsObserve, + yjsObserveDeep, +} from '@toeverything/infra'; +import dayjs from 'dayjs'; +import { nanoid } from 'nanoid'; +import { map, type Observable, switchMap } from 'rxjs'; +import { Array as YArray } from 'yjs'; + +import type { FilterParams } from '../../collection-rules'; +import type { WorkspaceService } from '../../workspace'; + +export interface CollectionInfo { + id: string; + name: string; + rules: { + filters: FilterParams[]; + }; + allowList: string[]; +} + +export class CollectionStore extends Store { + constructor(private readonly workspaceService: WorkspaceService) { + super(); + } + + private get rootYDoc() { + return this.workspaceService.workspace.rootYDoc; + } + + private get workspaceSettingYMap() { + return this.rootYDoc.getMap('setting'); + } + + watchCollectionMetas() { + return yjsGetPath(this.workspaceSettingYMap, 'collections').pipe( + switchMap(yjsObserveDeep), + map(yjs => { + if (yjs instanceof YArray) { + return yjs.map(v => { + return { + id: v.id as string, + name: v.name as string, + // for old code compatibility + title: v.name as string, + }; + }); + } else { + return []; + } + }) + ); + } + + watchCollectionIds() { + return yjsGetPath(this.workspaceSettingYMap, 'collections').pipe( + switchMap(yjsObserve), + map(yjs => { + if (yjs instanceof YArray) { + return yjs.map(v => { + return v.id as string; + }); + } else { + return []; + } + }) + ); + } + + watchCollectionInfo(id: string): Observable { + return yjsGetPath(this.workspaceSettingYMap, 'collections').pipe( + switchMap(yjsObserve), + map(meta => { + if (meta instanceof YArray) { + // meta is YArray, `for-of` is faster then `for` + for (const doc of meta) { + if (doc && doc.id === id) { + return doc; + } + } + return null; + } else { + return null; + } + }), + switchMap(yjsObserveDeep), + map(yjs => { + if (yjs) { + return this.migrateCollectionInfo(yjs as LegacyCollectionInfo); + } else { + return null; + } + }) + ); + } + + createCollection(info: Partial>) { + const id = nanoid(); + let yArray = this.rootYDoc.getMap('setting').get('collections') as + | YArray + | undefined; + + if (!(yArray instanceof YArray)) { + // if collections list is not a YArray, create a new one + yArray = new YArray(); + this.rootYDoc.getMap('setting').set('collections', yArray); + } + + yArray.push([ + { + id: id, + name: info.name ?? '', + rules: info.rules ?? { filters: [] }, + allowList: info.allowList ?? [], + }, + ]); + + return id; + } + + deleteCollection(id: string) { + const yArray = this.rootYDoc.getMap('setting').get('collections') as + | YArray + | undefined; + + if (!(yArray instanceof YArray)) { + throw new Error('Collections is not a YArray'); + } + + for (let i = 0; i < yArray.length; i++) { + const collection = yArray.get(i); + if (collection.id === id) { + yArray.delete(i); + return; + } + } + } + + updateCollectionInfo(id: string, info: Partial>) { + const yArray = this.rootYDoc.getMap('setting').get('collections') as + | YArray + | undefined; + + if (!(yArray instanceof YArray)) { + throw new Error('Collections is not a YArray'); + } + + let collectionIndex = 0; + for (const collection of yArray) { + if (collection.id === id) { + this.rootYDoc.transact(() => { + yArray.delete(collectionIndex, 1); + yArray.insert(collectionIndex, [ + { + id: collection.id, + name: info.name ?? collection.name, + rules: info.rules ?? collection.rules, + allowList: info.allowList ?? collection.allowList, + }, + ]); + }); + + return; + } + collectionIndex++; + } + } + + migrateCollectionInfo( + legacyCollectionInfo: LegacyCollectionInfo + ): CollectionInfo { + if ('rules' in legacyCollectionInfo) { + return legacyCollectionInfo as CollectionInfo; + } + return { + id: legacyCollectionInfo.id, + name: legacyCollectionInfo.name, + rules: { + filters: this.migrateFilterList(legacyCollectionInfo.filterList), + }, + allowList: legacyCollectionInfo.allowList, + }; + } + + migrateFilterList( + filterList: LegacyCollectionInfo['filterList'] + ): FilterParams[] { + return filterList.map(filter => { + const leftValue = filter.left.name; + const method = filter.funcName; + const args = filter.args.map(arg => arg.value); + const arg0 = args[0]; + if (leftValue === 'Created' || leftValue === 'Updated') { + const key = leftValue === 'Created' ? 'createdAt' : 'updatedAt'; + if (method === 'after' && typeof arg0 === 'number') { + return { + type: 'system', + key, + method: 'after', + value: dayjs(arg0).format('YYYY-MM-DD'), + }; + } else if (method === 'before' && typeof arg0 === 'number') { + return { + type: 'system', + key, + method: 'before', + value: dayjs(arg0).format('YYYY-MM-DD'), + }; + } else if (method === 'last' && typeof arg0 === 'number') { + return { + type: 'system', + key, + method: 'last', + value: dayjs().subtract(arg0, 'day').format('YYYY-MM-DD'), + }; + } + } else if (leftValue === 'Is Favourited') { + if (method === 'is') { + const value = arg0 ? 'true' : 'false'; + return { + type: 'system', + key: 'favorite', + method: 'is', + value, + }; + } + } else if (leftValue === 'Tags') { + if (method === 'is not empty') { + return { + type: 'system', + key: 'tags', + method: 'is-not-empty', + }; + } else if (method === 'is empty') { + return { + type: 'system', + key: 'tags', + method: 'is-empty', + }; + } else if (method === 'contains all' && Array.isArray(arg0)) { + return { + type: 'system', + key: 'tags', + method: 'include-all', + value: arg0.join(','), + }; + } else if (method === 'contains one of' && Array.isArray(arg0)) { + return { + type: 'system', + key: 'tags', + method: 'include-any-of', + value: arg0.join(','), + }; + } else if (method === 'does not contains all' && Array.isArray(arg0)) { + return { + type: 'system', + key: 'tags', + method: 'not-include-all', + value: arg0.join(','), + }; + } else if ( + method === 'does not contains one of' && + Array.isArray(arg0) + ) { + return { + type: 'system', + key: 'tags', + method: 'not-include-any-of', + value: arg0.join(','), + }; + } + } else if (leftValue === 'Is Public' && method === 'is') { + return { + type: 'system', + key: 'shared', + method: 'is', + value: arg0 ? 'true' : 'false', + }; + } + + return { + type: 'unknown', + key: 'unknown', + method: 'unknown', + }; + }); + } +} diff --git a/packages/frontend/core/src/modules/doc/services/docs.ts b/packages/frontend/core/src/modules/doc/services/docs.ts index a19270305d..b0c6e09483 100644 --- a/packages/frontend/core/src/modules/doc/services/docs.ts +++ b/packages/frontend/core/src/modules/doc/services/docs.ts @@ -69,6 +69,10 @@ export class DocsService extends Service { return this.store.watchAllDocTagIds(); } + allDocIds$() { + return this.store.watchDocIds(); + } + allNonTrashDocIds$() { return this.store.watchNonTrashDocIds(); } diff --git a/packages/frontend/core/src/modules/quicksearch/impls/collections.ts b/packages/frontend/core/src/modules/quicksearch/impls/collections.ts index 6e7a03c8c4..9365d3990e 100644 --- a/packages/frontend/core/src/modules/quicksearch/impls/collections.ts +++ b/packages/frontend/core/src/modules/quicksearch/impls/collections.ts @@ -30,7 +30,7 @@ export class CollectionsQuickSearchSession LiveData.computed(get => { const query = get(this.query$); - const collections = get(this.collectionService.collections$); + const collections = get(this.collectionService.collectionMetas$); const fuse = new Fuse(collections, { keys: ['name'], diff --git a/packages/frontend/core/src/modules/search-menu/services/index.ts b/packages/frontend/core/src/modules/search-menu/services/index.ts index 4ead75a03b..240e3207af 100644 --- a/packages/frontend/core/src/modules/search-menu/services/index.ts +++ b/packages/frontend/core/src/modules/search-menu/services/index.ts @@ -1,7 +1,4 @@ -import type { - CollectionMeta, - TagMeta, -} from '@affine/core/components/page-list'; +import type { TagMeta } from '@affine/core/components/page-list'; import { I18n } from '@affine/i18n'; import { createSignalFromObservable } from '@blocksuite/affine/shared/utils'; import type { DocMeta } from '@blocksuite/affine/store'; @@ -18,7 +15,7 @@ import { html } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { map } from 'rxjs'; -import type { CollectionService } from '../../collection'; +import type { CollectionMeta, CollectionService } from '../../collection'; import type { DocDisplayMetaService } from '../../doc-display-meta'; import type { DocsSearchService } from '../../docs-search'; import { type RecentDocsService } from '../../quicksearch'; @@ -298,7 +295,7 @@ export class SearchMenuService extends Service { action: SearchCollectionMenuAction, _abortSignal: AbortSignal ): LinkedMenuGroup { - const collections = this.collectionService.collections$.value; + const collections = this.collectionService.collectionMetas$.value; if (query.trim().length === 0) { return { name: I18n.t('com.affine.editor.at-menu.collections', { diff --git a/packages/frontend/core/src/modules/share-doc/entities/share-docs-list.ts b/packages/frontend/core/src/modules/share-doc/entities/share-docs-list.ts index c85fddc34a..844812ccf2 100644 --- a/packages/frontend/core/src/modules/share-doc/entities/share-docs-list.ts +++ b/packages/frontend/core/src/modules/share-doc/entities/share-docs-list.ts @@ -11,7 +11,7 @@ import { onStart, smartRetry, } from '@toeverything/infra'; -import { tap } from 'rxjs'; +import { map, tap } from 'rxjs'; import type { GlobalCache } from '../../storage'; import type { WorkspaceService } from '../../workspace'; @@ -22,7 +22,12 @@ type ShareDocListType = GetWorkspacePublicPagesQuery['workspace']['publicDocs']; export const logger = new DebugLogger('affine:share-doc-list'); export class ShareDocsList extends Entity { - list$ = LiveData.from(this.cache.watch('share-docs'), []); + list$ = LiveData.from( + this.cache + .watch('share-docs') + .pipe(map(list => list ?? [])), + [] + ); isLoading$ = new LiveData(false); error$ = new LiveData(null); diff --git a/packages/frontend/core/src/modules/workspace-property/types.ts b/packages/frontend/core/src/modules/workspace-property/types.ts index 5f0fca80e5..d7e87bafca 100644 --- a/packages/frontend/core/src/modules/workspace-property/types.ts +++ b/packages/frontend/core/src/modules/workspace-property/types.ts @@ -13,7 +13,13 @@ type DateFilters = export type WorkspacePropertyTypes = { tags: { - filter: 'include' | 'is-not-empty' | 'is-empty'; + filter: + | 'include-all' + | 'include-any-of' + | 'not-include-all' + | 'not-include-any-of' + | 'is-not-empty' + | 'is-empty'; }; text: { filter: 'is' | 'is-not' | 'is-not-empty' | 'is-empty'; diff --git a/packages/frontend/core/src/utils/user-setting.ts b/packages/frontend/core/src/utils/user-setting.ts index bb1b642267..7d83d695b2 100644 --- a/packages/frontend/core/src/utils/user-setting.ts +++ b/packages/frontend/core/src/utils/user-setting.ts @@ -1,4 +1,3 @@ -import type { Collection } from '@affine/env/filter'; import type { Workspace } from '@blocksuite/affine/store'; import { nanoid } from 'nanoid'; import type { Map as YMap } from 'yjs'; @@ -29,13 +28,6 @@ export class UserSetting { } return this.setting.whenLoaded; } - - /** - * @deprecated - */ - get view() { - return this.setting.getMap('view') as YMap; - } } export const getUserSetting = (docCollection: Workspace, userId: string) => { diff --git a/tests/affine-local/e2e/all-page.spec.ts b/tests/affine-local/e2e/all-page.spec.ts index 8e025c64c7..f236122cd0 100644 --- a/tests/affine-local/e2e/all-page.spec.ts +++ b/tests/affine-local/e2e/all-page.spec.ts @@ -1,17 +1,6 @@ /* oxlint-disable unicorn/prefer-dom-node-dataset */ import { test } from '@affine-test/kit/playwright'; -import { - changeFilter, - checkDatePicker, - checkDatePickerMonth, - checkFilterName, - clickDatePicker, - createFirstFilter, - createPageWithTag, - getPagesCount, - selectMonthFromMonthPicker, - selectTag, -} from '@affine-test/kit/utils/filter'; +import { getPagesCount } from '@affine-test/kit/utils/filter'; import { openHomePage } from '@affine-test/kit/utils/load-page'; import { clickNewPageButton, @@ -52,75 +41,6 @@ test('all page can create new edgeless page', async ({ page }) => { await expect(page.locator('affine-edgeless-root')).toBeVisible(); }); -test('allow creation of filters by favorite', async ({ page }) => { - await openHomePage(page); - await waitForEditorLoad(page); - await clickSideBarAllPageButton(page); - await createFirstFilter(page, 'Favourited'); - await page - .locator('[data-testid="filter-arg"]', { hasText: 'true' }) - .locator('div') - .click(); - expect(await page.locator('[data-testid="filter-arg"]').textContent()).toBe( - 'false' - ); -}); - -test('use monthpicker to modify the month of datepicker', async ({ page }) => { - await openHomePage(page); - await waitForEditorLoad(page); - await clickSideBarAllPageButton(page); - await createFirstFilter(page, 'Created'); - await checkFilterName(page, 'after'); - // init date - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - await checkDatePicker(page, yesterday); - // change month - await clickDatePicker(page); - const lastMonth = new Date(); - lastMonth.setMonth(lastMonth.getMonth() - 1); - const datePicker = page.locator( - '[role="dialog"] [data-testid="date-picker-calendar"]' - ); - await selectMonthFromMonthPicker(datePicker, lastMonth); - await checkDatePickerMonth(datePicker, lastMonth); - // change month - const nextMonth = new Date(); - nextMonth.setMonth(nextMonth.getMonth() + 1); - await selectMonthFromMonthPicker(datePicker, nextMonth); - await checkDatePickerMonth(datePicker, nextMonth); -}); - -test('allow creation of filters by tags', async ({ page }) => { - await openHomePage(page); - await waitForEditorLoad(page); - await clickSideBarAllPageButton(page); - await waitForAllPagesLoad(page); - const pageCount = await getPagesCount(page); - expect(pageCount).not.toBe(0); - await createFirstFilter(page, 'Tags'); - await checkFilterName(page, 'is not empty'); - const pagesWithTags = await page - .locator('[data-testid="page-list-item"]') - .all(); - const pagesWithTagsCount = pagesWithTags.length; - expect(pagesWithTagsCount).toBe(0); - await createPageWithTag(page, { title: 'Page A', tags: ['Page A'] }); - await createPageWithTag(page, { title: 'Page B', tags: ['Page B'] }); - await clickSideBarAllPageButton(page); - await createFirstFilter(page, 'Tags'); - await checkFilterName(page, 'is not empty'); - expect(await getPagesCount(page)).toBe(pagesWithTagsCount + 2); - await changeFilter(page, 'contains all'); - expect(await getPagesCount(page)).toBe(pageCount + 2); - await selectTag(page, 'Page A'); - expect(await getPagesCount(page)).toBe(1); - await changeFilter(page, 'does not contains all'); - await selectTag(page, 'Page B'); - expect(await getPagesCount(page)).toBe(pageCount + 1); -}); - test('enable selection and use ESC to disable selection', async ({ page }) => { await openHomePage(page); await waitForEditorLoad(page); @@ -155,8 +75,8 @@ test('enable selection and use ESC to disable selection', async ({ page }) => { .count() ).toBeGreaterThan(0); - // wait for 300ms - await page.waitForTimeout(300); + // wait for 500ms + await page.waitForTimeout(500); // esc again, checkboxes should disappear await page.keyboard.press('Escape'); diff --git a/tests/affine-local/e2e/local-first-collections-items.spec.ts b/tests/affine-local/e2e/local-first-collections-items.spec.ts index b54a0ecf8d..7cd81ad6a7 100644 --- a/tests/affine-local/e2e/local-first-collections-items.spec.ts +++ b/tests/affine-local/e2e/local-first-collections-items.spec.ts @@ -34,48 +34,43 @@ const createAndPinCollection = async ( collectionName?: string; } ) => { - await clickNewPageButton(page); - await getBlockSuiteEditorTitle(page).click(); - await getBlockSuiteEditorTitle(page).fill('test page'); - - // fixme: remove this timeout. looks like an issue with useBindWorkbenchToBrowserRouter? - await page.waitForTimeout(500); - await page.getByTestId('all-pages').click(); - const cell = page.getByTestId('page-list-item-title').getByText('test page'); - await expect(cell).toBeVisible(); - await page.getByTestId('create-first-filter').click({ - delay: 200, - }); - await page - .getByTestId('variable-select') - .getByTestId(`filler-tag-Created`) - .click({ - delay: 200, - }); - await page.getByTestId('save-as-collection').click({ - delay: 200, - }); + await page.getByTestId('navigation-panel-bar-add-collection-button').click(); const title = page.getByTestId('prompt-modal-input'); await expect(title).toBeVisible(); await title.fill(options?.collectionName ?? 'test collection'); await page.getByTestId('prompt-modal-confirm').click(); await page.waitForTimeout(100); + await page + .locator('[data-testid^="navigation-panel-collection-"]') + .first() + .click(); + await page.getByTestId('collection-add-doc-button').click(); + await page.getByTestId('confirm-modal-confirm').click(); + + // fixme: remove this timeout. looks like an issue with useBindWorkbenchToBrowserRouter? + await page.waitForTimeout(500); + + await getBlockSuiteEditorTitle(page).click(); + await getBlockSuiteEditorTitle(page).fill('test page'); + + await page.getByTestId('all-pages').click(); + + const cell = page.getByTestId('page-list-item-title').getByText('test page'); + await expect(cell).toBeVisible(); }; test('Show collections items in sidebar', async ({ page }) => { await removeOnboardingPages(page); await createAndPinCollection(page); const collections = page.getByTestId('navigation-panel-collections'); - await collections.getByTestId('category-divider-collapse-button').click(); const items = collections.locator( '[data-testid^="navigation-panel-collection-"]' ); await expect(items).toHaveCount(1); const first = items.first(); - expect(await first.textContent()).toBe('test collection'); - await first.getByTestId('navigation-panel-collapsed-button').click(); + expect((await first.textContent())!.startsWith('test collection')).toBe(true); const collectionPage = first .locator('[data-testid^="navigation-panel-doc-"]') .nth(0); @@ -118,34 +113,12 @@ test('edit collection', async ({ page }) => { await removeOnboardingPages(page); await createAndPinCollection(page); const collections = page.getByTestId('navigation-panel-collections'); - await collections.getByTestId('category-divider-collapse-button').click(); - const items = collections.locator( - '[data-testid^="navigation-panel-collection-"]' - ); - await expect(items).toHaveCount(1); - const first = items.first(); - await first.hover(); - await first - .getByTestId('navigation-panel-tree-node-operation-button') - .click(); - const editCollection = page.getByText('Rename'); - await editCollection.click(); - await page.getByTestId('rename-modal-input').fill('123'); - await page.keyboard.press('Enter'); - await page.waitForTimeout(100); - expect(await first.textContent()).toBe('123'); -}); - -test('edit collection and change filter date', async ({ page }) => { - await removeOnboardingPages(page); - await createAndPinCollection(page); - const collections = page.getByTestId('navigation-panel-collections'); - await collections.getByTestId('category-divider-collapse-button').click(); const items = collections.locator( '[data-testid^="navigation-panel-collection-"]' ); await expect(items).toHaveCount(1); const first = items.first(); + await first.getByTestId('navigation-panel-collapsed-button').first().click(); await first.hover(); await first .getByTestId('navigation-panel-tree-node-operation-button') diff --git a/tests/kit/src/utils/filter.ts b/tests/kit/src/utils/filter.ts index 17ac802dd6..0cb8e47c5d 100644 --- a/tests/kit/src/utils/filter.ts +++ b/tests/kit/src/utils/filter.ts @@ -1,43 +1,4 @@ -import type { Locator, Page } from '@playwright/test'; -import { expect } from '@playwright/test'; - -import { clickNewPageButton, getBlockSuiteEditorTitle } from './page-logic'; - -const monthNames = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', -]; - -export const createFirstFilter = async (page: Page, name: string) => { - await page.locator('[data-testid="create-first-filter"]').click(); - await page - .locator('[data-testid="variable-select-item"]', { hasText: name }) - .click(); - await page.keyboard.press('Escape'); -}; - -export const checkFilterName = async (page: Page, name: string) => { - const filterName = await page - .locator('[data-testid="filter-name"]') - .textContent(); - expect(filterName).toBe(name); -}; - -const dateFormat = (date: Date) => { - const month = monthNames[date.getMonth()]; - const day = date.getDate().toString().padStart(2, '0'); - return `${month} ${day}`; -}; +import type { Page } from '@playwright/test'; // fixme: there could be multiple page lists in the Page export const getPagesCount = async (page: Page) => { @@ -54,97 +15,6 @@ export const getPagesCount = async (page: Page) => { return count ? parseInt(count) : 0; }; -export const checkDatePicker = async (page: Page, date: Date) => { - expect( - await page - .locator('[data-testid="filter-arg"]') - .locator('input') - .inputValue() - ).toBe(dateFormat(date)); -}; - -export const clickDatePicker = async (page: Page) => { - await page.locator('[data-testid="filter-arg"]').locator('input').click(); -}; - -const clickMonthPicker = async (page: Page | Locator) => { - await page.locator('[data-testid="month-picker-button"]').click(); -}; - -export const fillDatePicker = async (page: Page, date: Date) => { - await page - .locator('[data-testid="filter-arg"]') - .locator('input') - .fill(dateFormat(date)); -}; - -export const selectMonthFromMonthPicker = async ( - page: Page | Locator, - date: Date -) => { - const month = (date.getMonth() + 1).toString().padStart(2, '0'); - const year = date.getFullYear(); - // Open the month picker popup - await clickMonthPicker(page); - const selectMonth = async (): Promise => { - const selectedYear = +(await page - .getByTestId('month-picker-current-year') - .innerText()); - if (selectedYear > year) { - await page.locator('[data-testid="date-picker-nav-prev"]').click(); - return await selectMonth(); - } else if (selectedYear < year) { - await page.locator('[data-testid="date-picker-nav-next"]').click(); - return await selectMonth(); - } - // Click on the day cell - const monthCell = page.locator( - `[data-is-month-cell][aria-label="${year}-${month}"]` - ); - await monthCell.click(); - }; - await selectMonth(); -}; - -export const checkDatePickerMonth = async ( - page: Page | Locator, - date: Date -) => { - expect( - await page.getByTestId('month-picker-button').evaluate(e => e.dataset.month) - ).toBe(date.getMonth().toString()); -}; - -const createTag = async (page: Page, name: string) => { - await page.keyboard.type(name); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('Enter'); -}; - -export const createPageWithTag = async ( - page: Page, - options: { - title: string; - tags: string[]; - } -) => { - await page.getByTestId('all-pages').click(); - await clickNewPageButton(page); - await getBlockSuiteEditorTitle(page).click(); - await getBlockSuiteEditorTitle(page).fill('test page'); - await page.getByTestId('page-info-collapse').click(); - await page.locator('[data-testid="property-tags-value"]').click(); - for (const name of options.tags) { - await createTag(page, name); - } - await page.keyboard.press('Escape'); -}; - -export const changeFilter = async (page: Page, to: string) => { - await page.getByTestId('filter-name').click(); - await page.getByTestId(`filler-tag-${to}`).click(); -}; - export async function selectTag(page: Page, name: string | RegExp) { await page.getByTestId('filter-arg').click(); await page.getByTestId(`multi-select-${name}`).click(); diff --git a/tests/kit/src/utils/page-logic.ts b/tests/kit/src/utils/page-logic.ts index 980efd1af0..c22934fdc2 100644 --- a/tests/kit/src/utils/page-logic.ts +++ b/tests/kit/src/utils/page-logic.ts @@ -30,10 +30,13 @@ export async function waitForEditorLoad(page: Page) { } export async function waitForAllPagesLoad(page: Page) { - // if filters tag is rendered, we believe all_pages is ready - await page.waitForSelector('[data-testid="create-first-filter"]', { - timeout: 20000, - }); + // if page-list-header-selection-checkbox is rendered, we believe all_pages is ready + await page.waitForSelector( + '[data-testid="page-list-header-selection-checkbox"]', + { + timeout: 20000, + } + ); } export async function clickNewPageButton(page: Page, title?: string) {