From 6d662b8a54eddbfffca43d670edbcdfddce4a9d1 Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Thu, 22 May 2025 09:42:33 +0000 Subject: [PATCH] feat(core): new doc list for editing collection docs and rules (#12320) close AF-2626 ## Summary by CodeRabbit - **New Features** - Added support for debounced input changes in input fields, improving performance for rapid typing scenarios. - Enhanced document explorer with dynamic visibility controls for drag handles and "more" menu options. - Introduced a new filter for searching documents by title, enabling more precise filtering in collections. - Added a direct search method for document titles to improve search accuracy and speed. - **Bug Fixes** - Improved layout and centering of icons in document list items. - Updated border styles across collection editor components for a more consistent appearance. - **Refactor** - Simplified page selection and rule-matching logic in collection and selector components by consolidating state management and leveraging context-driven rendering. - Removed deprecated and redundant hooks for page list configuration. - **Chores** - Updated code to use new theme variables for border colors, ensuring visual consistency with the latest design standards. --- .../src/hooks/use-debounce-callback.ts | 27 +++ .../component/src/ui/input/row-input.tsx | 8 +- .../explorer/docs-view/doc-list-item.css.ts | 3 + .../explorer/docs-view/doc-list-item.tsx | 3 +- .../explorer/docs-view/docs-list.tsx | 36 +-- .../explorer/docs-view/more-menu.tsx | 10 +- .../core/src/components/explorer/types.ts | 2 + .../hooks/affine/use-all-page-list-config.tsx | 99 -------- .../components/page-list/docs/select-page.tsx | 218 ++++++++---------- .../page-list/selector/selector-layout.tsx | 1 + .../collection-editor/edit-collection.css.ts | 16 +- .../collection-editor/edit-collection.tsx | 14 +- .../dialogs/collection-editor/rules-mode.tsx | 190 ++++++++------- .../collection-rules/impls/filters/title.ts | 24 ++ .../src/modules/collection-rules/index.ts | 5 + .../docs-search/services/docs-search.ts | 23 ++ 16 files changed, 334 insertions(+), 345 deletions(-) create mode 100644 packages/frontend/component/src/hooks/use-debounce-callback.ts delete mode 100644 packages/frontend/core/src/components/hooks/affine/use-all-page-list-config.tsx create mode 100644 packages/frontend/core/src/modules/collection-rules/impls/filters/title.ts diff --git a/packages/frontend/component/src/hooks/use-debounce-callback.ts b/packages/frontend/component/src/hooks/use-debounce-callback.ts new file mode 100644 index 0000000000..938f0d144c --- /dev/null +++ b/packages/frontend/component/src/hooks/use-debounce-callback.ts @@ -0,0 +1,27 @@ +import { debounce } from 'lodash-es'; +import { useEffect, useMemo, useRef } from 'react'; + +export const useDebounceCallback = any>( + callback: T, + delay?: number, + options?: Parameters[2] +) => { + const callbackRef = useRef(callback); + + const debouncedCallback = useMemo( + () => debounce(callbackRef.current, delay, options), + [delay, options] + ); + + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + useEffect(() => { + return () => { + debouncedCallback.cancel(); + }; + }, [debouncedCallback]); + + return debouncedCallback; +}; diff --git a/packages/frontend/component/src/ui/input/row-input.tsx b/packages/frontend/component/src/ui/input/row-input.tsx index 93c84b96fc..e4bb1073b1 100644 --- a/packages/frontend/component/src/ui/input/row-input.tsx +++ b/packages/frontend/component/src/ui/input/row-input.tsx @@ -10,6 +10,7 @@ import type { import { forwardRef, useCallback, useEffect, useState } from 'react'; import { useAutoFocus, useAutoSelect } from '../../hooks'; +import { useDebounceCallback } from '../../hooks/use-debounce-callback'; export type RowInputProps = { disabled?: boolean; @@ -21,6 +22,7 @@ export type RowInputProps = { style?: CSSProperties; onEnter?: (value: string) => void; [key: `data-${string}`]: string; + debounce?: number; } & Omit, 'onChange' | 'size' | 'onBlur'>; // RowInput component that is used in the selector layout for search input @@ -37,6 +39,7 @@ export const RowInput = forwardRef( onBlur, autoFocus, autoSelect, + debounce, ...otherProps }: RowInputProps, upstreamRef: ForwardedRef @@ -66,7 +69,7 @@ export const RowInput = forwardRef( if (!onBlur) return; selectRef.current?.addEventListener('blur', onBlur as any); return () => { - // eslint-disable-next-line react-hooks/exhaustive-deps + // oxlint-disable-next-line react-hooks/exhaustive-deps selectRef.current?.removeEventListener('blur', onBlur as any); }; }, [onBlur, selectRef]); @@ -77,6 +80,7 @@ export const RowInput = forwardRef( }, [propsOnChange] ); + const debounceHandleChange = useDebounceCallback(handleChange, debounce); const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -105,7 +109,7 @@ export const RowInput = forwardRef( ref={inputRef} disabled={disabled} style={style} - onChange={handleChange} + onChange={debounce ? debounceHandleChange : handleChange} onKeyDown={handleKeyDown} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} diff --git a/packages/frontend/core/src/components/explorer/docs-view/doc-list-item.css.ts b/packages/frontend/core/src/components/explorer/docs-view/doc-list-item.css.ts index dd8fd999be..f6be756660 100644 --- a/packages/frontend/core/src/components/explorer/docs-view/doc-list-item.css.ts +++ b/packages/frontend/core/src/components/explorer/docs-view/doc-list-item.css.ts @@ -75,6 +75,9 @@ export const listIcon = style({ height: 24, fontSize: 24, color: cssVarV2.icon.primary, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', }); export const listContent = style({ width: 0, diff --git a/packages/frontend/core/src/components/explorer/docs-view/doc-list-item.tsx b/packages/frontend/core/src/components/explorer/docs-view/doc-list-item.tsx index 908fb87f7a..624ff84dfe 100644 --- a/packages/frontend/core/src/components/explorer/docs-view/doc-list-item.tsx +++ b/packages/frontend/core/src/components/explorer/docs-view/doc-list-item.tsx @@ -193,6 +193,7 @@ const DragHandle = memo(function DragHandle({ }: HTMLProps & { preview?: ReactNode }) { const contextValue = useContext(DocExplorerContext); const selectMode = useLiveData(contextValue.selectMode$); + const showDragHandle = useLiveData(contextValue.showDragHandle$); const { dragRef, CustomDragPreview } = useDraggable( () => ({ @@ -210,7 +211,7 @@ const DragHandle = memo(function DragHandle({ [id] ); - if (selectMode || !id) { + if (selectMode || !id || !showDragHandle) { return null; } diff --git a/packages/frontend/core/src/components/explorer/docs-view/docs-list.tsx b/packages/frontend/core/src/components/explorer/docs-view/docs-list.tsx index 9e7a65b130..184b0dfbfc 100644 --- a/packages/frontend/core/src/components/explorer/docs-view/docs-list.tsx +++ b/packages/frontend/core/src/components/explorer/docs-view/docs-list.tsx @@ -80,7 +80,7 @@ const calcCardHeightById = (id: string) => { return 250 + value * 10; }; -const DocListItemComponent = memo(function DocListItemComponent({ +export const DocListItemComponent = memo(function DocListItemComponent({ itemId, groupId, }: { @@ -216,22 +216,24 @@ export const DocsExplorer = ({ [] )} /> - -
- {{ count: selectedDocIds.length } as any} -
- selected - - } - /> + {!disableMultiDelete ? ( + +
+ {{ count: selectedDocIds.length } as any} +
+ selected + + } + /> + ) : null} ); }; diff --git a/packages/frontend/core/src/components/explorer/docs-view/more-menu.tsx b/packages/frontend/core/src/components/explorer/docs-view/more-menu.tsx index 0a66776ac4..faca6cd5d1 100644 --- a/packages/frontend/core/src/components/explorer/docs-view/more-menu.tsx +++ b/packages/frontend/core/src/components/explorer/docs-view/more-menu.tsx @@ -21,10 +21,11 @@ import { SplitViewIcon, } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; -import { useCallback } from 'react'; +import { useCallback, useContext } from 'react'; import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper'; import { IsFavoriteIcon } from '../../pure/icons'; +import { DocExplorerContext } from '../context'; interface DocOperationProps { docId: string; @@ -203,6 +204,13 @@ export const MoreMenuButton = ({ docId: string; iconProps?: IconButtonProps; }) => { + const contextValue = useContext(DocExplorerContext); + const showMoreOperation = useLiveData(contextValue.showMoreOperation$); + + if (!showMoreOperation) { + return null; + } + return ( } {...iconProps} /> diff --git a/packages/frontend/core/src/components/explorer/types.ts b/packages/frontend/core/src/components/explorer/types.ts index fe4fcc9cbb..e00925abff 100644 --- a/packages/frontend/core/src/components/explorer/types.ts +++ b/packages/frontend/core/src/components/explorer/types.ts @@ -14,6 +14,8 @@ export interface ExplorerDisplayPreference { displayProperties?: string[]; showDocIcon?: boolean; showDocPreview?: boolean; + showMoreOperation?: boolean; + showDragHandle?: boolean; quickFavorite?: boolean; quickTrash?: boolean; quickSplit?: boolean; diff --git a/packages/frontend/core/src/components/hooks/affine/use-all-page-list-config.tsx b/packages/frontend/core/src/components/hooks/affine/use-all-page-list-config.tsx deleted file mode 100644 index 36f23bf7d7..0000000000 --- a/packages/frontend/core/src/components/hooks/affine/use-all-page-list-config.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { toast } from '@affine/component'; -import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta'; -import { FavoriteTag } from '@affine/core/components/page-list/components/favorite-tag'; -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 { useI18n } from '@affine/i18n'; -import type { DocMeta, Workspace } from '@blocksuite/affine/store'; -import { useLiveData, useService } from '@toeverything/infra'; -import { type ReactNode, useCallback, useEffect, useMemo } from 'react'; - -export type AllPageListConfig = { - allPages: DocMeta[]; - docCollection: Workspace; - /** - * Return `undefined` if the page is not public - */ - getPublicMode: (id: string) => undefined | 'page' | 'edgeless'; - getPage: (id: string) => DocMeta | undefined; - favoriteRender: (page: DocMeta) => ReactNode; -}; - -/** - * @deprecated very poor performance - */ -export const useAllPageListConfig = () => { - const currentWorkspace = useService(WorkspaceService).workspace; - const shareDocsListService = useService(ShareDocsListService); - const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$); - - useEffect(() => { - // TODO(@eyhn): loading & error UI - shareDocsListService.shareDocs?.revalidate(); - }, [shareDocsListService]); - - const workspace = currentWorkspace.docCollection; - const pageMetas = useBlockSuiteDocMeta(workspace); - const pageMap = useMemo( - () => Object.fromEntries(pageMetas.map(page => [page.id, page])), - [pageMetas] - ); - const favAdapter = useService(CompatibleFavoriteItemsAdapter); - const t = useI18n(); - const favoriteItems = useLiveData(favAdapter.favorites$); - - const isActive = useCallback( - (page: DocMeta) => { - return favoriteItems.some(fav => fav.id === page.id); - }, - [favoriteItems] - ); - const onToggleFavoritePage = useCallback( - (page: DocMeta) => { - const status = isActive(page); - favAdapter.toggle(page.id, 'doc'); - toast( - status - ? t['com.affine.toastMessage.removedFavorites']() - : t['com.affine.toastMessage.addedFavorites']() - ); - }, - [favAdapter, isActive, t] - ); - - return useMemo(() => { - return { - allPages: pageMetas, - getPublicMode(id) { - 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; - } - }, - docCollection: currentWorkspace.docCollection, - getPage: id => pageMap[id], - favoriteRender: page => { - return ( - onToggleFavoritePage(page)} - active={isActive(page)} - /> - ); - }, - }; - }, [ - pageMetas, - currentWorkspace.docCollection, - shareDocs, - pageMap, - isActive, - onToggleFavoritePage, - ]); -}; 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 0c7d798ab9..f5b6810923 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,37 +1,26 @@ -import { IconButton, Menu, toast } from '@affine/component'; -import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta'; +import { IconButton, Menu } from '@affine/component'; 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 { 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, - useMemo, - useState, -} from 'react'; +import { memo, type ReactNode, useCallback, useEffect, useState } from 'react'; +import { + createDocExplorerContext, + DocExplorerContext, +} from '../../explorer/context'; +import { DocsExplorer } from '../../explorer/docs-view/docs-list'; import { Filters } from '../../filter'; import { AddFilterMenu } from '../../filter/add-filter'; -import { AffineShapeIcon, FavoriteTag } from '..'; -import { usePageHeaderColsDef } from '../header-col-def'; -import { PageListItemRenderer } from '../page-group'; -import { ListTableHeader } from '../page-header'; +import { AffineShapeIcon } from '..'; import { SelectorLayout } from '../selector/selector-layout'; -import type { ListItem } from '../types'; -import { VirtualizedList } from '../virtualized-list'; import * as styles from './select-page.css'; -import { useSearch } from './use-search'; -export const SelectPage = ({ +export const SelectPage = memo(function SelectPage({ init = [], onConfirm, onCancel, @@ -46,87 +35,89 @@ export const SelectPage = ({ init?: string[]; onConfirm?: (data: string[]) => void; onCancel?: () => void; -}) => { +}) { const t = useI18n(); - const [value, setValue] = useState(init); - const onChange = useCallback( - (value: string[]) => { - propsOnChange?.(value); - setValue(value); - }, - [propsOnChange] - ); - const confirm = useCallback(() => { - onConfirm?.(value); - }, [value, onConfirm]); - const clearSelected = useCallback(() => { - onChange([]); - }, [onChange]); - const { - workspaceService, - compatibleFavoriteItemsAdapter, - shareDocsListService, - collectionRulesService, - } = useServices({ + const [searchText, setSearchText] = useState(''); + + const { shareDocsListService, collectionRulesService } = useServices({ ShareDocsListService, - WorkspaceService, - CompatibleFavoriteItemsAdapter, CollectionRulesService, }); - const workspace = workspaceService.workspace; - const docCollection = workspace.docCollection; - const pageMetas = useBlockSuiteDocMeta(docCollection); - const favourites = useLiveData(compatibleFavoriteItemsAdapter.favorites$); + const [docExplorerContextValue] = useState(() => { + return createDocExplorerContext({ + displayProperties: ['createdAt', 'updatedAt', 'tags'], + quickFavorite: true, + showMoreOperation: false, + showDragHandle: false, + }); + }); + + // init context value + useEffect(() => { + docExplorerContextValue.selectMode$.next(true); + docExplorerContextValue.selectedDocIds$.next(init); + }, [ + docExplorerContextValue.selectMode$, + docExplorerContextValue.selectedDocIds$, + init, + ]); + + const groups = useLiveData(docExplorerContextValue.groups$); + const selectedDocIds = useLiveData(docExplorerContextValue.selectedDocIds$); + const isEmpty = + groups.length === 0 || + (groups.length && groups.every(group => group.items.length === 0)); + + const confirm = useCallback(() => { + onConfirm?.(docExplorerContextValue.selectedDocIds$.value); + }, [onConfirm, docExplorerContextValue.selectedDocIds$]); + const clearSelected = useCallback(() => { + docExplorerContextValue.selectedDocIds$.next([]); + }, [docExplorerContextValue.selectedDocIds$]); + + useEffect(() => { + const ob = docExplorerContextValue.selectedDocIds$.subscribe(value => { + propsOnChange?.(value); + }); + return () => { + ob.unsubscribe(); + }; + }, [propsOnChange, docExplorerContextValue.selectedDocIds$]); useEffect(() => { shareDocsListService.shareDocs?.revalidate(); }, [shareDocsListService.shareDocs]); - const isFavorite = useCallback( - (meta: DocMeta) => favourites.some(fav => fav.id === meta.id), - [favourites] - ); - - const onToggleFavoritePage = useCallback( - (page: DocMeta) => { - const status = isFavorite(page); - compatibleFavoriteItemsAdapter.toggle(page.id, 'doc'); - toast( - status - ? t['com.affine.toastMessage.removedFavorites']() - : t['com.affine.toastMessage.addedFavorites']() - ); - }, - [compatibleFavoriteItemsAdapter, isFavorite, t] - ); - - const pageHeaderColsDef = usePageHeaderColsDef(); 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(filteredPageMetas); - useEffect(() => { + const searchFilter = searchText + ? { + type: 'system', + key: 'title', + method: 'match', + value: searchText, + } + : null; + const watchFilters: FilterParams[] = + filters.length > 0 + ? filters + : [ + // if no filters are present, match all non-trash documents + { + type: 'system', + key: 'trash', + method: 'is', + value: 'false', + }, + ]; + + if (searchFilter) { + watchFilters.push(searchFilter); + } const subscription = collectionRulesService .watch({ - filters: - filters.length > 0 - ? filters - : [ - // if no filters are present, match all non-trash documents - { - type: 'system', - key: 'trash', - method: 'is', - value: 'false', - }, - ], + filters: watchFilters, extraFilters: [ { type: 'system', @@ -143,40 +134,23 @@ export const SelectPage = ({ ], }) .subscribe(result => { - setFilteredDocIds(result.groups.flatMap(group => group.items)); + docExplorerContextValue.groups$.next(result.groups); }); return () => { subscription.unsubscribe(); }; - }, [collectionRulesService, filters]); - - const operationsRenderer = useCallback( - (item: ListItem) => { - const page = item as DocMeta; - return ( - onToggleFavoritePage(page)} - active={isFavorite(page)} - /> - ); - }, - [isFavorite, onToggleFavoritePage] - ); - - const pageHeaderRenderer = useCallback(() => { - return ; - }, [pageHeaderColsDef]); - - const pageItemRenderer = useCallback((item: ListItem) => { - return ; - }, []); + }, [ + collectionRulesService, + docExplorerContextValue.groups$, + filters, + searchText, + ]); return ( ) : null} - {searchedList.length ? ( - + {!isEmpty ? ( + + + ) : ( )} ); -}; +}); export const EmptyList = ({ search }: { search?: string }) => { const t = useI18n(); return ( diff --git a/packages/frontend/core/src/components/page-list/selector/selector-layout.tsx b/packages/frontend/core/src/components/page-list/selector/selector-layout.tsx index 69ff4afd49..4814b8e040 100644 --- a/packages/frontend/core/src/components/page-list/selector/selector-layout.tsx +++ b/packages/frontend/core/src/components/page-list/selector/selector-layout.tsx @@ -51,6 +51,7 @@ export const SelectorLayout = ({ className={styles.search} placeholder={searchPlaceholder} onChange={onSearchChange} + debounce={200} /> diff --git a/packages/frontend/core/src/desktop/dialogs/collection-editor/edit-collection.css.ts b/packages/frontend/core/src/desktop/dialogs/collection-editor/edit-collection.css.ts index d10aa4e827..721e1c4408 100644 --- a/packages/frontend/core/src/desktop/dialogs/collection-editor/edit-collection.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/collection-editor/edit-collection.css.ts @@ -1,4 +1,5 @@ import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; import { style } from '@vanilla-extract/css'; export const ellipsis = style({ overflow: 'hidden', @@ -14,22 +15,25 @@ export const rulesBottom = style({ display: 'flex', justifyContent: 'space-between', padding: '20px 24px', - borderTop: `1px solid ${cssVar('borderColor')}`, + borderTop: `1px solid ${cssVarV2.layer.insideBorder.border}`, flexWrap: 'wrap', gap: '12px', }); +export const includeListGroup = style({ + borderTop: `1px solid ${cssVarV2.layer.insideBorder.border}`, +}); export const includeListTitle = style({ fontSize: 14, fontWeight: 400, lineHeight: '22px', color: cssVar('textSecondaryColor'), - padding: '4px 16px', - borderTop: `1px solid ${cssVar('borderColor')}`, + padding: '8px', + paddingBottom: 0, }); export const rulesContainerRight = style({ flex: 2, flexDirection: 'column', - borderLeft: `1px solid ${cssVar('borderColor')}`, + borderLeft: `1px solid ${cssVarV2.layer.insideBorder.border}`, overflowX: 'hidden', overflowY: 'auto', }); @@ -60,7 +64,7 @@ export const includeItem = style({ overflow: 'hidden', gap: 16, whiteSpace: 'nowrap', - border: `1px solid ${cssVar('borderColor')}`, + border: `1px solid ${cssVarV2.layer.insideBorder.border}`, borderRadius: 8, padding: '4px 8px 4px', }); @@ -143,5 +147,5 @@ export const rulesTitle = style({ fontSize: 20, lineHeight: '24px', color: cssVar('textSecondaryColor'), - borderBottom: `1px solid ${cssVar('borderColor')}`, + borderBottom: `1px solid ${cssVarV2.layer.insideBorder.border}`, }); diff --git a/packages/frontend/core/src/desktop/dialogs/collection-editor/edit-collection.tsx b/packages/frontend/core/src/desktop/dialogs/collection-editor/edit-collection.tsx index 5332b9410b..dc5808dffa 100644 --- a/packages/frontend/core/src/desktop/dialogs/collection-editor/edit-collection.tsx +++ b/packages/frontend/core/src/desktop/dialogs/collection-editor/edit-collection.tsx @@ -1,5 +1,4 @@ import { Button, RadioGroup } from '@affine/component'; -import { useAllPageListConfig } from '@affine/core/components/hooks/affine/use-all-page-list-config'; import { SelectPage } from '@affine/core/components/page-list/docs/select-page'; import type { CollectionInfo } from '@affine/core/modules/collection'; import { useI18n } from '@affine/i18n'; @@ -26,7 +25,6 @@ export const EditCollection = ({ mode: initMode, }: EditCollectionProps) => { const t = useI18n(); - const config = useAllPageListConfig(); const [value, onChange] = useState(init); const [mode, setMode] = useState<'page' | 'rule'>( initMode ?? (init.rules.filters.length === 0 ? 'page' : 'rule') @@ -44,12 +42,9 @@ export const EditCollection = ({ allowList: init.allowList, }); }, [init, value]); - const onIdsChange = useCallback( - (ids: string[]) => { - onChange({ ...value, allowList: ids }); - }, - [value] - ); + const onIdsChange = useCallback((ids: string[]) => { + onChange(prev => ({ ...prev, allowList: ids })); + }, []); const buttons = useMemo( () => ( <> @@ -104,14 +99,13 @@ export const EditCollection = ({ > {mode === 'page' ? ( ) : ( void; reset: () => void; buttons: ReactNode; switchMode: ReactNode; - allPageListConfig: AllPageListConfig; }) => { const t = useI18n(); const [showPreview, setShowPreview] = useState(true); const docsService = useService(DocsService); const collectionRulesService = useService(CollectionRulesService); const [rulesPageIds, setRulesPageIds] = useState([]); + const [docExplorerContextValue] = useState(() => + createDocExplorerContext({ + displayProperties: ['createdAt', 'updatedAt', 'tags'], + showDragHandle: false, + showMoreOperation: false, + quickFavorite: true, + }) + ); useEffect(() => { const subscription = collectionRulesService @@ -74,32 +85,58 @@ export const RulesMode = ({ }; }, [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) && - !meta.trash - ); - }); - }, [allPageListConfig.allPages, collection.allowList, rulesPageIds]); + const masonryItems = useMemo( + () => + [ + { + id: 'rules-group', + height: 0, + children: null, + items: rulesPageIds.length + ? rulesPageIds.map(docId => { + return { + id: docId, + height: 42, + Component: DocListItemComponent, + }; + }) + : [ + { + id: 'rules-empty', + height: 300, + children: ( + + ), + }, + ], + }, + { + id: 'allow-list-group', + height: 30, + children: ( +
+ {t['com.affine.editCollection.rules.include.title']()} +
+ ), + className: styles.includeListGroup, + items: collection.allowList.map(docId => { + return { + id: docId, + height: 42, + Component: DocListItemComponent, + }; + }), + }, + ] satisfies MasonryGroup[], + [collection.allowList, collection.rules.filters.length, rulesPageIds, t] + ); const [expandInclude, setExpandInclude] = useState( collection.allowList.length > 0 ); - const operationsRenderer = useCallback( - (item: ListItem) => { - const page = item as DocMeta; - return allPageListConfig.favoriteRender(page); - }, - [allPageListConfig] - ); const tips = useMemo( () => ( @@ -170,9 +207,6 @@ export const RulesMode = ({ }} > {collection.allowList.map(id => { - const page = allPageListConfig.allPages.find( - v => v.id === id - ); return (
@@ -196,15 +230,7 @@ export const RulesMode = ({
{t['com.affine.editCollection.rules.include.is']()}
-
- {page?.title || t['Untitled']()} -
+
- - {rulesPages.length > 0 ? ( - - ) : ( - + + - )} - {allowListPages.length > 0 ? ( -
-
- {t['com.affine.editCollection.rules.include.title']()} -
- -
- ) : null} -
+ +
@@ -278,8 +282,8 @@ export const RulesMode = ({ Selected @@ -342,3 +346,23 @@ const RulesEmpty = ({
); }; + +const DocTitle = memo(function DocTitle({ id }: { id: string }) { + const docDisplayMetaService = useService(DocDisplayMetaService); + const docsService = useService(DocsService); + const doc = useLiveData(docsService.list.doc$(id)); + const trash = useLiveData(doc?.trash$); + const title = useLiveData(docDisplayMetaService.title$(id)); + + return ( +
+ {title} +
+ ); +}); diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/title.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/title.ts new file mode 100644 index 0000000000..a1c3e0d382 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/title.ts @@ -0,0 +1,24 @@ +import type { DocsSearchService } from '@affine/core/modules/docs-search'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { FilterProvider } from '../../provider'; +import type { FilterParams } from '../../types'; + +export class TitleFilterProvider extends Service implements FilterProvider { + constructor(private readonly docsSearchService: DocsSearchService) { + super(); + } + + filter$(params: FilterParams): Observable> { + const method = params.method as 'match'; + + if (method === 'match') { + return this.docsSearchService + .searchTitle$(params.value ?? '') + .pipe(map(list => new Set(list))); + } + + throw new Error(`Unsupported method: ${params.method}`); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/index.ts b/packages/frontend/core/src/modules/collection-rules/index.ts index ee52f0ad57..4ba7b5b5ee 100644 --- a/packages/frontend/core/src/modules/collection-rules/index.ts +++ b/packages/frontend/core/src/modules/collection-rules/index.ts @@ -1,6 +1,7 @@ import type { Framework } from '@toeverything/infra'; import { DocsService } from '../doc'; +import { DocsSearchService } from '../docs-search'; import { FavoriteService } from '../favorite'; import { ShareDocsListService } from '../share-doc'; import { TagService } from '../tag'; @@ -19,6 +20,7 @@ import { SharedFilterProvider } from './impls/filters/shared'; import { SystemFilterProvider } from './impls/filters/system'; import { TagsFilterProvider } from './impls/filters/tags'; import { TextPropertyFilterProvider } from './impls/filters/text'; +import { TitleFilterProvider } from './impls/filters/title'; import { TrashFilterProvider } from './impls/filters/trash'; import { UpdatedAtFilterProvider } from './impls/filters/updated-at'; import { UpdatedByFilterProvider } from './impls/filters/updated-by'; @@ -130,6 +132,9 @@ export function configureCollectionRulesModule(framework: Framework) { ShareDocsListService, DocsService, ]) + .impl(FilterProvider('system:title'), TitleFilterProvider, [ + DocsSearchService, + ]) // --------------- Group By --------------- .impl(GroupByProvider('system'), SystemGroupByProvider) .impl(GroupByProvider('property'), PropertyGroupByProvider, [ diff --git a/packages/frontend/core/src/modules/docs-search/services/docs-search.ts b/packages/frontend/core/src/modules/docs-search/services/docs-search.ts index e6c3c21b43..78dac29f6b 100644 --- a/packages/frontend/core/src/modules/docs-search/services/docs-search.ts +++ b/packages/frontend/core/src/modules/docs-search/services/docs-search.ts @@ -26,6 +26,29 @@ export class DocsSearchService extends Service { errorMessage: null, } as IndexerSyncState); + searchTitle$(query: string) { + return this.indexer + .search$( + 'doc', + { + type: 'match', + field: 'title', + match: query, + }, + { + pagination: { + skip: 0, + limit: Infinity, + }, + } + ) + .pipe( + map(({ nodes }) => { + return nodes.map(node => node.id); + }) + ); + } + search$(query: string): Observable< { docId: string;