From 5f0605a5d90a2fbb27dc8d01c528b795d879d0d4 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Fri, 8 Sep 2023 15:02:22 -0700 Subject: [PATCH] feat: page view storage with cloud support (#4238) --- apps/core/src/atoms/cloud-user.ts | 4 + .../block-suite-page-list/index.tsx | 3 +- .../collections/add-collection-button.tsx | 3 +- .../collections/collections-list.tsx | 9 +- .../src/components/root-app-sidebar/index.tsx | 3 +- apps/core/src/components/workspace-header.tsx | 5 +- apps/core/src/pages/workspace/all-page.tsx | 3 +- apps/core/src/pages/workspace/detail-page.tsx | 3 +- apps/core/src/providers/session-provider.tsx | 15 +- apps/core/src/utils/user-setting.ts | 206 ++++++++++++++++++ .../__tests__/use-all-page-setting.spec.ts | 18 +- .../src/components/page-list/all-page.tsx | 15 +- .../src/components/page-list/type.ts | 12 +- .../page-list/use-collection-manager.ts | 92 +++----- .../page-list/view/collection-bar.tsx | 11 +- tests/affine-cloud/e2e/collaboration.spec.ts | 30 +++ 16 files changed, 338 insertions(+), 94 deletions(-) create mode 100644 apps/core/src/atoms/cloud-user.ts create mode 100644 apps/core/src/utils/user-setting.ts diff --git a/apps/core/src/atoms/cloud-user.ts b/apps/core/src/atoms/cloud-user.ts new file mode 100644 index 0000000000..2b43c0cf25 --- /dev/null +++ b/apps/core/src/atoms/cloud-user.ts @@ -0,0 +1,4 @@ +import { atom } from 'jotai'; +import type { SessionContextValue } from 'next-auth/react'; + +export const sessionAtom = atom | null>(null); diff --git a/apps/core/src/components/blocksuite/block-suite-page-list/index.tsx b/apps/core/src/components/blocksuite/block-suite-page-list/index.tsx index 57d891e82d..b905d1965d 100644 --- a/apps/core/src/components/blocksuite/block-suite-page-list/index.tsx +++ b/apps/core/src/components/blocksuite/block-suite-page-list/index.tsx @@ -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 ( { 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(); diff --git a/apps/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx b/apps/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx index 28edbb5c8d..f6967a1644 100644 --- a/apps/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx +++ b/apps/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx @@ -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), diff --git a/apps/core/src/components/root-app-sidebar/index.tsx b/apps/core/src/components/root-app-sidebar/index.tsx index 53c96ef2f7..aa9b14f346 100644 --- a/apps/core/src/components/root-app-sidebar/index.tsx +++ b/apps/core/src/components/root-app-sidebar/index.tsx @@ -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 () => { diff --git a/apps/core/src/components/workspace-header.tsx b/apps/core/src/components/workspace-header.tsx index 6b22db1be8..836d39a4f0 100644 --- a/apps/core/src/components/workspace-header.tsx +++ b/apps/core/src/components/workspace-header.tsx @@ -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) { - const setting = useCollectionManager(currentWorkspaceId); const setAppHeader = useSetAtom(appHeaderAtom); const currentWorkspace = useWorkspace(currentWorkspaceId); + const setting = useCollectionManager(currentCollectionsAtom); const getPageInfoById = useGetPageInfoById( currentWorkspace.blockSuiteWorkspace ); diff --git a/apps/core/src/pages/workspace/all-page.tsx b/apps/core/src/pages/workspace/all-page.tsx index 8edba7aad9..bf5ebc9301 100644 --- a/apps/core/src/pages/workspace/all-page.tsx +++ b/apps/core/src/pages/workspace/all-page.tsx @@ -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); diff --git a/apps/core/src/pages/workspace/detail-page.tsx b/apps/core/src/pages/workspace/detail-page.tsx index a68b79c203..33ae305b77 100644 --- a/apps/core/src/pages/workspace/detail-page.tsx +++ b/apps/core/src/pages/workspace/detail-page.tsx @@ -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); diff --git a/apps/core/src/providers/session-provider.tsx b/apps/core/src/providers/session-provider.tsx index 24b093543f..2b1e4d96c0 100644 --- a/apps/core/src/providers/session-provider.tsx +++ b/apps/core/src/providers/session-provider.tsx @@ -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>(); + 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 ( - - {children} + {children} ); }; diff --git a/apps/core/src/utils/user-setting.ts b/apps/core/src/utils/user-setting.ts new file mode 100644 index 0000000000..06122f99d3 --- /dev/null +++ b/apps/core/src/utils/user-setting.ts @@ -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 { + get: (key: string) => Promise; + set: (key: string, value: Value) => Promise; + delete: (key: string) => Promise; + list: () => Promise; +} + +type Subscribe = () => void; + +const collectionDBAtom = atom( + openDB('page-view', 1, { + upgrade(database) { + database.createObjectStore('view', { + keyPath: 'id', + }); + }, + }) +); + +const callbackSet = new Set(); + +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 +): Promise => { + 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(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(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; + 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; + // 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; + const settingDoc = settingMap.get(userId) as YDoc; + const viewMap = settingDoc.getMap('view') as YMap; + await Promise.all([ + ...added.map(async v => { + viewMap.set(v.id, v); + }), + ...removed.map(async v => { + viewMap.delete(v.id); + }), + ]); + } + } +); diff --git a/packages/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts b/packages/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts index ec9c288883..4e083b0954 100644 --- a/packages/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts +++ b/packages/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts @@ -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([]); + +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({ diff --git a/packages/component/src/components/page-list/all-page.tsx b/packages/component/src/components/page-list/all-page.tsx index 0fee81c9e4..6b2aee3935 100644 --- a/packages/component/src/components/page-list/all-page.tsx +++ b/packages/component/src/components/page-list/all-page.tsx @@ -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 = ({ {tableItem} ); @@ -158,7 +161,7 @@ const AllPagesHead = ({ export const PageList = ({ isPublicWorkspace = false, - workspaceId, + collectionsAtom, list, onCreateNewPage, onCreateNewEdgeless, @@ -203,7 +206,7 @@ export const PageList = ({ void; onCreateNewEdgeless: () => void; onImportFile: () => void; @@ -59,5 +61,5 @@ export type DraggableTitleCellData = { pageId: string; pageTitle: string; pagePreview?: string; - icon: React.ReactElement; + icon: ReactElement; }; diff --git a/packages/component/src/components/page-list/use-collection-manager.ts b/packages/component/src/components/page-list/use-collection-manager.ts index 68f21146a1..1744952f2f 100644 --- a/packages/component/src/components/page-list/use-collection-manager.ts +++ b/packages/component/src/components/page-list/use-collection-manager.ts @@ -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> = - typeof window === 'undefined' - ? // never resolve in SSR - new Promise(() => {}) - : openDB('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( - ['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[])], + Promise +>; + +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( diff --git a/packages/component/src/components/page-list/view/collection-bar.tsx b/packages/component/src/components/page-list/view/collection-bar.tsx index 5ace27b31a..b2c8257102 100644 --- a/packages/component/src/components/page-list/view/collection-bar.tsx +++ b/packages/component/src/components/page-list/view/collection-bar.tsx @@ -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({ diff --git a/tests/affine-cloud/e2e/collaboration.spec.ts b/tests/affine-cloud/e2e/collaboration.spec.ts index 1250adf246..607f541c00 100644 --- a/tests/affine-cloud/e2e/collaboration.spec.ts +++ b/tests/affine-cloud/e2e/collaboration.spec.ts @@ -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);