mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-11-23 23:02:55 +03:00
feat: page view storage with cloud support (#4238)
This commit is contained in:
parent
58a935b31d
commit
5f0605a5d9
4
apps/core/src/atoms/cloud-user.ts
Normal file
4
apps/core/src/atoms/cloud-user.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { atom } from 'jotai';
|
||||
import type { SessionContextValue } from 'next-auth/react';
|
||||
|
||||
export const sessionAtom = atom<SessionContextValue<true> | null>(null);
|
@ -19,6 +19,7 @@ import { useGetPageInfoById } from '../../../hooks/use-get-page-info';
|
||||
import type { BlockSuiteWorkspace } from '../../../shared';
|
||||
import { toast } from '../../../utils';
|
||||
import { filterPage } from '../../../utils/filter';
|
||||
import { currentCollectionsAtom } from '../../../utils/user-setting';
|
||||
import { emptyDescButton, emptyDescKbd, pageListEmptyStyle } from './index.css';
|
||||
import { usePageHelper } from './utils';
|
||||
|
||||
@ -277,7 +278,7 @@ export const BlockSuitePageList = ({
|
||||
|
||||
return (
|
||||
<PageList
|
||||
workspaceId={blockSuiteWorkspace.id}
|
||||
collectionsAtom={currentCollectionsAtom}
|
||||
propertiesMeta={blockSuiteWorkspace.meta.properties}
|
||||
getPageInfo={getPageInfo}
|
||||
onCreateNewPage={createPage}
|
||||
|
@ -11,6 +11,7 @@ import { IconButton } from '@toeverything/components/button';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useGetPageInfoById } from '../../../../hooks/use-get-page-info';
|
||||
import { currentCollectionsAtom } from '../../../../utils/user-setting';
|
||||
|
||||
type AddCollectionButtonProps = {
|
||||
workspace: Workspace;
|
||||
@ -20,7 +21,7 @@ export const AddCollectionButton = ({
|
||||
workspace,
|
||||
}: AddCollectionButtonProps) => {
|
||||
const getPageInfo = useGetPageInfoById(workspace);
|
||||
const setting = useCollectionManager(workspace.id);
|
||||
const setting = useCollectionManager(currentCollectionsAtom);
|
||||
const t = useAFFiNEI18N();
|
||||
const [show, showUpdateCollection] = useState(false);
|
||||
const [defaultCollection, setDefaultCollection] = useState<Collection>();
|
||||
|
@ -29,11 +29,12 @@ import {
|
||||
} from '@toeverything/components/menu';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import type { ReactElement } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useGetPageInfoById } from '../../../../hooks/use-get-page-info';
|
||||
import { useNavigateHelper } from '../../../../hooks/use-navigate-helper';
|
||||
import { filterPage } from '../../../../utils/filter';
|
||||
import { currentCollectionsAtom } from '../../../../utils/user-setting';
|
||||
import type { CollectionsListProps } from '../index';
|
||||
import { Page } from './page';
|
||||
import * as styles from './styles.css';
|
||||
@ -148,8 +149,8 @@ const CollectionRenderer = ({
|
||||
workspace: Workspace;
|
||||
getPageInfo: GetPageInfoById;
|
||||
}) => {
|
||||
const [collapsed, setCollapsed] = React.useState(true);
|
||||
const setting = useCollectionManager(workspace.id);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const setting = useCollectionManager(currentCollectionsAtom);
|
||||
const { jumpToSubPath } = useNavigateHelper();
|
||||
const clickCollection = useCallback(() => {
|
||||
jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
|
||||
@ -272,7 +273,7 @@ const CollectionRenderer = ({
|
||||
};
|
||||
export const CollectionsList = ({ workspace }: CollectionsListProps) => {
|
||||
const metas = useBlockSuitePageMeta(workspace);
|
||||
const { savedCollections } = useSavedCollections(workspace.id);
|
||||
const { savedCollections } = useSavedCollections(currentCollectionsAtom);
|
||||
const getPageInfo = useGetPageInfoById(workspace);
|
||||
const pinedCollections = useMemo(
|
||||
() => savedCollections.filter(v => v.pinned),
|
||||
|
@ -28,6 +28,7 @@ import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useHistoryAtom } from '../../atoms/history';
|
||||
import { useAppSetting } from '../../atoms/settings';
|
||||
import type { AllWorkspace } from '../../shared';
|
||||
import { currentCollectionsAtom } from '../../utils/user-setting';
|
||||
import { CollectionsList } from '../pure/workspace-slider-bar/collections';
|
||||
import { AddCollectionButton } from '../pure/workspace-slider-bar/collections/add-collection-button';
|
||||
import { AddFavouriteButton } from '../pure/workspace-slider-bar/favorite/add-favourite-button';
|
||||
@ -98,7 +99,7 @@ export const RootAppSidebar = ({
|
||||
}: RootAppSidebarProps): ReactElement => {
|
||||
const currentWorkspaceId = currentWorkspace.id;
|
||||
const [appSettings] = useAppSetting();
|
||||
const { backToAll } = useCollectionManager(currentWorkspace.id);
|
||||
const { backToAll } = useCollectionManager(currentCollectionsAtom);
|
||||
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
|
||||
const t = useAFFiNEI18N();
|
||||
const onClickNewPage = useCallback(async () => {
|
||||
|
@ -18,6 +18,7 @@ import { useCallback } from 'react';
|
||||
import { appHeaderAtom, mainContainerAtom } from '../atoms/element';
|
||||
import { useGetPageInfoById } from '../hooks/use-get-page-info';
|
||||
import { useWorkspace } from '../hooks/use-workspace';
|
||||
import { currentCollectionsAtom } from '../utils/user-setting';
|
||||
import { SharePageModal } from './affine/share-page-modal';
|
||||
import { BlockSuiteHeaderTitle } from './blocksuite/block-suite-header-title';
|
||||
import { filterContainerStyle } from './filter-container.css';
|
||||
@ -27,7 +28,7 @@ import { WorkspaceModeFilterTab } from './pure/workspace-mode-filter-tab';
|
||||
|
||||
const FilterContainer = ({ workspaceId }: { workspaceId: string }) => {
|
||||
const currentWorkspace = useWorkspace(workspaceId);
|
||||
const setting = useCollectionManager(workspaceId);
|
||||
const setting = useCollectionManager(currentCollectionsAtom);
|
||||
const saveToCollection = useCallback(
|
||||
async (collection: Collection) => {
|
||||
await setting.saveCollection(collection);
|
||||
@ -78,10 +79,10 @@ export function WorkspaceHeader({
|
||||
currentWorkspaceId,
|
||||
currentEntry,
|
||||
}: WorkspaceHeaderProps<WorkspaceFlavour>) {
|
||||
const setting = useCollectionManager(currentWorkspaceId);
|
||||
const setAppHeader = useSetAtom(appHeaderAtom);
|
||||
|
||||
const currentWorkspace = useWorkspace(currentWorkspaceId);
|
||||
const setting = useCollectionManager(currentCollectionsAtom);
|
||||
const getPageInfoById = useGetPageInfoById(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
|
@ -11,6 +11,7 @@ import { redirect } from 'react-router-dom';
|
||||
import { getUIAdapter } from '../../adapters/workspace';
|
||||
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
|
||||
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||
import { currentCollectionsAtom } from '../../utils/user-setting';
|
||||
|
||||
export const loader: LoaderFunction = async args => {
|
||||
const rootStore = getCurrentStore();
|
||||
@ -35,7 +36,7 @@ export const loader: LoaderFunction = async args => {
|
||||
export const AllPage = () => {
|
||||
const { jumpToPage } = useNavigateHelper();
|
||||
const [currentWorkspace] = useCurrentWorkspace();
|
||||
const setting = useCollectionManager(currentWorkspace.id);
|
||||
const setting = useCollectionManager(currentCollectionsAtom);
|
||||
const onClickPage = useCallback(
|
||||
(pageId: string, newTab?: boolean) => {
|
||||
assertExists(currentWorkspace);
|
||||
|
@ -26,6 +26,7 @@ import { setPageModeAtom } from '../../atoms';
|
||||
import { currentModeAtom } from '../../atoms/mode';
|
||||
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
|
||||
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||
import { currentCollectionsAtom } from '../../utils/user-setting';
|
||||
|
||||
const DetailPageImpl = (): ReactElement => {
|
||||
const { openPage, jumpToSubPath } = useNavigateHelper();
|
||||
@ -34,7 +35,7 @@ const DetailPageImpl = (): ReactElement => {
|
||||
assertExists(currentWorkspace);
|
||||
assertExists(currentPageId);
|
||||
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
|
||||
const collectionManager = useCollectionManager(currentWorkspace.id);
|
||||
const collectionManager = useCollectionManager(currentCollectionsAtom);
|
||||
const mode = useAtomValue(currentModeAtom);
|
||||
const setPageMode = useSetAtom(setPageModeAtom);
|
||||
|
||||
|
@ -4,21 +4,27 @@ import { pushNotificationAtom } from '@affine/component/notification-center';
|
||||
import { isDesktop } from '@affine/env/constant';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { refreshRootMetadataAtom } from '@affine/workspace/atom';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useAtom, useSetAtom } from 'jotai';
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import { SessionProvider, useSession } from 'next-auth/react';
|
||||
import { type PropsWithChildren, startTransition, useRef } from 'react';
|
||||
|
||||
import { sessionAtom } from '../atoms/cloud-user';
|
||||
import { useOnceSignedInEvents } from '../atoms/event';
|
||||
|
||||
const SessionReporter = () => {
|
||||
const SessionDefence = (props: PropsWithChildren) => {
|
||||
const session = useSession();
|
||||
const prevSession = useRef<ReturnType<typeof useSession>>();
|
||||
const [sessionInAtom, setSession] = useAtom(sessionAtom);
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
const refreshMetadata = useSetAtom(refreshRootMetadataAtom);
|
||||
const onceSignedInEvents = useOnceSignedInEvents();
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
if (sessionInAtom !== session && session.status === 'authenticated') {
|
||||
setSession(session);
|
||||
}
|
||||
|
||||
if (prevSession.current !== session && session.status !== 'loading') {
|
||||
// unauthenticated -> authenticated
|
||||
if (
|
||||
@ -42,14 +48,13 @@ const SessionReporter = () => {
|
||||
}
|
||||
prevSession.current = session;
|
||||
}
|
||||
return null;
|
||||
return props.children;
|
||||
};
|
||||
|
||||
export const CloudSessionProvider = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<SessionProvider refetchOnWindowFocus>
|
||||
<SessionReporter />
|
||||
{children}
|
||||
<SessionDefence>{children}</SessionDefence>
|
||||
</SessionProvider>
|
||||
);
|
||||
};
|
||||
|
206
apps/core/src/utils/user-setting.ts
Normal file
206
apps/core/src/utils/user-setting.ts
Normal file
@ -0,0 +1,206 @@
|
||||
import type { CollectionsAtom } from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||
import { currentWorkspaceAtom } from '@toeverything/infra/atom';
|
||||
import { type DBSchema, openDB } from 'idb';
|
||||
import { atom } from 'jotai';
|
||||
import { atomWithObservable } from 'jotai/utils';
|
||||
import { Observable } from 'rxjs';
|
||||
import type { Map as YMap } from 'yjs';
|
||||
import { Doc as YDoc } from 'yjs';
|
||||
|
||||
import { sessionAtom } from '../atoms/cloud-user';
|
||||
|
||||
export interface PageCollectionDBV1 extends DBSchema {
|
||||
view: {
|
||||
key: Collection['id'];
|
||||
value: Collection;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StorageCRUD<Value> {
|
||||
get: (key: string) => Promise<Value | null>;
|
||||
set: (key: string, value: Value) => Promise<string>;
|
||||
delete: (key: string) => Promise<void>;
|
||||
list: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
type Subscribe = () => void;
|
||||
|
||||
const collectionDBAtom = atom(
|
||||
openDB<PageCollectionDBV1>('page-view', 1, {
|
||||
upgrade(database) {
|
||||
database.createObjectStore('view', {
|
||||
keyPath: 'id',
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const callbackSet = new Set<Subscribe>();
|
||||
|
||||
const localCollectionCRUDAtom = atom(get => ({
|
||||
get: async (key: string) => {
|
||||
const db = await get(collectionDBAtom);
|
||||
const t = db.transaction('view').objectStore('view');
|
||||
return (await t.get(key)) ?? null;
|
||||
},
|
||||
set: async (key: string, value: Collection) => {
|
||||
const db = await get(collectionDBAtom);
|
||||
const t = db.transaction('view', 'readwrite').objectStore('view');
|
||||
await t.put(value);
|
||||
callbackSet.forEach(cb => cb());
|
||||
return key;
|
||||
},
|
||||
delete: async (key: string) => {
|
||||
const db = await get(collectionDBAtom);
|
||||
const t = db.transaction('view', 'readwrite').objectStore('view');
|
||||
callbackSet.forEach(cb => cb());
|
||||
await t.delete(key);
|
||||
},
|
||||
list: async () => {
|
||||
const db = await get(collectionDBAtom);
|
||||
const t = db.transaction('view').objectStore('view');
|
||||
return t.getAllKeys();
|
||||
},
|
||||
}));
|
||||
|
||||
const getCollections = async (
|
||||
storage: StorageCRUD<Collection>
|
||||
): Promise<Collection[]> => {
|
||||
return storage
|
||||
.list()
|
||||
.then(async keys => {
|
||||
return await Promise.all(keys.map(key => storage.get(key))).then(v =>
|
||||
v.filter((v): v is Collection => v !== null)
|
||||
);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to load collections', error);
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
const pageCollectionBaseAtom = atomWithObservable<Collection[]>(get => {
|
||||
const currentWorkspacePromise = get(currentWorkspaceAtom);
|
||||
const session = get(sessionAtom);
|
||||
const localCRUD = get(localCollectionCRUDAtom);
|
||||
const userId = session?.data?.user.id ?? null;
|
||||
|
||||
const useLocalStorage = userId === null;
|
||||
|
||||
return new Observable<Collection[]>(subscriber => {
|
||||
// initial value
|
||||
subscriber.next([]);
|
||||
if (useLocalStorage) {
|
||||
getCollections(localCRUD).then(collections => {
|
||||
subscriber.next(collections);
|
||||
});
|
||||
const fn = () => {
|
||||
getCollections(localCRUD).then(collections => {
|
||||
subscriber.next(collections);
|
||||
});
|
||||
};
|
||||
callbackSet.add(fn);
|
||||
return () => {
|
||||
callbackSet.delete(fn);
|
||||
};
|
||||
} else {
|
||||
const group = new DisposableGroup();
|
||||
currentWorkspacePromise.then(async currentWorkspace => {
|
||||
const collectionsFromLocal = await getCollections(localCRUD);
|
||||
const rootDoc = currentWorkspace.doc;
|
||||
const settingMap = rootDoc.getMap('settings') as YMap<YDoc>;
|
||||
if (!settingMap.has(userId)) {
|
||||
settingMap.set(
|
||||
userId,
|
||||
new YDoc({
|
||||
guid: `${rootDoc.guid}:settings:${userId}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
const settingDoc = settingMap.get(userId) as YDoc;
|
||||
if (!settingDoc.isLoaded) {
|
||||
settingDoc.load();
|
||||
await settingDoc.whenLoaded;
|
||||
}
|
||||
const viewMap = settingDoc.getMap('view') as YMap<Collection>;
|
||||
// sync local storage to doc
|
||||
collectionsFromLocal.map(v => viewMap.set(v.id, v));
|
||||
// delete from indexeddb
|
||||
Promise.all(
|
||||
collectionsFromLocal.map(async v => {
|
||||
await localCRUD.delete(v.id);
|
||||
})
|
||||
).catch(error => {
|
||||
console.error('Failed to delete collections from indexeddb', error);
|
||||
});
|
||||
const collectionsFromDoc: Collection[] = Array.from(viewMap.keys())
|
||||
.map(key => viewMap.get(key))
|
||||
.filter((v): v is Collection => !!v);
|
||||
const collections = [...collectionsFromDoc];
|
||||
subscriber.next(collections);
|
||||
if (group.disposed) {
|
||||
return;
|
||||
}
|
||||
const fn = () => {
|
||||
const collectionsFromDoc: Collection[] = Array.from(viewMap.keys())
|
||||
.map(key => viewMap.get(key))
|
||||
.filter((v): v is Collection => !!v);
|
||||
const collections = [...collectionsFromLocal, ...collectionsFromDoc];
|
||||
subscriber.next(collections);
|
||||
};
|
||||
viewMap.observe(fn);
|
||||
group.add(() => {
|
||||
viewMap.unobserve(fn);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
group.dispose();
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const currentCollectionsAtom: CollectionsAtom = atom(
|
||||
get => get(pageCollectionBaseAtom),
|
||||
async (get, set, apply) => {
|
||||
const collections = await get(pageCollectionBaseAtom);
|
||||
let newCollections: Collection[];
|
||||
if (typeof apply === 'function') {
|
||||
newCollections = apply(collections);
|
||||
} else {
|
||||
newCollections = apply;
|
||||
}
|
||||
const session = get(sessionAtom);
|
||||
const userId = session?.data?.user.id ?? null;
|
||||
const useLocalStorage = userId === null;
|
||||
const added = newCollections.filter(v => !collections.includes(v));
|
||||
const removed = collections.filter(v => !newCollections.includes(v));
|
||||
if (useLocalStorage) {
|
||||
const localCRUD = get(localCollectionCRUDAtom);
|
||||
await Promise.all([
|
||||
...added.map(async v => {
|
||||
await localCRUD.set(v.id, v);
|
||||
}),
|
||||
...removed.map(async v => {
|
||||
await localCRUD.delete(v.id);
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
const currentWorkspace = await get(currentWorkspaceAtom);
|
||||
const rootDoc = currentWorkspace.doc;
|
||||
const settingMap = rootDoc.getMap('settings') as YMap<YDoc>;
|
||||
const settingDoc = settingMap.get(userId) as YDoc;
|
||||
const viewMap = settingDoc.getMap('view') as YMap<Collection>;
|
||||
await Promise.all([
|
||||
...added.map(async v => {
|
||||
viewMap.set(v.id, v);
|
||||
}),
|
||||
...removed.map(async v => {
|
||||
viewMap.delete(v.id);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
);
|
@ -3,16 +3,30 @@
|
||||
*/
|
||||
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 { useCollectionManager } from '../use-collection-manager';
|
||||
import {
|
||||
type CollectionsAtom,
|
||||
useCollectionManager,
|
||||
} from '../use-collection-manager';
|
||||
|
||||
const defaultMeta = { tags: { options: [] } };
|
||||
|
||||
const baseAtom = atom<Collection[]>([]);
|
||||
|
||||
const mockAtom: CollectionsAtom = atom(
|
||||
get => get(baseAtom),
|
||||
async (get, set, update) => {
|
||||
set(baseAtom, update);
|
||||
}
|
||||
);
|
||||
|
||||
test('useAllPageSetting', async () => {
|
||||
const settingHook = renderHook(() => useCollectionManager('test'));
|
||||
const settingHook = renderHook(() => useCollectionManager(mockAtom));
|
||||
const prevCollection = settingHook.result.current.currentCollection;
|
||||
expect(settingHook.result.current.savedCollections).toEqual([]);
|
||||
await settingHook.result.current.updateCollection({
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { CollectionBar } from '@affine/component/page-list';
|
||||
import {
|
||||
CollectionBar,
|
||||
type CollectionsAtom,
|
||||
} from '@affine/component/page-list';
|
||||
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
@ -36,7 +39,7 @@ interface AllPagesHeadProps {
|
||||
importFile: () => void;
|
||||
getPageInfo: GetPageInfoById;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
workspaceId: string;
|
||||
collectionsAtom: CollectionsAtom;
|
||||
}
|
||||
|
||||
const AllPagesHead = ({
|
||||
@ -47,7 +50,7 @@ const AllPagesHead = ({
|
||||
importFile,
|
||||
getPageInfo,
|
||||
propertiesMeta,
|
||||
workspaceId,
|
||||
collectionsAtom,
|
||||
}: AllPagesHeadProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const titleList = useMemo(
|
||||
@ -147,10 +150,10 @@ const AllPagesHead = ({
|
||||
<TableHead>
|
||||
<TableHeadRow>{tableItem}</TableHeadRow>
|
||||
<CollectionBar
|
||||
workspaceId={workspaceId}
|
||||
columnsCount={titleList.length}
|
||||
getPageInfo={getPageInfo}
|
||||
propertiesMeta={propertiesMeta}
|
||||
collectionsAtom={collectionsAtom}
|
||||
/>
|
||||
</TableHead>
|
||||
);
|
||||
@ -158,7 +161,7 @@ const AllPagesHead = ({
|
||||
|
||||
export const PageList = ({
|
||||
isPublicWorkspace = false,
|
||||
workspaceId,
|
||||
collectionsAtom,
|
||||
list,
|
||||
onCreateNewPage,
|
||||
onCreateNewEdgeless,
|
||||
@ -203,7 +206,7 @@ export const PageList = ({
|
||||
<StyledTableContainer ref={ref}>
|
||||
<Table showBorder={hasScrollTop} style={{ maxHeight: '100%' }}>
|
||||
<AllPagesHead
|
||||
workspaceId={workspaceId}
|
||||
collectionsAtom={collectionsAtom}
|
||||
propertiesMeta={propertiesMeta}
|
||||
isPublicWorkspace={isPublicWorkspace}
|
||||
sorter={sorter}
|
||||
|
@ -1,6 +1,8 @@
|
||||
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
|
||||
@ -15,7 +17,7 @@ export type ListData = {
|
||||
pageId: string;
|
||||
icon: JSX.Element;
|
||||
title: string;
|
||||
preview?: React.ReactNode;
|
||||
preview?: ReactNode;
|
||||
tags: Tag[];
|
||||
favorite: boolean;
|
||||
createDate: Date;
|
||||
@ -34,7 +36,7 @@ export type TrashListData = {
|
||||
pageId: string;
|
||||
icon: JSX.Element;
|
||||
title: string;
|
||||
preview?: React.ReactNode;
|
||||
preview?: ReactNode;
|
||||
createDate: Date;
|
||||
// TODO remove optional after assert that trashDate is always set
|
||||
trashDate?: Date;
|
||||
@ -45,9 +47,9 @@ export type TrashListData = {
|
||||
|
||||
export type PageListProps = {
|
||||
isPublicWorkspace?: boolean;
|
||||
workspaceId: string;
|
||||
collectionsAtom: CollectionsAtom;
|
||||
list: ListData[];
|
||||
fallback?: React.ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onCreateNewPage: () => void;
|
||||
onCreateNewEdgeless: () => void;
|
||||
onImportFile: () => void;
|
||||
@ -59,5 +61,5 @@ export type DraggableTitleCellData = {
|
||||
pageId: string;
|
||||
pageTitle: string;
|
||||
pagePreview?: string;
|
||||
icon: React.ReactElement;
|
||||
icon: ReactElement;
|
||||
};
|
||||
|
@ -1,42 +1,19 @@
|
||||
import type { Collection, Filter, VariableMap } from '@affine/env/filter';
|
||||
import type { DBSchema } from 'idb';
|
||||
import type { IDBPDatabase } from 'idb';
|
||||
import { openDB } from 'idb';
|
||||
import { useAtom } from 'jotai';
|
||||
import { atomWithReset, RESET } from 'jotai/utils';
|
||||
import type { WritableAtom } from 'jotai/vanilla';
|
||||
import { useCallback } from 'react';
|
||||
import useSWRImmutable from 'swr/immutable';
|
||||
import { NIL } from 'uuid';
|
||||
|
||||
import { evalFilterList } from './filter';
|
||||
|
||||
type PersistenceCollection = Collection;
|
||||
|
||||
export interface PageCollectionDBV1 extends DBSchema {
|
||||
view: {
|
||||
key: PersistenceCollection['id'];
|
||||
value: PersistenceCollection;
|
||||
};
|
||||
}
|
||||
|
||||
const pageCollectionDBPromise: Promise<IDBPDatabase<PageCollectionDBV1>> =
|
||||
typeof window === 'undefined'
|
||||
? // never resolve in SSR
|
||||
new Promise<any>(() => {})
|
||||
: openDB<PageCollectionDBV1>('page-view', 1, {
|
||||
upgrade(database) {
|
||||
database.createObjectStore('view', {
|
||||
keyPath: 'id',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const defaultCollection = {
|
||||
id: NIL,
|
||||
name: 'All',
|
||||
filterList: [],
|
||||
workspaceId: 'temporary',
|
||||
};
|
||||
|
||||
const collectionAtom = atomWithReset<{
|
||||
currentId: string;
|
||||
defaultCollection: Collection;
|
||||
@ -45,69 +22,62 @@ const collectionAtom = atomWithReset<{
|
||||
defaultCollection: defaultCollection,
|
||||
});
|
||||
|
||||
export const useSavedCollections = (workspaceId: string) => {
|
||||
const { data: savedCollections, mutate } = useSWRImmutable<Collection[]>(
|
||||
['affine', 'page-collection', workspaceId],
|
||||
{
|
||||
fetcher: async () => {
|
||||
const db = await pageCollectionDBPromise;
|
||||
const t = db.transaction('view').objectStore('view');
|
||||
const all = await t.getAll();
|
||||
return all.filter(v => v.workspaceId === workspaceId);
|
||||
},
|
||||
suspense: true,
|
||||
fallbackData: [],
|
||||
revalidateOnMount: true,
|
||||
}
|
||||
);
|
||||
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;
|
||||
}
|
||||
const db = await pageCollectionDBPromise;
|
||||
const t = db.transaction('view', 'readwrite').objectStore('view');
|
||||
await t.put(collection);
|
||||
await mutate();
|
||||
await setCollections(old => [...old, collection]);
|
||||
},
|
||||
[mutate]
|
||||
[setCollections]
|
||||
);
|
||||
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();
|
||||
await setCollections(old => old.filter(v => v.id !== id));
|
||||
},
|
||||
[mutate]
|
||||
[setCollections]
|
||||
);
|
||||
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 ?? [])],
|
||||
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 ?? [])],
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
[saveCollection, savedCollections]
|
||||
[setCollections]
|
||||
);
|
||||
return {
|
||||
savedCollections: savedCollections ?? [],
|
||||
savedCollections,
|
||||
saveCollection,
|
||||
deleteCollection,
|
||||
addPage,
|
||||
};
|
||||
};
|
||||
|
||||
export const useCollectionManager = (workspaceId: string) => {
|
||||
export const useCollectionManager = (collectionsAtom: CollectionsAtom) => {
|
||||
const { savedCollections, saveCollection, deleteCollection, addPage } =
|
||||
useSavedCollections(workspaceId);
|
||||
useSavedCollections(collectionsAtom);
|
||||
const [collectionData, setCollectionData] = useAtom(collectionAtom);
|
||||
|
||||
const updateCollection = useCallback(
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { EditCollectionModel } from '@affine/component/page-list';
|
||||
import {
|
||||
type CollectionsAtom,
|
||||
EditCollectionModel,
|
||||
} from '@affine/component/page-list';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
@ -15,14 +18,14 @@ import { useActions } from './use-action';
|
||||
interface CollectionBarProps {
|
||||
getPageInfo: GetPageInfoById;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
collectionsAtom: CollectionsAtom;
|
||||
columnsCount: number;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export const CollectionBar = (props: CollectionBarProps) => {
|
||||
const { getPageInfo, propertiesMeta, columnsCount, workspaceId } = props;
|
||||
const { getPageInfo, propertiesMeta, columnsCount, collectionsAtom } = props;
|
||||
const t = useAFFiNEI18N();
|
||||
const setting = useCollectionManager(workspaceId);
|
||||
const setting = useCollectionManager(collectionsAtom);
|
||||
const collection = setting.currentCollection;
|
||||
const [open, setOpen] = useState(false);
|
||||
const actions = useActions({
|
||||
|
@ -139,6 +139,36 @@ test.describe('collaboration', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('can sync collections between different browser', async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
await page.reload();
|
||||
await waitForEditorLoad(page);
|
||||
await createLocalWorkspace(
|
||||
{
|
||||
name: 'test',
|
||||
},
|
||||
page
|
||||
);
|
||||
await enableCloudWorkspace(page);
|
||||
await page.getByTestId('slider-bar-add-collection-button').click();
|
||||
const title = page.getByTestId('input-collection-title');
|
||||
await title.isVisible();
|
||||
await title.fill('test collection');
|
||||
await page.getByTestId('save-collection').click();
|
||||
|
||||
{
|
||||
const context = await browser.newContext();
|
||||
const page2 = await context.newPage();
|
||||
await loginUser(page2, user.email);
|
||||
await page2.goto(page.url());
|
||||
waitForEditorLoad(page2);
|
||||
const collections = page2.getByTestId('collections');
|
||||
await expect(collections.getByText('test collection')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('exit successfully and re-login', async ({ page }) => {
|
||||
await page.reload();
|
||||
await clickSideBarAllPageButton(page);
|
||||
|
Loading…
Reference in New Issue
Block a user