refactor: extract store package (#1109)

This commit is contained in:
Himself65
2023-02-18 02:41:22 -06:00
committed by GitHub
parent af28418e61
commit 9d21c3efbb
34 changed files with 681 additions and 337 deletions

View File

@@ -0,0 +1,18 @@
{
"name": "@affine/store",
"private": true,
"main": "./src/index.ts",
"dependencies": {
"@affine/datacenter": "workspace:*",
"@blocksuite/blocks": "0.4.0-20230217095654-a561b36",
"@blocksuite/editor": "0.4.0-20230217095654-a561b36",
"@blocksuite/global": "0.4.0-20230217095654-a561b36",
"@blocksuite/react": "0.4.0-20230217095654-a561b36",
"@blocksuite/store": "0.4.0-20230217095654-a561b36",
"react": "^18.2.0",
"zustand": "^4.3.3"
},
"devDependencies": {
"@types/react": "^18.0.28"
}
}

View File

@@ -0,0 +1,61 @@
import { BlockHub } from '@blocksuite/blocks';
import { EditorContainer } from '@blocksuite/editor';
import { Page, Workspace } from '@blocksuite/store';
import { GlobalActionsCreator } from '..';
export interface BlockSuiteState {
currentWorkspace: Workspace | null;
editor: EditorContainer | null;
currentPage: Page | null;
blockHub: BlockHub | null;
}
export const createBlockSuiteState = (): BlockSuiteState => ({
currentWorkspace: null,
currentPage: null,
blockHub: null,
editor: null,
});
export interface BlockSuiteActions {
loadPage: (pageId: string) => void;
setEditor: (editor: EditorContainer) => void;
setWorkspace: (workspace: Workspace) => void;
setBlockHub: (blockHub: BlockHub) => void;
}
export const createBlockSuiteActions: GlobalActionsCreator<
BlockSuiteActions
> = (set, get) => ({
setWorkspace: workspace => {
set({
currentWorkspace: workspace,
});
},
setEditor: editor => {
set({
editor,
});
},
loadPage: pageId => {
const { currentWorkspace } = get();
if (currentWorkspace === null) {
console.warn('currentWorkspace is null');
return;
}
const page = currentWorkspace.getPage(pageId);
if (page === null) {
console.warn('cannot find page ', pageId);
return;
}
set({
currentPage: page,
});
},
setBlockHub: blockHub => {
set({
blockHub,
});
},
});

View File

@@ -0,0 +1,193 @@
import { getDataCenter, WorkspaceUnit } from '@affine/datacenter';
import { DataCenter } from '@affine/datacenter';
import { Disposable, DisposableGroup } from '@blocksuite/global/utils';
import type { PageMeta as StorePageMeta } from '@blocksuite/store';
import React, { useCallback, useEffect } from 'react';
const DEFAULT_WORKSPACE_NAME = 'Demo Workspace';
export const createDefaultWorkspace = async (dataCenter: DataCenter) => {
return dataCenter.createWorkspace({
name: DEFAULT_WORKSPACE_NAME,
});
};
export interface PageMeta extends StorePageMeta {
favorite: boolean;
trash: boolean;
trashDate: number;
updatedDate: number;
mode: 'edgeless' | 'page';
}
import { GlobalActionsCreator, useGlobalState, useGlobalStateApi } from '..';
export type DataCenterState = {
readonly dataCenter: DataCenter;
readonly dataCenterPromise: Promise<DataCenter>;
currentDataCenterWorkspace: WorkspaceUnit | null;
dataCenterPageList: PageMeta[];
blobDataSynced: boolean;
};
export type DataCenterActions = {
loadWorkspace: (
workspaceId: string,
signal?: AbortSignal
) => Promise<WorkspaceUnit | null>;
};
export const createDataCenterState = (): DataCenterState => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
dataCenter: null!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
dataCenterPromise: null!,
currentDataCenterWorkspace: null,
dataCenterPageList: [],
blobDataSynced: false,
});
export const createDataCenterActions: GlobalActionsCreator<
DataCenterActions
> = (set, get) => ({
loadWorkspace: async (workspaceId, signal) => {
const { dataCenter, currentDataCenterWorkspace } = get();
if (!dataCenter.workspaces.find(v => v.id.toString() === workspaceId)) {
return null;
}
if (workspaceId === currentDataCenterWorkspace?.id) {
return currentDataCenterWorkspace;
}
const workspace = (await dataCenter.loadWorkspace(workspaceId)) ?? null;
if (signal?.aborted) {
// do not update state if aborted
return null;
}
let isOwner;
if (workspace?.provider === 'local') {
// isOwner is useful only in the cloud
isOwner = true;
} else {
const userInfo = get().user; // We must ensure workspace.owner exists, then ensure id same.
isOwner = userInfo?.id === workspace?.owner?.id;
}
const pageList =
(workspace?.blocksuiteWorkspace?.meta.pageMetas as PageMeta[]) ?? [];
if (workspace?.blocksuiteWorkspace) {
set({
currentWorkspace: workspace.blocksuiteWorkspace,
});
}
set({
isOwner,
currentDataCenterWorkspace: workspace,
dataCenterPageList: pageList,
});
return workspace;
},
});
export function DataCenterPreloader({ children }: React.PropsWithChildren) {
const dataCenter = useGlobalState(useCallback(store => store.dataCenter, []));
const dataCenterPromise = useGlobalState(
useCallback(store => store.dataCenterPromise, [])
);
const api = useGlobalStateApi();
//# region effect for updating workspace page list
useEffect(() => {
return api.subscribe(
store => store.currentDataCenterWorkspace,
currentWorkspace => {
const disposableGroup = new DisposableGroup();
disposableGroup.add(
currentWorkspace?.blocksuiteWorkspace?.meta.pagesUpdated.on(() => {
if (
Array.isArray(
currentWorkspace.blocksuiteWorkspace?.meta.pageMetas
)
) {
api.setState({
dataCenterPageList: currentWorkspace.blocksuiteWorkspace?.meta
.pageMetas as PageMeta[],
});
}
})
);
return () => {
disposableGroup.dispose();
};
}
);
}, [api]);
//# endregion
//# region effect for blobDataSynced
useEffect(
() =>
api.subscribe(
store => store.currentDataCenterWorkspace,
workspace => {
if (!workspace?.blocksuiteWorkspace) {
return;
}
const controller = new AbortController();
const blocksuiteWorkspace = workspace.blocksuiteWorkspace;
let syncChangeDisposable: Disposable | undefined;
async function subscribe() {
const blobStorage = await blocksuiteWorkspace.blobs;
if (controller.signal.aborted) {
return;
}
syncChangeDisposable =
blobStorage?.signals.onBlobSyncStateChange.on(() => {
if (controller.signal.aborted) {
syncChangeDisposable?.dispose();
return;
} else {
api.setState({
blobDataSynced: blobStorage?.uploading,
});
}
});
}
subscribe();
return () => {
controller.abort();
syncChangeDisposable?.dispose();
};
}
),
[api]
);
//# endregion
if (!dataCenter && !dataCenterPromise) {
const promise = getDataCenter();
api.setState({ dataCenterPromise: promise });
promise.then(async dataCenter => {
// Ensure datacenter has at least one workspace
if (dataCenter.workspaces.length === 0) {
await createDefaultWorkspace(dataCenter);
}
// set initial state
api.setState({
dataCenter,
currentWorkspace: null,
currentDataCenterWorkspace: null,
dataCenterPageList: [],
user:
(await dataCenter.getUserInfo(
dataCenter.providers.filter(p => p.id !== 'local')[0]?.id
)) || null,
});
});
throw promise;
}
if (!dataCenter) {
throw dataCenterPromise;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,90 @@
import type React from 'react';
import { createContext, useContext, useMemo } from 'react';
import { createStore, StateCreator, useStore } from 'zustand';
import { combine, subscribeWithSelector } from 'zustand/middleware';
import type { UseBoundStore } from 'zustand/react';
import {
BlockSuiteActions,
BlockSuiteState,
createBlockSuiteActions,
createBlockSuiteState,
} from './blocksuite';
import {
createDataCenterActions,
createDataCenterState,
DataCenterActions,
DataCenterState,
} from './datacenter';
import {
createUserActions,
createUserState,
UserActions,
UserState,
} from './user';
export type GlobalActionsCreator<Actions, Store = GlobalState> = StateCreator<
Store,
[['zustand/subscribeWithSelector', unknown]],
[],
Actions
>;
export interface GlobalState
extends BlockSuiteState,
UserState,
DataCenterState {}
export interface GlobalActions
extends BlockSuiteActions,
UserActions,
DataCenterActions {}
const create = () =>
createStore(
subscribeWithSelector(
combine<GlobalState, GlobalActions>(
{
...createBlockSuiteState(),
...createUserState(),
...createDataCenterState(),
},
/* deepscan-disable TOO_MANY_ARGS */
(set, get, api) => ({
...createBlockSuiteActions(set, get, api),
...createUserActions(set, get, api),
...createDataCenterActions(set, get, api),
})
/* deepscan-enable TOO_MANY_ARGS */
)
)
);
type Store = ReturnType<typeof create>;
const GlobalStateContext = createContext<Store | null>(null);
export const useGlobalStateApi = () => {
const api = useContext(GlobalStateContext);
if (!api) {
throw new Error('cannot find modal context');
}
return api;
};
export const useGlobalState: UseBoundStore<Store> = ((
selector: Parameters<UseBoundStore<Store>>[0],
equals: Parameters<UseBoundStore<Store>>[1]
) => {
const api = useGlobalStateApi();
return useStore(api, selector, equals);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any;
export const GlobalAppProvider: React.FC<React.PropsWithChildren> =
function ModelProvider({ children }) {
return (
<GlobalStateContext.Provider value={useMemo(() => create(), [])}>
{children}
</GlobalStateContext.Provider>
);
};

View File

@@ -0,0 +1,57 @@
import { User } from '@affine/datacenter';
import { GlobalActionsCreator } from '..';
export interface UserState {
user: User | null;
isOwner: boolean;
}
export interface UserActions {
login: () => Promise<User | null>;
logout: () => Promise<void>;
}
export const createUserState = (): UserState => ({
// initialized in DataCenterLoader (restore from localStorage)
user: null,
isOwner: false,
});
export const createUserActions: GlobalActionsCreator<UserActions> = (
set,
get
) => {
return {
login: async () => {
const { dataCenter, currentDataCenterWorkspace: workspace } = get();
try {
await dataCenter.login();
const user = (await dataCenter.getUserInfo()) as User;
if (!user) {
// Add ErrorBoundary
throw new Error('User info not found');
}
let isOwner;
if (workspace?.provider === 'local') {
// isOwner is useful only in the cloud
isOwner = true;
} else {
isOwner = user.id === workspace?.owner?.id;
}
set({ user, isOwner });
return user;
} catch (error) {
return null; // login failed
}
},
logout: async () => {
const { dataCenter } = get();
await dataCenter.logout();
set({ user: null });
},
};
};

View File

@@ -0,0 +1,3 @@
export * from './app';
export type { PageMeta } from './app/datacenter';
export { createDefaultWorkspace, DataCenterPreloader } from './app/datacenter';

View File

@@ -0,0 +1,53 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import { DataCenter, getDataCenter } from '@affine/datacenter';
import {
createDefaultWorkspace,
DataCenterPreloader,
GlobalAppProvider,
useGlobalState,
useGlobalStateApi,
} from '@affine/store';
import { render } from '@testing-library/react';
import React from 'react';
import { describe, expect, test } from 'vitest';
describe('App Store', () => {
test('init', async () => {
const dataCenterPromise = getDataCenter();
const dataCenter = await dataCenterPromise;
await createDefaultWorkspace(dataCenter);
const Inner = () => {
const state = useGlobalState();
expect(state).toBeTypeOf('object');
expect(state.dataCenter).toBeInstanceOf(DataCenter);
expect(state.dataCenterPromise).toBeInstanceOf(Promise);
state.dataCenterPromise.then(dc => expect(dc).toBe(state.dataCenter));
return <div>Test2</div>;
};
const Loader = ({ children }: React.PropsWithChildren) => {
const api = useGlobalStateApi();
if (!api.getState().dataCenter) {
api.setState({
dataCenter,
dataCenterPromise,
});
}
return <>{children}</>;
};
const App = () => (
<GlobalAppProvider>
<div>Test1</div>
<Loader>
<Inner />
</Loader>
</GlobalAppProvider>
);
const app = render(<App />);
app.getByText('Test2');
});
});