mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-08 12:45:55 +08:00
refactor(infra): directory structure (#4615)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
+51
@@ -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';
|
||||
+1550
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
};
|
||||
+49
@@ -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>
|
||||
);
|
||||
};
|
||||
+28
@@ -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>
|
||||
);
|
||||
};
|
||||
+18
@@ -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;
|
||||
}
|
||||
+17
@@ -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';
|
||||
+56
@@ -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]);
|
||||
};
|
||||
Reference in New Issue
Block a user