feat: support for view management (#2892)

This commit is contained in:
3720
2023-06-30 13:40:00 +08:00
committed by GitHub
parent d3393cb0fc
commit 9d0db78f64
45 changed files with 1936 additions and 477 deletions

View File

@@ -57,7 +57,7 @@
"@blocksuite/blocks": "0.0.0-20230629103121-76e6587d-nightly",
"@blocksuite/editor": "0.0.0-20230629103121-76e6587d-nightly",
"@blocksuite/global": "0.0.0-20230629103121-76e6587d-nightly",
"@blocksuite/icons": "^2.1.21",
"@blocksuite/icons": "^2.1.23",
"@blocksuite/lit": "0.0.0-20230629103121-76e6587d-nightly",
"@blocksuite/store": "0.0.0-20230629103121-76e6587d-nightly",
"@types/react": "^18.2.14",

View File

@@ -10,6 +10,7 @@ export const root = style({
cursor: 'pointer',
padding: '0 8px 0 12px',
fontSize: 'var(--affine-font-sm)',
margin: '2px 0',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color)',
@@ -22,11 +23,12 @@ export const root = style({
color: 'var(--affine-text-secondary-color)',
pointerEvents: 'none',
},
'&[data-active="true"]:hover': {
background:
// make this a variable?
'linear-gradient(0deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.04)), rgba(0, 0, 0, 0.04);',
},
// this is not visible in dark mode
// '&[data-active="true"]:hover': {
// background:
// // make this a variable?
// 'linear-gradient(0deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.04)), rgba(0, 0, 0, 0.04)',
// },
'&[data-collapsible="true"]': {
width: 'calc(100% + 8px)',
transform: 'translateX(-8px)',
@@ -39,6 +41,7 @@ export const content = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
});
export const icon = style({

View File

@@ -12,6 +12,7 @@ export interface MenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
disabled?: boolean;
collapsed?: boolean; // true, false, undefined. undefined means no collapse
onCollapsedChange?: (collapsed: boolean) => void;
postfix?: React.ReactElement;
}
export interface MenuLinkItemProps
@@ -28,6 +29,7 @@ export const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
disabled,
collapsed,
onCollapsedChange,
postfix,
...props
},
ref
@@ -43,7 +45,6 @@ export const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
ref={ref}
{...props}
className={clsx([styles.root, props.className])}
onClick={onClick}
data-active={active}
data-disabled={disabled}
data-collapsible={collapsible}
@@ -68,11 +69,15 @@ export const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
)}
{React.cloneElement(icon, {
className: clsx([styles.icon, icon.props.className]),
onClick: onClick,
})}
</div>
)}
<div className={styles.content}>{children}</div>
<div onClick={onClick} className={styles.content}>
{children}
</div>
{postfix}
</div>
);
}

View File

@@ -4,7 +4,6 @@
import 'fake-indexeddb/auto';
import { renderHook } from '@testing-library/react';
import { RESET } from 'jotai/utils';
import { expect, test } from 'vitest';
import { createDefaultFilter, vars } from '../filter/vars';
@@ -12,22 +11,22 @@ import { useAllPageSetting } from '../use-all-page-setting';
test('useAllPageSetting', async () => {
const settingHook = renderHook(() => useAllPageSetting());
const prevView = settingHook.result.current.currentView;
expect(settingHook.result.current.savedViews).toEqual([]);
settingHook.result.current.setCurrentView(view => ({
...view,
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])],
}));
});
settingHook.rerender();
const nextView = settingHook.result.current.currentView;
expect(nextView).not.toBe(prevView);
expect(nextView.filterList).toEqual([createDefaultFilter(vars[0])]);
settingHook.result.current.setCurrentView(RESET);
await settingHook.result.current.createView({
...settingHook.result.current.currentView,
const nextCollection = settingHook.result.current.currentCollection;
expect(nextCollection).not.toBe(prevCollection);
expect(nextCollection.filterList).toEqual([createDefaultFilter(vars[0])]);
settingHook.result.current.backToAll();
await settingHook.result.current.saveCollection({
...settingHook.result.current.currentCollection,
id: '1',
});
settingHook.rerender();
expect(settingHook.result.current.savedViews.length).toBe(1);
expect(settingHook.result.current.savedViews[0].id).toBe('1');
expect(settingHook.result.current.savedCollections.length).toBe(1);
expect(settingHook.result.current.savedCollections[0].id).toBe('1');
});

