mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-11 20:08:37 +00:00
feat(core): add page group and display properties (#6228)
close TOV-23 https://github.com/toeverything/AFFiNE/assets/102217452/c05474de-b73c-40ab-9f18-cc43bb9fd828
This commit is contained in:
@@ -107,7 +107,6 @@ export const VirtualizedCollectionList = ({
|
||||
ref={listRef}
|
||||
selectable="toggle"
|
||||
draggable={false}
|
||||
groupBy={false}
|
||||
atTopThreshold={80}
|
||||
atTopStateChange={setHideHeaderCreateNewCollection}
|
||||
onSelectionActiveChange={setShowFloatingToolbar}
|
||||
|
||||
@@ -16,7 +16,6 @@ export const headerCell = style({
|
||||
borderRight: `1px solid ${cssVar('hoverColorFilled')}`,
|
||||
},
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
columnGap: '4px',
|
||||
position: 'relative',
|
||||
|
||||
@@ -22,6 +22,7 @@ export const ListHeaderCell = ({
|
||||
alignment,
|
||||
flex,
|
||||
style,
|
||||
hidden,
|
||||
hideInSmallContainer,
|
||||
children,
|
||||
}: HeaderCellProps) => {
|
||||
@@ -39,6 +40,7 @@ export const ListHeaderCell = ({
|
||||
className={styles.headerCell}
|
||||
data-sortable={sortable ? true : undefined}
|
||||
data-sorting={sorting ? true : undefined}
|
||||
hidden={hidden}
|
||||
style={style}
|
||||
role="columnheader"
|
||||
hideInSmallContainer={hideInSmallContainer}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const menu = style({
|
||||
minWidth: '220px',
|
||||
});
|
||||
|
||||
export const arrowDownSmallIcon = style({
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
color: cssVar('iconColor'),
|
||||
});
|
||||
|
||||
export const headerDisplayButton = style({
|
||||
marginLeft: '16px',
|
||||
['WebkitAppRegion' as string]: 'no-drag',
|
||||
});
|
||||
|
||||
export const subMenuTrigger = style({
|
||||
paddingRight: '8px',
|
||||
});
|
||||
|
||||
export const subMenuItem = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
flexWrap: 'nowrap',
|
||||
selectors: {
|
||||
'&[data-active="true"]': {
|
||||
color: cssVar('primaryColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const subMenuTriggerContent = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
fontWeight: 500,
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
|
||||
export const currentGroupType = style({
|
||||
fontWeight: 400,
|
||||
color: cssVar('textSecondaryColor'),
|
||||
});
|
||||
|
||||
export const listOption = style({
|
||||
padding: '4px 12px',
|
||||
height: '28px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 500,
|
||||
color: cssVar('textSecondaryColor'),
|
||||
marginBottom: '4px',
|
||||
});
|
||||
export const properties = style({
|
||||
padding: '4px 12px',
|
||||
height: '28px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
export const propertiesWrapper = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
maxWidth: '200px',
|
||||
gap: '8px',
|
||||
padding: '4px 12px',
|
||||
});
|
||||
|
||||
export const propertyButton = style({
|
||||
color: cssVar('textDisableColor'),
|
||||
selectors: {
|
||||
'&[data-active="true"]': {
|
||||
color: cssVar('textPrimaryColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
MenuSub,
|
||||
} from '@affine/component';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ArrowDownSmallIcon, DoneIcon } from '@blocksuite/icons';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { pageGroupByTypeAtom } from '../group-definitions';
|
||||
import type { PageDisplayProperties, PageGroupByType } from '../types';
|
||||
import { usePageDisplayProperties } from '../use-page-display-properties';
|
||||
import * as styles from './page-display-menu.css';
|
||||
|
||||
type GroupOption = {
|
||||
value: PageGroupByType;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const PageDisplayMenu = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [group, setGroup] = useAtom(pageGroupByTypeAtom);
|
||||
const [properties, setProperties] = usePageDisplayProperties();
|
||||
const handleSelect = useCallback(
|
||||
(value: PageGroupByType) => {
|
||||
setGroup(value);
|
||||
},
|
||||
[setGroup]
|
||||
);
|
||||
const propertyOptions: Array<{
|
||||
key: keyof PageDisplayProperties;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
}> = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
key: 'bodyNotes',
|
||||
onClick: () => setProperties('bodyNotes', !properties['bodyNotes']),
|
||||
label: t['com.affine.page.display.display-properties.body-notes'](),
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
onClick: () => setProperties('tags', !properties['tags']),
|
||||
label: t['Tags'](),
|
||||
},
|
||||
{
|
||||
key: 'createDate',
|
||||
onClick: () => setProperties('createDate', !properties['createDate']),
|
||||
label: t['Created'](),
|
||||
},
|
||||
{
|
||||
key: 'updatedDate',
|
||||
onClick: () => setProperties('updatedDate', !properties['updatedDate']),
|
||||
label: t['Updated'](),
|
||||
},
|
||||
];
|
||||
}, [properties, setProperties, t]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const groupOptions: GroupOption[] = [
|
||||
{
|
||||
value: 'createDate',
|
||||
label: t['Created'](),
|
||||
},
|
||||
{
|
||||
value: 'updatedDate',
|
||||
label: t['Updated'](),
|
||||
},
|
||||
{
|
||||
value: 'tag',
|
||||
label: t['com.affine.page.display.grouping.group-by-tag'](),
|
||||
},
|
||||
{
|
||||
value: 'favourites',
|
||||
label: t['com.affine.page.display.grouping.group-by-favourites'](),
|
||||
},
|
||||
{
|
||||
value: 'none',
|
||||
label: t['com.affine.page.display.grouping.no-grouping'](),
|
||||
},
|
||||
];
|
||||
|
||||
const subItems = groupOptions.map(option => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
onSelect={() => handleSelect(option.value)}
|
||||
data-active={group === option.value}
|
||||
endFix={group === option.value ? <DoneIcon fontSize={'20px'} /> : null}
|
||||
className={styles.subMenuItem}
|
||||
data-testid={`group-by-${option.value}`}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
</MenuItem>
|
||||
));
|
||||
|
||||
const currentGroupType = groupOptions.find(
|
||||
option => option.value === group
|
||||
)?.label;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuSub
|
||||
subContentOptions={{
|
||||
alignOffset: -8,
|
||||
sideOffset: 12,
|
||||
}}
|
||||
triggerOptions={{ className: styles.subMenuTrigger }}
|
||||
items={subItems}
|
||||
>
|
||||
<div
|
||||
className={styles.subMenuTriggerContent}
|
||||
data-testid="page-display-grouping-menuItem"
|
||||
>
|
||||
<span>{t['com.affine.page.display.grouping']()}</span>
|
||||
<span className={styles.currentGroupType}>{currentGroupType}</span>
|
||||
</div>
|
||||
</MenuSub>
|
||||
<MenuSeparator />
|
||||
<div className={styles.listOption}>
|
||||
{t['com.affine.page.display.list-option']()}
|
||||
</div>
|
||||
<div className={styles.properties}>
|
||||
{t['com.affine.page.display.display-properties']()}
|
||||
</div>
|
||||
<div className={styles.propertiesWrapper}>
|
||||
{propertyOptions.map(option => (
|
||||
<Button
|
||||
key={option.label}
|
||||
className={styles.propertyButton}
|
||||
onClick={option.onClick}
|
||||
data-active={properties[option.key]}
|
||||
data-testid={`property-${option.key}`}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}, [group, handleSelect, properties, propertyOptions, t]);
|
||||
return (
|
||||
<Menu
|
||||
items={items}
|
||||
contentOptions={{
|
||||
className: styles.menu,
|
||||
|
||||
align: 'end',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
iconPosition="end"
|
||||
icon={<ArrowDownSmallIcon className={styles.arrowDownSmallIcon} />}
|
||||
className={styles.headerDisplayButton}
|
||||
data-testid="page-display-menu-button"
|
||||
>
|
||||
{t['com.affine.page.display']()}
|
||||
</Button>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { WorkbenchLink } from '../../../modules/workbench/view/workbench-link';
|
||||
import type { DraggableTitleCellData, PageListItemProps } from '../types';
|
||||
import { usePageDisplayProperties } from '../use-page-display-properties';
|
||||
import { ColWrapper, formatDate, stopPropagation } from '../utils';
|
||||
import * as styles from './page-list-item.css';
|
||||
import { PageTags } from './page-tags';
|
||||
@@ -15,6 +16,7 @@ const ListTitleCell = ({
|
||||
title,
|
||||
preview,
|
||||
}: Pick<PageListItemProps, 'title' | 'preview'>) => {
|
||||
const [displayProperties] = usePageDisplayProperties();
|
||||
return (
|
||||
<div data-testid="page-list-item-title" className={styles.titleCell}>
|
||||
<div
|
||||
@@ -23,7 +25,7 @@ const ListTitleCell = ({
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{preview ? (
|
||||
{preview && displayProperties['bodyNotes'] ? (
|
||||
<div
|
||||
data-testid="page-list-item-preview-text"
|
||||
className={styles.titleCellPreview}
|
||||
@@ -123,6 +125,7 @@ const PageListOperationsCell = ({
|
||||
};
|
||||
|
||||
export const PageListItem = (props: PageListItemProps) => {
|
||||
const [displayProperties] = usePageDisplayProperties();
|
||||
const pageTitleElement = useMemo(() => {
|
||||
return (
|
||||
<div className={styles.dragPageItemOverlay}>
|
||||
@@ -182,14 +185,29 @@ export const PageListItem = (props: PageListItemProps) => {
|
||||
</div>
|
||||
<ListTitleCell title={props.title} preview={props.preview} />
|
||||
</ColWrapper>
|
||||
<ColWrapper flex={4} alignment="end" style={{ overflow: 'visible' }}>
|
||||
<ColWrapper
|
||||
flex={4}
|
||||
alignment="end"
|
||||
style={{ overflow: 'visible' }}
|
||||
hidden={!displayProperties['tags']}
|
||||
>
|
||||
<PageTagsCell pageId={props.pageId} />
|
||||
</ColWrapper>
|
||||
</ColWrapper>
|
||||
<ColWrapper flex={1} alignment="end" hideInSmallContainer>
|
||||
<ColWrapper
|
||||
flex={1}
|
||||
alignment="end"
|
||||
hideInSmallContainer
|
||||
hidden={!displayProperties['createDate']}
|
||||
>
|
||||
<PageCreateDateCell createDate={props.createDate} />
|
||||
</ColWrapper>
|
||||
<ColWrapper flex={1} alignment="end" hideInSmallContainer>
|
||||
<ColWrapper
|
||||
flex={1}
|
||||
alignment="end"
|
||||
hideInSmallContainer
|
||||
hidden={!displayProperties['updatedDate']}
|
||||
>
|
||||
<PageUpdatedDateCell updatedDate={props.updatedDate} />
|
||||
</ColWrapper>
|
||||
{props.operations ? (
|
||||
|
||||
@@ -13,7 +13,8 @@ import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
|
||||
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
|
||||
import { pageHeaderColsDef } from '../header-col-def';
|
||||
import { usePageItemGroupDefinitions } from '../group-definitions';
|
||||
import { usePageHeaderColsDef } from '../header-col-def';
|
||||
import { PageOperationCell } from '../operation-cell';
|
||||
import { PageListItemRenderer } from '../page-group';
|
||||
import { ListTableHeader } from '../page-header';
|
||||
@@ -108,6 +109,7 @@ export const VirtualizedPageList = ({
|
||||
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
|
||||
const pageOperations = usePageOperationsRenderer();
|
||||
const { isPreferredEdgeless } = usePageHelper(currentWorkspace.docCollection);
|
||||
const pageHeaderColsDef = usePageHeaderColsDef();
|
||||
|
||||
const filteredPageMetas = useFilteredPageMetas(currentWorkspace, pageMetas, {
|
||||
filters,
|
||||
@@ -139,7 +141,7 @@ export const VirtualizedPageList = ({
|
||||
|
||||
const pageHeaderRenderer = useCallback(() => {
|
||||
return <ListTableHeader headerCols={pageHeaderColsDef} />;
|
||||
}, []);
|
||||
}, [pageHeaderColsDef]);
|
||||
|
||||
const pageItemRenderer = useCallback((item: ListItem) => {
|
||||
return <PageListItemRenderer {...item} />;
|
||||
@@ -179,6 +181,8 @@ export const VirtualizedPageList = ({
|
||||
hideFloatingToolbar();
|
||||
}, [filteredSelectedPageIds, hideFloatingToolbar, pageMetas, setTrashModal]);
|
||||
|
||||
const group = usePageItemGroupDefinitions();
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualizedList
|
||||
@@ -189,6 +193,7 @@ export const VirtualizedPageList = ({
|
||||
atTopStateChange={setHideHeaderCreateNewPage}
|
||||
onSelectionActiveChange={setShowFloatingToolbar}
|
||||
heading={heading}
|
||||
groupBy={group}
|
||||
selectedIds={filteredSelectedPageIds}
|
||||
onSelectedIdsChange={setSelectedPageIds}
|
||||
items={pageMetasToRender}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const groupLabelWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
});
|
||||
|
||||
export const tagIcon = style({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
marginLeft: '4px',
|
||||
marginRight: '6px',
|
||||
});
|
||||
|
||||
export const groupLabel = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
lineHeight: '1.5em',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
});
|
||||
|
||||
export const pageCount = style({
|
||||
fontSize: cssVar('fontBase'),
|
||||
lineHeight: '1.6em',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
});
|
||||
|
||||
export const favouritedIcon = style({
|
||||
color: cssVar('primaryColor'),
|
||||
marginRight: '6px',
|
||||
fontSize: '16px',
|
||||
});
|
||||
|
||||
export const notFavouritedIcon = style({
|
||||
color: cssVar('iconColor'),
|
||||
marginRight: '6px',
|
||||
fontSize: '16px',
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { TagService } from '@affine/core/modules/tag';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { FavoritedIcon, FavoriteIcon } from '@blocksuite/icons';
|
||||
import type { DocMeta } from '@blocksuite/store';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { atomWithStorage } from 'jotai/utils';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
|
||||
import * as styles from './group-definitions.css';
|
||||
import type {
|
||||
DateKey,
|
||||
ItemGroupDefinition,
|
||||
ListItem,
|
||||
PageGroupByType,
|
||||
} from './types';
|
||||
import { betweenDaysAgo, withinDaysAgo } from './utils';
|
||||
|
||||
export const pageGroupByTypeAtom = atomWithStorage<PageGroupByType>(
|
||||
'pageGroupByType',
|
||||
'updatedDate'
|
||||
);
|
||||
|
||||
const GroupLabel = ({
|
||||
label,
|
||||
count,
|
||||
icon,
|
||||
id,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
icon?: ReactNode;
|
||||
}) => (
|
||||
<div className={styles.groupLabelWrapper}>
|
||||
{icon}
|
||||
<div
|
||||
className={styles.groupLabel}
|
||||
data-testid={`group-label-${id}-${count}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div className={styles.pageCount}>{` · ${count}`}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// todo: optimize date matchers
|
||||
export const useDateGroupDefinitions = <T extends ListItem>(
|
||||
key: DateKey
|
||||
): ItemGroupDefinition<T>[] => {
|
||||
const t = useAFFiNEI18N();
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'today',
|
||||
label: count => (
|
||||
<GroupLabel
|
||||
id="today"
|
||||
label={t['com.affine.today']()}
|
||||
count={count}
|
||||
/>
|
||||
),
|
||||
match: item =>
|
||||
withinDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 1),
|
||||
},
|
||||
{
|
||||
id: 'yesterday',
|
||||
label: count => (
|
||||
<GroupLabel
|
||||
id="yesterday"
|
||||
label={t['com.affine.yesterday']()}
|
||||
count={count}
|
||||
/>
|
||||
),
|
||||
match: item =>
|
||||
betweenDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 1, 2),
|
||||
},
|
||||
{
|
||||
id: 'last7Days',
|
||||
label: count => (
|
||||
<GroupLabel
|
||||
id="last7Days"
|
||||
label={t['com.affine.last7Days']()}
|
||||
count={count}
|
||||
/>
|
||||
),
|
||||
match: item =>
|
||||
betweenDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 2, 7),
|
||||
},
|
||||
{
|
||||
id: 'last30Days',
|
||||
label: count => (
|
||||
<GroupLabel
|
||||
id="last30Days"
|
||||
label={t['com.affine.last30Days']()}
|
||||
count={count}
|
||||
/>
|
||||
),
|
||||
match: item =>
|
||||
betweenDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 7, 30),
|
||||
},
|
||||
{
|
||||
id: 'moreThan30Days',
|
||||
label: count => (
|
||||
<GroupLabel
|
||||
id="moreThan30Days"
|
||||
label={t['com.affine.moreThan30Days']()}
|
||||
count={count}
|
||||
/>
|
||||
),
|
||||
match: item =>
|
||||
!withinDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 30),
|
||||
},
|
||||
],
|
||||
[key, t]
|
||||
);
|
||||
};
|
||||
export const useTagGroupDefinitions = (): ItemGroupDefinition<ListItem>[] => {
|
||||
const tagService = useService(TagService);
|
||||
const tagMetas = useLiveData(tagService.tagMetas$);
|
||||
return useMemo(() => {
|
||||
return tagMetas.map(tag => ({
|
||||
id: tag.id,
|
||||
label: count => (
|
||||
<GroupLabel
|
||||
id={tag.title}
|
||||
label={tag.title}
|
||||
count={count}
|
||||
icon={
|
||||
<div
|
||||
className={styles.tagIcon}
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
}}
|
||||
></div>
|
||||
}
|
||||
/>
|
||||
),
|
||||
match: item => (item as DocMeta).tags?.includes(tag.id),
|
||||
}));
|
||||
}, [tagMetas]);
|
||||
};
|
||||
|
||||
export const useFavoriteGroupDefinitions = <
|
||||
T extends ListItem,
|
||||
>(): ItemGroupDefinition<T>[] => {
|
||||
const t = useAFFiNEI18N();
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'favourited',
|
||||
label: count => (
|
||||
<GroupLabel
|
||||
id="favourited"
|
||||
label={t['com.affine.page.group-header.favourited']()}
|
||||
count={count}
|
||||
icon={<FavoritedIcon className={styles.favouritedIcon} />}
|
||||
/>
|
||||
),
|
||||
match: item => !!(item as DocMeta).favorite,
|
||||
},
|
||||
{
|
||||
id: 'notFavourited',
|
||||
label: count => (
|
||||
<GroupLabel
|
||||
id="notFavourited"
|
||||
label={t['com.affine.page.group-header.not-favourited']()}
|
||||
count={count}
|
||||
icon={<FavoriteIcon className={styles.notFavouritedIcon} />}
|
||||
/>
|
||||
),
|
||||
match: item => !(item as DocMeta).favorite,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
};
|
||||
|
||||
export const usePageItemGroupDefinitions = () => {
|
||||
const key = useAtomValue(pageGroupByTypeAtom);
|
||||
const tagGroupDefinitions = useTagGroupDefinitions();
|
||||
const createDateGroupDefinitions = useDateGroupDefinitions('createDate');
|
||||
const updatedDateGroupDefinitions = useDateGroupDefinitions('updatedDate');
|
||||
const favouriteGroupDefinitions = useFavoriteGroupDefinitions();
|
||||
|
||||
return useMemo(() => {
|
||||
const itemGroupDefinitions = {
|
||||
createDate: createDateGroupDefinitions,
|
||||
updatedDate: updatedDateGroupDefinitions,
|
||||
tag: tagGroupDefinitions,
|
||||
favourites: favouriteGroupDefinitions,
|
||||
none: undefined,
|
||||
|
||||
// add more here later
|
||||
// todo: some page group definitions maybe dynamic
|
||||
};
|
||||
return itemGroupDefinitions[key];
|
||||
}, [
|
||||
createDateGroupDefinitions,
|
||||
favouriteGroupDefinitions,
|
||||
key,
|
||||
tagGroupDefinitions,
|
||||
updatedDateGroupDefinitions,
|
||||
]);
|
||||
};
|
||||
@@ -1,45 +1,55 @@
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { ListHeaderTitleCell } from './page-header';
|
||||
import type { HeaderColDef } from './types';
|
||||
|
||||
export const pageHeaderColsDef: HeaderColDef[] = [
|
||||
{
|
||||
key: 'title',
|
||||
content: <ListHeaderTitleCell />,
|
||||
flex: 6,
|
||||
alignment: 'start',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
content: <Trans i18nKey="Tags" />,
|
||||
flex: 3,
|
||||
alignment: 'end',
|
||||
},
|
||||
{
|
||||
key: 'createDate',
|
||||
content: <Trans i18nKey="Created" />,
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
alignment: 'end',
|
||||
hideInSmallContainer: true,
|
||||
},
|
||||
{
|
||||
key: 'updatedDate',
|
||||
content: <Trans i18nKey="Updated" />,
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
alignment: 'end',
|
||||
hideInSmallContainer: true,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
content: '',
|
||||
flex: 1,
|
||||
alignment: 'end',
|
||||
},
|
||||
];
|
||||
import { usePageDisplayProperties } from './use-page-display-properties';
|
||||
export const usePageHeaderColsDef = (): HeaderColDef[] => {
|
||||
const [displayProperties] = usePageDisplayProperties();
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'title',
|
||||
content: <ListHeaderTitleCell />,
|
||||
flex: 6,
|
||||
alignment: 'start',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
content: <Trans i18nKey="Tags" />,
|
||||
flex: 3,
|
||||
alignment: 'end',
|
||||
hidden: !displayProperties['tags'],
|
||||
},
|
||||
{
|
||||
key: 'createDate',
|
||||
content: <Trans i18nKey="Created" />,
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
alignment: 'end',
|
||||
hideInSmallContainer: true,
|
||||
hidden: !displayProperties['createDate'],
|
||||
},
|
||||
{
|
||||
key: 'updatedDate',
|
||||
content: <Trans i18nKey="Updated" />,
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
alignment: 'end',
|
||||
hideInSmallContainer: true,
|
||||
hidden: !displayProperties['updatedDate'],
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
content: '',
|
||||
flex: 1,
|
||||
alignment: 'end',
|
||||
},
|
||||
],
|
||||
[displayProperties]
|
||||
);
|
||||
};
|
||||
|
||||
export const collectionHeaderColsDef: HeaderColDef[] = [
|
||||
{
|
||||
|
||||
@@ -2,10 +2,12 @@ export * from './collections';
|
||||
export * from './components/favorite-tag';
|
||||
export * from './components/floating-toolbar';
|
||||
export * from './components/new-page-button';
|
||||
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';
|
||||
export * from './operation-cell';
|
||||
@@ -16,6 +18,7 @@ export * from './tags';
|
||||
export * from './types';
|
||||
export * from './use-collection-manager';
|
||||
export * from './use-filtered-page-metas';
|
||||
export * from './use-page-display-properties';
|
||||
export * from './utils';
|
||||
export * from './view';
|
||||
export * from './virtualized-list';
|
||||
|
||||
@@ -1,61 +1,10 @@
|
||||
import { Trans } from '@affine/i18n';
|
||||
|
||||
import type {
|
||||
DateKey,
|
||||
ItemGroupDefinition,
|
||||
ItemGroupProps,
|
||||
ListItem,
|
||||
} from './types';
|
||||
import { betweenDaysAgo, withinDaysAgo } from './utils';
|
||||
|
||||
// todo: optimize date matchers
|
||||
const getDateGroupDefinitions = <T extends ListItem>(
|
||||
key: DateKey
|
||||
): ItemGroupDefinition<T>[] => [
|
||||
{
|
||||
id: 'today',
|
||||
label: <Trans i18nKey="com.affine.today" />,
|
||||
match: item =>
|
||||
withinDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 1),
|
||||
},
|
||||
{
|
||||
id: 'yesterday',
|
||||
label: <Trans i18nKey="com.affine.yesterday" />,
|
||||
match: item =>
|
||||
betweenDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 1, 2),
|
||||
},
|
||||
{
|
||||
id: 'last7Days',
|
||||
label: <Trans i18nKey="com.affine.last7Days" />,
|
||||
match: item =>
|
||||
betweenDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 2, 7),
|
||||
},
|
||||
{
|
||||
id: 'last30Days',
|
||||
label: <Trans i18nKey="com.affine.last30Days" />,
|
||||
match: item =>
|
||||
betweenDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 7, 30),
|
||||
},
|
||||
{
|
||||
id: 'moreThan30Days',
|
||||
label: <Trans i18nKey="com.affine.moreThan30Days" />,
|
||||
match: item =>
|
||||
!withinDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 30),
|
||||
},
|
||||
];
|
||||
|
||||
const itemGroupDefinitions = {
|
||||
createDate: getDateGroupDefinitions('createDate'),
|
||||
updatedDate: getDateGroupDefinitions('updatedDate'),
|
||||
// add more here later
|
||||
// todo: some page group definitions maybe dynamic
|
||||
};
|
||||
import type { ItemGroupDefinition, ItemGroupProps, ListItem } from './types';
|
||||
|
||||
export function itemsToItemGroups<T extends ListItem>(
|
||||
items: T[],
|
||||
key?: DateKey
|
||||
groupDefs?: ItemGroupDefinition<T>[] | false
|
||||
): ItemGroupProps<T>[] {
|
||||
if (!key) {
|
||||
if (!groupDefs) {
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
@@ -66,8 +15,12 @@ export function itemsToItemGroups<T extends ListItem>(
|
||||
}
|
||||
|
||||
// assume pages are already sorted, we will use the page order to determine the group order
|
||||
const groupDefs = itemGroupDefinitions[key];
|
||||
const groups: ItemGroupProps<T>[] = [];
|
||||
let groups: ItemGroupProps<T>[] = groupDefs.map(groupDef => ({
|
||||
id: groupDef.id,
|
||||
label: undefined, // Will be set later
|
||||
items: [],
|
||||
allItems: items,
|
||||
}));
|
||||
|
||||
for (const item of items) {
|
||||
// for a single page, there could be multiple groups that it belongs to
|
||||
@@ -76,19 +29,24 @@ export function itemsToItemGroups<T extends ListItem>(
|
||||
const group = groups.find(g => g.id === groupDef.id);
|
||||
if (group) {
|
||||
group.items.push(item);
|
||||
} else {
|
||||
const label =
|
||||
typeof groupDef.label === 'function'
|
||||
? groupDef.label()
|
||||
: groupDef.label;
|
||||
groups.push({
|
||||
id: groupDef.id,
|
||||
label: label,
|
||||
items: [item],
|
||||
allItems: items,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now that all items have been added to groups, we can get the correct label for each group
|
||||
groups = groups
|
||||
.map(group => {
|
||||
const groupDef = groupDefs.find(def => def.id === group.id);
|
||||
if (groupDef) {
|
||||
if (typeof groupDef.label === 'function') {
|
||||
group.label = groupDef.label(group.items.length);
|
||||
} else {
|
||||
group.label = groupDef.label;
|
||||
}
|
||||
}
|
||||
return group;
|
||||
})
|
||||
.filter(group => group.items.length > 0);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ export const hideInSmallContainer = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const hidden = style({
|
||||
display: 'none',
|
||||
});
|
||||
export const favoriteCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import { pageHeaderColsDef } from './header-col-def';
|
||||
import { usePageHeaderColsDef } from './header-col-def';
|
||||
import * as styles from './list.css';
|
||||
import { ItemGroup } from './page-group';
|
||||
import { ListTableHeader } from './page-header';
|
||||
@@ -134,7 +134,7 @@ ListInnerWrapper.displayName = 'ListInnerWrapper';
|
||||
|
||||
const ListInner = (props: ListProps<ListItem>) => {
|
||||
const groups = useAtomValue(groupsAtom);
|
||||
|
||||
const pageHeaderColsDef = usePageHeaderColsDef();
|
||||
const hideHeader = props.hideHeader;
|
||||
return (
|
||||
<div className={clsx(props.className, styles.root)}>
|
||||
|
||||
@@ -45,7 +45,6 @@ export const header = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0px 16px 0px 6px',
|
||||
gap: 4,
|
||||
height: '28px',
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
':hover': {
|
||||
@@ -88,6 +87,8 @@ export const selectAllButton = style({
|
||||
});
|
||||
export const collapsedIcon = style({
|
||||
opacity: 0,
|
||||
fontSize: '20px',
|
||||
color: cssVar('iconColor'),
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
selectors: {
|
||||
'&[data-collapsed="false"]': {
|
||||
@@ -99,8 +100,6 @@ export const collapsedIcon = style({
|
||||
},
|
||||
});
|
||||
export const collapsedIconContainer = style({
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -114,6 +114,7 @@ export const ListTableHeader = ({
|
||||
data-selection-active={selectionState.selectionActive}
|
||||
>
|
||||
{headerCols.map(col => {
|
||||
const isTagHidden = col.key === 'tags' && col.hidden;
|
||||
return (
|
||||
<ListHeaderCell
|
||||
flex={col.flex}
|
||||
@@ -123,8 +124,12 @@ export const ListTableHeader = ({
|
||||
sortable={col.sortable}
|
||||
sorting={sorter.key === col.key}
|
||||
order={sorter.order}
|
||||
hidden={isTagHidden ? false : col.hidden}
|
||||
onSort={onSort}
|
||||
style={{ overflow: 'visible' }}
|
||||
style={{
|
||||
overflow: 'visible',
|
||||
visibility: isTagHidden ? 'hidden' : 'visible',
|
||||
}}
|
||||
hideInSmallContainer={col.hideInSmallContainer}
|
||||
>
|
||||
{col.content}
|
||||
|
||||
@@ -186,20 +186,9 @@ export const sorterAtom = atom(
|
||||
);
|
||||
|
||||
export const groupsAtom = atom(get => {
|
||||
let groupBy = get(selectAtom(listPropsAtom, props => props.groupBy));
|
||||
const groupBy = get(selectAtom(listPropsAtom, props => props.groupBy));
|
||||
const sorter = get(sorterAtom);
|
||||
|
||||
if (groupBy === false) {
|
||||
groupBy = undefined;
|
||||
} else if (groupBy === undefined) {
|
||||
groupBy =
|
||||
sorter.key === 'createDate' || sorter.key === 'updatedDate'
|
||||
? sorter.key
|
||||
: // default sort
|
||||
!sorter.key
|
||||
? DEFAULT_SORT_KEY
|
||||
: undefined;
|
||||
}
|
||||
return itemsToItemGroups<ListItem>(sorter.items, groupBy);
|
||||
});
|
||||
|
||||
|
||||
@@ -84,7 +84,6 @@ export const VirtualizedTagList = ({
|
||||
ref={listRef}
|
||||
selectable="toggle"
|
||||
draggable={false}
|
||||
groupBy={false}
|
||||
atTopThreshold={80}
|
||||
onSelectionActiveChange={setShowFloatingToolbar}
|
||||
heading={<TagListHeader onOpen={onOpenCreate} />}
|
||||
|
||||
@@ -82,6 +82,12 @@ export interface SortBy {
|
||||
}
|
||||
|
||||
export type DateKey = 'createDate' | 'updatedDate';
|
||||
export type PageGroupByType =
|
||||
| 'createDate'
|
||||
| 'updatedDate'
|
||||
| 'tag'
|
||||
| 'favourites'
|
||||
| 'none';
|
||||
|
||||
export interface ListProps<T> {
|
||||
// required data:
|
||||
@@ -89,7 +95,7 @@ export interface ListProps<T> {
|
||||
docCollection: DocCollection;
|
||||
className?: string;
|
||||
hideHeader?: boolean; // whether or not to hide the header. default is false (showing header)
|
||||
groupBy?: ItemGroupByType | false;
|
||||
groupBy?: ItemGroupDefinition<T>[];
|
||||
isPreferredEdgeless?: (pageId: string) => boolean; // determines the icon used for each row
|
||||
rowAsLink?: boolean;
|
||||
selectable?: 'toggle' | boolean; // show selection checkbox. toggle means showing a toggle selection in header on click; boolean == true means showing a selection checkbox for each item
|
||||
@@ -117,7 +123,7 @@ export interface ItemListHandle {
|
||||
export interface ItemGroupDefinition<T> {
|
||||
id: string;
|
||||
// using a function to render custom group header
|
||||
label: (() => ReactNode) | ReactNode;
|
||||
label: ((count: number) => ReactNode) | ReactNode;
|
||||
match: (item: T) => boolean;
|
||||
}
|
||||
|
||||
@@ -146,6 +152,7 @@ export type HeaderColDef = {
|
||||
alignment?: ColWrapperProps['alignment'];
|
||||
sortable?: boolean;
|
||||
hideInSmallContainer?: boolean;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
export type ColWrapperProps = PropsWithChildren<{
|
||||
@@ -155,3 +162,10 @@ export type ColWrapperProps = PropsWithChildren<{
|
||||
hideInSmallContainer?: boolean;
|
||||
}> &
|
||||
React.HTMLAttributes<Element>;
|
||||
|
||||
export type PageDisplayProperties = {
|
||||
bodyNotes: boolean;
|
||||
tags: boolean;
|
||||
createDate: boolean;
|
||||
updatedDate: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useAtom } from 'jotai';
|
||||
import { atomWithStorage } from 'jotai/utils';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { PageDisplayProperties } from './types';
|
||||
|
||||
export const pageDisplayPropertiesAtom = atomWithStorage<PageDisplayProperties>(
|
||||
'pageDisplayProperties',
|
||||
{
|
||||
bodyNotes: true,
|
||||
tags: true,
|
||||
createDate: true,
|
||||
updatedDate: true,
|
||||
}
|
||||
);
|
||||
|
||||
export const usePageDisplayProperties = (): [
|
||||
PageDisplayProperties,
|
||||
(key: keyof PageDisplayProperties, value: boolean) => void,
|
||||
] => {
|
||||
const [properties, setProperties] = useAtom(pageDisplayPropertiesAtom);
|
||||
const onChange = useCallback(
|
||||
(key: keyof PageDisplayProperties, value: boolean) => {
|
||||
setProperties(prev => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[setProperties]
|
||||
);
|
||||
return [properties, onChange];
|
||||
};
|
||||
@@ -75,6 +75,7 @@ export const ColWrapper = forwardRef<HTMLDivElement, ColWrapperProps>(
|
||||
flex,
|
||||
alignment,
|
||||
hideInSmallContainer,
|
||||
hidden,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
@@ -95,6 +96,7 @@ export const ColWrapper = forwardRef<HTMLDivElement, ColWrapperProps>(
|
||||
}}
|
||||
data-hide-item={hideInSmallContainer ? true : undefined}
|
||||
className={clsx(className, styles.colWrapper, {
|
||||
[styles.hidden]: hidden,
|
||||
[styles.hideInSmallContainer]: hideInSmallContainer,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useCallback } from 'react';
|
||||
|
||||
import { FilterList } from '../../filter/filter-list';
|
||||
import { VariableSelect } from '../../filter/vars';
|
||||
import { pageHeaderColsDef } from '../../header-col-def';
|
||||
import { usePageHeaderColsDef } from '../../header-col-def';
|
||||
import { PageListItemRenderer } from '../../page-group';
|
||||
import { ListTableHeader } from '../../page-header';
|
||||
import type { ListItem } from '../../types';
|
||||
@@ -47,6 +47,7 @@ export const PagesMode = ({
|
||||
publicMode: allPageListConfig.getPublicMode(meta.id),
|
||||
}))
|
||||
);
|
||||
const pageHeaderColsDef = usePageHeaderColsDef();
|
||||
const { searchText, updateSearchText, searchedList } =
|
||||
useSearch(filteredList);
|
||||
const clearSelected = useCallback(() => {
|
||||
@@ -68,7 +69,7 @@ export const PagesMode = ({
|
||||
}, []);
|
||||
const pageHeaderRenderer = useCallback(() => {
|
||||
return <ListTableHeader headerCols={pageHeaderColsDef} />;
|
||||
}, []);
|
||||
}, [pageHeaderColsDef]);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
@@ -127,7 +128,6 @@ export const PagesMode = ({
|
||||
<VirtualizedList
|
||||
className={styles.pageList}
|
||||
items={searchedList}
|
||||
groupBy={false}
|
||||
docCollection={allPageListConfig.docCollection}
|
||||
selectable
|
||||
onSelectedIdsChange={ids => {
|
||||
|
||||
@@ -265,7 +265,6 @@ export const RulesMode = ({
|
||||
hideHeader
|
||||
className={styles.resultPages}
|
||||
items={rulesPages}
|
||||
groupBy={false}
|
||||
docCollection={allPageListConfig.docCollection}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
operationsRenderer={operationsRenderer}
|
||||
@@ -285,7 +284,6 @@ export const RulesMode = ({
|
||||
hideHeader
|
||||
className={styles.resultPages}
|
||||
items={allowListPages}
|
||||
groupBy={false}
|
||||
docCollection={allPageListConfig.docCollection}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
operationsRenderer={operationsRenderer}
|
||||
|
||||
@@ -117,7 +117,6 @@ export const SelectPage = ({
|
||||
items={searchedList}
|
||||
docCollection={allPageListConfig.docCollection}
|
||||
selectable
|
||||
groupBy={false}
|
||||
onSelectedIdsChange={onChange}
|
||||
selectedIds={value}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AllPageListOperationsMenu,
|
||||
PageDisplayMenu,
|
||||
PageListNewPageButton,
|
||||
} from '@affine/core/components/page-list';
|
||||
import { Header } from '@affine/core/components/pure/header';
|
||||
@@ -32,15 +33,18 @@ export const AllPageHeader = ({
|
||||
/>
|
||||
}
|
||||
right={
|
||||
<PageListNewPageButton
|
||||
size="small"
|
||||
className={clsx(
|
||||
styles.headerCreateNewButton,
|
||||
!showCreateNew && styles.headerCreateNewButtonHidden
|
||||
)}
|
||||
>
|
||||
<PlusIcon />
|
||||
</PageListNewPageButton>
|
||||
<>
|
||||
<PageListNewPageButton
|
||||
size="small"
|
||||
className={clsx(
|
||||
styles.headerCreateNewButton,
|
||||
!showCreateNew && styles.headerCreateNewButtonHidden
|
||||
)}
|
||||
>
|
||||
<PlusIcon />
|
||||
</PageListNewPageButton>
|
||||
<PageDisplayMenu />
|
||||
</>
|
||||
}
|
||||
center={<WorkspaceModeFilterTab activeFilter={'docs'} />}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ export const scrollContainer = style({
|
||||
export const headerCreateNewButton = style({
|
||||
transition: 'opacity 0.1s ease-in-out',
|
||||
});
|
||||
|
||||
export const headerCreateNewCollectionIconButton = style({
|
||||
padding: '4px 8px',
|
||||
fontSize: '16px',
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useFilteredPageMetas,
|
||||
VirtualizedList,
|
||||
} from '@affine/core/components/page-list';
|
||||
import { pageHeaderColsDef } from '@affine/core/components/page-list/header-col-def';
|
||||
import { usePageHeaderColsDef } from '@affine/core/components/page-list/header-col-def';
|
||||
import { Header } from '@affine/core/components/pure/header';
|
||||
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
|
||||
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
|
||||
@@ -60,6 +60,7 @@ export const TrashPage = () => {
|
||||
useBlockSuiteMetaHelper(docCollection);
|
||||
const { isPreferredEdgeless } = usePageHelper(docCollection);
|
||||
const t = useAFFiNEI18N();
|
||||
const pageHeaderColsDef = usePageHeaderColsDef();
|
||||
|
||||
const pageOperationsRenderer = useCallback(
|
||||
(item: ListItem) => {
|
||||
@@ -92,7 +93,7 @@ export const TrashPage = () => {
|
||||
}, []);
|
||||
const pageHeaderRenderer = useCallback(() => {
|
||||
return <ListTableHeader headerCols={pageHeaderColsDef} />;
|
||||
}, []);
|
||||
}, [pageHeaderColsDef]);
|
||||
return (
|
||||
<>
|
||||
<ViewHeaderIsland>
|
||||
@@ -104,7 +105,6 @@ export const TrashPage = () => {
|
||||
<VirtualizedList
|
||||
items={filteredPageMetas}
|
||||
rowAsLink
|
||||
groupBy={false}
|
||||
isPreferredEdgeless={isPreferredEdgeless}
|
||||
docCollection={currentWorkspace.docCollection}
|
||||
operationsRenderer={pageOperationsRenderer}
|
||||
|
||||
Reference in New Issue
Block a user