feat(core): new doc list for editing collection docs and rules (#12320)

close AF-2626

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
CatsJuice
2025-05-22 09:42:33 +00:00
parent 4b9428e6f4
commit 6d662b8a54
16 changed files with 334 additions and 345 deletions

View File

@@ -0,0 +1,27 @@
import { debounce } from 'lodash-es';
import { useEffect, useMemo, useRef } from 'react';
export const useDebounceCallback = <T extends (...args: any[]) => any>(
callback: T,
delay?: number,
options?: Parameters<typeof debounce>[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;
};

View File

@@ -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<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size' | 'onBlur'>;
// RowInput component that is used in the selector layout for search input
@@ -37,6 +39,7 @@ export const RowInput = forwardRef<HTMLInputElement, RowInputProps>(
onBlur,
autoFocus,
autoSelect,
debounce,
...otherProps
}: RowInputProps,
upstreamRef: ForwardedRef<HTMLInputElement>
@@ -66,7 +69,7 @@ export const RowInput = forwardRef<HTMLInputElement, RowInputProps>(
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<HTMLInputElement, RowInputProps>(
},
[propsOnChange]
);
const debounceHandleChange = useDebounceCallback(handleChange, debounce);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
@@ -105,7 +109,7 @@ export const RowInput = forwardRef<HTMLInputElement, RowInputProps>(
ref={inputRef}
disabled={disabled}
style={style}
onChange={handleChange}
onChange={debounce ? debounceHandleChange : handleChange}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}

View File

@@ -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,

View File

@@ -193,6 +193,7 @@ const DragHandle = memo(function DragHandle({
}: HTMLProps<HTMLDivElement> & { preview?: ReactNode }) {
const contextValue = useContext(DocExplorerContext);
const selectMode = useLiveData(contextValue.selectMode$);
const showDragHandle = useLiveData(contextValue.showDragHandle$);
const { dragRef, CustomDragPreview } = useDraggable<AffineDNDData>(
() => ({
@@ -210,7 +211,7 @@ const DragHandle = memo(function DragHandle({
[id]
);
if (selectMode || !id) {
if (selectMode || !id || !showDragHandle) {
return null;
}

View File

@@ -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 = ({
[]
)}
/>
<ListFloatingToolbar
open={!!selectMode}
onDelete={handleMultiDelete}
onClose={handleCloseFloatingToolbar}
content={
<Trans
i18nKey="com.affine.page.toolbar.selected"
count={selectedDocIds.length}
>
<div style={{ color: cssVarV2.text.secondary }}>
{{ count: selectedDocIds.length } as any}
</div>
selected
</Trans>
}
/>
{!disableMultiDelete ? (
<ListFloatingToolbar
open={!!selectMode}
onDelete={handleMultiDelete}
onClose={handleCloseFloatingToolbar}
content={
<Trans
i18nKey="com.affine.page.toolbar.selected"
count={selectedDocIds.length}
>
<div style={{ color: cssVarV2.text.secondary }}>
{{ count: selectedDocIds.length } as any}
</div>
selected
</Trans>
}
/>
) : null}
</>
);
};

View File

@@ -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 (
<MoreMenu docId={docId} {...menuProps}>
<IconButton icon={<MoreVerticalIcon />} {...iconProps} />

View File

@@ -14,6 +14,8 @@ export interface ExplorerDisplayPreference {
displayProperties?: string[];
showDocIcon?: boolean;
showDocPreview?: boolean;
showMoreOperation?: boolean;
showDragHandle?: boolean;
quickFavorite?: boolean;
quickTrash?: boolean;
quickSplit?: boolean;

View File

@@ -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<AllPageListConfig>(() => {
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 (
<FavoriteTag
style={{ marginRight: 8 }}
onClick={() => onToggleFavoritePage(page)}
active={isActive(page)}
/>
);
},
};
}, [
pageMetas,
currentWorkspace.docCollection,
shareDocs,
pageMap,
isActive,
onToggleFavoritePage,
]);
};

View File

@@ -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<FilterParams[]>([]);
const [filteredDocIds, setFilteredDocIds] = useState<string[]>([]);
const filteredPageMetas = useMemo(() => {
const idSet = new Set(filteredDocIds);
return pageMetas.filter(page => idSet.has(page.id));
}, [pageMetas, filteredDocIds]);
const { searchText, updateSearchText, searchedList } =
useSearch(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 (
<FavoriteTag
style={{ marginRight: 8 }}
onClick={() => onToggleFavoritePage(page)}
active={isFavorite(page)}
/>
);
},
[isFavorite, onToggleFavoritePage]
);
const pageHeaderRenderer = useCallback(() => {
return <ListTableHeader headerCols={pageHeaderColsDef} />;
}, [pageHeaderColsDef]);
const pageItemRenderer = useCallback((item: ListItem) => {
return <PageListItemRenderer {...item} />;
}, []);
}, [
collectionRulesService,
docExplorerContextValue.groups$,
filters,
searchText,
]);
return (
<SelectorLayout
searchPlaceholder={t['com.affine.editCollection.search.placeholder']()}
selectedCount={value.length}
onSearch={updateSearchText}
selectedCount={selectedDocIds.length}
onSearch={setSearchText}
onClear={clearSelected}
onCancel={onCancel}
onConfirm={confirm}
@@ -206,25 +180,17 @@ export const SelectPage = ({
<Filters filters={filters} onChange={setFilters} />
</div>
) : null}
{searchedList.length ? (
<VirtualizedList
className={styles.pageList}
items={searchedList}
docCollection={docCollection}
selectable
onSelectedIdsChange={onChange}
selectedIds={value}
operationsRenderer={operationsRenderer}
itemRenderer={pageItemRenderer}
headerRenderer={pageHeaderRenderer}
/>
{!isEmpty ? (
<DocExplorerContext.Provider value={docExplorerContextValue}>
<DocsExplorer disableMultiDelete />
</DocExplorerContext.Provider>
) : (
<EmptyList search={searchText} />
)}
</div>
</SelectorLayout>
);
};
});
export const EmptyList = ({ search }: { search?: string }) => {
const t = useI18n();
return (

View File

@@ -51,6 +51,7 @@ export const SelectorLayout = ({
className={styles.search}
placeholder={searchPlaceholder}
onChange={onSearchChange}
debounce={200}
/>
</header>

View File

@@ -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}`,
});

View File

@@ -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<CollectionInfo>(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' ? (
<SelectPage
init={value.allowList}
init={init.allowList}
onChange={onIdsChange}
header={switchMode}
buttons={buttons}
/>
) : (
<RulesMode
allPageListConfig={config}
collection={value}
switchMode={switchMode}
reset={reset}

View File

@@ -1,28 +1,33 @@
import { Button, IconButton, Tooltip } from '@affine/component';
import { Filters } from '@affine/core/components/filter';
import type { AllPageListConfig } from '@affine/core/components/hooks/affine/use-all-page-list-config';
import {
AffineShapeIcon,
List,
type ListItem,
ListScrollContainer,
} from '@affine/core/components/page-list';
Button,
IconButton,
Masonry,
type MasonryGroup,
Tooltip,
} from '@affine/component';
import {
createDocExplorerContext,
DocExplorerContext,
} from '@affine/core/components/explorer/context';
import { DocListItemComponent } from '@affine/core/components/explorer/docs-view/docs-list';
import { Filters } from '@affine/core/components/filter';
import { AffineShapeIcon } from '@affine/core/components/page-list';
import type { CollectionInfo } from '@affine/core/modules/collection';
import { CollectionRulesService } from '@affine/core/modules/collection-rules';
import { DocsService } from '@affine/core/modules/doc';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { Trans, useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
import {
CloseIcon,
EdgelessIcon,
PageIcon,
ToggleRightIcon,
} from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import clsx from 'clsx';
import type { ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import * as styles from './edit-collection.css';
@@ -32,20 +37,26 @@ export const RulesMode = ({
reset,
buttons,
switchMode,
allPageListConfig,
}: {
collection: CollectionInfo;
updateCollection: (collection: CollectionInfo) => 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<string[]>([]);
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: (
<RulesEmpty
noRules={collection.rules.filters.length === 0}
fullHeight
/>
),
},
],
},
{
id: 'allow-list-group',
height: 30,
children: (
<div className={styles.includeListTitle}>
{t['com.affine.editCollection.rules.include.title']()}
</div>
),
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 (
<div className={styles.includeItem} key={id}>
<div className={styles.includeItemContent}>
@@ -196,15 +230,7 @@ export const RulesMode = ({
<div className={styles.includeItemContentIs}>
{t['com.affine.editCollection.rules.include.is']()}
</div>
<div
className={clsx(
styles.includeItemTitle,
page?.trash && styles.trashTitle,
styles.ellipsis
)}
>
{page?.title || t['Untitled']()}
</div>
<DocTitle id={id} />
</div>
<IconButton
size="14"
@@ -226,41 +252,19 @@ export const RulesMode = ({
</div>
</div>
</div>
<ListScrollContainer
className={styles.rulesContainerRight}
style={{
display: showPreview ? 'flex' : 'none',
}}
>
{rulesPages.length > 0 ? (
<List
hideHeader
className={styles.resultPages}
items={rulesPages}
docCollection={allPageListConfig.docCollection}
operationsRenderer={operationsRenderer}
></List>
) : (
<RulesEmpty
noRules={collection.rules.filters.length === 0}
fullHeight={allowListPages.length === 0}
<div className={styles.rulesContainerRight}>
<DocExplorerContext.Provider value={docExplorerContextValue}>
<Masonry
items={masonryItems}
columns={1}
gapY={12}
virtualScroll
paddingX={12}
groupHeaderGapWithItems={12}
groupsGap={12}
/>
)}
{allowListPages.length > 0 ? (
<div>
<div className={styles.includeListTitle}>
{t['com.affine.editCollection.rules.include.title']()}
</div>
<List
hideHeader
className={styles.resultPages}
items={allowListPages}
docCollection={allPageListConfig.docCollection}
operationsRenderer={operationsRenderer}
></List>
</div>
) : null}
</ListScrollContainer>
</DocExplorerContext.Provider>
</div>
</div>
<div className={styles.rulesBottom}>
<div className={styles.bottomLeft}>
@@ -278,8 +282,8 @@ export const RulesMode = ({
<Trans
i18nKey="com.affine.editCollection.rules.countTips"
values={{
selectedCount: allowListPages.length,
filteredCount: rulesPages.length,
selectedCount: collection.allowList.length,
filteredCount: rulesPageIds.length,
}}
>
Selected
@@ -342,3 +346,23 @@ const RulesEmpty = ({
</div>
);
};
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 (
<div
className={clsx(
styles.includeItemTitle,
trash && styles.trashTitle,
styles.ellipsis
)}
>
{title}
</div>
);
});

View File

@@ -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<Set<string>> {
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}`);
}
}

View File

@@ -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, [

View File

@@ -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;