View File

@@ -1,4 +1,6 @@
import { CollectionBar } from '@affine/component/page-list';
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
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';
@@ -31,12 +33,14 @@ const AllPagesHead = ({
createNewPage,
createNewEdgeless,
importFile,
getPageInfo,
}: {
isPublicWorkspace: boolean;
sorter: ReturnType<typeof useSorter<ListData>>;
createNewPage: () => void;
createNewEdgeless: () => void;
importFile: () => void;
getPageInfo: GetPageInfoById;
}) => {
const t = useAFFiNEI18N();
const titleList = [
@@ -72,7 +76,6 @@ const AllPagesHead = ({
} satisfies CSSProperties,
},
];
return (
<TableHead>
<TableHeadRow>
@@ -107,6 +110,7 @@ const AllPagesHead = ({
</TableCell>
))}
</TableHeadRow>
<CollectionBar getPageInfo={getPageInfo} />
</TableHead>
);
};
@@ -118,6 +122,7 @@ export const PageList = ({
onCreateNewEdgeless,
onImportFile,
fallback,
getPageInfo,
}: PageListProps) => {
const sorter = useSorter<ListData>({
data: list,
@@ -160,6 +165,7 @@ export const PageList = ({
createNewPage={onCreateNewPage}
createNewEdgeless={onCreateNewEdgeless}
importFile={onImportFile}
getPageInfo={getPageInfo}
/>
<AllPagesBody
isPublicWorkspace={isPublicWorkspace}

View File

@@ -5,6 +5,7 @@ import { Menu } from '../../..';
import { Condition } from './condition';
import * as styles from './index.css';
import { CreateFilterMenu } from './vars';
export const FilterList = ({
value,
onChange,
@@ -13,7 +14,13 @@ export const FilterList = ({
onChange: (value: Filter[]) => void;
}) => {
return (
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 10,
}}
>
{value.map((filter, i) => {
return (
<div className={styles.filterItemStyle} key={i}>

View File

@@ -28,7 +28,6 @@ export const filterItemStyle = style({
border: '1px solid var(--affine-border-color)',
borderRadius: '8px',
background: 'var(--affine-white)',
margin: '4px',
padding: '4px 8px',
});

View File

@@ -1,3 +1,5 @@
import type { GetPageInfoById } from '@affine/env/page-info';
/**
* Get the keys of an object type whose values are of a given type
*
@@ -45,6 +47,7 @@ export type PageListProps = {
onCreateNewPage: () => void;
onCreateNewEdgeless: () => void;
onImportFile: () => void;
getPageInfo: GetPageInfoById;
};
export type DraggableTitleCellData = {

View File

@@ -1,29 +1,29 @@
import type { Filter, VariableMap, View } from '@affine/env/filter';
import type { Collection, Filter, VariableMap } from '@affine/env/filter';
import type { DBSchema } from 'idb';
import { openDB } from 'idb';
import type { IDBPDatabase } from 'idb/build/entry';
import { useAtom } from 'jotai';
import { atomWithReset } from 'jotai/utils';
import { atomWithReset, RESET } from 'jotai/utils';
import { useCallback } from 'react';
import useSWRImmutable from 'swr/immutable';
import { NIL } from 'uuid';
import { evalFilterList } from './filter';
type PersistenceView = View;
type PersistenceCollection = Collection;
export interface PageViewDBV1 extends DBSchema {
export interface PageCollectionDBV1 extends DBSchema {
view: {
key: PersistenceView['id'];
value: PersistenceView;
key: PersistenceCollection['id'];
value: PersistenceCollection;
};
}
const pageViewDBPromise: Promise<IDBPDatabase<PageViewDBV1>> =
const pageCollectionDBPromise: Promise<IDBPDatabase<PageCollectionDBV1>> =
typeof window === 'undefined'
? // never resolve in SSR
new Promise<any>(() => {})
: openDB<PageViewDBV1>('page-view', 1, {
: openDB<PageCollectionDBV1>('page-view', 1, {
upgrade(database) {
database.createObjectStore('view', {
keyPath: 'id',
@@ -31,18 +31,24 @@ const pageViewDBPromise: Promise<IDBPDatabase<PageViewDBV1>> =
},
});
const currentViewAtom = atomWithReset<View>({
name: 'default',
id: NIL,
filterList: [],
const collectionAtom = atomWithReset<{
currentId: string;
defaultCollection: Collection;
}>({
currentId: NIL,
defaultCollection: {
id: NIL,
name: 'All',
filterList: [],
},
});
export const useAllPageSetting = () => {
const { data: savedViews, mutate } = useSWRImmutable(
['affine', 'page-view'],
export const useSavedCollections = () => {
const { data: savedCollections, mutate } = useSWRImmutable<Collection[]>(
['affine', 'page-collection'],
{
fetcher: async () => {
const db = await pageViewDBPromise;
const db = await pageCollectionDBPromise;
const t = db.transaction('view').objectStore('view');
return await t.getAll();
},
@@ -51,29 +57,98 @@ export const useAllPageSetting = () => {
revalidateOnMount: true,
}
);
const [currentView, setCurrentView] = useAtom(currentViewAtom);
const createView = useCallback(
async (view: View) => {
if (view.id === NIL) {
const saveCollection = useCallback(
async (collection: Collection) => {
if (collection.id === NIL) {
return;
}
const db = await pageViewDBPromise;
const db = await pageCollectionDBPromise;
const t = db.transaction('view', 'readwrite').objectStore('view');
await t.put(view);
await t.put(collection);
await mutate();
},
[mutate]
);
const deleteCollection = useCallback(
async (id: string) => {
if (id === NIL) {
return;
}
const db = await pageCollectionDBPromise;
const t = db.transaction('view', 'readwrite').objectStore('view');
await t.delete(id);
await mutate();
},
[mutate]
);
const addPage = useCallback(
async (collectionId: string, pageId: string) => {
const collection = savedCollections?.find(v => v.id === collectionId);
if (!collection) {
return;
}
await saveCollection({
...collection,
allowList: [pageId, ...(collection.allowList ?? [])],
});
},
[saveCollection, savedCollections]
);
return {
currentView,
savedViews: savedViews as View[],
savedCollections: savedCollections ?? [],
saveCollection,
deleteCollection,
addPage,
};
};
export const useAllPageSetting = () => {
const { savedCollections, saveCollection, deleteCollection, addPage } =
useSavedCollections();
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 currentCollection =
collectionData.currentId === NIL
? collectionData.defaultCollection
: savedCollections.find(v => v.id === collectionData.currentId) ??
collectionData.defaultCollection;
return {
currentCollection: currentCollection,
savedCollections,
isDefault: currentCollection.id === NIL,
// actions
createView,
setCurrentView,
saveCollection,
updateCollection,
selectCollection,
backToAll,
deleteCollection,
addPage,
};
};
export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) =>

View File

@@ -0,0 +1,48 @@
import { style } from '@vanilla-extract/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',
},
},
});
export const pinIcon = style({
display: 'none',
selectors: {
[`${option}:hover &`]: {
display: 'block',
},
},
});

View File

@@ -0,0 +1,126 @@
import { EditCollectionModel } from '@affine/component/page-list';
import type { GetPageInfoById } from '@affine/env/page-info';
import {
DeleteIcon,
FilterIcon,
PinedIcon,
PinIcon,
UnpinIcon,
ViewLayersIcon,
} from '@blocksuite/icons';
import clsx from 'clsx';
import type { ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Button } from '../../../ui/button/button';
import { useAllPageSetting } from '../use-all-page-setting';
import * as styles from './collection-bar.css';
export const CollectionBar = ({
getPageInfo,
}: {
getPageInfo: GetPageInfoById;
}) => {
const setting = useAllPageSetting();
const collection = setting.currentCollection;
const [open, setOpen] = useState(false);
const actions: {
icon: ReactNode;
click: () => void;
className?: string;
name: string;
}[] = useMemo(
() => [
{
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',
className: styles.pin,
click: () => {
return setting.updateCollection({
...collection,
pinned: !collection.pinned,
});
},
},
{
icon: <FilterIcon />,
name: 'edit',
click: () => {
setOpen(true);
},
},
{
icon: <DeleteIcon style={{ color: 'red' }} />,
name: 'delete',
click: () => {
setting.deleteCollection(collection.id).catch(err => {
console.error(err);
});
},
},
],
[setting, collection]
);
const onClose = useCallback(() => setOpen(false), []);
return !setting.isDefault ? (
<tr style={{ userSelect: 'none' }}>
<td>
<div className={styles.view}>
<EditCollectionModel
getPageInfo={getPageInfo}
init={collection}
open={open}
onClose={onClose}
onConfirm={setting.updateCollection}
></EditCollectionModel>
<ViewLayersIcon
style={{
height: 20,
width: 20,
}}
/>
<div style={{ marginRight: 10 }}>
{setting.currentCollection.name}
</div>
{actions.map(action => {
return (
<div
key={action.name}
data-testid={`collection-bar-option-${action.name}`}
onClick={action.click}
className={clsx(styles.option, action.className)}
>
{action.icon}
</div>
);
})}
</div>
</td>
<td></td>
<td></td>
<td
style={{
display: 'flex',
justifyContent: 'end',
}}
>
<Button style={{ border: 'none' }} onClick={() => setting.backToAll()}>
Back to all
</Button>
</td>
</tr>
) : null;
};

View File

@@ -0,0 +1,207 @@
import { 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)',
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',
});
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%',
padding: '4px 8px',
fontSize: 'var(--affine-font-xs)',
background: 'var(--affine-white)',
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)',
},
});
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({
overflow: 'hidden',
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': {
backgroundColor: 'var(--affine-hover-color)',
},
});

View File

@@ -0,0 +1,232 @@
import { EditCollectionModel } from '@affine/component/page-list';
import type { Collection, Filter } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
DeleteIcon,
FilteredIcon,
FilterIcon,
FolderIcon,
PinIcon,
ViewLayersIcon,
} from '@blocksuite/icons';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import type { MouseEvent, ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Button, MenuItem } from '../../..';
import Menu from '../../../ui/menu/menu';
import { appSidebarOpenAtom } from '../../app-sidebar';
import { CreateFilterMenu } from '../filter/vars';
import type { useAllPageSetting } from '../use-all-page-setting';
import * as styles from './collection-list.css';
const CollectionOption = ({
collection,
setting,
updateCollection,
}: {
collection: Collection;
setting: ReturnType<typeof useAllPageSetting>;
updateCollection: (view: Collection) => void;
}) => {
const actions: {
icon: ReactNode;
click: () => void;
className?: string;
name: string;
}[] = useMemo(
() => [
{
icon: <PinIcon />,
name: 'pin',
click: () => {
return setting.updateCollection({
...collection,
pinned: !collection.pinned,
});
},
},
{
icon: <FilterIcon />,
name: 'edit',
click: () => {
updateCollection(collection);
},
},
{
icon: <DeleteIcon style={{ color: 'red' }} />,
name: 'delete',
click: () => {
setting.deleteCollection(collection.id).catch(err => {
console.error(err);
});
},
},
],
[setting, updateCollection, collection]
);
const selectCollection = useCallback(
() => setting.selectCollection(collection.id),
[setting, collection.id]
);
return (
<MenuItem
data-testid="collection-select-option"
icon={<ViewLayersIcon></ViewLayersIcon>}
onClick={selectCollection}
key={collection.id}
className={styles.viewMenu}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>{collection.name}</div>
<div
style={{
display: 'flex',
alignItems: 'center',
}}
>
{actions.map((v, i) => {
const onClick = (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
v.click();
};
return (
<div
data-testid={`collection-select-option-${v.name}`}
key={i}
onClick={onClick}
style={{ marginLeft: i === 0 ? 28 : undefined }}
className={clsx(styles.viewOption, v.className)}
>
{v.icon}
</div>
);
})}
</div>
</div>
</MenuItem>
);
};
export const CollectionList = ({
setting,
getPageInfo,
}: {
setting: ReturnType<typeof useAllPageSetting>;
getPageInfo: GetPageInfoById;
}) => {
const t = useAFFiNEI18N();
const [open] = useAtom(appSidebarOpenAtom);
const [collection, setCollection] = useState<Collection>();
const onChange = useCallback(
(filterList: Filter[]) => {
return setting.updateCollection({
...setting.currentCollection,
filterList,
});
},
[setting]
);
const closeUpdateCollectionModal = useCallback(
() => setCollection(undefined),
[]
);
const onConfirm = useCallback(
(view: Collection) => {
return setting.updateCollection(view).then(() => {
closeUpdateCollectionModal();
});
},
[closeUpdateCollectionModal, setting]
);
return (
<div
className={clsx({
[styles.filterButtonCollapse]: !open,
})}
style={{
marginLeft: 4,
display: 'flex',
alignItems: 'center',
}}
>
{setting.savedCollections.length > 0 && (
<Menu
trigger="click"
content={
<div style={{ minWidth: 150 }}>
<MenuItem
icon={<FolderIcon></FolderIcon>}
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
size="small"
className={clsx(styles.viewButton)}
hoverColor="var(--affine-icon-color)"
data-testid="collection-select"
>
{setting.currentCollection.name}
</Button>
</Menu>
)}
<Menu
trigger="click"
placement="bottom-start"
content={
<CreateFilterMenu
value={setting.currentCollection.filterList}
onChange={onChange}
/>
}
>
<Button
icon={<FilteredIcon />}
className={clsx(styles.filterButton)}
size="small"
hoverColor="var(--affine-icon-color)"
data-testid="create-first-filter"
>
{t['com.affine.filter']()}
</Button>
</Menu>
<EditCollectionModel
getPageInfo={getPageInfo}
init={collection}
open={!!collection}
onClose={closeUpdateCollectionModal}
onConfirm={onConfirm}
></EditCollectionModel>
</div>
);
};

View File

@@ -0,0 +1,291 @@
import type { Collection } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info';
import {
EdgelessIcon,
PageIcon,
RemoveIcon,
SaveIcon,
} from '@blocksuite/icons';
import { useCallback, useState } from 'react';
import {
Button,
Input,
Modal,
ModalCloseButton,
ModalWrapper,
ScrollableContainer,
} from '../../..';
import { FilterList } from '../filter';
import * as styles from './collection-list.css';
type CreateCollectionProps = {
title?: string;
init: Collection;
onConfirm: (collection: Collection) => void;
onConfirmText?: string;
getPageInfo: GetPageInfoById;
};
export const EditCollectionModel = ({
init,
onConfirm,
open,
onClose,
getPageInfo,
}: {
init?: Collection;
onConfirm: (view: Collection) => void;
open: boolean;
onClose: () => void;
getPageInfo: GetPageInfoById;
}) => {
return (
<Modal open={open} onClose={onClose}>
<ModalWrapper
width={600}
style={{
padding: '40px',
background: 'var(--affine-background-primary-color)',
}}
>
<ModalCloseButton
top={12}
right={12}
onClick={onClose}
hoverColor="var(--affine-icon-color)"
/>
{init ? (
<EditCollection
title="Update Collection"
onConfirmText="Save"
init={init}
getPageInfo={getPageInfo}
onCancel={onClose}
onConfirm={view => {
onConfirm(view);
onClose();
}}
/>
) : null}
</ModalWrapper>
</Modal>
);
};
const Page = ({
id,
onClick,
getPageInfo,
}: {
id: string;
onClick: (id: string) => void;
getPageInfo: GetPageInfoById;
}) => {
const page = getPageInfo(id);
if (!page) {
return null;
}
const icon = page.isEdgeless ? (
<EdgelessIcon
style={{
width: 17.5,
height: 17.5,
}}
/>
) : (
<PageIcon
style={{
width: 17.5,
height: 17.5,
}}
/>
);
const click = () => {
onClick(id);
};
return (
<div className={styles.pageContainer}>
<div className={styles.pageIcon}>{icon}</div>
<div className={styles.pageTitle}>{page.title}</div>
<div onClick={click} className={styles.deleteIcon}>
<RemoveIcon />
</div>
</div>
);
};
export const EditCollection = ({
title,
init,
onConfirm,
onCancel,
onConfirmText,
getPageInfo,
}: CreateCollectionProps & {
onCancel: () => void;
}) => {
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]
);
return (
<div
style={{
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
}}
>
<div className={styles.saveTitle}>
{title ?? 'Save As New Collection'}
</div>
<ScrollableContainer
className={styles.scrollContainer}
viewPortClassName={styles.container}
>
<div className={styles.excludeList}>
<div className={styles.excludeTitle}>
Exclude from this collection
</div>
{value.excludeList ? (
<div className={styles.excludeListContent}>
{value.excludeList.map(id => {
return (
<Page
id={id}
getPageInfo={getPageInfo}
key={id}
onClick={removeFromExcludeList}
/>
);
})}
</div>
) : null}
<div className={styles.excludeTip}>
These pages will never appear in the current collection
</div>
</div>
<div
style={{
backgroundColor: 'var(--affine-hover-color)',
borderRadius: 8,
padding: 18,
marginTop: 20,
}}
>
<div className={styles.filterTitle}>Filters</div>
<FilterList
value={value.filterList}
onChange={list =>
onChange({
...value,
filterList: list,
})
}
></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
data-testid="input-collection-title"
placeholder="Untitled Collection"
value={value.name}
onChange={text =>
onChange({
...value,
name: text,
})
}
/>
</div>
</ScrollableContainer>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
marginTop: 40,
}}
>
<Button className={styles.cancelButton} onClick={onCancel}>
Cancel
</Button>
<Button
style={{
marginLeft: 20,
borderRadius: '8px',
}}
data-testid="save-collection"
type="primary"
onClick={() => {
if (value.name.trim().length > 0) {
onConfirm(value);
}
}}
>
{onConfirmText ?? 'Create'}
</Button>
</div>
</div>
);
};
export const SaveCollectionButton = ({
init,
onConfirm,
getPageInfo,
}: CreateCollectionProps) => {
const [show, changeShow] = useState(false);
return (
<>
<Button
className={styles.saveButton}
onClick={() => changeShow(true)}
size="middle"
data-testid="save-as-collection"
>
<div className={styles.saveButtonContainer}>
<div className={styles.saveIcon}>
<SaveIcon />
</div>
<div className={styles.saveText}>Save As Collection</div>
</div>
</Button>
<EditCollectionModel
init={init}
onConfirm={onConfirm}
open={show}
getPageInfo={getPageInfo}
onClose={() => changeShow(false)}
/>
</>
);
};

View File

@@ -1,112 +0,0 @@
import type { Filter, View } from '@affine/env/filter';
import { SaveIcon } from '@blocksuite/icons';
import { uuidv4 } from '@blocksuite/store';
import { useState } from 'react';
import { Button, Input, Modal, ModalCloseButton, ModalWrapper } from '../../..';
import { FilterList } from '../filter';
import * as styles from './view-list.css';
type CreateViewProps = {
init: Filter[];
onConfirm: (view: View) => void;
};
const CreateView = ({
init,
onConfirm,
onCancel,
}: CreateViewProps & { onCancel: () => void }) => {
const [value, onChange] = useState<View>({
name: '',
filterList: init,
id: uuidv4(),
});
return (
<div>
<div className={styles.saveTitle}>Save As New View</div>
<div
style={{
backgroundColor: 'var(--affine-hover-color)',
borderRadius: 8,
padding: 20,
marginTop: 20,
}}
>
<FilterList
value={value.filterList}
onChange={list => onChange({ ...value, filterList: list })}
></FilterList>
</div>
<div style={{ marginTop: 20 }}>
<Input
placeholder="Untitled View"
value={value.name}
onChange={text => onChange({ ...value, name: text })}
/>
</div>
<div
style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 40 }}
>
<Button className={styles.cancelButton} onClick={onCancel}>
Cancel
</Button>
<Button
style={{ marginLeft: 20, borderRadius: '8px' }}
type="primary"
onClick={() => {
if (value.name.trim().length > 0) {
onConfirm(value);
}
}}
>
Create
</Button>
</div>
</div>
);
};
export const SaveViewButton = ({ init, onConfirm }: CreateViewProps) => {
const [show, changeShow] = useState(false);
return (
<>
<Button
className={styles.saveButton}
onClick={() => changeShow(true)}
size="middle"
>
<div className={styles.saveButtonContainer}>
<div className={styles.saveIcon}>
<SaveIcon />
</div>
<div className={styles.saveText}>Save View</div>
</div>
</Button>
<Modal open={show} onClose={() => changeShow(false)}>
<ModalWrapper
width={560}
style={{
padding: '40px',
background: 'var(--affine-background-primary-color)',
}}
>
<ModalCloseButton
top={12}
right={12}
onClick={() => changeShow(false)}
hoverColor="var(--affine-icon-color)"
/>
<CreateView
init={init}
onCancel={() => changeShow(false)}
onConfirm={view => {
onConfirm(view);
changeShow(false);
}}
/>
</ModalWrapper>
</Modal>
</>
);
};

View File

@@ -1,2 +1,3 @@
export * from './create-view';
export * from './view-list';
export * from './collection-bar';
export * from './collection-list';
export * from './create-collection';

View File

@@ -1,76 +0,0 @@
import { style } from '@vanilla-extract/css';
export const filterButton = style({
borderRadius: '8px',
height: '100%',
padding: '4px 8px',
fontSize: 'var(--affine-font-xs)',
background: 'var(--affine-white)',
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)',
},
});
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',
});

View File

@@ -1,72 +0,0 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FilteredIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { Button, MenuItem } from '../../..';
import Menu from '../../../ui/menu/menu';
import { appSidebarOpenAtom } from '../../app-sidebar';
import { CreateFilterMenu } from '../filter/vars';
import type { useAllPageSetting } from '../use-all-page-setting';
import * as styles from './view-list.css';
export const ViewList = ({
setting,
}: {
setting: ReturnType<typeof useAllPageSetting>;
}) => {
const [open] = useAtom(appSidebarOpenAtom);
const t = useAFFiNEI18N();
return (
<div style={{ marginLeft: 4, display: 'flex', alignItems: 'center' }}>
{setting.savedViews.length > 0 && (
<Menu
trigger="click"
content={
<div>
{setting.savedViews.map(view => {
return (
<MenuItem
onClick={() => setting.setCurrentView(view)}
key={view.id}
>
{view.name}
</MenuItem>
);
})}
</div>
}
>
<Button style={{ marginRight: 12, cursor: 'pointer' }}>
{setting.currentView.name}
</Button>
</Menu>
)}
<Menu
trigger="click"
placement="bottom-start"
content={
<CreateFilterMenu
value={setting.currentView.filterList}
onChange={filterList => {
setting.setCurrentView(view => ({
...view,
filterList,
}));
}}
/>
}
>
<Button
icon={<FilteredIcon />}
className={clsx(styles.filterButton, {
[styles.filterButtonCollapse]: !open,
})}
size="small"
hoverColor="var(--affine-icon-color)"
>
{t['com.affine.filter']()}
</Button>
</Menu>
</div>
);
};

View File

@@ -8,22 +8,28 @@ import * as styles from './index.css';
export type ScrollableContainerProps = {
showScrollTopBorder?: boolean;
inTableView?: boolean;
className?: string;
viewPortClassName?: string;
};
export const ScrollableContainer = ({
children,
showScrollTopBorder = false,
inTableView = false,
className,
viewPortClassName,
}: PropsWithChildren<ScrollableContainerProps>) => {
const [hasScrollTop, ref] = useHasScrollTop();
return (
<ScrollArea.Root className={styles.scrollableContainerRoot}>
<ScrollArea.Root
className={clsx(styles.scrollableContainerRoot, className)}
>
<div
data-has-scroll-top={hasScrollTop}
className={clsx({ [styles.scrollTopBorder]: showScrollTopBorder })}
/>
<ScrollArea.Viewport
className={clsx([styles.scrollableViewport])}
className={clsx([styles.scrollableViewport, viewPortClassName])}
ref={ref}
>
<div className={styles.scrollableContainer}>{children}</div>

View File

@@ -25,8 +25,11 @@ export type Filter = {
args: Literal[];
};
export type View = {
export type Collection = {
id: string;
name: string;
pinned?: boolean;
filterList: Filter[];
allowList?: string[];
excludeList?: string[];
};

7
packages/env/src/page-info.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
export type PageInfo = {
isEdgeless: boolean;
title: string;
id: string;
};
export type GetPageInfoById = (id: string) => PageInfo | undefined;

View File

@@ -7,7 +7,7 @@ import type {
} from '@blocksuite/store';
import type { FC, PropsWithChildren } from 'react';
import type { View } from './filter';
import type { Collection } from './filter';
import type { Workspace as RemoteWorkspace } from './workspace/legacy-cloud';
export enum WorkspaceVersion {
@@ -185,7 +185,7 @@ type PageDetailProps<Flavour extends keyof WorkspaceRegistry> =
type PageListProps<_Flavour extends keyof WorkspaceRegistry> = {
blockSuiteWorkspace: BlockSuiteWorkspace;
onOpenPage: (pageId: string, newTab?: boolean) => void;
view: View;
collection: Collection;
};
export interface WorkspaceUISchema<Flavour extends keyof WorkspaceRegistry> {

View File

@@ -193,6 +193,7 @@
"Check Our Docs": "Check Our Docs",
"Get in touch! Join our communities": "Get in touch! Join our communities.",
"Favorites": "Favourites",
"Collections": "Collections",
"Download data": "Download {{CoreOrAll}} data",
"Back Home": "Back Home",
"Set a Workspace name": "Set a Workspace name",