From 61b99c593409712609950873a7504d0ac6da6557 Mon Sep 17 00:00:00 2001 From: EYHN Date: Wed, 14 May 2025 18:18:43 +0900 Subject: [PATCH] feat(core): add pinned collections to all docs (#12269) --- .../core/src/components/explorer/context.ts | 5 +- .../core/src/components/filter/add-filter.tsx | 16 +- .../core/src/components/filter/filters.tsx | 5 +- .../core/src/components/filter/styles.css.ts | 14 +- .../pages/workspace/all-page/all-page.css.ts | 23 +- .../pages/workspace/all-page/all-page.tsx | 197 ++++++++++++++---- .../workspace/all-page/migration-data.css.ts | 10 + .../all-page/pinned-collections.css.ts | 34 +++ .../workspace/all-page/pinned-collections.tsx | 180 ++++++++++++++++ .../core/src/modules/collection/index.ts | 9 +- .../collection/services/pinned-collection.ts | 71 +++++++ .../collection/stores/pinned-collection.ts | 30 +++ .../core/src/modules/db/schema/schema.ts | 4 + .../i18n/src/i18n-completenesses.json | 2 +- packages/frontend/i18n/src/i18n.gen.ts | 4 + packages/frontend/i18n/src/resources/en.json | 1 + 16 files changed, 558 insertions(+), 47 deletions(-) create mode 100644 packages/frontend/core/src/desktop/pages/workspace/all-page/pinned-collections.css.ts create mode 100644 packages/frontend/core/src/desktop/pages/workspace/all-page/pinned-collections.tsx create mode 100644 packages/frontend/core/src/modules/collection/services/pinned-collection.ts create mode 100644 packages/frontend/core/src/modules/collection/stores/pinned-collection.ts diff --git a/packages/frontend/core/src/components/explorer/context.ts b/packages/frontend/core/src/components/explorer/context.ts index bb35e4b86..8ab7a2a05 100644 --- a/packages/frontend/core/src/components/explorer/context.ts +++ b/packages/frontend/core/src/components/explorer/context.ts @@ -12,7 +12,9 @@ export type DocExplorerContextType = { selectedDocIds$: LiveData; prevCheckAnchorId$?: LiveData; } & { - [K in keyof ExplorerPreference as `${K}$`]: LiveData; + [K in keyof Omit as `${K}$`]: LiveData< + ExplorerPreference[K] + >; }; export const DocExplorerContext = createContext( @@ -27,7 +29,6 @@ export const createDocExplorerContext = () => selectMode$: new LiveData(false), selectedDocIds$: new LiveData([]), prevCheckAnchorId$: new LiveData(null), - filters$: new LiveData([]), groupBy$: new LiveData(undefined), orderBy$: new LiveData(undefined), displayProperties$: new LiveData( diff --git a/packages/frontend/core/src/components/filter/add-filter.tsx b/packages/frontend/core/src/components/filter/add-filter.tsx index 47e282096..0ca6a47a4 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 { FavoriteIcon, PlusIcon } from '@blocksuite/icons/rc'; +import { ArrowLeftBigIcon, FavoriteIcon, PlusIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; import { WorkspacePropertyIcon, WorkspacePropertyName } from '../properties'; @@ -11,8 +11,10 @@ import * as styles from './styles.css'; export const AddFilterMenu = ({ onAdd, + onBack, }: { onAdd: (params: FilterParams) => void; + onBack?: () => void; }) => { const t = useI18n(); const workspacePropertyService = useService(WorkspacePropertyService); @@ -20,9 +22,17 @@ export const AddFilterMenu = ({ return ( <> -
- {t['com.affine.filter']()} +
+ {onBack && ( + + + + )} +
+ {t['com.affine.filter']()} +
+ } diff --git a/packages/frontend/core/src/components/filter/filters.tsx b/packages/frontend/core/src/components/filter/filters.tsx index 20f80a563..2518df675 100644 --- a/packages/frontend/core/src/components/filter/filters.tsx +++ b/packages/frontend/core/src/components/filter/filters.tsx @@ -1,4 +1,5 @@ import type { FilterParams } from '@affine/core/modules/collection-rules'; +import clsx from 'clsx'; import { AddFilter } from './add-filter'; import { Filter } from './filter'; @@ -6,9 +7,11 @@ import * as styles from './styles.css'; export const Filters = ({ filters, + className, onChange, }: { filters: FilterParams[]; + className?: string; onChange?: (filters: FilterParams[]) => void; }) => { const handleDelete = (index: number) => { @@ -20,7 +23,7 @@ export const Filters = ({ }; return ( -
+
{filters.map((filter, index) => { return ( { const t = useI18n(); const docsService = useService(DocsService); + const collectionService = useService(CollectionService); + const pinnedCollectionService = useService(PinnedCollectionService); + + const [selectedCollectionId, setSelectedCollectionId] = useState< + string | null + >(null); + const selectedCollection = useLiveData( + selectedCollectionId + ? collectionService.collection$(selectedCollectionId) + : null + ); + + useEffect(() => { + // if selected collection is not found, set selected collection id to null + if (!selectedCollection && selectedCollectionId) { + setSelectedCollectionId(null); + } + }, [selectedCollection, selectedCollectionId]); + + const selectedCollectionInfo = useLiveData( + selectedCollection ? selectedCollection.info$ : null + ); + + const [tempFilters, setTempFilters] = useState([]); const [explorerContextValue] = useState(createDocExplorerContext); const view = useLiveData(explorerContextValue.view$); - const filters = useLiveData(explorerContextValue.filters$); const groupBy = useLiveData(explorerContextValue.groupBy$); const orderBy = useLiveData(explorerContextValue.orderBy$); const groups = useLiveData(explorerContextValue.groups$); @@ -112,8 +146,8 @@ export const AllPage = () => { const collapsedGroups = useLiveData(explorerContextValue.collapsedGroups$); const selectMode = useLiveData(explorerContextValue.selectMode$); + const { openPromptModal } = usePromptModal(); const { openConfirmModal } = useConfirmModal(); - const masonryItems = useMemo(() => { const items = groups.map((group: any) => { return { @@ -143,12 +177,21 @@ export const AllPage = () => { const collectionRulesService = useService(CollectionRulesService); useEffect(() => { const subscription = collectionRulesService - .watch({ - filters: - filters && filters.length > 0 - ? filters - : [ - // if no filters are present, match all non-trash documents + .watch( + // collection filters and temp filters can't exist at the same time + selectedCollectionInfo + ? { + filters: selectedCollectionInfo.rules.filters, + groupBy, + orderBy, + extraAllowList: selectedCollectionInfo.allowList, + extraFilters: [ + { + type: 'system', + key: 'empty-journal', + method: 'is', + value: 'false', + }, { type: 'system', key: 'trash', @@ -156,23 +199,38 @@ export const AllPage = () => { value: 'false', }, ], - groupBy, - orderBy, - extraFilters: [ - { - type: 'system', - key: 'empty-journal', - method: 'is', - value: 'false', - }, - { - type: 'system', - key: 'trash', - method: 'is', - value: 'false', - }, - ], - }) + } + : { + filters: + tempFilters && tempFilters.length > 0 + ? tempFilters + : [ + // if no filters are present, match all non-trash documents + { + type: 'system', + key: 'trash', + method: 'is', + value: 'false', + }, + ], + groupBy, + orderBy, + extraFilters: [ + { + type: 'system', + key: 'empty-journal', + method: 'is', + value: 'false', + }, + { + type: 'system', + key: 'trash', + method: 'is', + value: 'false', + }, + ], + } + ) .subscribe({ next: result => { explorerContextValue.groups$.next(result.groups); @@ -186,10 +244,12 @@ export const AllPage = () => { }; }, [ collectionRulesService, - explorerContextValue.groups$, - filters, + explorerContextValue, groupBy, orderBy, + selectedCollection, + selectedCollectionInfo, + tempFilters, ]); useEffect(() => { @@ -206,12 +266,9 @@ export const AllPage = () => { }; }, [explorerContextValue]); - const handleFilterChange = useCallback( - (filters: FilterParams[]) => { - explorerContextValue.filters$.next(filters); - }, - [explorerContextValue] - ); + const handleFilterChange = useCallback((filters: FilterParams[]) => { + setTempFilters(filters); + }, []); const handleCloseFloatingToolbar = useCallback(() => { explorerContextValue.selectMode$.next(false); @@ -246,6 +303,42 @@ export const AllPage = () => { }); }, [docsService.list, openConfirmModal, selectedDocIds, t]); + const handleSaveFilters = useCallback(() => { + openPromptModal({ + title: t['com.affine.editCollection.saveCollection'](), + label: t['com.affine.editCollectionName.name'](), + inputOptions: { + placeholder: t['com.affine.editCollectionName.name.placeholder'](), + }, + children: t['com.affine.editCollectionName.createTips'](), + confirmText: t['com.affine.editCollection.save'](), + cancelText: t['com.affine.editCollection.button.cancel'](), + confirmButtonOptions: { + variant: 'primary', + }, + onConfirm(name) { + const id = collectionService.createCollection({ + name, + rules: { + filters: tempFilters, + }, + }); + pinnedCollectionService.addPinnedCollection({ + collectionId: id, + index: pinnedCollectionService.indexAt('after'), + }); + setTempFilters([]); + setSelectedCollectionId(id); + }, + }); + }, [ + collectionService, + openPromptModal, + pinnedCollectionService, + t, + tempFilters, + ]); + return ( @@ -255,10 +348,40 @@ export const AllPage = () => {
-
- - + +
+ setSelectedCollectionId(null)} + onClickCollection={collectionId => { + setSelectedCollectionId(collectionId); + setTempFilters([]); + }} + onAddFilter={params => { + setSelectedCollectionId(null); + setTempFilters([...(tempFilters ?? []), params]); + }} + hiddenAdd={tempFilters.length > 0} + />
+ {tempFilters.length > 0 && ( +
+ + + +
+ )}
void; +}) => { + const t = useI18n(); + const collectionService = useService(CollectionService); + const collection = useLiveData( + collectionService.collection$(record.collectionId) + ); + const name = useLiveData(collection?.name$); + if (!collection) { + return null; + } + return ( +
+ {name ?? t['Untitled']()} +
+ ); +}; + +export const PinnedCollections = ({ + activeCollectionId, + onClickAll, + onClickCollection, + onAddFilter, + hiddenAdd, +}: { + activeCollectionId: string | null; + onClickAll: () => void; + onClickCollection: (collectionId: string) => void; + onAddFilter: (params: FilterParams) => void; + hiddenAdd?: boolean; +}) => { + const t = useI18n(); + const pinnedCollectionService = useService(PinnedCollectionService); + const pinnedCollections = useLiveData( + pinnedCollectionService.sortedPinnedCollections$ + ); + + const handleAddPinnedCollection = (collectionId: string) => { + pinnedCollectionService.addPinnedCollection({ + collectionId, + index: pinnedCollectionService.indexAt('after'), + }); + }; + + return ( +
+
+ {t['com.affine.all-docs.pinned-collection.all']()} +
+ {pinnedCollections.map(record => ( + onClickCollection(record.collectionId)} + /> + ))} + {!hiddenAdd && ( + + )} +
+ ); +}; + +export const AddPinnedCollection = ({ + onAddPinnedCollection, + onAddFilter, +}: { + onAddPinnedCollection: (collectionId: string) => void; + onAddFilter: (params: FilterParams) => void; +}) => { + return ( + + } + > + + + + + ); +}; + +export const AddPinnedCollectionMenuContent = ({ + onAddPinnedCollection, + onAddFilter, +}: { + onAddPinnedCollection: (collectionId: string) => void; + onAddFilter: (params: FilterParams) => void; +}) => { + const [addingFilter, setAddingFilter] = useState(false); + const collectionService = useService(CollectionService); + const collectionMetas = useLiveData(collectionService.collectionMetas$); + const pinnedCollectionService = useService(PinnedCollectionService); + const pinnedCollections = useLiveData( + pinnedCollectionService.pinnedCollections$ + ); + + const unpinnedCollectionMetas = useMemo( + () => + collectionMetas.filter( + meta => + !pinnedCollections.some( + collection => collection.collectionId === meta.id + ) + ), + [pinnedCollections, collectionMetas] + ); + + const t = useI18n(); + + return !addingFilter ? ( + <> + } + onClick={e => { + // prevent default to avoid closing the menu + e.preventDefault(); + setAddingFilter(true); + }} + > + {t['com.affine.filter']()} + + {unpinnedCollectionMetas.length > 0 && } + {unpinnedCollectionMetas.map(meta => ( + } + suffixIcon={} + onClick={() => { + onAddPinnedCollection(meta.id); + }} + > + {meta.name ?? t['Untitled']()} + + ))} + + ) : ( + setAddingFilter(false)} onAdd={onAddFilter} /> + ); +}; diff --git a/packages/frontend/core/src/modules/collection/index.ts b/packages/frontend/core/src/modules/collection/index.ts index 77ca8cc55..1727e455a 100644 --- a/packages/frontend/core/src/modules/collection/index.ts +++ b/packages/frontend/core/src/modules/collection/index.ts @@ -1,20 +1,27 @@ export { Collection } from './entities/collection'; export type { CollectionMeta } from './services/collection'; export { CollectionService } from './services/collection'; +export { PinnedCollectionService } from './services/pinned-collection'; export type { CollectionInfo } from './stores/collection'; +export type { PinnedCollectionRecord } from './stores/pinned-collection'; import { type Framework } from '@toeverything/infra'; import { CollectionRulesService } from '../collection-rules'; +import { WorkspaceDBService } from '../db'; import { WorkspaceScope, WorkspaceService } from '../workspace'; import { Collection } from './entities/collection'; import { CollectionService } from './services/collection'; +import { PinnedCollectionService } from './services/pinned-collection'; import { CollectionStore } from './stores/collection'; +import { PinnedCollectionStore } from './stores/pinned-collection'; export function configureCollectionModule(framework: Framework) { framework .scope(WorkspaceScope) .service(CollectionService, [CollectionStore]) .store(CollectionStore, [WorkspaceService]) - .entity(Collection, [CollectionStore, CollectionRulesService]); + .entity(Collection, [CollectionStore, CollectionRulesService]) + .store(PinnedCollectionStore, [WorkspaceDBService]) + .service(PinnedCollectionService, [PinnedCollectionStore]); } diff --git a/packages/frontend/core/src/modules/collection/services/pinned-collection.ts b/packages/frontend/core/src/modules/collection/services/pinned-collection.ts new file mode 100644 index 000000000..495ebbde3 --- /dev/null +++ b/packages/frontend/core/src/modules/collection/services/pinned-collection.ts @@ -0,0 +1,71 @@ +import { + generateFractionalIndexingKeyBetween, + LiveData, + Service, +} from '@toeverything/infra'; + +import type { + PinnedCollectionRecord, + PinnedCollectionStore, +} from '../stores/pinned-collection'; + +export class PinnedCollectionService extends Service { + constructor(private readonly pinnedCollectionStore: PinnedCollectionStore) { + super(); + } + + pinnedCollections$ = LiveData.from( + this.pinnedCollectionStore.watchPinnedCollections(), + [] + ); + + sortedPinnedCollections$ = this.pinnedCollections$.map(records => + records.toSorted((a, b) => { + return a.index > b.index ? 1 : -1; + }) + ); + + addPinnedCollection(record: PinnedCollectionRecord) { + this.pinnedCollectionStore.addPinnedCollection(record); + } + + removePinnedCollection(collectionId: string) { + this.pinnedCollectionStore.removePinnedCollection(collectionId); + } + + indexAt(at: 'before' | 'after', targetId?: string) { + if (!targetId) { + if (at === 'before') { + const first = this.sortedPinnedCollections$.value.at(0); + return generateFractionalIndexingKeyBetween(null, first?.index || null); + } else { + const last = this.sortedPinnedCollections$.value.at(-1); + return generateFractionalIndexingKeyBetween(last?.index || null, null); + } + } else { + const sortedChildren = this.sortedPinnedCollections$.value; + const targetIndex = sortedChildren.findIndex( + node => node.collectionId === targetId + ); + if (targetIndex === -1) { + throw new Error('Target node not found'); + } + const target = sortedChildren[targetIndex]; + const before: PinnedCollectionRecord | null = + sortedChildren[targetIndex - 1] || null; + const after: PinnedCollectionRecord | null = + sortedChildren[targetIndex + 1] || null; + if (at === 'before') { + return generateFractionalIndexingKeyBetween( + before?.index || null, + target.index + ); + } else { + return generateFractionalIndexingKeyBetween( + target.index, + after?.index || null + ); + } + } + } +} diff --git a/packages/frontend/core/src/modules/collection/stores/pinned-collection.ts b/packages/frontend/core/src/modules/collection/stores/pinned-collection.ts new file mode 100644 index 000000000..3e5afa44c --- /dev/null +++ b/packages/frontend/core/src/modules/collection/stores/pinned-collection.ts @@ -0,0 +1,30 @@ +import { Store } from '@toeverything/infra'; +import type { Observable } from 'rxjs'; + +import type { WorkspaceDBService } from '../../db'; + +export interface PinnedCollectionRecord { + collectionId: string; + index: string; +} + +export class PinnedCollectionStore extends Store { + constructor(private readonly workspaceDBService: WorkspaceDBService) { + super(); + } + + watchPinnedCollections(): Observable { + return this.workspaceDBService.db.pinnedCollections.find$(); + } + + addPinnedCollection(record: PinnedCollectionRecord) { + this.workspaceDBService.db.pinnedCollections.create({ + collectionId: record.collectionId, + index: record.index, + }); + } + + removePinnedCollection(collectionId: string) { + this.workspaceDBService.db.pinnedCollections.delete(collectionId); + } +} diff --git a/packages/frontend/core/src/modules/db/schema/schema.ts b/packages/frontend/core/src/modules/db/schema/schema.ts index a284feb62..0aa10a03f 100644 --- a/packages/frontend/core/src/modules/db/schema/schema.ts +++ b/packages/frontend/core/src/modules/db/schema/schema.ts @@ -42,6 +42,10 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = { isDeleted: f.boolean().optional(), // we will keep deleted properties in the database, for override legacy data }, + pinnedCollections: { + collectionId: f.string().primaryKey(), + index: f.string(), + }, } as const satisfies DBSchemaBuilder; export type AFFiNEWorkspaceDbSchema = typeof AFFiNE_WORKSPACE_DB_SCHEMA; diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 9130d14c5..121e0e2f9 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -6,7 +6,7 @@ "el-GR": 96, "en": 100, "es-AR": 96, - "es-CL": 98, + "es-CL": 97, "es": 96, "fa": 96, "fr": 96, diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 8ba786591..9c6e780ce 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -6968,6 +6968,10 @@ export function useAFFiNEI18N(): { * `Select checkbox` */ ["com.affine.all-docs.quick-action.select"](): string; + /** + * `All` + */ + ["com.affine.all-docs.pinned-collection.all"](): string; /** * `core` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index f365eb4eb..446f47814 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1739,6 +1739,7 @@ "com.affine.all-docs.quick-action.split": "Open in split view", "com.affine.all-docs.quick-action.tab": "Open in new tab", "com.affine.all-docs.quick-action.select": "Select checkbox", + "com.affine.all-docs.pinned-collection.all": "All", "core": "core", "dark": "Dark", "invited you to join": "invited you to join",