refactor(infra): directory structure (#4615)

This commit is contained in:
Joooye_34
2023-10-18 23:30:08 +08:00
committed by GitHub
parent 814d552be8
commit bed9310519
1150 changed files with 539 additions and 584 deletions
@@ -0,0 +1,189 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import type {
Filter,
LiteralValue,
PropertiesMeta,
Ref,
VariableMap,
} from '@affine/env/filter';
import { createI18n, I18nextProvider } from '@affine/i18n';
import { assertExists } from '@blocksuite/global/utils';
import { render } from '@testing-library/react';
import type { ReactElement } from 'react';
import { useState } from 'react';
import { describe, expect, test } from 'vitest';
import { Condition } from '../filter/condition';
import { tBoolean, tDate } from '../filter/logical/custom-type';
import { toLiteral } from '../filter/shared-types';
import type { FilterMatcherDataType } from '../filter/vars';
import { filterMatcher } from '../filter/vars';
import { filterByFilterList } from '../use-collection-manager';
const ref = (name: keyof VariableMap): Ref => {
return {
type: 'ref',
name,
};
};
const mockVariableMap = (vars: Partial<VariableMap>): VariableMap => {
return {
Created: 0,
Updated: 0,
'Is Favourited': false,
Tags: [],
...vars,
};
};
const mockPropertiesMeta = (meta: Partial<PropertiesMeta>): PropertiesMeta => {
return {
tags: {
options: [],
},
...meta,
};
};
const filter = (
matcherData: FilterMatcherDataType,
left: Ref,
args: LiteralValue[]
): Filter => {
return {
type: 'filter',
left,
funcName: matcherData.name,
args: args.map(toLiteral),
};
};
describe('match filter', () => {
test('boolean variable will match `is` filter', () => {
const is = filterMatcher
.allMatchedData(tBoolean.create())
.find(v => v.name === 'is');
expect(is?.name).toBe('is');
});
test('Date variable will match `before` filter', () => {
const before = filterMatcher
.allMatchedData(tDate.create())
.find(v => v.name === 'before');
expect(before?.name).toBe('before');
});
});
describe('eval filter', () => {
test('before', async () => {
const before = filterMatcher.findData(v => v.name === 'before');
assertExists(before);
const filter1 = filter(before, ref('Created'), [
new Date(2023, 5, 28).getTime(),
]);
const filter2 = filter(before, ref('Created'), [
new Date(2023, 5, 30).getTime(),
]);
const filter3 = filter(before, ref('Created'), [
new Date(2023, 5, 29).getTime(),
]);
const varMap = mockVariableMap({
Created: new Date(2023, 5, 29).getTime(),
});
expect(filterByFilterList([filter1], varMap)).toBe(false);
expect(filterByFilterList([filter2], varMap)).toBe(true);
expect(filterByFilterList([filter3], varMap)).toBe(false);
});
test('after', async () => {
const after = filterMatcher.findData(v => v.name === 'after');
assertExists(after);
const filter1 = filter(after, ref('Created'), [
new Date(2023, 5, 28).getTime(),
]);
const filter2 = filter(after, ref('Created'), [
new Date(2023, 5, 30).getTime(),
]);
const filter3 = filter(after, ref('Created'), [
new Date(2023, 5, 29).getTime(),
]);
const varMap = mockVariableMap({
Created: new Date(2023, 5, 29).getTime(),
});
expect(filterByFilterList([filter1], varMap)).toBe(true);
expect(filterByFilterList([filter2], varMap)).toBe(false);
expect(filterByFilterList([filter3], varMap)).toBe(false);
});
test('is', async () => {
const is = filterMatcher.findData(v => v.name === 'is');
assertExists(is);
const filter1 = filter(is, ref('Is Favourited'), [false]);
const filter2 = filter(is, ref('Is Favourited'), [true]);
const varMap = mockVariableMap({
'Is Favourited': true,
});
expect(filterByFilterList([filter1], varMap)).toBe(false);
expect(filterByFilterList([filter2], varMap)).toBe(true);
});
});
describe('render filter', () => {
test('boolean condition value change', async () => {
const i18n = createI18n();
const is = filterMatcher.match(tBoolean.create());
assertExists(is);
const Wrapper = () => {
const [value, onChange] = useState(
filter(is, ref('Is Favourited'), [true])
);
return (
<I18nextProvider i18n={i18n}>
<Condition
propertiesMeta={mockPropertiesMeta({})}
value={value}
onChange={onChange}
/>
</I18nextProvider>
);
};
const result = render(<Wrapper />);
const dom = await result.findByText('true');
dom.click();
await result.findByText('false');
result.unmount();
});
const WrapperCreator = (fn: FilterMatcherDataType) =>
function Wrapper(): ReactElement {
const [value, onChange] = useState(
filter(fn, ref('Created'), [new Date(2023, 5, 29).getTime()])
);
return (
<Condition
propertiesMeta={mockPropertiesMeta({})}
value={value}
onChange={onChange}
/>
);
};
test('date condition function change', async () => {
const dateFunction = filterMatcher.match(tDate.create());
assertExists(dateFunction);
const Wrapper = WrapperCreator(dateFunction);
const result = render(<Wrapper />);
const dom = await result.findByTestId('filter-name');
dom.click();
await result.findByTestId('filter-name');
result.unmount();
});
test('date condition variable change', async () => {
const dateFunction = filterMatcher.match(tDate.create());
assertExists(dateFunction);
const Wrapper = WrapperCreator(dateFunction);
const result = render(<Wrapper />);
const dom = await result.findByTestId('variable-name');
dom.click();
await result.findByTestId('variable-name');
result.unmount();
});
});
@@ -0,0 +1,51 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import type { Collection } from '@affine/env/filter';
import { renderHook } from '@testing-library/react';
import { atom } from 'jotai';
import { expect, test } from 'vitest';
import { createDefaultFilter, vars } from '../filter/vars';
import {
type CollectionsAtom,
useCollectionManager,
} from '../use-collection-manager';
const defaultMeta = { tags: { options: [] } };
const baseAtom = atom<Collection[]>([]);
const mockAtom: CollectionsAtom = atom(
get => get(baseAtom),
async (_, set, update) => {
set(baseAtom, update);
}
);
test('useAllPageSetting', async () => {
const settingHook = renderHook(() => useCollectionManager(mockAtom));
const prevCollection = settingHook.result.current.currentCollection;
expect(settingHook.result.current.savedCollections).toEqual([]);
await settingHook.result.current.updateCollection({
...settingHook.result.current.currentCollection,
filterList: [createDefaultFilter(vars[0], defaultMeta)],
workspaceId: 'test',
});
settingHook.rerender();
const nextCollection = settingHook.result.current.currentCollection;
expect(nextCollection).not.toBe(prevCollection);
expect(nextCollection.filterList).toEqual([
createDefaultFilter(vars[0], defaultMeta),
]);
settingHook.result.current.backToAll();
await settingHook.result.current.saveCollection({
...settingHook.result.current.currentCollection,
id: '1',
});
settingHook.rerender();
expect(settingHook.result.current.savedCollections.length).toBe(1);
expect(settingHook.result.current.savedCollections[0].id).toBe('1');
});
@@ -0,0 +1,321 @@
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
import type { PropertiesMeta } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowDownBigIcon, ArrowUpBigIcon } from '@blocksuite/icons';
import { useMediaQuery, useTheme } from '@mui/material';
import type React from 'react';
import { type CSSProperties, useMemo } from 'react';
import {
ScrollableContainer,
Table,
TableBody,
TableCell,
TableHead,
TableHeadRow,
} from '../..';
import { TableBodyRow } from '../../ui/table';
import { useHasScrollTop } from '../app-sidebar/sidebar-containers/use-has-scroll-top';
import { AllPagesBody } from './all-pages-body';
import { NewPageButton } from './components/new-page-buttton';
import { TitleCell } from './components/title-cell';
import { AllPageListMobileView, TrashListMobileView } from './mobile';
import { TrashOperationCell } from './operation-cell';
import { StyledTableContainer } from './styles';
import type { ListData, PageListProps, TrashListData } from './type';
import type { CollectionsAtom } from './use-collection-manager';
import { useSorter } from './use-sorter';
import { formatDate, useIsSmallDevices } from './utils';
import { CollectionBar } from './view/collection-bar';
interface AllPagesHeadProps {
isPublicWorkspace: boolean;
sorter: ReturnType<typeof useSorter<ListData>>;
createNewPage: () => void;
createNewEdgeless: () => void;
importFile: () => void;
getPageInfo: GetPageInfoById;
propertiesMeta: PropertiesMeta;
collectionsAtom: CollectionsAtom;
}
const AllPagesHead = ({
isPublicWorkspace,
sorter,
createNewPage,
createNewEdgeless,
importFile,
getPageInfo,
propertiesMeta,
collectionsAtom,
}: AllPagesHeadProps) => {
const t = useAFFiNEI18N();
const titleList = useMemo(
() => [
{
key: 'title',
content: t['Title'](),
proportion: 0.5,
},
{
key: 'tags',
content: t['Tags'](),
proportion: 0.2,
},
{
key: 'createDate',
content: t['Created'](),
proportion: 0.1,
tableCellStyle: {
width: '110px',
} satisfies CSSProperties,
},
{
key: 'updatedDate',
content: t['Updated'](),
proportion: 0.1,
tableCellStyle: {
width: '110px',
} satisfies CSSProperties,
},
{
key: 'unsortable_action',
content: (
<NewPageButton
createNewPage={createNewPage}
createNewEdgeless={createNewEdgeless}
importFile={importFile}
/>
),
showWhen: () => !isPublicWorkspace,
sortable: false,
tableCellStyle: {
width: '140px',
} satisfies CSSProperties,
styles: {
justifyContent: 'flex-end',
} satisfies CSSProperties,
},
],
[createNewEdgeless, createNewPage, importFile, isPublicWorkspace, t]
);
const tableItem = useMemo(
() =>
titleList
.filter(({ showWhen = () => true }) => showWhen())
.map(
({
key,
content,
proportion,
sortable = true,
styles,
tableCellStyle,
}) => (
<TableCell
key={key}
proportion={proportion}
active={sorter.key === key}
style={tableCellStyle}
onClick={
sortable
? () => sorter.shiftOrder(key as keyof ListData)
: undefined
}
>
<div
style={{
display: 'flex',
alignItems: 'center',
...styles,
}}
>
{content}
{sorter.key === key &&
(sorter.order === 'asc' ? (
<ArrowUpBigIcon width={24} height={24} />
) : (
<ArrowDownBigIcon width={24} height={24} />
))}
</div>
</TableCell>
)
),
[sorter, titleList]
);
return (
<TableHead>
<TableHeadRow>{tableItem}</TableHeadRow>
<CollectionBar
columnsCount={titleList.length}
getPageInfo={getPageInfo}
propertiesMeta={propertiesMeta}
collectionsAtom={collectionsAtom}
/>
</TableHead>
);
};
export const PageList = ({
isPublicWorkspace = false,
collectionsAtom,
list,
onCreateNewPage,
onCreateNewEdgeless,
onImportFile,
fallback,
getPageInfo,
propertiesMeta,
}: PageListProps) => {
const sorter = useSorter<ListData>({
data: list,
key: DEFAULT_SORT_KEY,
order: 'desc',
});
const [hasScrollTop, ref] = useHasScrollTop();
const isSmallDevices = useIsSmallDevices();
if (isSmallDevices) {
return (
<ScrollableContainer inTableView>
<AllPageListMobileView
isPublicWorkspace={isPublicWorkspace}
createNewPage={onCreateNewPage}
createNewEdgeless={onCreateNewEdgeless}
importFile={onImportFile}
list={sorter.data}
/>
</ScrollableContainer>
);
}
const groupKey =
sorter.key === 'createDate' || sorter.key === 'updatedDate'
? sorter.key
: // default sort
!sorter.key
? DEFAULT_SORT_KEY
: undefined;
return sorter.data.length === 0 && fallback ? (
<StyledTableContainer>{fallback}</StyledTableContainer>
) : (
<ScrollableContainer inTableView>
<StyledTableContainer ref={ref}>
<Table showBorder={hasScrollTop} style={{ maxHeight: '100%' }}>
<AllPagesHead
collectionsAtom={collectionsAtom}
propertiesMeta={propertiesMeta}
isPublicWorkspace={isPublicWorkspace}
sorter={sorter}
createNewPage={onCreateNewPage}
createNewEdgeless={onCreateNewEdgeless}
importFile={onImportFile}
getPageInfo={getPageInfo}
/>
<AllPagesBody
isPublicWorkspace={isPublicWorkspace}
groupKey={groupKey}
data={sorter.data}
/>
</Table>
</StyledTableContainer>
</ScrollableContainer>
);
};
const TrashListHead = () => {
const t = useAFFiNEI18N();
return (
<TableHead>
<TableHeadRow>
<TableCell proportion={0.5}>{t['Title']()}</TableCell>
<TableCell proportion={0.2}>{t['Created']()}</TableCell>
<TableCell proportion={0.2}>{t['Moved to Trash']()}</TableCell>
<TableCell proportion={0.1}></TableCell>
</TableHeadRow>
</TableHead>
);
};
interface PageListTrashViewProps {
list: TrashListData[];
fallback?: React.ReactNode;
}
export const PageListTrashView = ({
list,
fallback,
}: PageListTrashViewProps) => {
const t = useAFFiNEI18N();
const theme = useTheme();
const [hasScrollTop, ref] = useHasScrollTop();
const isSmallDevices = useMediaQuery(theme.breakpoints.down('sm'));
if (isSmallDevices) {
const mobileList = list.map(({ pageId, icon, title, onClickPage }) => ({
title,
icon,
pageId,
onClickPage,
}));
return <TrashListMobileView list={mobileList} />;
}
const ListItems = list.map(
(
{
pageId,
title,
preview,
icon,
createDate,
trashDate,
onClickPage,
onPermanentlyDeletePage,
onRestorePage,
},
index
) => {
return (
<TableBodyRow
data-testid={`page-list-item-${pageId}`}
key={`${pageId}-${index}`}
>
<TitleCell
icon={icon}
text={title || t['Untitled']()}
desc={preview}
onClick={onClickPage}
/>
<TableCell onClick={onClickPage}>{formatDate(createDate)}</TableCell>
<TableCell onClick={onClickPage}>
{trashDate ? formatDate(trashDate) : '--'}
</TableCell>
<TableCell
style={{ padding: 0 }}
data-testid={`more-actions-${pageId}`}
>
<TrashOperationCell
onPermanentlyDeletePage={onPermanentlyDeletePage}
onRestorePage={onRestorePage}
onOpenPage={onClickPage}
/>
</TableCell>
</TableBodyRow>
);
}
);
return list.length === 0 && fallback ? (
<StyledTableContainer>{fallback}</StyledTableContainer>
) : (
<ScrollableContainer inTableView>
<StyledTableContainer ref={ref}>
<Table showBorder={hasScrollTop}>
<TrashListHead />
<TableBody>{ListItems}</TableBody>
</Table>
</StyledTableContainer>
</ScrollableContainer>
);
};
@@ -0,0 +1,188 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useDraggable } from '@dnd-kit/core';
import type { ReactNode } from 'react';
import { Fragment } from 'react';
import { styled } from '../../styles';
import { TableBody, TableCell } from '../../ui/table';
import { FavoriteTag } from './components/favorite-tag';
import { Tags } from './components/tags';
import { TitleCell } from './components/title-cell';
import { OperationCell } from './operation-cell';
import { StyledTableBodyRow } from './styles';
import type { DateKey, DraggableTitleCellData, ListData } from './type';
import { useDateGroup } from './use-date-group';
import { formatDate, useIsSmallDevices } from './utils';
export const GroupRow = ({ children }: { children: ReactNode }) => {
return (
<StyledTableBodyRow>
<TableCell
style={{
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-sm)',
background: 'initial',
cursor: 'default',
}}
>
{children}
</TableCell>
</StyledTableBodyRow>
);
};
export const AllPagesBody = ({
isPublicWorkspace,
data,
groupKey,
}: {
isPublicWorkspace: boolean;
data: ListData[];
groupKey?: DateKey;
}) => {
const t = useAFFiNEI18N();
const isSmallDevices = useIsSmallDevices();
const dataWithGroup = useDateGroup({ data, key: groupKey });
return (
<TableBody style={{ overflowY: 'auto', height: '100%' }}>
{dataWithGroup.map(
(
{
groupName,
pageId,
title,
preview,
tags,
icon,
isPublicPage,
favorite,
createDate,
updatedDate,
onClickPage,
bookmarkPage,
onOpenPageInNewTab,
removeToTrash,
onDisablePublicSharing,
},
index
) => {
const displayTitle = title || t['Untitled']();
return (
<Fragment key={pageId}>
{groupName &&
(index === 0 ||
dataWithGroup[index - 1].groupName !== groupName) && (
<GroupRow>{groupName}</GroupRow>
)}
<StyledTableBodyRow data-testid={`page-list-item-${pageId}`}>
<DraggableTitleCell
pageId={pageId}
draggableData={{
pageId,
pageTitle: displayTitle,
icon,
}}
icon={icon}
text={displayTitle}
desc={preview}
data-testid="title"
onClick={onClickPage}
/>
<TableCell
data-testid="tags"
hidden={isSmallDevices}
onClick={onClickPage}
style={{ fontSize: 'var(--affine-font-xs)' }}
>
<Tags value={tags}></Tags>
</TableCell>
<TableCell
data-testid="created-date"
ellipsis={true}
hidden={isSmallDevices}
onClick={onClickPage}
style={{ fontSize: 'var(--affine-font-xs)' }}
>
{formatDate(createDate)}
</TableCell>
<TableCell
data-testid="updated-date"
ellipsis={true}
hidden={isSmallDevices}
onClick={onClickPage}
style={{ fontSize: 'var(--affine-font-xs)' }}
>
{formatDate(updatedDate ?? createDate)}
</TableCell>
{!isPublicWorkspace && (
<TableCell
style={{
padding: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '10px',
}}
data-testid={`more-actions-${pageId}`}
>
<FavoriteTag
className={favorite ? '' : 'favorite-button'}
onClick={bookmarkPage}
active={!!favorite}
/>
<OperationCell
favorite={favorite}
isPublic={isPublicPage}
onOpenPageInNewTab={onOpenPageInNewTab}
onToggleFavoritePage={bookmarkPage}
onRemoveToTrash={removeToTrash}
onDisablePublicSharing={onDisablePublicSharing}
/>
</TableCell>
)}
</StyledTableBodyRow>
</Fragment>
);
}
)}
</TableBody>
);
};
const FullSizeButton = styled('button')(() => ({
width: '100%',
height: '100%',
display: 'block',
}));
type DraggableTitleCellProps = {
pageId: string;
draggableData?: DraggableTitleCellData;
} & React.ComponentProps<typeof TitleCell>;
function DraggableTitleCell({
pageId,
draggableData,
...props
}: DraggableTitleCellProps) {
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
id: 'page-list-item-title-' + pageId,
data: draggableData,
});
return (
<TitleCell
ref={setNodeRef}
style={{ opacity: isDragging ? 0.5 : 1 }}
{...props}
>
{/* Use `button` for draggable element */}
{/* See https://docs.dndkit.com/api-documentation/draggable/usedraggable#role */}
{element => (
<FullSizeButton {...listeners} {...attributes}>
{element}
</FullSizeButton>
)}
</TitleCell>
);
}
@@ -0,0 +1,58 @@
import { style } from '@vanilla-extract/css';
export const divider = style({
width: '0.5px',
height: '16px',
background: 'var(--affine-divider-color)',
// fix dropdown button click area
margin: '0 4px',
marginRight: 0,
});
export const dropdownWrapper = style({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
paddingLeft: '4px',
paddingRight: '10px',
});
export const dropdownIcon = style({
borderRadius: '4px',
selectors: {
[`${dropdownWrapper}:hover &`]: {
background: 'var(--affine-hover-color)',
},
},
});
export const dropdownBtn = style({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 10px',
// fix dropdown button click area
paddingRight: 0,
color: 'var(--affine-text-primary-color)',
fontWeight: 600,
background: 'var(--affine-button-gray-color)',
boxShadow: 'var(--affine-float-button-shadow)',
borderRadius: '8px',
fontSize: 'var(--affine-font-sm)',
// width: '100%',
height: '32px',
userSelect: 'none',
whiteSpace: 'nowrap',
cursor: 'pointer',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color-filled)',
},
},
});
export const menuContent = style({
backgroundColor: 'var(--affine-background-overlay-panel-color)',
});
@@ -0,0 +1,36 @@
import { ArrowDownSmallIcon } from '@blocksuite/icons';
import {
type ButtonHTMLAttributes,
forwardRef,
type MouseEventHandler,
} from 'react';
import * as styles from './dropdown.css';
type DropdownButtonProps = {
onClickDropDown?: MouseEventHandler<HTMLElement>;
} & ButtonHTMLAttributes<HTMLButtonElement>;
export const DropdownButton = forwardRef<
HTMLButtonElement,
DropdownButtonProps
>(({ onClickDropDown, children, ...props }, ref) => {
const handleClickDropDown: MouseEventHandler<HTMLElement> = e => {
e.stopPropagation();
onClickDropDown?.(e);
};
return (
<button ref={ref} className={styles.dropdownBtn} {...props}>
<span>{children}</span>
<span className={styles.divider} />
<span className={styles.dropdownWrapper} onClick={handleClickDropDown}>
<ArrowDownSmallIcon
className={styles.dropdownIcon}
width={16}
height={16}
/>
</span>
</button>
);
});
DropdownButton.displayName = 'DropdownButton';
@@ -0,0 +1,50 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FavoritedIcon, FavoriteIcon } from '@blocksuite/icons';
import {
IconButton,
type IconButtonProps,
} from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import Lottie from 'lottie-react';
import { forwardRef, useCallback, useState } from 'react';
import favoritedAnimation from './favorited-animation/data.json';
export const FavoriteTag = forwardRef<
HTMLButtonElement,
{
active: boolean;
} & Omit<IconButtonProps, 'children'>
>(({ active, onClick, ...props }, ref) => {
const [playAnimation, setPlayAnimation] = useState(false);
const t = useAFFiNEI18N();
const handleClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onClick?.(e);
setPlayAnimation(!active);
},
[active, onClick]
);
return (
<Tooltip content={active ? t['Favorited']() : t['Favorite']()} side="top">
<IconButton ref={ref} active={active} onClick={handleClick} {...props}>
{active ? (
playAnimation ? (
<Lottie
loop={false}
animationData={favoritedAnimation}
onComplete={() => setPlayAnimation(false)}
style={{ width: '20px', height: '20px' }}
/>
) : (
<FavoritedIcon data-testid="favorited-icon" />
)
) : (
<FavoriteIcon />
)}
</IconButton>
</Tooltip>
);
});
FavoriteTag.displayName = 'FavoriteTag';
@@ -0,0 +1,105 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { EdgelessIcon, ImportIcon, PageIcon } from '@blocksuite/icons';
import { Menu } from '@toeverything/components/menu';
import { useCallback, useState } from 'react';
import { BlockCard } from '../../card/block-card';
import { DropdownButton } from './dropdown';
import { menuContent } from './dropdown.css';
type NewPageButtonProps = {
createNewPage: () => void;
createNewEdgeless: () => void;
importFile: () => void;
};
export const CreateNewPagePopup = ({
createNewPage,
createNewEdgeless,
importFile,
}: NewPageButtonProps) => {
const t = useAFFiNEI18N();
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '8px',
}}
>
<BlockCard
title={t['New Page']()}
desc={t['com.affine.write_with_a_blank_page']()}
right={<PageIcon width={20} height={20} />}
onClick={createNewPage}
data-testid="new-page-button-in-all-page"
/>
<BlockCard
title={t['com.affine.new_edgeless']()}
desc={t['com.affine.draw_with_a_blank_whiteboard']()}
right={<EdgelessIcon width={20} height={20} />}
onClick={createNewEdgeless}
data-testid="new-edgeless-button-in-all-page"
/>
<BlockCard
title={t['com.affine.new_import']()}
desc={t['com.affine.import_file']()}
right={<ImportIcon width={20} height={20} />}
onClick={importFile}
data-testid="import-button-in-all-page"
/>
{/* TODO Import */}
</div>
);
};
export const NewPageButton = ({
createNewPage,
createNewEdgeless,
importFile,
}: NewPageButtonProps) => {
const t = useAFFiNEI18N();
const [open, setOpen] = useState(false);
return (
<Menu
items={
<CreateNewPagePopup
createNewPage={useCallback(() => {
createNewPage();
setOpen(false);
}, [createNewPage])}
createNewEdgeless={useCallback(() => {
createNewEdgeless();
setOpen(false);
}, [createNewEdgeless])}
importFile={useCallback(() => {
importFile();
setOpen(false);
}, [importFile])}
/>
}
rootOptions={{
open,
}}
contentOptions={{
className: menuContent,
align: 'end',
hideWhenDetached: true,
onInteractOutside: useCallback(() => {
setOpen(false);
}, []),
}}
>
<DropdownButton
onClick={useCallback(() => {
createNewPage();
setOpen(false);
}, [createNewPage])}
onClickDropDown={useCallback(() => setOpen(open => !open), [])}
>
{t['New Page']()}
</DropdownButton>
</Menu>
);
};
@@ -0,0 +1,30 @@
import { style } from '@vanilla-extract/css';
export const tagList = style({
display: 'flex',
flexWrap: 'nowrap',
gap: 10,
overflow: 'hidden',
});
export const tagListFull = style({
display: 'flex',
flexWrap: 'wrap',
gap: 10,
maxWidth: 300,
padding: 10,
overflow: 'hidden',
});
export const tag = style({
flexShrink: 0,
padding: '2px 10px',
borderRadius: 6,
fontSize: 12,
lineHeight: '16px',
fontWeight: 400,
maxWidth: '100%',
color: 'var(--affine-text-primary-color)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
@@ -0,0 +1,24 @@
import type { Tag } from '@affine/env/filter';
import { Menu } from '@toeverything/components/menu';
import * as styles from './tags.css';
// fixme: This component should use popover instead of menu
export const Tags = ({ value }: { value: Tag[] }) => {
const list = value.map(tag => {
return (
<div
key={tag.id}
className={styles.tag}
style={{ backgroundColor: tag.color }}
>
{tag.value}
</div>
);
});
return (
<Menu items={<div className={styles.tagListFull}>{list}</div>}>
<div className={styles.tagList}>{list}</div>
</Menu>
);
};
@@ -0,0 +1,66 @@
import React, { useCallback } from 'react';
import type { TableCellProps } from '../../..';
import { Content, TableCell } from '../../..';
import {
StyledTitleContentWrapper,
StyledTitleLink,
StyledTitlePreview,
} from '../styles';
type TitleCellProps = {
icon: JSX.Element;
text: string;
desc?: React.ReactNode;
suffix?: JSX.Element;
/**
* Customize the children of the cell
* @param element
* @returns
*/
children?: (element: React.ReactElement) => React.ReactNode;
} & Omit<TableCellProps, 'children'>;
export const TitleCell = React.forwardRef<HTMLTableCellElement, TitleCellProps>(
({ icon, text, desc, suffix, children: render, ...props }, ref) => {
const renderChildren = useCallback(() => {
const childElement = (
<>
<StyledTitleLink>
{icon}
<StyledTitleContentWrapper>
<Content
ellipsis={true}
maxWidth="100%"
color="inherit"
fontSize="var(--affine-font-sm)"
weight="600"
lineHeight="18px"
>
{text}
</Content>
{desc && (
<StyledTitlePreview
ellipsis={true}
color="var(--affine-text-secondary-color)"
>
{desc}
</StyledTitlePreview>
)}
</StyledTitleContentWrapper>
</StyledTitleLink>
{suffix}
</>
);
return render ? render(childElement) : childElement;
}, [desc, icon, render, suffix, text]);
return (
<TableCell ref={ref} {...props}>
{renderChildren()}
</TableCell>
);
}
);
TitleCell.displayName = 'TitleCell';
@@ -0,0 +1,172 @@
import type { Filter, Literal } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import { Menu, MenuItem } from '@toeverything/components/menu';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import { FilterTag } from './filter-tag-translation';
import * as styles from './index.css';
import { literalMatcher } from './literal-matcher';
import { tBoolean } from './logical/custom-type';
import type { TFunction, TType } from './logical/typesystem';
import { typesystem } from './logical/typesystem';
import { variableDefineMap } from './shared-types';
import { filterMatcher, VariableSelect, vars } from './vars';
export const Condition = ({
value,
onChange,
propertiesMeta,
}: {
value: Filter;
onChange: (filter: Filter) => void;
propertiesMeta: PropertiesMeta;
}) => {
const data = useMemo(() => {
const data = filterMatcher.find(v => v.data.name === value.funcName);
if (!data) {
return;
}
const instance = typesystem.instance(
{},
[variableDefineMap[value.left.name].type(propertiesMeta)],
tBoolean.create(),
data.type
);
return {
render: data.data.render,
type: instance,
};
}, [propertiesMeta, value.funcName, value.left.name]);
if (!data) {
return null;
}
const render =
data.render ??
(({ ast }) => {
const args = renderArgs(value, onChange, data.type);
return (
<div
style={{ display: 'flex', userSelect: 'none', alignItems: 'center' }}
>
<Menu
items={
<VariableSelect
propertiesMeta={propertiesMeta}
selected={[]}
onSelect={onChange}
/>
}
>
<div data-testid="variable-name" className={styles.filterTypeStyle}>
<div className={styles.filterTypeIconStyle}>
{variableDefineMap[ast.left.name].icon}
</div>
<FilterTag name={ast.left.name} />
</div>
</Menu>
<Menu
items={
<FunctionSelect
propertiesMeta={propertiesMeta}
value={value}
onChange={onChange}
/>
}
>
<div className={styles.switchStyle} data-testid="filter-name">
<FilterTag name={ast.funcName} />
</div>
</Menu>
{args}
</div>
);
});
return <>{render({ ast: value })}</>;
};
const FunctionSelect = ({
value,
onChange,
propertiesMeta,
}: {
value: Filter;
onChange: (value: Filter) => void;
propertiesMeta: PropertiesMeta;
}) => {
const list = useMemo(() => {
const type = vars.find(v => v.name === value.left.name)?.type;
if (!type) {
return [];
}
return filterMatcher.allMatchedData(type(propertiesMeta));
}, [propertiesMeta, value.left.name]);
return (
<div data-testid="filter-name-select">
{list.map(v => (
<MenuItem
onClick={() => {
onChange({
...value,
funcName: v.name,
args: v.defaultArgs().map(v => ({ type: 'literal', value: v })),
});
}}
key={v.name}
>
<FilterTag name={v.name} />
</MenuItem>
))}
</div>
);
};
export const Arg = ({
type,
value,
onChange,
}: {
type: TType;
value: Literal;
onChange: (lit: Literal) => void;
}) => {
const data = useMemo(() => literalMatcher.match(type), [type]);
if (!data) {
return null;
}
return (
<div data-testid="filter-arg" style={{ marginLeft: 4, fontWeight: 600 }}>
{data.render({
type,
value: value?.value,
onChange: v => onChange({ type: 'literal', value: v }),
})}
</div>
);
};
export const renderArgs = (
filter: Filter,
onChange: (value: Filter) => void,
type: TFunction
): ReactNode => {
const rest = type.args.slice(1);
return rest.map((argType, i) => {
const value = filter.args[i];
return (
<Arg
key={i}
type={argType}
value={value}
onChange={value => {
const args = type.args.map((_, index) =>
i === index ? value : filter.args[index]
);
onChange({
...filter,
args,
});
}}
></Arg>
);
});
};
@@ -0,0 +1,25 @@
import type { Filter, Literal, Ref, VariableMap } from '@affine/env/filter';
import { filterMatcher } from './vars';
const evalRef = (ref: Ref, variableMap: VariableMap) => {
return variableMap[ref.name];
};
const evalLiteral = (lit?: Literal) => {
return lit?.value;
};
const evalFilter = (filter: Filter, variableMap: VariableMap): boolean => {
const impl = filterMatcher.findData(v => v.name === filter.funcName)?.impl;
if (!impl) {
throw new Error('No function implementation found');
}
const leftValue = evalRef(filter.left, variableMap);
const args = filter.args.map(evalLiteral);
return impl(leftValue, ...args);
};
export const evalFilterList = (
filterList: Filter[],
variableMap: VariableMap
) => {
return filterList.every(filter => evalFilter(filter, variableMap));
};
@@ -0,0 +1,80 @@
import type { Filter } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, PlusIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { IconButton } from '@toeverything/components/button';
import { Menu } from '@toeverything/components/menu';
import { Condition } from './condition';
import * as styles from './index.css';
import { CreateFilterMenu } from './vars';
export const FilterList = ({
value,
onChange,
propertiesMeta,
}: {
value: Filter[];
onChange: (value: Filter[]) => void;
propertiesMeta: PropertiesMeta;
}) => {
const t = useAFFiNEI18N();
return (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 10,
alignItems: 'center',
}}
>
{value.map((filter, i) => {
return (
<div className={styles.filterItemStyle} key={i}>
<Condition
propertiesMeta={propertiesMeta}
value={filter}
onChange={filter => {
onChange(
value.map((old, oldIndex) => (oldIndex === i ? filter : old))
);
}}
/>
<div
className={styles.filterItemCloseStyle}
onClick={() => {
onChange(value.filter((_, index) => i !== index));
}}
>
<CloseIcon />
</div>
</div>
);
})}
<Menu
items={
<CreateFilterMenu
value={value}
onChange={onChange}
propertiesMeta={propertiesMeta}
/>
}
>
{value.length === 0 ? (
<Button
icon={<PlusIcon />}
iconPosition="end"
style={{ fontSize: 'var(--affine-font-xs)', padding: '0 8px' }}
>
{t['com.affine.filterList.button.add']()}
</Button>
) : (
<IconButton size="small">
<PlusIcon />
</IconButton>
)}
</Menu>
</div>
);
};
@@ -0,0 +1,49 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
type FilterTagProps = {
name: string;
};
const useFilterTag = ({ name }: FilterTagProps) => {
const t = useAFFiNEI18N();
switch (name) {
case 'Created':
return t['Created']();
case 'Updated':
return t['Updated']();
case 'Tags':
return t['Tags']();
case 'Is Favourited':
return t['com.affine.filter.is-favourited']();
case 'after':
return t['com.affine.filter.after']();
case 'before':
return t['com.affine.filter.before']();
case 'is':
return t['com.affine.filter.is']();
case 'is not empty':
return t['com.affine.filter.is not empty']();
case 'is empty':
return t['com.affine.filter.is empty']();
case 'contains all':
return t['com.affine.filter.contains all']();
case 'contains one of':
return t['com.affine.filter.contains one of']();
case 'does not contains all':
return t['com.affine.filter.does not contains all']();
case 'does not contains one of':
return t['com.affine.filter.does not contains one of']();
case 'true':
return t['com.affine.filter.true']();
case 'false':
return t['com.affine.filter.false']();
default:
return name;
}
};
export const FilterTag = ({ name }: FilterTagProps) => {
const tag = useFilterTag({ name });
return <span data-testid={`filler-tag-${tag}`}>{tag}</span>;
};
@@ -0,0 +1,82 @@
import { style } from '@vanilla-extract/css';
export const menuItemStyle = style({
fontSize: 'var(--affine-font-xs)',
});
export const variableSelectTitleStyle = style({
margin: '7px 16px',
fontWeight: 500,
lineHeight: '20px',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
});
export const variableSelectDividerStyle = style({
marginTop: '2px',
marginBottom: '2px',
marginLeft: '12px',
marginRight: '8px',
height: '1px',
background: 'var(--affine-border-color)',
});
export const menuItemTextStyle = style({
fontSize: 'var(--affine-font-xs)',
});
export const filterItemStyle = style({
display: 'flex',
border: '1px solid var(--affine-border-color)',
borderRadius: '8px',
background: 'var(--affine-white)',
padding: '4px 8px',
});
export const filterItemCloseStyle = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
marginLeft: '4px',
});
export const inputStyle = style({
fontSize: 'var(--affine-font-xs)',
padding: '2px 4px',
transition: 'all 0.15s ease-in-out',
':hover': {
cursor: 'pointer',
background: 'var(--affine-hover-color)',
borderRadius: '4px',
},
});
export const switchStyle = style({
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
padding: '2px 4px',
transition: 'all 0.15s ease-in-out',
display: 'flex',
alignItems: 'center',
':hover': {
cursor: 'pointer',
background: 'var(--affine-hover-color)',
borderRadius: '4px',
},
});
export const filterTypeStyle = style({
fontSize: 'var(--affine-font-sm)',
display: 'flex',
alignItems: 'center',
padding: '2px 4px',
transition: 'all 0.15s ease-in-out',
marginRight: '6px',
':hover': {
cursor: 'pointer',
background: 'var(--affine-hover-color)',
borderRadius: '4px',
},
});
export const filterTypeIconStyle = style({
fontSize: 'var(--affine-font-base)',
marginRight: '6px',
padding: '1px 0',
display: 'flex',
alignItems: 'center',
color: 'var(--affine-icon-color)',
});
@@ -0,0 +1,3 @@
export * from './eval';
export * from './filter-list';
export * from './utils';
@@ -0,0 +1,70 @@
import type { LiteralValue, Tag } from '@affine/env/filter';
import dayjs from 'dayjs';
import type { ReactNode } from 'react';
import { AFFiNEDatePicker } from '../../date-picker';
import { FilterTag } from './filter-tag-translation';
import { inputStyle } from './index.css';
import { tBoolean, tDate, tTag } from './logical/custom-type';
import { Matcher } from './logical/matcher';
import type { TType } from './logical/typesystem';
import { tArray, typesystem } from './logical/typesystem';
import { MultiSelect } from './multi-select';
export const literalMatcher = new Matcher<{
render: (props: {
type: TType;
value: LiteralValue;
onChange: (lit: LiteralValue) => void;
}) => ReactNode;
}>((type, target) => {
return typesystem.isSubtype(type, target);
});
literalMatcher.register(tBoolean.create(), {
render: ({ value, onChange }) => (
<div
className={inputStyle}
style={{ cursor: 'pointer' }}
onClick={() => {
onChange(!value);
}}
>
<FilterTag name={value?.toString()} />
</div>
),
});
literalMatcher.register(tDate.create(), {
render: ({ value, onChange }) => (
<AFFiNEDatePicker
value={dayjs(value as number).format('YYYY-MM-DD')}
onChange={e => {
onChange(dayjs(e, 'YYYY-MM-DD').valueOf());
}}
/>
),
});
const getTagsOfArrayTag = (type: TType): Tag[] => {
if (type.type === 'array') {
if (tTag.is(type.ele)) {
return type.ele.data?.tags ?? [];
}
return [];
} else {
return [];
}
};
literalMatcher.register(tArray(tTag.create()), {
render: ({ type, value, onChange }) => {
return (
<MultiSelect
value={(value ?? []) as string[]}
onChange={value => onChange(value)}
options={getTagsOfArrayTag(type).map(v => ({
label: v.value,
value: v.id,
}))}
></MultiSelect>
);
},
});
@@ -0,0 +1,21 @@
import type { Tag } from '@affine/env/filter';
import { DataHelper, typesystem } from './typesystem';
export const tNumber = typesystem.defineData(
DataHelper.create<{ value: number }>('Number')
);
export const tString = typesystem.defineData(
DataHelper.create<{ value: string }>('String')
);
export const tBoolean = typesystem.defineData(
DataHelper.create<{ value: boolean }>('Boolean')
);
export const tDate = typesystem.defineData(
DataHelper.create<{ value: number }>('Date')
);
export const tTag = typesystem.defineData<{ tags: Tag[] }>({
name: 'Tag',
supers: [],
});
@@ -0,0 +1,53 @@
import type { TType } from './typesystem';
import { typesystem } from './typesystem';
type MatcherData<Data, Type extends TType = TType> = { type: Type; data: Data };
export class Matcher<Data, Type extends TType = TType> {
private list: MatcherData<Data, Type>[] = [];
constructor(private _match?: (type: Type, target: TType) => boolean) {}
register(type: Type, data: Data) {
this.list.push({ type, data });
}
match(type: TType) {
const match = this._match ?? typesystem.isSubtype.bind(typesystem);
for (const t of this.list) {
if (match(t.type, type)) {
return t.data;
}
}
return;
}
allMatched(type: TType): MatcherData<Data>[] {
const match = this._match ?? typesystem.isSubtype.bind(typesystem);
const result: MatcherData<Data>[] = [];
for (const t of this.list) {
if (match(t.type, type)) {
result.push(t);
}
}
return result;
}
allMatchedData(type: TType): Data[] {
return this.allMatched(type).map(v => v.data);
}
findData(f: (data: Data) => boolean): Data | undefined {
return this.list.find(data => f(data.data))?.data;
}
find(
f: (data: MatcherData<Data, Type>) => boolean
): MatcherData<Data, Type> | undefined {
return this.list.find(f);
}
all(): MatcherData<Data, Type>[] {
return this.list;
}
}
@@ -0,0 +1,283 @@
/**
* This file will be moved to a separate package soon.
*/
export interface TUnion {
type: 'union';
title: 'union';
list: TType[];
}
export const tUnion = (list: TType[]): TUnion => ({
type: 'union',
title: 'union',
list,
});
// TODO treat as data type
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export interface TArray<Ele extends TType = TType> {
type: 'array';
ele: Ele;
title: 'array';
}
export const tArray = <const T extends TType>(ele: T): TArray<T> => {
return {
type: 'array',
title: 'array',
ele,
};
};
export type TTypeVar = {
type: 'typeVar';
title: 'typeVar';
name: string;
bound: TType;
};
export const tTypeVar = (name: string, bound: TType): TTypeVar => {
return {
type: 'typeVar',
title: 'typeVar',
name,
bound,
};
};
export type TTypeRef = {
type: 'typeRef';
title: 'typeRef';
name: string;
};
export const tTypeRef = (name: string): TTypeRef => {
return {
type: 'typeRef',
title: 'typeRef',
name,
};
};
export type TFunction = {
type: 'function';
title: 'function';
typeVars: TTypeVar[];
args: TType[];
rt: TType;
};
export const tFunction = (fn: {
typeVars?: TTypeVar[];
args: TType[];
rt: TType;
}): TFunction => {
return {
type: 'function',
title: 'function',
typeVars: fn.typeVars ?? [],
args: fn.args,
rt: fn.rt,
};
};
export type TType = TDataType | TArray | TUnion | TTypeRef | TFunction;
export type DataTypeShape = Record<string, unknown>;
export type TDataType<Data extends DataTypeShape = Record<string, unknown>> = {
type: 'data';
name: string;
data?: Data;
};
export type ValueOfData<T extends DataDefine> = T extends DataDefine<infer R>
? R
: never;
export class DataDefine<Data extends DataTypeShape = Record<string, unknown>> {
constructor(
private config: DataDefineConfig<Data>,
private dataMap: Map<string, DataDefine>
) {}
create(data?: Data): TDataType<Data> {
return {
type: 'data',
name: this.config.name,
data,
};
}
is(data: TType): data is TDataType<Data> {
if (data.type !== 'data') {
return false;
}
return data.name === this.config.name;
}
private isByName(name: string): boolean {
return name === this.config.name;
}
isSubOf(superType: TDataType): boolean {
if (this.is(superType)) {
return true;
}
return this.config.supers.some(sup => sup.isSubOf(superType));
}
private isSubOfByName(superType: string): boolean {
if (this.isByName(superType)) {
return true;
}
return this.config.supers.some(sup => sup.isSubOfByName(superType));
}
isSuperOf(subType: TDataType): boolean {
const dataDefine = this.dataMap.get(subType.name);
if (!dataDefine) {
throw new Error('bug');
}
return dataDefine.isSubOfByName(this.config.name);
}
}
// type DataTypeVar = {};
// TODO support generic data type
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface DataDefineConfig<T extends DataTypeShape> {
name: string;
supers: DataDefine[];
_phantom?: T;
}
interface DataHelper<T extends DataTypeShape> {
create<V = Record<string, unknown>>(name: string): DataDefineConfig<T & V>;
extends<V extends DataTypeShape>(
dataDefine: DataDefine<V>
): DataHelper<T & V>;
}
const createDataHelper = <T extends DataTypeShape = Record<string, unknown>>(
...supers: DataDefine[]
): DataHelper<T> => {
return {
create(name: string) {
return {
name,
supers,
};
},
extends(dataDefine) {
return createDataHelper(...supers, dataDefine);
},
};
};
export const DataHelper = createDataHelper();
export class Typesystem {
dataMap = new Map<string, DataDefine<any>>();
defineData<T extends DataTypeShape>(
config: DataDefineConfig<T>
): DataDefine<T> {
const result = new DataDefine(config, this.dataMap);
this.dataMap.set(config.name, result);
return result;
}
isDataType(t: TType): t is TDataType {
return t.type === 'data';
}
isSubtype(
superType: TType,
sub: TType,
context?: Record<string, TType>
): boolean {
if (superType.type === 'typeRef') {
// TODO both are ref
if (context && sub.type != 'typeRef') {
context[superType.name] = sub;
}
// TODO bound
return true;
}
if (sub.type === 'typeRef') {
// TODO both are ref
if (context) {
context[sub.name] = superType;
}
return true;
}
if (tUnknown.is(superType)) {
return true;
}
if (superType.type === 'union') {
return superType.list.some(type => this.isSubtype(type, sub, context));
}
if (sub.type === 'union') {
return sub.list.every(type => this.isSubtype(superType, type, context));
}
if (this.isDataType(sub)) {
const dataDefine = this.dataMap.get(sub.name);
if (!dataDefine) {
throw new Error('bug');
}
if (!this.isDataType(superType)) {
return false;
}
return dataDefine.isSubOf(superType);
}
if (superType.type === 'array' || sub.type === 'array') {
if (superType.type !== 'array' || sub.type !== 'array') {
return false;
}
return this.isSubtype(superType.ele, sub.ele, context);
}
return false;
}
subst(context: Record<string, TType>, template: TFunction): TFunction {
const subst = (type: TType): TType => {
if (this.isDataType(type)) {
return type;
}
switch (type.type) {
case 'typeRef':
return { ...context[type.name] };
case 'union':
return tUnion(type.list.map(type => subst(type)));
case 'array':
return tArray(subst(type.ele));
case 'function':
throw new Error('TODO');
}
};
const result = tFunction({
args: template.args.map(type => subst(type)),
rt: subst(template.rt),
});
return result;
}
instance(
context: Record<string, TType>,
realArgs: TType[],
realRt: TType,
template: TFunction
): TFunction {
const ctx = { ...context };
template.args.forEach((arg, i) => {
const realArg = realArgs[i];
if (realArg) {
this.isSubtype(arg, realArg, ctx);
}
});
this.isSubtype(realRt, template.rt);
return this.subst(ctx, template);
}
}
export const typesystem = new Typesystem();
export const tUnknown = typesystem.defineData(DataHelper.create('Unknown'));
@@ -0,0 +1,50 @@
import { style } from '@vanilla-extract/css';
export const content = style({
fontSize: 12,
color: 'var(--affine-text-primary-color)',
borderRadius: 8,
padding: '3px 4px',
cursor: 'pointer',
':hover': {
backgroundColor: 'var(--affine-hover-color)',
},
});
export const text = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 350,
});
export const optionList = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
padding: '0 4px',
});
export const selectOption = style({
display: 'flex',
alignItems: 'center',
fontSize: 14,
height: 26,
borderRadius: 5,
maxWidth: 240,
minWidth: 100,
padding: '0 12px',
cursor: 'pointer',
':hover': {
backgroundColor: 'var(--affine-hover-color)',
},
});
export const optionLabel = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
});
export const done = style({
display: 'flex',
alignItems: 'center',
color: 'var(--affine-primary-color)',
marginLeft: 8,
});
@@ -0,0 +1,65 @@
import { Menu, MenuItem } from '@toeverything/components/menu';
import type { MouseEvent } from 'react';
import { useMemo } from 'react';
import * as styles from './multi-select.css';
export const MultiSelect = ({
value,
onChange,
options,
}: {
value: string[];
onChange: (value: string[]) => void;
options: {
label: string;
value: string;
}[];
}) => {
const optionMap = useMemo(
() => Object.fromEntries(options.map(v => [v.value, v])),
[options]
);
return (
<Menu
items={
<div data-testid="multi-select" className={styles.optionList}>
{options.map(option => {
const selected = value.includes(option.value);
const click = (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (selected) {
onChange(value.filter(v => v !== option.value));
} else {
onChange([...value, option.value]);
}
};
return (
<MenuItem
data-testid={`multi-select-${option.label}`}
checked={selected}
onClick={click}
key={option.value}
>
{option.label}
</MenuItem>
);
})}
</div>
}
>
<div className={styles.content}>
{value.length ? (
<div className={styles.text}>
{value.map(id => optionMap[id]?.label).join(', ')}
</div>
) : (
<div style={{ color: 'var(--affine-text-secondary-color)' }}>
Empty
</div>
)}
</div>
</Menu>
);
};
@@ -0,0 +1,62 @@
import type {
Literal,
LiteralValue,
PropertiesMeta,
VariableMap,
} from '@affine/env/filter';
import {
CreatedIcon,
FavoriteIcon,
TagsIcon,
UpdatedIcon,
} from '@blocksuite/icons';
import type { ReactElement } from 'react';
import { tBoolean, tDate, tTag } from './logical/custom-type';
import type { TType } from './logical/typesystem';
import { tArray } from './logical/typesystem';
export const toLiteral = (value: LiteralValue): Literal => ({
type: 'literal',
value,
});
export type FilterVariable = {
name: keyof VariableMap;
type: (propertiesMeta: PropertiesMeta) => TType;
icon: ReactElement;
};
export const variableDefineMap = {
Created: {
type: () => tDate.create(),
icon: <CreatedIcon />,
},
Updated: {
type: () => tDate.create(),
icon: <UpdatedIcon />,
},
'Is Favourited': {
type: () => tBoolean.create(),
icon: <FavoriteIcon />,
},
Tags: {
type: meta => tArray(tTag.create({ tags: meta.tags?.options ?? [] })),
icon: <TagsIcon />,
},
// Imported: {
// type: tBoolean.create(),
// },
// 'Daily Note': {
// type: tBoolean.create(),
// },
} satisfies Record<string, Omit<FilterVariable, 'name'>>;
export type InternalVariableMap = {
[K in keyof typeof variableDefineMap]: LiteralValue;
};
declare module '@affine/env/filter' {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface VariableMap extends InternalVariableMap {}
}
@@ -0,0 +1,10 @@
import type { Filter } from '@affine/env/filter';
export const createTagFilter = (id: string): Filter => {
return {
type: 'filter',
left: { type: 'ref', name: 'Tags' },
funcName: 'contains all',
args: [{ type: 'literal', value: [id] }],
};
};
@@ -0,0 +1,291 @@
import type {
Filter,
LiteralValue,
PropertiesMeta,
VariableMap,
} from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
MenuIcon,
MenuItem,
MenuSeparator,
} from '@toeverything/components/menu';
import dayjs from 'dayjs';
import type { ReactNode } from 'react';
import { FilterTag } from './filter-tag-translation';
import * as styles from './index.css';
import { tBoolean, tDate, tTag } from './logical/custom-type';
import { Matcher } from './logical/matcher';
import type { TFunction } from './logical/typesystem';
import {
tArray,
tFunction,
tTypeRef,
tTypeVar,
typesystem,
} from './logical/typesystem';
import type { FilterVariable } from './shared-types';
import { variableDefineMap } from './shared-types';
export const vars: FilterVariable[] = Object.entries(variableDefineMap).map(
([key, value]) => ({
name: key as keyof VariableMap,
type: value.type,
icon: value.icon,
})
);
export const createDefaultFilter = (
variable: FilterVariable,
propertiesMeta: PropertiesMeta
): Filter => {
const data = filterMatcher.match(variable.type(propertiesMeta));
if (!data) {
throw new Error('No matching function found');
}
return {
type: 'filter',
left: {
type: 'ref',
name: variable.name,
},
funcName: data.name,
args: data.defaultArgs().map(value => ({
type: 'literal',
value,
})),
};
};
export const CreateFilterMenu = ({
value,
onChange,
propertiesMeta,
}: {
value: Filter[];
onChange: (value: Filter[]) => void;
propertiesMeta: PropertiesMeta;
}) => {
return (
<VariableSelect
propertiesMeta={propertiesMeta}
selected={value}
onSelect={filter => {
onChange([...value, filter]);
}}
/>
);
};
export const VariableSelect = ({
onSelect,
propertiesMeta,
}: {
selected: Filter[];
onSelect: (value: Filter) => void;
propertiesMeta: PropertiesMeta;
}) => {
const t = useAFFiNEI18N();
return (
<div data-testid="variable-select">
<div className={styles.variableSelectTitleStyle}>
{t['com.affine.filter']()}
</div>
<MenuSeparator />
{vars
// .filter(v => !selected.find(filter => filter.left.name === v.name))
.map(v => (
<MenuItem
preFix={<MenuIcon>{variableDefineMap[v.name].icon}</MenuIcon>}
key={v.name}
onClick={() => {
onSelect(createDefaultFilter(v, propertiesMeta));
}}
className={styles.menuItemStyle}
>
<div
data-testid="variable-select-item"
className={styles.menuItemTextStyle}
>
<FilterTag name={v.name} />
</div>
</MenuItem>
))}
</div>
);
};
export type FilterMatcherDataType = {
name: string;
defaultArgs: () => LiteralValue[];
render?: (props: { ast: Filter }) => ReactNode;
impl: (...args: (LiteralValue | undefined)[]) => boolean;
};
export const filterMatcher = new Matcher<FilterMatcherDataType, TFunction>(
(type, target) => {
const staticType = typesystem.subst(
Object.fromEntries(type.typeVars?.map(v => [v.name, v.bound]) ?? []),
type
);
const firstArg = staticType.args[0];
return firstArg && typesystem.isSubtype(firstArg, target);
}
);
filterMatcher.register(
tFunction({
args: [tBoolean.create(), tBoolean.create()],
rt: tBoolean.create(),
}),
{
name: 'is',
defaultArgs: () => [true],
impl: (value, target) => {
return value == target;
},
}
);
filterMatcher.register(
tFunction({
args: [tDate.create(), tDate.create()],
rt: tBoolean.create(),
}),
{
name: 'after',
defaultArgs: () => {
return [dayjs().subtract(1, 'day').endOf('day').valueOf()];
},
impl: (date, target) => {
if (typeof date !== 'number' || typeof target !== 'number') {
throw new Error('argument type error');
}
return dayjs(date).isAfter(dayjs(target).endOf('day'));
},
}
);
filterMatcher.register(
tFunction({
args: [tDate.create(), tDate.create()],
rt: tBoolean.create(),
}),
{
name: 'before',
defaultArgs: () => [dayjs().endOf('day').valueOf()],
impl: (date, target) => {
if (typeof date !== 'number' || typeof target !== 'number') {
throw new Error('argument type error');
}
return dayjs(date).isBefore(dayjs(target).startOf('day'));
},
}
);
const safeArray = (arr: unknown): LiteralValue[] => {
return Array.isArray(arr) ? arr : [];
};
filterMatcher.register(
tFunction({
args: [tArray(tTag.create())],
rt: tBoolean.create(),
}),
{
name: 'is not empty',
defaultArgs: () => [],
impl: tags => {
const safeTags = safeArray(tags);
return safeTags.length > 0;
},
}
);
filterMatcher.register(
tFunction({
args: [tArray(tTag.create())],
rt: tBoolean.create(),
}),
{
name: 'is empty',
defaultArgs: () => [],
impl: tags => {
const safeTags = safeArray(tags);
return safeTags.length == 0;
},
}
);
filterMatcher.register(
tFunction({
typeVars: [tTypeVar('T', tTag.create())],
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
rt: tBoolean.create(),
}),
{
name: 'contains all',
defaultArgs: () => [],
impl: (tags, target) => {
if (!Array.isArray(target)) {
return true;
}
const safeTags = safeArray(tags);
return target.every(id => safeTags.includes(id));
},
}
);
filterMatcher.register(
tFunction({
typeVars: [tTypeVar('T', tTag.create())],
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
rt: tBoolean.create(),
}),
{
name: 'contains one of',
defaultArgs: () => [],
impl: (tags, target) => {
if (!Array.isArray(target)) {
return true;
}
const safeTags = safeArray(tags);
return target.some(id => safeTags.includes(id));
},
}
);
filterMatcher.register(
tFunction({
typeVars: [tTypeVar('T', tTag.create())],
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
rt: tBoolean.create(),
}),
{
name: 'does not contains all',
defaultArgs: () => [],
impl: (tags, target) => {
if (!Array.isArray(target)) {
return true;
}
const safeTags = safeArray(tags);
return !target.every(id => safeTags.includes(id));
},
}
);
filterMatcher.register(
tFunction({
typeVars: [tTypeVar('T', tTag.create())],
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
rt: tBoolean.create(),
}),
{
name: 'does not contains one of',
defaultArgs: () => [],
impl: (tags, target) => {
if (!Array.isArray(target)) {
return true;
}
const safeTags = safeArray(tags);
return !target.some(id => safeTags.includes(id));
},
}
);
@@ -0,0 +1,12 @@
export * from './all-page';
export * from './components/favorite-tag';
export * from './components/new-page-buttton';
export * from './components/title-cell';
export * from './filter';
export * from './operation-cell';
export * from './operation-menu-items';
export * from './styles';
export * from './type';
export * from './use-collection-manager';
export * from './utils';
export * from './view';
@@ -0,0 +1,129 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
Content,
Table,
TableBody,
TableCell,
TableHead,
TableHeadRow,
} from '../../..';
import { AllPagesBody } from './all-pages-body';
import { NewPageButton } from './components/new-page-buttton';
import {
StyledTableBodyRow,
StyledTableContainer,
StyledTitleLink,
} from './styles';
import type { ListData } from './type';
const MobileHead = ({
isPublicWorkspace,
createNewPage,
createNewEdgeless,
importFile,
}: {
isPublicWorkspace: boolean;
createNewPage: () => void;
createNewEdgeless: () => void;
importFile: () => void;
}) => {
const t = useAFFiNEI18N();
return (
<TableHead>
<TableHeadRow>
<TableCell proportion={0.8}>{t['Title']()}</TableCell>
{!isPublicWorkspace && (
<TableCell>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
}}
>
<NewPageButton
createNewPage={createNewPage}
createNewEdgeless={createNewEdgeless}
importFile={importFile}
/>
</div>
</TableCell>
)}
</TableHeadRow>
</TableHead>
);
};
export const AllPageListMobileView = ({
list,
isPublicWorkspace,
createNewPage,
createNewEdgeless,
importFile,
}: {
isPublicWorkspace: boolean;
list: ListData[];
createNewPage: () => void;
createNewEdgeless: () => void;
importFile: () => void;
}) => {
return (
<StyledTableContainer>
<Table>
<MobileHead
isPublicWorkspace={isPublicWorkspace}
createNewPage={createNewPage}
createNewEdgeless={createNewEdgeless}
importFile={importFile}
/>
<AllPagesBody
isPublicWorkspace={isPublicWorkspace}
data={list}
// update groupKey after support sort by create date
groupKey="updatedDate"
/>
</Table>
</StyledTableContainer>
);
};
// TODO align to {@link AllPageListMobileView}
export const TrashListMobileView = ({
list,
}: {
list: {
pageId: string;
title: string;
icon: JSX.Element;
onClickPage: () => void;
}[];
}) => {
const t = useAFFiNEI18N();
const ListItems = list.map(({ pageId, title, icon, onClickPage }, index) => {
return (
<StyledTableBodyRow
data-testid={`page-list-item-${pageId}`}
key={`${pageId}-${index}`}
>
<TableCell onClick={onClickPage}>
<StyledTitleLink>
{icon}
<Content ellipsis={true} color="inherit">
{title || t['Untitled']()}
</Content>
</StyledTitleLink>
</TableCell>
</StyledTableBodyRow>
);
});
return (
<StyledTableContainer>
<Table>
<TableBody>{ListItems}</TableBody>
</Table>
</StyledTableContainer>
);
};
@@ -0,0 +1,157 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
DeletePermanentlyIcon,
FavoritedIcon,
FavoriteIcon,
MoreVerticalIcon,
OpenInNewIcon,
ResetIcon,
} from '@blocksuite/icons';
import { IconButton } from '@toeverything/components/button';
import { Menu, MenuIcon, MenuItem } from '@toeverything/components/menu';
import { ConfirmModal } from '@toeverything/components/modal';
import { Tooltip } from '@toeverything/components/tooltip';
import { useState } from 'react';
import { FlexWrapper } from '../../..';
import { DisablePublicSharing, MoveToTrash } from './operation-menu-items';
export interface OperationCellProps {
favorite: boolean;
isPublic: boolean;
onOpenPageInNewTab: () => void;
onToggleFavoritePage: () => void;
onRemoveToTrash: () => void;
onDisablePublicSharing: () => void;
}
export const OperationCell = ({
favorite,
isPublic,
onOpenPageInNewTab,
onToggleFavoritePage,
onRemoveToTrash,
onDisablePublicSharing,
}: OperationCellProps) => {
const t = useAFFiNEI18N();
const [openDisableShared, setOpenDisableShared] = useState(false);
const OperationMenu = (
<>
{isPublic && (
<DisablePublicSharing
data-testid="disable-public-sharing"
onSelect={() => {
setOpenDisableShared(true);
}}
/>
)}
<MenuItem
onClick={onToggleFavoritePage}
preFix={
<MenuIcon>
{favorite ? (
<FavoritedIcon style={{ color: 'var(--affine-primary-color)' }} />
) : (
<FavoriteIcon />
)}
</MenuIcon>
}
>
{favorite
? t['com.affine.favoritePageOperation.remove']()
: t['com.affine.favoritePageOperation.add']()}
</MenuItem>
{!environment.isDesktop && (
<MenuItem
onClick={onOpenPageInNewTab}
preFix={
<MenuIcon>
<OpenInNewIcon />
</MenuIcon>
}
>
{t['com.affine.openPageOperation.newTab']()}
</MenuItem>
)}
<MoveToTrash data-testid="move-to-trash" onSelect={onRemoveToTrash} />
</>
);
return (
<>
<FlexWrapper alignItems="center" justifyContent="center">
<Menu
items={OperationMenu}
contentOptions={{
align: 'end',
}}
>
<IconButton type="plain" data-testid="page-list-operation-button">
<MoreVerticalIcon />
</IconButton>
</Menu>
</FlexWrapper>
<DisablePublicSharing.DisablePublicSharingModal
onConfirm={onDisablePublicSharing}
open={openDisableShared}
onOpenChange={setOpenDisableShared}
/>
</>
);
};
export interface TrashOperationCellProps {
onPermanentlyDeletePage: () => void;
onRestorePage: () => void;
onOpenPage: () => void;
}
export const TrashOperationCell = ({
onPermanentlyDeletePage,
onRestorePage,
}: TrashOperationCellProps) => {
const t = useAFFiNEI18N();
const [open, setOpen] = useState(false);
return (
<FlexWrapper>
<Tooltip content={t['com.affine.trashOperation.restoreIt']()} side="top">
<IconButton
style={{ marginRight: '12px' }}
onClick={() => {
onRestorePage();
}}
>
<ResetIcon />
</IconButton>
</Tooltip>
<Tooltip
content={t['com.affine.trashOperation.deletePermanently']()}
side="top"
align="end"
>
<IconButton
onClick={() => {
setOpen(true);
}}
>
<DeletePermanentlyIcon />
</IconButton>
</Tooltip>
<ConfirmModal
title={`${t['com.affine.trashOperation.deletePermanently']()}?`}
description={t['com.affine.trashOperation.deleteDescription']()}
cancelText={t['com.affine.confirmModal.button.cancel']()}
confirmButtonOptions={{
type: 'error',
children: t['com.affine.trashOperation.delete'](),
}}
open={open}
onOpenChange={setOpen}
onConfirm={() => {
onPermanentlyDeletePage();
setOpen(false);
}}
/>
</FlexWrapper>
);
};
@@ -0,0 +1,28 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ShareIcon } from '@blocksuite/icons';
import {
MenuIcon,
MenuItem,
type MenuItemProps,
} from '@toeverything/components/menu';
import { PublicLinkDisableModal } from '../../share-menu';
export const DisablePublicSharing = (props: MenuItemProps) => {
const t = useAFFiNEI18N();
return (
<MenuItem
type="danger"
preFix={
<MenuIcon>
<ShareIcon />
</MenuIcon>
}
{...props}
>
{t['Disable Public Sharing']()}
</MenuItem>
);
};
DisablePublicSharing.DisablePublicSharingModal = PublicLinkDisableModal;
@@ -0,0 +1,124 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
ExportIcon,
ExportToHtmlIcon,
ExportToMarkdownIcon,
ExportToPdfIcon,
ExportToPngIcon,
} from '@blocksuite/icons';
import { MenuIcon, MenuItem, MenuSub } from '@toeverything/components/menu';
import { type ReactNode, useMemo } from 'react';
import { transitionStyle } from './index.css';
interface ExportMenuItemProps<T> {
onSelect: () => void;
className?: string;
type: T;
icon: ReactNode;
label: string;
}
interface ExportProps {
exportHandler: (type: 'pdf' | 'html' | 'png' | 'markdown') => Promise<void>;
className?: string;
}
export function ExportMenuItem<T>({
onSelect,
className,
type,
icon,
label,
}: ExportMenuItemProps<T>) {
return (
<MenuItem
className={className}
data-testid={`export-to-${type}`}
onSelect={onSelect}
block
preFix={<MenuIcon>{icon}</MenuIcon>}
>
{label}
</MenuItem>
);
}
export const ExportMenuItems = ({
exportHandler,
className = transitionStyle,
}: ExportProps) => {
const t = useAFFiNEI18N();
const itemMap = useMemo(
() => [
{
component: ExportMenuItem,
props: {
onSelect: () => exportHandler('pdf'),
className: className,
type: 'pdf',
icon: <ExportToPdfIcon />,
label: t['Export to PDF'](),
},
},
{
component: ExportMenuItem,
props: {
onSelect: () => exportHandler('html'),
className: className,
type: 'html',
icon: <ExportToHtmlIcon />,
label: t['Export to HTML'](),
},
},
{
component: ExportMenuItem,
props: {
onSelect: () => exportHandler('png'),
className: className,
type: 'png',
icon: <ExportToPngIcon />,
label: t['Export to PNG'](),
},
},
{
component: ExportMenuItem,
props: {
onSelect: () => exportHandler('markdown'),
className: className,
type: 'markdown',
icon: <ExportToMarkdownIcon />,
label: t['Export to Markdown'](),
},
},
],
[className, exportHandler, t]
);
const items = itemMap.map(({ component: Component, props }) => (
<Component key={props.label} {...props} />
));
return <>{items}</>;
};
export const Export = ({ exportHandler, className }: ExportProps) => {
const t = useAFFiNEI18N();
const items = (
<ExportMenuItems exportHandler={exportHandler} className={className} />
);
return (
<MenuSub
items={items}
triggerOptions={{
className: transitionStyle,
preFix: (
<MenuIcon>
<ExportIcon />
</MenuIcon>
),
['data-testid' as string]: 'export-menu',
}}
>
{t.Export()}
</MenuSub>
);
};
@@ -0,0 +1,18 @@
import { ContentParser } from '@blocksuite/blocks/content-parser';
import type { Page } from '@blocksuite/store';
const contentParserWeakMap = new WeakMap<Page, ContentParser>();
export function getContentParser(page: Page) {
if (!contentParserWeakMap.has(page)) {
contentParserWeakMap.set(
page,
new ContentParser(page, {
imageProxyEndpoint: !environment.isDesktop
? runtimeConfig.imageProxyUrl
: undefined,
})
);
}
return contentParserWeakMap.get(page) as ContentParser;
}
@@ -0,0 +1,17 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const moveToTrashStyle = style({
padding: '4px 12px',
':hover': {
backgroundColor: 'var(--affine-background-error-color)',
color: 'var(--affine-error-color)',
},
});
globalStyle(`${moveToTrashStyle}:hover svg`, {
color: 'var(--affine-error-color)',
});
export const transitionStyle = style({
transition: 'all 0.3s',
});
@@ -0,0 +1,4 @@
export * from './disable-public-sharing';
export * from './export';
// export * from './MoveTo';
export * from './move-to-trash';
@@ -0,0 +1,56 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { DeleteTemporarilyIcon } from '@blocksuite/icons';
import {
MenuIcon,
MenuItem,
type MenuItemProps,
} from '@toeverything/components/menu';
import {
ConfirmModal,
type ConfirmModalProps,
} from '@toeverything/components/modal';
export const MoveToTrash = (props: MenuItemProps) => {
const t = useAFFiNEI18N();
return (
<MenuItem
preFix={
<MenuIcon>
<DeleteTemporarilyIcon />
</MenuIcon>
}
type="danger"
{...props}
>
{t['com.affine.moveToTrash.title']()}
</MenuItem>
);
};
const MoveToTrashConfirm = ({
title,
...confirmModalProps
}: {
title: string;
} & ConfirmModalProps) => {
const t = useAFFiNEI18N();
return (
<ConfirmModal
title={t['com.affine.moveToTrash.confirmModal.title']()}
description={t['com.affine.moveToTrash.confirmModal.description']({
title: title || 'Untitled',
})}
cancelText={t['com.affine.confirmModal.button.cancel']()}
confirmButtonOptions={{
['data-testid' as string]: 'confirm-delete-page',
type: 'error',
children: t.Delete(),
}}
{...confirmModalProps}
/>
);
};
MoveToTrash.ConfirmModal = MoveToTrashConfirm;
@@ -0,0 +1,8 @@
import type { HTMLAttributes } from 'react';
export type CommonMenuItemProps<SelectParams = undefined> = {
// onItemClick is triggered when the item is clicked, sometimes after item click, it still has some internal logic to run(like popover a new menu), so we need to have a separate callback for that
onItemClick?: () => void;
// onSelect is triggered when the item is selected, it's the final callback for the item click
onSelect?: (params?: SelectParams) => void;
} & HTMLAttributes<HTMLButtonElement>;
@@ -0,0 +1,84 @@
import { displayFlex, styled } from '../../styles';
import { Content } from '../../ui/layout/content';
import { TableBodyRow } from '../../ui/table/table-row';
export const StyledTableContainer = styled('div')(({ theme }) => {
return {
height: '100%',
minHeight: '600px',
padding: '0 32px 180px 32px',
maxWidth: '100%',
[theme.breakpoints.down('sm')]: {
padding: '52px 0px',
'tr > td:first-of-type': {
borderTopLeftRadius: '0px',
borderBottomLeftRadius: '0px',
},
'tr > td:last-of-type': {
borderTopRightRadius: '0px',
borderBottomRightRadius: '0px',
},
},
};
});
/**
* @deprecated
*/
export const StyledTitleWrapper = styled('div')(() => {
return {
...displayFlex('flex-start', 'center'),
a: {
color: 'inherit',
},
'a:visited': {
color: 'unset',
},
'a:hover': {
color: 'var(--affine-primary-color)',
},
};
});
export const StyledTitleLink = styled('div')(() => {
return {
...displayFlex('flex-start', 'center'),
color: 'var(--affine-text-primary-color)',
'>svg': {
fontSize: '24px',
marginRight: '12px',
color: 'var(--affine-icon-color)',
},
};
});
export const StyledTitleContentWrapper = styled('div')(() => {
return {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
width: '100%',
overflow: 'hidden',
};
});
export const StyledTitlePreview = styled(Content)(() => {
return {
fontWeight: 400,
fontSize: 'var(--affine-font-xs)',
maxWidth: '100%',
};
});
export const StyledTableBodyRow = styled(TableBodyRow)(() => {
return {
cursor: 'pointer',
'.favorite-button': {
visibility: 'hidden',
},
'&:hover': {
'.favorite-button': {
visibility: 'visible',
},
},
};
});
@@ -0,0 +1,65 @@
import type { CollectionsAtom } from '@affine/component/page-list/use-collection-manager';
import type { Tag } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info';
import type { ReactElement, ReactNode } from 'react';
/**
* Get the keys of an object type whose values are of a given type
*
* See https://stackoverflow.com/questions/54520676/in-typescript-how-to-get-the-keys-of-an-object-type-whose-values-are-of-a-given
*/
export type KeysMatching<T, V> = {
[K in keyof T]-?: T[K] extends V ? K : never;
}[keyof T];
export type ListData = {
pageId: string;
icon: JSX.Element;
title: string;
preview?: ReactNode;
tags: Tag[];
favorite: boolean;
createDate: Date;
updatedDate: Date;
isPublicPage: boolean;
onClickPage: () => void;
onOpenPageInNewTab: () => void;
bookmarkPage: () => void;
removeToTrash: () => void;
onDisablePublicSharing: () => void;
};
export type DateKey = KeysMatching<ListData, Date>;
export type TrashListData = {
pageId: string;
icon: JSX.Element;
title: string;
preview?: ReactNode;
createDate: Date;
// TODO remove optional after assert that trashDate is always set
trashDate?: Date;
onClickPage: () => void;
onRestorePage: () => void;
onPermanentlyDeletePage: () => void;
};
export type PageListProps = {
isPublicWorkspace?: boolean;
collectionsAtom: CollectionsAtom;
list: ListData[];
fallback?: ReactNode;
onCreateNewPage: () => void;
onCreateNewEdgeless: () => void;
onImportFile: () => void;
getPageInfo: GetPageInfoById;
propertiesMeta: PropertiesMeta;
};
export type DraggableTitleCellData = {
pageId: string;
pageTitle: string;
pagePreview?: string;
icon: ReactElement;
};
@@ -0,0 +1,141 @@
import type { Collection, Filter, VariableMap } from '@affine/env/filter';
import { useAtom } from 'jotai';
import { atomWithReset, RESET } from 'jotai/utils';
import type { WritableAtom } from 'jotai/vanilla';
import { useCallback } from 'react';
import { NIL } from 'uuid';
import { evalFilterList } from './filter';
const defaultCollection = {
id: NIL,
name: 'All',
filterList: [],
workspaceId: 'temporary',
};
const collectionAtom = atomWithReset<{
currentId: string;
defaultCollection: Collection;
}>({
currentId: NIL,
defaultCollection: defaultCollection,
});
export type CollectionsAtom = WritableAtom<
Collection[] | Promise<Collection[]>,
[Collection[] | ((collection: Collection[]) => Collection[])],
Promise<void>
>;
export const useSavedCollections = (collectionAtom: CollectionsAtom) => {
const [savedCollections, setCollections] = useAtom(collectionAtom);
const saveCollection = useCallback(
async (collection: Collection) => {
if (collection.id === NIL) {
return;
}
await setCollections(old => [...old, collection]);
},
[setCollections]
);
const deleteCollection = useCallback(
async (id: string) => {
if (id === NIL) {
return;
}
await setCollections(old => old.filter(v => v.id !== id));
},
[setCollections]
);
const addPage = useCallback(
async (collectionId: string, pageId: string) => {
await setCollections(old => {
const collection = old.find(v => v.id === collectionId);
if (!collection) {
return old;
}
return [
...old.filter(v => v.id !== collectionId),
{
...collection,
allowList: [pageId, ...(collection.allowList ?? [])],
},
];
});
},
[setCollections]
);
return {
savedCollections,
saveCollection,
deleteCollection,
addPage,
};
};
export const useCollectionManager = (collectionsAtom: CollectionsAtom) => {
const { savedCollections, saveCollection, deleteCollection, addPage } =
useSavedCollections(collectionsAtom);
const [collectionData, setCollectionData] = useAtom(collectionAtom);
const updateCollection = useCallback(
async (collection: Collection) => {
if (collection.id === NIL) {
setCollectionData({
...collectionData,
defaultCollection: collection,
});
} else {
await saveCollection(collection);
}
},
[collectionData, saveCollection, setCollectionData]
);
const selectCollection = useCallback(
(id: string) => {
setCollectionData({
...collectionData,
currentId: id,
});
},
[collectionData, setCollectionData]
);
const backToAll = useCallback(() => {
setCollectionData(RESET);
}, [setCollectionData]);
const setTemporaryFilter = useCallback(
(filterList: Filter[]) => {
setCollectionData({
currentId: NIL,
defaultCollection: {
...defaultCollection,
filterList: filterList,
},
});
},
[setCollectionData]
);
const currentCollection =
collectionData.currentId === NIL
? collectionData.defaultCollection
: savedCollections.find(v => v.id === collectionData.currentId) ??
collectionData.defaultCollection;
return {
currentCollection: currentCollection,
savedCollections,
isDefault: currentCollection.id === NIL,
// actions
saveCollection,
updateCollection,
selectCollection,
backToAll,
deleteCollection,
addPage,
setTemporaryFilter,
};
};
export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) =>
evalFilterList(filterList, varMap);
@@ -0,0 +1,65 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { DateKey, ListData } from './type';
import {
isLastMonth,
isLastWeek,
isLastYear,
isToday,
isYesterday,
} from './utils';
export const useDateGroup = ({
data,
key,
}: {
data: ListData[];
key?: DateKey;
}) => {
const t = useAFFiNEI18N();
if (!key) {
return data.map(item => ({ ...item, groupName: '' }));
}
const fallbackGroup = {
id: 'earlier',
label: t['com.affine.earlier'](),
match: (_date: Date) => true,
};
const groups = [
{
id: 'today',
label: t['com.affine.today'](),
match: (date: Date) => isToday(date),
},
{
id: 'yesterday',
label: t['com.affine.yesterday'](),
match: (date: Date) => isYesterday(date) && !isToday(date),
},
{
id: 'last7Days',
label: t['com.affine.last7Days'](),
match: (date: Date) => isLastWeek(date) && !isYesterday(date),
},
{
id: 'last30Days',
label: t['com.affine.last30Days'](),
match: (date: Date) => isLastMonth(date) && !isLastWeek(date),
},
{
id: 'currentYear',
label: t['com.affine.currentYear'](),
match: (date: Date) => isLastYear(date) && !isLastMonth(date),
},
] as const;
return data.map(item => {
const group = groups.find(group => group.match(item[key])) ?? fallbackGroup;
return {
...item,
groupName: group.label,
};
});
};
@@ -0,0 +1,105 @@
import { useState } from 'react';
type SorterConfig<
T extends Record<string | number | symbol, unknown> = Record<
string | number | symbol,
unknown
>,
> = {
data: T[];
key: keyof T;
order: 'asc' | 'desc' | 'none';
sortingFn?: (
ctx: {
key: keyof T;
order: 'asc' | 'desc';
},
a: T,
b: T
) => number;
};
const defaultSortingFn: SorterConfig['sortingFn'] = (ctx, a, b) => {
const valA = a[ctx.key];
const valB = b[ctx.key];
const revert = ctx.order === 'desc';
const revertSymbol = revert ? -1 : 1;
if (typeof valA === 'string' && typeof valB === 'string') {
return valA.localeCompare(valB) * revertSymbol;
}
if (typeof valA === 'number' && typeof valB === 'number') {
return valA - valB * revertSymbol;
}
if (valA instanceof Date && valB instanceof Date) {
return (valA.getTime() - valB.getTime()) * revertSymbol;
}
if (!valA) {
return -1 * revertSymbol;
}
if (!valB) {
return 1 * revertSymbol;
}
if (Array.isArray(valA) && Array.isArray(valB)) {
return (valA.length - valB.length) * revertSymbol;
}
console.warn(
'Unsupported sorting type! Please use custom sorting function.',
valA,
valB
);
return 0;
};
export const useSorter = <T extends Record<keyof any, unknown>>({
data,
sortingFn = defaultSortingFn,
...defaultSorter
}: SorterConfig<T> & { order: 'asc' | 'desc' }) => {
const [sorter, setSorter] = useState<Omit<SorterConfig<T>, 'data'>>({
...defaultSorter,
// We should not show sorting icon at first time
order: 'none',
});
const sortCtx =
sorter.order === 'none'
? {
key: defaultSorter.key,
order: defaultSorter.order,
}
: {
key: sorter.key,
order: sorter.order,
};
const compareFn = (a: T, b: T) => sortingFn(sortCtx, a, b);
const sortedData = data.sort(compareFn);
const shiftOrder = (key?: keyof T) => {
const orders = ['asc', 'desc', 'none'] as const;
if (key && key !== sorter.key) {
// Key changed
setSorter({
...sorter,
key,
order: orders[0],
});
return;
}
setSorter({
...sorter,
order: orders[(orders.indexOf(sorter.order) + 1) % orders.length],
});
};
return {
data: sortedData,
order: sorter.order,
key: sorter.order !== 'none' ? sorter.key : null,
/**
* @deprecated In most cases, we no necessary use `updateSorter` directly.
*/
updateSorter: (newVal: Partial<SorterConfig<T>>) =>
setSorter({ ...sorter, ...newVal }),
shiftOrder,
resetSorter: () => setSorter(defaultSorter),
};
};
@@ -0,0 +1,71 @@
import { useMediaQuery, useTheme } from '@mui/material';
export const useIsSmallDevices = () => {
const theme = useTheme();
const isSmallDevices = useMediaQuery(theme.breakpoints.down(900));
return isSmallDevices;
};
export function isToday(date: Date): boolean {
const today = new Date();
return (
date.getDate() == today.getDate() &&
date.getMonth() == today.getMonth() &&
date.getFullYear() == today.getFullYear()
);
}
export function isYesterday(date: Date): boolean {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return (
date.getFullYear() === yesterday.getFullYear() &&
date.getMonth() === yesterday.getMonth() &&
date.getDate() === yesterday.getDate()
);
}
export function isLastWeek(date: Date): boolean {
const today = new Date();
const lastWeek = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() - 7
);
return date >= lastWeek && date < today;
}
export function isLastMonth(date: Date): boolean {
const today = new Date();
const lastMonth = new Date(
today.getFullYear(),
today.getMonth() - 1,
today.getDate()
);
return date >= lastMonth && date < today;
}
export function isLastYear(date: Date): boolean {
const today = new Date();
const lastYear = new Date(
today.getFullYear() - 1,
today.getMonth(),
today.getDate()
);
return date >= lastYear && date < today;
}
export const formatDate = (date: Date): string => {
// yyyy-mm-dd MM-DD HH:mm
// const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
if (isToday(date)) {
// HH:mm
return `${hours}:${minutes}`;
}
// MM-DD HH:mm
return `${month}-${day} ${hours}:${minutes}`;
};
@@ -0,0 +1,56 @@
import { style } from '@vanilla-extract/css';
import { viewMenu } from './collection-list.css';
export const view = style({
display: 'flex',
alignItems: 'center',
gap: 10,
fontSize: 14,
fontWeight: 600,
height: '100%',
paddingLeft: 16,
});
export const option = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 4,
cursor: 'pointer',
borderRadius: 4,
':hover': {
backgroundColor: 'var(--affine-hover-color)',
},
opacity: 0,
selectors: {
[`${view}:hover &`]: {
opacity: 1,
},
},
});
export const pin = style({
opacity: 1,
});
export const pinedIcon = style({
display: 'block',
selectors: {
[`${option}:hover &`]: {
display: 'none',
},
[`${viewMenu}:hover &`]: {
display: 'none',
},
},
});
export const pinIcon = style({
display: 'none',
selectors: {
[`${option}:hover &`]: {
display: 'block',
},
[`${viewMenu}:hover &`]: {
display: 'block',
},
},
});
@@ -0,0 +1,106 @@
import type { PropertiesMeta } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ViewLayersIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import clsx from 'clsx';
import { useState } from 'react';
import {
type CollectionsAtom,
useCollectionManager,
} from '../use-collection-manager';
import * as styles from './collection-bar.css';
import { EditCollectionModal } from './create-collection';
import { useActions } from './use-action';
interface CollectionBarProps {
getPageInfo: GetPageInfoById;
propertiesMeta: PropertiesMeta;
collectionsAtom: CollectionsAtom;
columnsCount: number;
}
export const CollectionBar = (props: CollectionBarProps) => {
const { getPageInfo, propertiesMeta, columnsCount, collectionsAtom } = props;
const t = useAFFiNEI18N();
const setting = useCollectionManager(collectionsAtom);
const collection = setting.currentCollection;
const [open, setOpen] = useState(false);
const actions = useActions({
collection,
setting,
openEdit: () => setOpen(true),
});
return !setting.isDefault ? (
<tr style={{ userSelect: 'none' }}>
<td>
<div className={styles.view}>
<EditCollectionModal
propertiesMeta={propertiesMeta}
getPageInfo={getPageInfo}
init={collection}
open={open}
onOpenChange={setOpen}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onConfirm={setting.updateCollection}
/>
<ViewLayersIcon
style={{
height: 20,
width: 20,
}}
/>
<Tooltip
content={setting.currentCollection.name}
rootOptions={{
delayDuration: 1500,
}}
>
<div
style={{
marginRight: 10,
maxWidth: 200,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{setting.currentCollection.name}
</div>
</Tooltip>
{actions.map(action => {
return (
<Tooltip key={action.name} content={action.tooltip}>
<div
data-testid={`collection-bar-option-${action.name}`}
onClick={action.click}
className={clsx(styles.option, action.className)}
>
{action.icon}
</div>
</Tooltip>
);
})}
</div>
</td>
{Array.from({ length: columnsCount - 2 }).map((_, i) => (
<td key={i}></td>
))}
<td
style={{
display: 'flex',
justifyContent: 'end',
}}
>
<Button
style={{ border: 'none', position: 'static' }}
onClick={() => setting.backToAll()}
>
{t['com.affine.collectionBar.backToAll']()}
</Button>
</td>
</tr>
) : null;
};
@@ -0,0 +1,227 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const menuTitleStyle = style({
marginLeft: '12px',
marginTop: '10px',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
});
export const menuDividerStyle = style({
marginTop: '2px',
marginBottom: '2px',
marginLeft: '12px',
marginRight: '8px',
height: '1px',
background: 'var(--affine-border-color)',
});
export const viewButton = style({
borderRadius: '8px',
height: '100%',
padding: '4px 8px',
fontSize: 'var(--affine-font-xs)',
background: 'var(--affine-white)',
['WebkitAppRegion' as string]: 'no-drag',
maxWidth: '150px',
color: 'var(--affine-text-secondary-color)',
border: '1px solid var(--affine-border-color)',
transition: 'margin-left 0.2s ease-in-out',
':hover': {
borderColor: 'var(--affine-border-color)',
background: 'var(--affine-hover-color)',
},
marginRight: '20px',
});
globalStyle(`${viewButton} > span`, {
display: 'block',
width: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const viewMenu = style({});
export const viewOption = style({
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 6,
width: 24,
height: 24,
opacity: 0,
':hover': {
backgroundColor: 'var(--affine-hover-color)',
},
selectors: {
[`${viewMenu}:hover &`]: {
opacity: 1,
},
},
});
export const deleteOption = style({
':hover': {
backgroundColor: '#FFEFE9',
},
});
export const filterButton = style({
borderRadius: '8px',
height: '100%',
width: '100%',
marginRight: '20px',
padding: '4px 8px',
fontSize: 'var(--affine-font-xs)',
background: 'var(--affine-white)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--affine-text-secondary-color)',
border: '1px solid var(--affine-border-color)',
['WebkitAppRegion' as string]: 'no-drag',
transition: 'margin-left 0.2s ease-in-out',
':hover': {
borderColor: 'var(--affine-border-color)',
background: 'var(--affine-hover-color)',
},
});
export const filterButtonCollapse = style({
marginLeft: '20px',
});
export const viewDivider = style({
'::after': {
content: '""',
display: 'block',
width: '100%',
height: '1px',
background: 'var(--affine-border-color)',
position: 'absolute',
bottom: 0,
left: 0,
margin: '0 1px',
},
});
export const saveButton = style({
marginTop: '4px',
borderRadius: '8px',
padding: '8px 0',
':hover': {
background: 'var(--affine-hover-color)',
color: 'var(--affine-text-primary-color)',
border: '1px solid var(--affine-border-color)',
},
});
export const saveButtonContainer = style({
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
width: '100%',
height: '100%',
padding: '8px',
});
export const saveIcon = style({
display: 'flex',
alignItems: 'center',
fontSize: 'var(--affine-font-sm)',
marginRight: '8px',
});
export const saveText = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 'var(--affine-font-sm)',
});
export const cancelButton = style({
background: 'var(--affine-hover-color)',
borderRadius: '8px',
':hover': {
background: 'var(--affine-hover-color)',
color: 'var(--affine-text-primary-color)',
border: '1px solid var(--affine-border-color)',
},
});
export const saveTitle = style({
fontSize: 'var(--affine-font-h-6)',
fontWeight: '600',
lineHeight: '24px',
paddingBottom: 20,
});
export const allowList = style({});
export const allowTitle = style({
fontSize: 12,
margin: '20px 0',
});
export const allowListContent = style({
margin: '8px 0',
});
export const excludeList = style({
backgroundColor: 'var(--affine-background-warning-color)',
padding: 18,
borderRadius: 8,
});
export const excludeListContent = style({
margin: '8px 0',
});
export const filterTitle = style({
fontSize: 12,
fontWeight: 600,
marginBottom: 10,
});
export const excludeTitle = style({
fontSize: 12,
fontWeight: 600,
});
export const excludeTip = style({
color: 'var(--affine-text-secondary-color)',
fontSize: 12,
});
export const scrollContainer = style({
maxHeight: '70vh',
flex: 1,
display: 'flex',
flexDirection: 'column',
});
export const container = style({
display: 'flex',
flexDirection: 'column',
});
export const pageContainer = style({
fontSize: 14,
fontWeight: 600,
height: 32,
display: 'flex',
alignItems: 'center',
paddingLeft: 8,
paddingRight: 5,
});
export const pageIcon = style({
marginRight: 20,
display: 'flex',
alignItems: 'center',
});
export const pageTitle = style({
flex: 1,
});
export const deleteIcon = style({
marginLeft: 20,
display: 'flex',
alignItems: 'center',
borderRadius: 4,
padding: 4,
cursor: 'pointer',
':hover': {
color: 'var(--affine-error-color)',
backgroundColor: 'var(--affine-background-error-color)',
},
});
export const filterMenuTrigger = style({
padding: '6px 8px',
background: 'var(--affine-hover-color)',
});
@@ -0,0 +1,222 @@
import type { Collection, Filter } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FilteredIcon, FolderIcon, ViewLayersIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { Menu, MenuIcon, MenuItem } from '@toeverything/components/menu';
import { Tooltip } from '@toeverything/components/tooltip';
import clsx from 'clsx';
import type { MouseEvent } from 'react';
import { useCallback, useState } from 'react';
import { FlexWrapper } from '../../../ui/layout';
import { CreateFilterMenu } from '../filter/vars';
import type { useCollectionManager } from '../use-collection-manager';
import * as styles from './collection-list.css';
import { EditCollectionModal } from './create-collection';
import { useActions } from './use-action';
const CollectionOption = ({
collection,
setting,
updateCollection,
}: {
collection: Collection;
setting: ReturnType<typeof useCollectionManager>;
updateCollection: (view: Collection) => void;
}) => {
const actions = useActions({
collection,
setting,
openEdit: updateCollection,
});
const selectCollection = useCallback(
() => setting.selectCollection(collection.id),
[setting, collection.id]
);
return (
<MenuItem
data-testid="collection-select-option"
preFix={
<MenuIcon>
<ViewLayersIcon />
</MenuIcon>
}
onClick={selectCollection}
key={collection.id}
className={styles.viewMenu}
>
<Tooltip
content={collection.name}
side="right"
rootOptions={{
delayDuration: 1500,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div
style={{
maxWidth: '150px',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{collection.name}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
}}
>
{actions.map((action, i) => {
const onClick = (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
action.click();
};
return (
<div
data-testid={`collection-select-option-${action.name}`}
key={i}
onClick={onClick}
style={{ marginLeft: i === 0 ? 28 : undefined }}
className={clsx(styles.viewOption, action.className)}
>
{action.icon}
</div>
);
})}
</div>
</div>
</Tooltip>
</MenuItem>
);
};
export const CollectionList = ({
setting,
getPageInfo,
propertiesMeta,
}: {
setting: ReturnType<typeof useCollectionManager>;
getPageInfo: GetPageInfoById;
propertiesMeta: PropertiesMeta;
}) => {
const t = useAFFiNEI18N();
const [collection, setCollection] = useState<Collection>();
const onChange = useCallback(
(filterList: Filter[]) => {
setting
.updateCollection({
...setting.currentCollection,
filterList,
})
.catch(err => {
console.error(err);
});
},
[setting]
);
const closeUpdateCollectionModal = useCallback((open: boolean) => {
if (!open) {
setCollection(undefined);
}
}, []);
const onConfirm = useCallback(
async (view: Collection) => {
await setting.updateCollection(view);
closeUpdateCollectionModal(false);
},
[closeUpdateCollectionModal, setting]
);
return (
<FlexWrapper alignItems="center">
{setting.savedCollections.length > 0 && (
<Menu
items={
<div style={{ minWidth: 150 }}>
<MenuItem
preFix={
<MenuIcon>
<FolderIcon />
</MenuIcon>
}
onClick={setting.backToAll}
className={styles.viewMenu}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>All</div>
</div>
</MenuItem>
<div className={styles.menuTitleStyle}>Saved Collection</div>
<div className={styles.menuDividerStyle}></div>
{setting.savedCollections.map(view => (
<CollectionOption
key={view.id}
collection={view}
setting={setting}
updateCollection={setCollection}
/>
))}
</div>
}
>
<Button
data-testid="collection-select"
style={{ marginRight: '20px' }}
>
<Tooltip
content={setting.currentCollection.name}
rootOptions={{
delayDuration: 1500,
}}
>
<>{setting.currentCollection.name}</>
</Tooltip>
</Button>
</Menu>
)}
<Menu
items={
<CreateFilterMenu
propertiesMeta={propertiesMeta}
value={setting.currentCollection.filterList}
onChange={onChange}
/>
}
>
<Button
className={styles.filterMenuTrigger}
type="default"
icon={<FilteredIcon />}
data-testid="create-first-filter"
>
{t['com.affine.filter']()}
</Button>
</Menu>
<EditCollectionModal
propertiesMeta={propertiesMeta}
getPageInfo={getPageInfo}
init={collection}
open={!!collection}
onOpenChange={closeUpdateCollectionModal}
onConfirm={onConfirm}
/>
</FlexWrapper>
);
};
@@ -0,0 +1,314 @@
import type { Collection, Filter } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
EdgelessIcon,
PageIcon,
RemoveIcon,
SaveIcon,
} from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { Modal } from '@toeverything/components/modal';
import { nanoid } from 'nanoid';
import { useCallback, useMemo, useState } from 'react';
import { Input, ScrollableContainer } from '../../..';
import { FilterList } from '../filter';
import * as styles from './collection-list.css';
interface EditCollectionModalProps {
init?: Collection;
title?: string;
open: boolean;
getPageInfo: GetPageInfoById;
propertiesMeta: PropertiesMeta;
onOpenChange: (open: boolean) => void;
onConfirm: (view: Collection) => Promise<void>;
}
export const EditCollectionModal = ({
init,
onConfirm,
open,
onOpenChange,
getPageInfo,
propertiesMeta,
title,
}: EditCollectionModalProps) => {
const t = useAFFiNEI18N();
const onConfirmOnCollection = useCallback(
(view: Collection) => {
onConfirm(view)
.then(() => {
onOpenChange(false);
})
.catch(err => {
console.error(err);
});
},
[onConfirm, onOpenChange]
);
const onCancel = useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
return (
<Modal
open={open}
onOpenChange={onOpenChange}
width={600}
contentOptions={{
style: { padding: '40px' },
}}
>
{init ? (
<EditCollection
propertiesMeta={propertiesMeta}
title={title}
onConfirmText={t['com.affine.editCollection.save']()}
init={init}
getPageInfo={getPageInfo}
onCancel={onCancel}
onConfirm={onConfirmOnCollection}
/>
) : null}
</Modal>
);
};
interface PageProps {
id: string;
getPageInfo: GetPageInfoById;
onClick: (id: string) => void;
}
const Page = ({ id, onClick, getPageInfo }: PageProps) => {
const page = getPageInfo(id);
const handleClick = useCallback(() => onClick(id), [id, onClick]);
return (
<>
{page ? (
<div className={styles.pageContainer}>
<div className={styles.pageIcon}>
{page.isEdgeless ? (
<EdgelessIcon style={{ width: 17.5, height: 17.5 }} />
) : (
<PageIcon style={{ width: 17.5, height: 17.5 }} />
)}
</div>
<div className={styles.pageTitle}>{page.title}</div>
<div onClick={handleClick} className={styles.deleteIcon}>
<RemoveIcon />
</div>
</div>
) : null}
</>
);
};
interface EditCollectionProps {
title?: string;
onConfirmText?: string;
init: Collection;
getPageInfo: GetPageInfoById;
propertiesMeta: PropertiesMeta;
onCancel: () => void;
onConfirm: (collection: Collection) => void;
}
export const EditCollection = ({
title,
init,
onConfirm,
onCancel,
onConfirmText,
getPageInfo,
propertiesMeta,
}: EditCollectionProps) => {
const t = useAFFiNEI18N();
const [value, onChange] = useState<Collection>(init);
const removeFromExcludeList = useCallback(
(id: string) => {
onChange({
...value,
excludeList: value.excludeList?.filter(v => v !== id),
});
},
[value]
);
const removeFromAllowList = useCallback(
(id: string) => {
onChange({
...value,
allowList: value.allowList?.filter(v => v !== id),
});
},
[value]
);
const isNameEmpty = useMemo(() => value.name.trim().length === 0, [value]);
const onSaveCollection = useCallback(() => {
if (!isNameEmpty) {
onConfirm(value);
}
}, [value, isNameEmpty, onConfirm]);
return (
<div
style={{
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
}}
>
<div className={styles.saveTitle}>
{title ?? t['com.affine.editCollection.updateCollection']()}
</div>
<ScrollableContainer
className={styles.scrollContainer}
viewPortClassName={styles.container}
>
{value.excludeList?.length ? (
<div className={styles.excludeList}>
<div className={styles.excludeTitle}>
Exclude from this collection
</div>
<div className={styles.excludeListContent}>
{value.excludeList.map(id => {
return (
<Page
id={id}
getPageInfo={getPageInfo}
key={id}
onClick={removeFromExcludeList}
/>
);
})}
</div>
<div className={styles.excludeTip}>
These pages will never appear in the current collection
</div>
</div>
) : null}
<div
style={{
backgroundColor: 'var(--affine-hover-color)',
borderRadius: 8,
padding: 18,
marginTop: 20,
minHeight: '200px',
}}
>
<div className={styles.filterTitle}>
{t['com.affine.editCollection.filters']()}
</div>
<FilterList
propertiesMeta={propertiesMeta}
value={value.filterList}
onChange={filterList => onChange({ ...value, filterList })}
/>
{value.allowList ? (
<div className={styles.allowList}>
<div className={styles.allowTitle}>With follow pages:</div>
<div className={styles.allowListContent}>
{value.allowList.map(id => {
return (
<Page
key={id}
id={id}
getPageInfo={getPageInfo}
onClick={removeFromAllowList}
/>
);
})}
</div>
</div>
) : null}
</div>
<div style={{ marginTop: 20 }}>
<Input
size="large"
data-testid="input-collection-title"
placeholder={t['com.affine.editCollection.untitledCollection']()}
defaultValue={value.name}
onChange={name => onChange({ ...value, name })}
onEnter={onSaveCollection}
/>
</div>
</ScrollableContainer>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
marginTop: 40,
}}
>
<Button size="large" onClick={onCancel}>
{t['com.affine.editCollection.button.cancel']()}
</Button>
<Button
style={{
marginLeft: 20,
}}
size="large"
data-testid="save-collection"
type="primary"
disabled={isNameEmpty}
onClick={onSaveCollection}
>
{onConfirmText ?? t['com.affine.editCollection.button.create']()}
</Button>
</div>
</div>
);
};
interface SaveCollectionButtonProps {
getPageInfo: GetPageInfoById;
propertiesMeta: PropertiesMeta;
filterList: Filter[];
workspaceId: string;
onConfirm: (collection: Collection) => Promise<void>;
}
export const SaveCollectionButton = ({
onConfirm,
getPageInfo,
propertiesMeta,
filterList,
workspaceId,
}: SaveCollectionButtonProps) => {
const [show, changeShow] = useState(false);
const [init, setInit] = useState<Collection>();
const handleClick = useCallback(() => {
changeShow(true);
setInit({
id: nanoid(),
name: '',
filterList,
workspaceId,
});
}, [changeShow, workspaceId, filterList]);
const t = useAFFiNEI18N();
return (
<>
<Button
onClick={handleClick}
data-testid="save-as-collection"
icon={<SaveIcon />}
size="large"
style={{ padding: '7px 8px' }}
>
{t['com.affine.editCollection.saveCollection']()}
</Button>
<EditCollectionModal
title={t['com.affine.editCollection.saveCollection']()}
propertiesMeta={propertiesMeta}
init={init}
onConfirm={onConfirm}
open={show}
getPageInfo={getPageInfo}
onOpenChange={changeShow}
/>
</>
);
};
@@ -0,0 +1,3 @@
export * from './collection-bar';
export * from './collection-list';
export * from './create-collection';
@@ -0,0 +1,86 @@
import type { Collection } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
DeleteIcon,
FilterIcon,
PinedIcon,
PinIcon,
UnpinIcon,
} from '@blocksuite/icons';
import { type ReactNode, useMemo } from 'react';
import type { useCollectionManager } from '../use-collection-manager';
import * as styles from './collection-bar.css';
interface CollectionBarAction {
icon: ReactNode;
click: () => void;
className?: string;
name: string;
tooltip: string;
}
export const useActions = ({
collection,
setting,
openEdit,
}: {
collection: Collection;
setting: ReturnType<typeof useCollectionManager>;
openEdit: (open: Collection) => void;
}) => {
const t = useAFFiNEI18N();
return useMemo<CollectionBarAction[]>(() => {
return [
{
icon: (
<>
{collection.pinned ? (
<PinedIcon className={styles.pinedIcon}></PinedIcon>
) : (
<PinIcon className={styles.pinedIcon}></PinIcon>
)}
{collection.pinned ? (
<UnpinIcon className={styles.pinIcon}></UnpinIcon>
) : (
<PinIcon className={styles.pinIcon}></PinIcon>
)}
</>
),
name: 'pin',
tooltip: collection.pinned
? t['com.affine.collection-bar.action.tooltip.unpin']()
: t['com.affine.collection-bar.action.tooltip.pin'](),
className: styles.pin,
click: () => {
setting
.updateCollection({
...collection,
pinned: !collection.pinned,
})
.catch(err => {
console.error(err);
});
},
},
{
icon: <FilterIcon />,
name: 'edit',
tooltip: t['com.affine.collection-bar.action.tooltip.edit'](),
click: () => {
openEdit(collection);
},
},
{
icon: <DeleteIcon style={{ color: 'var(--affine-error-color)' }} />,
name: 'delete',
tooltip: t['com.affine.collection-bar.action.tooltip.delete'](),
click: () => {
setting.deleteCollection(collection.id).catch(err => {
console.error(err);
});
},
},
];
}, [collection, t, setting, openEdit]);
};