mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-12 15:46:29 +08:00
refactor!: next generation AFFiNE code structure (#1176)
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { Unreachable } from '../../components/affine/affine-error-eoundary';
|
||||
import {
|
||||
AffineRemoteUnSyncedWorkspace,
|
||||
RemWorkspaceFlavour,
|
||||
transformToAffineSyncedWorkspace,
|
||||
} from '../../shared';
|
||||
import { apis } from '../../shared/apis';
|
||||
import { createEmptyBlockSuiteWorkspace } from '../../utils';
|
||||
|
||||
type Query = (typeof QueryKey)[keyof typeof QueryKey];
|
||||
|
||||
export const fetcher = async (
|
||||
query:
|
||||
| Query
|
||||
| [Query, string, boolean]
|
||||
| [Query, string]
|
||||
| [Query, string, string]
|
||||
) => {
|
||||
if (query === QueryKey.getUser) {
|
||||
return apis.auth.user ?? null;
|
||||
}
|
||||
if (Array.isArray(query)) {
|
||||
if (query[0] === QueryKey.downloadWorkspace) {
|
||||
if (typeof query[2] !== 'boolean') {
|
||||
throw new Unreachable();
|
||||
}
|
||||
return apis.downloadWorkspace(query[1], query[2]);
|
||||
} else if (query[0] === QueryKey.getMembers) {
|
||||
return apis.getWorkspaceMembers({
|
||||
id: query[1],
|
||||
});
|
||||
} else if (query[0] === QueryKey.getUserByEmail) {
|
||||
if (typeof query[2] !== 'string') {
|
||||
throw new Unreachable();
|
||||
}
|
||||
return apis.getUserByEmail({
|
||||
workspace_id: query[1],
|
||||
email: query[2],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (query === QueryKey.getWorkspaces) {
|
||||
return apis.getWorkspaces().then(workspaces => {
|
||||
return workspaces.map(workspace => {
|
||||
const remWorkspace: AffineRemoteUnSyncedWorkspace = {
|
||||
...workspace,
|
||||
flavour: RemWorkspaceFlavour.AFFINE,
|
||||
firstBinarySynced: false,
|
||||
blockSuiteWorkspace: createEmptyBlockSuiteWorkspace(
|
||||
workspace.id,
|
||||
(k: string) =>
|
||||
// fixme: token could be expired
|
||||
({ api: '/api/workspace', token: apis.auth.token }[k])
|
||||
),
|
||||
syncBinary: async () => {
|
||||
const binary = await apis.downloadWorkspace(
|
||||
workspace.id,
|
||||
workspace.public
|
||||
);
|
||||
if (remWorkspace.firstBinarySynced) {
|
||||
return null;
|
||||
}
|
||||
return transformToAffineSyncedWorkspace(remWorkspace, binary);
|
||||
},
|
||||
};
|
||||
return remWorkspace;
|
||||
});
|
||||
});
|
||||
}
|
||||
return (apis as any)[query]();
|
||||
}
|
||||
};
|
||||
|
||||
export const QueryKey = {
|
||||
getUser: 'getUser',
|
||||
getWorkspaces: 'getWorkspaces',
|
||||
downloadWorkspace: 'downloadWorkspace',
|
||||
getMembers: 'getMembers',
|
||||
getUserByEmail: 'getUserByEmail',
|
||||
} as const;
|
||||
@@ -0,0 +1,198 @@
|
||||
import { assertEquals } from '@blocksuite/store';
|
||||
import React from 'react';
|
||||
import { preload } from 'swr';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createAffineProviders } from '../../blocksuite';
|
||||
import { PageNotFoundError } from '../../components/affine/affine-error-eoundary';
|
||||
import { WorkspaceSettingDetail } from '../../components/affine/workspace-setting-detail';
|
||||
import { BlockSuitePageList } from '../../components/blocksuite/block-suite-page-list';
|
||||
import { PageDetailEditor } from '../../components/page-detail-editor';
|
||||
import {
|
||||
AffineRemoteSyncedWorkspace,
|
||||
AffineRemoteUnSyncedWorkspace,
|
||||
BlockSuiteWorkspace,
|
||||
LoadPriority,
|
||||
RemWorkspaceFlavour,
|
||||
} from '../../shared';
|
||||
import { apis } from '../../shared/apis';
|
||||
import { createEmptyBlockSuiteWorkspace } from '../../utils';
|
||||
import { WorkspacePlugin } from '..';
|
||||
import { fetcher, QueryKey } from './fetcher';
|
||||
|
||||
const kAffineLocal = 'affine-local-storage';
|
||||
const schema = z.object({
|
||||
id: z.string(),
|
||||
firstBinarySynced: z.boolean(),
|
||||
type: z.number(),
|
||||
public: z.boolean(),
|
||||
permission: z.number(),
|
||||
create_at: z.number(),
|
||||
});
|
||||
|
||||
export const AffinePlugin: WorkspacePlugin<RemWorkspaceFlavour.AFFINE> = {
|
||||
flavour: RemWorkspaceFlavour.AFFINE,
|
||||
loadPriority: LoadPriority.HIGH,
|
||||
createWorkspace: async (blockSuiteWorkspace: BlockSuiteWorkspace) => {
|
||||
const binary = BlockSuiteWorkspace.Y.encodeStateAsUpdate(
|
||||
blockSuiteWorkspace.doc
|
||||
);
|
||||
const { id } = await apis.createWorkspace(new Blob([binary.buffer]));
|
||||
return id;
|
||||
},
|
||||
deleteWorkspace: async workspace => {
|
||||
await apis.deleteWorkspace({
|
||||
id: workspace.id,
|
||||
});
|
||||
if (workspace.firstBinarySynced) {
|
||||
workspace.providers.forEach(p => p.cleanup());
|
||||
}
|
||||
},
|
||||
prefetchData: async dataCenter => {
|
||||
if (localStorage.getItem(kAffineLocal)) {
|
||||
const localData = JSON.parse(localStorage.getItem(kAffineLocal) || '[]');
|
||||
if (Array.isArray(localData)) {
|
||||
const workspacesDump = localData
|
||||
.map((item: any) => {
|
||||
const result = schema.safeParse(item);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as z.infer<typeof schema>[];
|
||||
const workspaces = workspacesDump.map(workspace => {
|
||||
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(
|
||||
workspace.id,
|
||||
(k: string) =>
|
||||
// fixme: token could be expired
|
||||
({ api: '/api/workspace', token: apis.auth.token }[k])
|
||||
);
|
||||
const affineWorkspace: AffineRemoteSyncedWorkspace = {
|
||||
...workspace,
|
||||
firstBinarySynced: true,
|
||||
blockSuiteWorkspace,
|
||||
providers: [...createAffineProviders(blockSuiteWorkspace)],
|
||||
flavour: RemWorkspaceFlavour.AFFINE,
|
||||
syncBinary: async () => {
|
||||
return { ...affineWorkspace };
|
||||
},
|
||||
};
|
||||
return affineWorkspace;
|
||||
});
|
||||
|
||||
// fixme: refactor to a function
|
||||
workspaces.forEach(workspace => {
|
||||
const exist = dataCenter.workspaces.findIndex(
|
||||
ws => ws.id === workspace.id
|
||||
);
|
||||
if (exist !== -1) {
|
||||
dataCenter.workspaces.splice(exist, 1, workspace);
|
||||
dataCenter.workspaces = [...dataCenter.workspaces];
|
||||
} else {
|
||||
dataCenter.workspaces = [...dataCenter.workspaces, workspace];
|
||||
}
|
||||
});
|
||||
dataCenter.callbacks.forEach(cb => cb());
|
||||
} else {
|
||||
localStorage.removeItem(kAffineLocal);
|
||||
}
|
||||
}
|
||||
const promise: Promise<AffineRemoteUnSyncedWorkspace[]> = preload(
|
||||
QueryKey.getWorkspaces,
|
||||
fetcher
|
||||
);
|
||||
return promise
|
||||
.then(async workspaces => {
|
||||
const promises = workspaces.map(workspace => {
|
||||
assertEquals(workspace.flavour, RemWorkspaceFlavour.AFFINE);
|
||||
if (!workspace.firstBinarySynced) {
|
||||
return workspace.syncBinary();
|
||||
}
|
||||
return workspace;
|
||||
});
|
||||
return Promise.all(promises)
|
||||
.then(workspaces => {
|
||||
workspaces.forEach(workspace => {
|
||||
if (workspace === null) {
|
||||
return;
|
||||
}
|
||||
const exist = dataCenter.workspaces.findIndex(
|
||||
ws => ws.id === workspace.id
|
||||
);
|
||||
if (exist !== -1) {
|
||||
dataCenter.workspaces.splice(exist, 1, workspace);
|
||||
dataCenter.workspaces = [...dataCenter.workspaces];
|
||||
} else {
|
||||
dataCenter.workspaces = [...dataCenter.workspaces, workspace];
|
||||
}
|
||||
});
|
||||
return workspaces;
|
||||
})
|
||||
.then(ws => {
|
||||
const workspaces = ws.filter(
|
||||
Boolean
|
||||
) as AffineRemoteSyncedWorkspace[];
|
||||
const dump = workspaces.map(workspace => {
|
||||
return {
|
||||
id: workspace.id,
|
||||
firstBinarySynced: workspace.firstBinarySynced,
|
||||
type: workspace.type,
|
||||
public: workspace.public,
|
||||
permission: workspace.permission,
|
||||
create_at: workspace.create_at,
|
||||
} satisfies z.infer<typeof schema>;
|
||||
});
|
||||
localStorage.setItem(kAffineLocal, JSON.stringify(dump));
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
},
|
||||
PageDetail: ({ currentWorkspace, currentPageId }) => {
|
||||
if (!currentWorkspace.firstBinarySynced) {
|
||||
return <div>Loading</div>;
|
||||
}
|
||||
const page = currentWorkspace.blockSuiteWorkspace.getPage(currentPageId);
|
||||
if (!page) {
|
||||
throw new PageNotFoundError(
|
||||
currentWorkspace.blockSuiteWorkspace,
|
||||
currentPageId
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<PageDetailEditor
|
||||
pageId={currentPageId}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
PageList: ({ blockSuiteWorkspace, onOpenPage }) => {
|
||||
return (
|
||||
<BlockSuitePageList
|
||||
onOpenPage={onOpenPage}
|
||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||
/>
|
||||
);
|
||||
},
|
||||
SettingsDetail: ({
|
||||
currentWorkspace,
|
||||
onChangeTab,
|
||||
currentTab,
|
||||
onDeleteWorkspace,
|
||||
onTransformWorkspace,
|
||||
}) => {
|
||||
return (
|
||||
<WorkspaceSettingDetail
|
||||
onDeleteWorkspace={onDeleteWorkspace}
|
||||
onChangeTab={onChangeTab}
|
||||
currentTab={currentTab}
|
||||
workspace={currentWorkspace}
|
||||
onTransferWorkspace={onTransformWorkspace}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { assertExists } from '@blocksuite/store';
|
||||
import React from 'react';
|
||||
|
||||
import { refreshDataCenter } from '../hooks/use-workspaces';
|
||||
import {
|
||||
BlockSuiteWorkspace,
|
||||
FlavourToWorkspace,
|
||||
LoadPriority,
|
||||
RemWorkspace,
|
||||
RemWorkspaceFlavour,
|
||||
SettingPanel,
|
||||
} from '../shared';
|
||||
import { AffinePlugin } from './affine';
|
||||
import { LocalPlugin } from './local';
|
||||
|
||||
type UIBaseProps<Flavour extends RemWorkspaceFlavour> = {
|
||||
currentWorkspace: FlavourToWorkspace[Flavour];
|
||||
};
|
||||
|
||||
type SettingProps<Flavour extends RemWorkspaceFlavour> =
|
||||
UIBaseProps<Flavour> & {
|
||||
currentTab: SettingPanel;
|
||||
onChangeTab: (tab: SettingPanel) => void;
|
||||
onDeleteWorkspace: () => void;
|
||||
onTransformWorkspace: (targetWorkspaceId: string) => void;
|
||||
};
|
||||
|
||||
type PageDetailProps<Flavour extends RemWorkspaceFlavour> =
|
||||
UIBaseProps<Flavour> & {
|
||||
currentPageId: string;
|
||||
};
|
||||
|
||||
type PageListProps<Flavour extends RemWorkspaceFlavour> = {
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
onOpenPage: (pageId: string, newTab?: boolean) => void;
|
||||
};
|
||||
|
||||
type SideBarMenuProps<Flavour extends RemWorkspaceFlavour> =
|
||||
UIBaseProps<Flavour> & {
|
||||
setSideBarOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export interface WorkspacePlugin<Flavour extends RemWorkspaceFlavour> {
|
||||
flavour: Flavour;
|
||||
// Plugin will be loaded according to the priority
|
||||
loadPriority: LoadPriority;
|
||||
// Fetch necessary data for the first render
|
||||
prefetchData: (
|
||||
dataCenter: {
|
||||
workspaces: RemWorkspace[];
|
||||
callbacks: Set<() => void>;
|
||||
},
|
||||
signal?: AbortSignal
|
||||
) => Promise<void>;
|
||||
|
||||
createWorkspace: (
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace
|
||||
) => Promise<string>;
|
||||
|
||||
deleteWorkspace: (workspace: FlavourToWorkspace[Flavour]) => Promise<void>;
|
||||
|
||||
//#region UI
|
||||
PageDetail: React.FC<PageDetailProps<Flavour>>;
|
||||
PageList: React.FC<PageListProps<Flavour>>;
|
||||
SettingsDetail: React.FC<SettingProps<Flavour>>;
|
||||
//#endregion
|
||||
}
|
||||
|
||||
export const WorkspacePlugins = {
|
||||
[RemWorkspaceFlavour.AFFINE]: AffinePlugin,
|
||||
[RemWorkspaceFlavour.LOCAL]: LocalPlugin,
|
||||
} satisfies {
|
||||
[Key in RemWorkspaceFlavour]: WorkspacePlugin<Key>;
|
||||
};
|
||||
|
||||
export async function transformWorkspace<
|
||||
From extends RemWorkspaceFlavour,
|
||||
To extends RemWorkspaceFlavour
|
||||
>(from: From, to: To, workspace: FlavourToWorkspace[From]): Promise<string> {
|
||||
// fixme: type cast
|
||||
await WorkspacePlugins[from].deleteWorkspace(workspace as any);
|
||||
const newId = await WorkspacePlugins[to].createWorkspace(
|
||||
workspace.blockSuiteWorkspace
|
||||
);
|
||||
// refresh the data center
|
||||
dataCenter.workspaces = [];
|
||||
await refreshDataCenter();
|
||||
assertExists(dataCenter.workspaces.some(w => w.id === newId));
|
||||
return newId;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { assertEquals, uuidv4 } from '@blocksuite/store';
|
||||
import React from 'react';
|
||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||
|
||||
import { createLocalProviders } from '../../blocksuite';
|
||||
import { PageNotFoundError } from '../../components/affine/affine-error-eoundary';
|
||||
import { WorkspaceSettingDetail } from '../../components/affine/workspace-setting-detail';
|
||||
import { BlockSuitePageList } from '../../components/blocksuite/block-suite-page-list';
|
||||
import { PageDetailEditor } from '../../components/page-detail-editor';
|
||||
import {
|
||||
LoadPriority,
|
||||
LocalWorkspace,
|
||||
RemWorkspaceFlavour,
|
||||
} from '../../shared';
|
||||
import { config } from '../../shared/env';
|
||||
import { createEmptyBlockSuiteWorkspace } from '../../utils';
|
||||
import { WorkspacePlugin } from '..';
|
||||
|
||||
export const kStoreKey = 'affine-local-workspace';
|
||||
|
||||
export const LocalPlugin: WorkspacePlugin<RemWorkspaceFlavour.LOCAL> = {
|
||||
flavour: RemWorkspaceFlavour.LOCAL,
|
||||
loadPriority: LoadPriority.LOW,
|
||||
createWorkspace: async blockSuiteWorkspace => {
|
||||
let ids: string[] = [];
|
||||
try {
|
||||
ids = JSON.parse(localStorage.getItem(kStoreKey) ?? '[]');
|
||||
if (!Array.isArray(ids)) {
|
||||
localStorage.setItem(kStoreKey, '[]');
|
||||
ids = [];
|
||||
}
|
||||
} catch (e) {
|
||||
localStorage.setItem(kStoreKey, '[]');
|
||||
ids = [];
|
||||
}
|
||||
const id = uuidv4();
|
||||
const persistence = new IndexeddbPersistence(id, blockSuiteWorkspace.doc);
|
||||
await persistence.whenSynced.then(() => {
|
||||
persistence.destroy();
|
||||
});
|
||||
ids.push(id);
|
||||
localStorage.setItem(kStoreKey, JSON.stringify(ids));
|
||||
return id;
|
||||
},
|
||||
deleteWorkspace: async workspace => {
|
||||
const id = workspace.id;
|
||||
let ids: string[];
|
||||
try {
|
||||
ids = JSON.parse(localStorage.getItem(kStoreKey) ?? '[]');
|
||||
if (!Array.isArray(ids)) {
|
||||
localStorage.setItem(kStoreKey, '[]');
|
||||
ids = [];
|
||||
}
|
||||
} catch (e) {
|
||||
localStorage.setItem(kStoreKey, '[]');
|
||||
ids = [];
|
||||
}
|
||||
const idx = ids.findIndex(x => x === id);
|
||||
if (idx === -1) {
|
||||
throw new Error('cannot find local workspace from localStorage');
|
||||
}
|
||||
workspace.providers.forEach(p => p.cleanup());
|
||||
ids.splice(idx, 1);
|
||||
assertEquals(
|
||||
ids.every(id => typeof id === 'string'),
|
||||
true
|
||||
);
|
||||
localStorage.setItem(kStoreKey, JSON.stringify(ids));
|
||||
},
|
||||
prefetchData: async (dataCenter, signal) => {
|
||||
if (typeof window === 'undefined') {
|
||||
// SSR mode, no local data
|
||||
return;
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
let ids: string[];
|
||||
try {
|
||||
ids = JSON.parse(localStorage.getItem(kStoreKey) ?? '[]');
|
||||
if (!Array.isArray(ids)) {
|
||||
localStorage.setItem(kStoreKey, '[]');
|
||||
ids = [];
|
||||
}
|
||||
} catch (e) {
|
||||
localStorage.setItem(kStoreKey, '[]');
|
||||
ids = [];
|
||||
}
|
||||
if (config.enableIndexedDBProvider) {
|
||||
const workspaces = await Promise.all(
|
||||
ids.map(id => {
|
||||
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(id);
|
||||
const workspace: LocalWorkspace = {
|
||||
id,
|
||||
flavour: RemWorkspaceFlavour.LOCAL,
|
||||
blockSuiteWorkspace: blockSuiteWorkspace,
|
||||
providers: [...createLocalProviders(blockSuiteWorkspace)],
|
||||
syncBinary: async () => {
|
||||
if (!config.enableIndexedDBProvider) {
|
||||
return {
|
||||
...workspace,
|
||||
};
|
||||
}
|
||||
const persistence = new IndexeddbPersistence(
|
||||
blockSuiteWorkspace.room as string,
|
||||
blockSuiteWorkspace.doc
|
||||
);
|
||||
return persistence.whenSynced.then(() => {
|
||||
persistence.destroy();
|
||||
return {
|
||||
...workspace,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
return workspace.syncBinary();
|
||||
})
|
||||
);
|
||||
workspaces.forEach(workspace => {
|
||||
if (workspace) {
|
||||
const exist = dataCenter.workspaces.findIndex(
|
||||
w => w.id === workspace.id
|
||||
);
|
||||
if (exist === -1) {
|
||||
dataCenter.workspaces = [...dataCenter.workspaces, workspace];
|
||||
} else {
|
||||
dataCenter.workspaces[exist] = workspace;
|
||||
dataCenter.workspaces = [...dataCenter.workspaces];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (dataCenter.workspaces.length === 0) {
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
console.info('no local workspace found, create a new one');
|
||||
const workspaceId = uuidv4();
|
||||
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(workspaceId);
|
||||
blockSuiteWorkspace.meta.setName('Untitled Workspace');
|
||||
localStorage.setItem(kStoreKey, JSON.stringify([workspaceId]));
|
||||
blockSuiteWorkspace.createPage(uuidv4());
|
||||
const workspace: LocalWorkspace = {
|
||||
id: workspaceId,
|
||||
flavour: RemWorkspaceFlavour.LOCAL,
|
||||
blockSuiteWorkspace: blockSuiteWorkspace,
|
||||
providers: [...createLocalProviders(blockSuiteWorkspace)],
|
||||
syncBinary: async () => {
|
||||
if (!config.enableIndexedDBProvider) {
|
||||
return {
|
||||
...workspace,
|
||||
};
|
||||
}
|
||||
const persistence = new IndexeddbPersistence(
|
||||
blockSuiteWorkspace.room as string,
|
||||
blockSuiteWorkspace.doc
|
||||
);
|
||||
return persistence.whenSynced.then(() => {
|
||||
persistence.destroy();
|
||||
return {
|
||||
...workspace,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
await workspace.syncBinary();
|
||||
if (signal?.aborted) {
|
||||
const persistence = new IndexeddbPersistence(
|
||||
blockSuiteWorkspace.room as string,
|
||||
blockSuiteWorkspace.doc
|
||||
);
|
||||
await persistence.clearData();
|
||||
return;
|
||||
}
|
||||
dataCenter.workspaces = [workspace];
|
||||
}
|
||||
},
|
||||
PageDetail: ({ currentWorkspace, currentPageId }) => {
|
||||
const page = currentWorkspace.blockSuiteWorkspace.getPage(currentPageId);
|
||||
if (!page) {
|
||||
throw new PageNotFoundError(
|
||||
currentWorkspace.blockSuiteWorkspace,
|
||||
currentPageId
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<PageDetailEditor
|
||||
pageId={currentPageId}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
PageList: ({ blockSuiteWorkspace, onOpenPage }) => {
|
||||
return (
|
||||
<BlockSuitePageList
|
||||
onOpenPage={onOpenPage}
|
||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||
/>
|
||||
);
|
||||
},
|
||||
SettingsDetail: ({
|
||||
currentWorkspace,
|
||||
onChangeTab,
|
||||
currentTab,
|
||||
onDeleteWorkspace,
|
||||
onTransformWorkspace,
|
||||
}) => {
|
||||
return (
|
||||
<WorkspaceSettingDetail
|
||||
onDeleteWorkspace={onDeleteWorkspace}
|
||||
onChangeTab={onChangeTab}
|
||||
currentTab={currentTab}
|
||||
workspace={currentWorkspace}
|
||||
onTransferWorkspace={onTransformWorkspace}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user