mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support publish interfaces
This commit is contained in:
parent
80a2ebc216
commit
4237a73810
@ -25,96 +25,9 @@
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
//
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import { JSDatabaseService } from '@/application/services/js-services/database.service';
|
||||
import { JSDocumentService } from '@/application/services/js-services/document.service';
|
||||
import { applyYDoc } from '@/application/ydoc/apply';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
Cypress.Commands.add('mockAPI', () => {
|
||||
cy.fixture('sign_in_success').then((json) => {
|
||||
cy.intercept('GET', `/api/user/verify/${json.access_token}`, {
|
||||
fixture: 'verify_token',
|
||||
}).as('verifyToken');
|
||||
cy.intercept('POST', '/gotrue/token?grant_type=password', json).as('loginSuccess');
|
||||
cy.intercept('POST', '/gotrue/token?grant_type=refresh_token', json).as('refreshToken');
|
||||
});
|
||||
cy.intercept('GET', '/api/user/profile', { fixture: 'user' }).as('getUserProfile');
|
||||
cy.intercept('GET', '/api/user/workspace', { fixture: 'user_workspace' }).as('getUserWorkspace');
|
||||
// Mock the API
|
||||
});
|
||||
|
||||
// Example use:
|
||||
// beforeEach(() => {
|
||||
// cy.mockAPI();
|
||||
// });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
Cypress.Commands.add('mockCurrentWorkspace', () => {
|
||||
cy.fixture('current_workspace').then((workspace) => {
|
||||
cy.stub(JSDatabaseService.prototype, 'currentWorkspace').resolves(workspace);
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
Cypress.Commands.add('mockGetWorkspaceDatabases', () => {
|
||||
cy.fixture('database/databases').then((databases) => {
|
||||
cy.stub(JSDatabaseService.prototype, 'getWorkspaceDatabases').resolves(databases);
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
Cypress.Commands.add('mockDatabase', () => {
|
||||
cy.mockCurrentWorkspace();
|
||||
cy.mockGetWorkspaceDatabases();
|
||||
|
||||
const ids = [
|
||||
'4c658817-20db-4f56-b7f9-0637a22dfeb6',
|
||||
'ce267d12-3b61-4ebb-bb03-d65272f5f817',
|
||||
'ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d',
|
||||
];
|
||||
|
||||
const mockOpenDatabase = cy.stub(JSDatabaseService.prototype, 'openDatabase');
|
||||
|
||||
ids.forEach((id) => {
|
||||
cy.fixture(`database/${id}`).then((database) => {
|
||||
cy.fixture(`database/rows/${id}`).then((rows) => {
|
||||
const doc = new Y.Doc();
|
||||
const rootRowsDoc = new Y.Doc();
|
||||
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
|
||||
const databaseState = new Uint8Array(database.data.doc_state);
|
||||
|
||||
applyYDoc(doc, databaseState);
|
||||
|
||||
Object.keys(rows).forEach((key) => {
|
||||
const data = rows[key];
|
||||
const rowDoc = new Y.Doc();
|
||||
|
||||
applyYDoc(rowDoc, new Uint8Array(data));
|
||||
rowsFolder.set(key, rowDoc);
|
||||
});
|
||||
mockOpenDatabase.withArgs(id).resolves({
|
||||
databaseDoc: doc,
|
||||
rows: rowsFolder,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
Cypress.Commands.add('mockDocument', (id: string) => {
|
||||
cy.fixture(`document/${id}`).then((subDocument) => {
|
||||
const doc = new Y.Doc();
|
||||
const state = new Uint8Array(subDocument.data.doc_state);
|
||||
|
||||
applyYDoc(doc, state);
|
||||
|
||||
cy.stub(JSDocumentService.prototype, 'openDocument').withArgs(id).resolves(doc);
|
||||
});
|
||||
});
|
||||
export {};
|
||||
|
@ -43,6 +43,8 @@
|
||||
"colorthief": "^2.4.0",
|
||||
"dayjs": "^1.11.9",
|
||||
"decimal.js": "^10.4.3",
|
||||
"dexie": "^4.0.7",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"emoji-mart": "^5.5.2",
|
||||
"emoji-regex": "^10.2.1",
|
||||
"events": "^3.3.0",
|
||||
@ -56,6 +58,7 @@
|
||||
"katex": "^0.16.7",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nanoid": "^4.0.0",
|
||||
"notistack": "^3.0.1",
|
||||
"numeral": "^2.0.6",
|
||||
"prismjs": "^1.29.0",
|
||||
"protoc-gen-ts": "0.8.7",
|
||||
|
@ -1,5 +1,9 @@
|
||||
lockfileVersion: '6.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
dependencies:
|
||||
'@appflowyinc/client-api-wasm':
|
||||
specifier: 0.0.3
|
||||
@ -58,6 +62,12 @@ dependencies:
|
||||
decimal.js:
|
||||
specifier: ^10.4.3
|
||||
version: 10.4.3
|
||||
dexie:
|
||||
specifier: ^4.0.7
|
||||
version: 4.0.7
|
||||
dexie-react-hooks:
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.7(@types/react@18.2.66)(dexie@4.0.7)(react@18.2.0)
|
||||
emoji-mart:
|
||||
specifier: ^5.5.2
|
||||
version: 5.6.0
|
||||
@ -97,6 +107,9 @@ dependencies:
|
||||
nanoid:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.2
|
||||
notistack:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0)
|
||||
numeral:
|
||||
specifier: ^2.0.6
|
||||
version: 2.0.6
|
||||
@ -5860,6 +5873,22 @@ packages:
|
||||
minimist: 1.2.8
|
||||
dev: true
|
||||
|
||||
/dexie-react-hooks@1.1.7(@types/react@18.2.66)(dexie@4.0.7)(react@18.2.0):
|
||||
resolution: {integrity: sha512-Lwv5W0Hk+uOW3kGnsU9GZoR1er1B7WQ5DSdonoNG+focTNeJbHW6vi6nBoX534VKI3/uwHebYzSw1fwY6a7mTw==}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16'
|
||||
dexie: ^3.2 || ^4.0.1-alpha
|
||||
react: '>=16'
|
||||
dependencies:
|
||||
'@types/react': 18.2.66
|
||||
dexie: 4.0.7
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/dexie@4.0.7:
|
||||
resolution: {integrity: sha512-M+Lo6rk4pekIfrc2T0o2tvVJwL6EAAM/B78DNfb8aaxFVoI1f8/rz5KTxuAnApkwqTSuxx7T5t0RKH7qprapGg==}
|
||||
dev: false
|
||||
|
||||
/didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
dev: true
|
||||
@ -8440,6 +8469,21 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/notistack@3.0.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-ntVZXXgSQH5WYfyU+3HfcXuKaapzAJ8fBLQ/G618rn3yvSzEbnOB8ZSOwhX+dAORy/lw+GC2N061JA0+gYWTVA==}
|
||||
engines: {node: '>=12.0.0', npm: '>=6.0.0'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
dependencies:
|
||||
clsx: 1.2.1
|
||||
goober: 2.1.14(csstype@3.1.3)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
- csstype
|
||||
dev: false
|
||||
|
||||
/npm-run-path@4.0.1:
|
||||
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
||||
engines: {node: '>=8'}
|
||||
@ -11414,7 +11458,3 @@ packages:
|
||||
resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==}
|
||||
engines: {node: '>=12.20'}
|
||||
dev: true
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
@ -18,7 +18,6 @@ import {
|
||||
useSortsSelector,
|
||||
} from '../selector';
|
||||
import { useDatabaseViewId } from '../context';
|
||||
import { IdProvider } from '@/components/_shared/context-provider/IdProvider';
|
||||
import { DatabaseContextProvider } from '@/components/database/DatabaseContext';
|
||||
import { withTestingDatabase } from '@/application/database-yjs/__tests__/withTestingData';
|
||||
import { expect } from '@jest/globals';
|
||||
@ -31,11 +30,9 @@ const wrapperCreator =
|
||||
(viewId: string, doc: YDoc, rowDocMap: Y.Map<YDoc>) =>
|
||||
({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<IdProvider objectId={viewId}>
|
||||
<DatabaseContextProvider viewId={viewId} databaseDoc={doc} rowDocMap={rowDocMap} readOnly={true}>
|
||||
{children}
|
||||
</DatabaseContextProvider>
|
||||
</IdProvider>
|
||||
<DatabaseContextProvider viewId={viewId} databaseDoc={doc} rowDocMap={rowDocMap} readOnly={true}>
|
||||
{children}
|
||||
</DatabaseContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { YDatabase, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
|
||||
import { ViewMeta } from '@/application/db/tables/view_metas';
|
||||
import { createContext, useContext } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
@ -9,6 +10,10 @@ export interface DatabaseContextState {
|
||||
rowDocMap: Y.Map<YDoc>;
|
||||
isDatabaseRowPage?: boolean;
|
||||
navigateToRow?: (rowId: string) => void;
|
||||
loadView?: (viewId: string) => Promise<YDoc>;
|
||||
getViewRowsMap?: (viewId: string, rowIds: string[]) => Promise<{ rows: Y.Map<YDoc>; destroy: () => void }>;
|
||||
loadViewMeta?: (viewId: string) => Promise<ViewMeta>;
|
||||
navigateToView?: (viewId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const DatabaseContext = createContext<DatabaseContextState | null>(null);
|
||||
|
@ -1,12 +1,4 @@
|
||||
import {
|
||||
FieldId,
|
||||
SortId,
|
||||
YDatabaseField,
|
||||
YDoc,
|
||||
YjsDatabaseKey,
|
||||
YjsEditorKey,
|
||||
YjsFolderKey,
|
||||
} from '@/application/collab.type';
|
||||
import { FieldId, SortId, YDatabaseField, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
|
||||
import { getCell, metaIdFromRowId, MIN_COLUMN_WIDTH } from '@/application/database-yjs/const';
|
||||
import {
|
||||
useDatabase,
|
||||
@ -19,7 +11,6 @@ import {
|
||||
import { filterBy, parseFilter } from '@/application/database-yjs/filter';
|
||||
import { groupByField } from '@/application/database-yjs/group';
|
||||
import { sortBy } from '@/application/database-yjs/sort';
|
||||
import { useViewsIdSelector } from '@/application/folder-yjs';
|
||||
import { parseYDatabaseCellToCell } from '@/application/database-yjs/cell.parse';
|
||||
import { DateTimeCell } from '@/application/database-yjs/cell.type';
|
||||
import * as dayjs from 'dayjs';
|
||||
@ -42,9 +33,8 @@ export interface Row {
|
||||
|
||||
const defaultVisible = [FieldVisibility.AlwaysShown, FieldVisibility.HideWhenEmpty];
|
||||
|
||||
export function useDatabaseViewsSelector(iidIndex: string) {
|
||||
export function useDatabaseViewsSelector(_iidIndex: string) {
|
||||
const database = useDatabase();
|
||||
const { viewsId: visibleViewsId, views: folderViews } = useViewsIdSelector();
|
||||
|
||||
const views = database?.get(YjsDatabaseKey.views);
|
||||
const [viewIds, setViewIds] = useState<string[]>([]);
|
||||
@ -65,22 +55,7 @@ export function useDatabaseViewsSelector(iidIndex: string) {
|
||||
return Number(viewB.created_at) - Number(viewA.created_at);
|
||||
});
|
||||
|
||||
const viewsId = [];
|
||||
|
||||
for (const viewItem of viewsSorted) {
|
||||
const [key] = viewItem;
|
||||
const view = folderViews?.get(key);
|
||||
|
||||
if (
|
||||
visibleViewsId.includes(key) &&
|
||||
view &&
|
||||
(view.get(YjsFolderKey.bid) === iidIndex || view.get(YjsFolderKey.id) === iidIndex)
|
||||
) {
|
||||
viewsId.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
setViewIds(viewsId);
|
||||
setViewIds(viewsSorted.map(([key]) => key));
|
||||
};
|
||||
|
||||
observerEvent();
|
||||
@ -89,7 +64,7 @@ export function useDatabaseViewsSelector(iidIndex: string) {
|
||||
return () => {
|
||||
views.unobserve(observerEvent);
|
||||
};
|
||||
}, [visibleViewsId, views, folderViews, iidIndex]);
|
||||
}, [views]);
|
||||
|
||||
return {
|
||||
childViews,
|
||||
|
@ -2,6 +2,17 @@ import { YDoc } from '@/application/collab.type';
|
||||
import { databasePrefix } from '@/application/constants';
|
||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||
import * as Y from 'yjs';
|
||||
import BaseDexie from 'dexie';
|
||||
import { viewMetasSchema, ViewMetasTable } from '@/application/db/tables/view_metas';
|
||||
|
||||
type DexieTables = ViewMetasTable;
|
||||
|
||||
export type Dexie<T = DexieTables> = BaseDexie & T;
|
||||
|
||||
export const db = new BaseDexie(`${databasePrefix}_cache`) as Dexie;
|
||||
const schema = Object.assign({}, viewMetasSchema);
|
||||
|
||||
db.version(1).stores(schema);
|
||||
|
||||
const openedSet = new Set<string>();
|
||||
|
||||
@ -31,11 +42,3 @@ export async function openCollabDB(docName: string): Promise<YDoc> {
|
||||
|
||||
return doc as YDoc;
|
||||
}
|
||||
|
||||
export function getCollabDBName(id: string, type: string, uuid?: string) {
|
||||
if (!uuid) {
|
||||
return `${type}_${id}`;
|
||||
}
|
||||
|
||||
return `${uuid}_${type}_${id}`;
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import { Table } from 'dexie';
|
||||
|
||||
export interface MetaData {
|
||||
view_id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
layout: number;
|
||||
extra: string | null;
|
||||
created_by: string | null;
|
||||
last_edited_by: string | null;
|
||||
last_edited_time: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type ViewMeta = {
|
||||
publish_name: string;
|
||||
|
||||
child_views: MetaData[];
|
||||
ancestor_views: MetaData[];
|
||||
} & MetaData;
|
||||
|
||||
export type ViewMetasTable = {
|
||||
view_metas: Table<ViewMeta>;
|
||||
};
|
||||
|
||||
export const viewMetasSchema = {
|
||||
view_metas: 'publish_name',
|
||||
};
|
@ -1,38 +0,0 @@
|
||||
import { ViewLayout, YFolder, YjsFolderKey } from '@/application/collab.type';
|
||||
import { createContext, useContext } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
export interface Crumb {
|
||||
viewId: string;
|
||||
rowId?: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const FolderContext = createContext<{
|
||||
folder: YFolder | null;
|
||||
onNavigateToView?: (viewId: string) => void;
|
||||
crumbs?: Crumb[];
|
||||
setCrumbs?: React.Dispatch<React.SetStateAction<Crumb[]>>;
|
||||
} | null>(null);
|
||||
|
||||
export const useFolderContext = () => {
|
||||
return useContext(FolderContext)?.folder;
|
||||
};
|
||||
|
||||
export const useViewLayout = () => {
|
||||
const folder = useFolderContext();
|
||||
const { objectId } = useParams();
|
||||
const views = folder?.get(YjsFolderKey.views);
|
||||
const view = objectId ? views?.get(objectId) : null;
|
||||
|
||||
return Number(view?.get(YjsFolderKey.layout)) as ViewLayout;
|
||||
};
|
||||
|
||||
export const useNavigateToView = () => {
|
||||
return useContext(FolderContext)?.onNavigateToView;
|
||||
};
|
||||
|
||||
export const useCrumbs = () => {
|
||||
return useContext(FolderContext)?.crumbs;
|
||||
};
|
@ -1,9 +0,0 @@
|
||||
export enum CoverType {
|
||||
NormalColor = 'color',
|
||||
GradientColor = 'gradient',
|
||||
BuildInImage = 'built_in',
|
||||
CustomImage = 'custom',
|
||||
LocalImage = 'local',
|
||||
UpsplashImage = 'unsplash',
|
||||
None = 'none',
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export * from './selector';
|
||||
export * from './context';
|
@ -1,70 +0,0 @@
|
||||
import { YjsFolderKey, YView } from '@/application/collab.type';
|
||||
import { useFolderContext } from '@/application/folder-yjs/context';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useViewsIdSelector() {
|
||||
const folder = useFolderContext();
|
||||
const [viewsId, setViewsId] = useState<string[]>([]);
|
||||
const views = folder?.get(YjsFolderKey.views);
|
||||
const trash = folder?.get(YjsFolderKey.section)?.get(YjsFolderKey.trash);
|
||||
const meta = folder?.get(YjsFolderKey.meta);
|
||||
|
||||
useEffect(() => {
|
||||
if (!views) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trashUid = trash ? Array.from(trash.keys())[0] : null;
|
||||
const userTrash = trashUid ? trash?.get(trashUid) : null;
|
||||
|
||||
const collectIds = () => {
|
||||
const trashIds = userTrash?.toJSON()?.map((item) => item.id) || [];
|
||||
|
||||
return Array.from(views.keys()).filter((id) => {
|
||||
return !trashIds.includes(id) && id !== meta?.get(YjsFolderKey.current_workspace);
|
||||
});
|
||||
};
|
||||
|
||||
setViewsId(collectIds());
|
||||
const observerEvent = () => setViewsId(collectIds());
|
||||
|
||||
views.observe(observerEvent);
|
||||
userTrash?.observe(observerEvent);
|
||||
|
||||
return () => {
|
||||
views.unobserve(observerEvent);
|
||||
userTrash?.unobserve(observerEvent);
|
||||
};
|
||||
}, [views, trash, meta]);
|
||||
|
||||
return {
|
||||
viewsId,
|
||||
views,
|
||||
};
|
||||
}
|
||||
|
||||
export function useViewSelector(viewId: string) {
|
||||
const folder = useFolderContext();
|
||||
const [clock, setClock] = useState<number>(0);
|
||||
const [view, setView] = useState<YView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!folder) return;
|
||||
|
||||
const view = folder.get(YjsFolderKey.views)?.get(viewId);
|
||||
|
||||
setView(view || null);
|
||||
const observerEvent = () => setClock((prev) => prev + 1);
|
||||
|
||||
view?.observe(observerEvent);
|
||||
|
||||
return () => {
|
||||
view?.unobserve(observerEvent);
|
||||
};
|
||||
}, [folder, viewId]);
|
||||
|
||||
return {
|
||||
clock,
|
||||
view,
|
||||
};
|
||||
}
|
151
frontend/appflowy_web_app/src/application/publish/context.tsx
Normal file
151
frontend/appflowy_web_app/src/application/publish/context.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { db } from '@/application/db';
|
||||
import { ViewMeta } from '@/application/db/tables/view_metas';
|
||||
import { notify } from '@/components/_shared/notify';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { createContext, useCallback, useContext } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export interface PublishContextType {
|
||||
namespace: string;
|
||||
publishName: string;
|
||||
viewMeta?: ViewMeta;
|
||||
toView: (viewId: string) => Promise<void>;
|
||||
loadViewMeta: (viewId: string) => Promise<ViewMeta>;
|
||||
getViewRowsMap?: (viewId: string, rowIds: string[]) => Promise<{ rows: Y.Map<YDoc>; destroy: () => void }>;
|
||||
|
||||
loadView: (viewId: string) => Promise<YDoc>;
|
||||
}
|
||||
|
||||
export const PublishContext = createContext<PublishContextType | null>(null);
|
||||
|
||||
export const PublishProvider = ({
|
||||
children,
|
||||
namespace,
|
||||
publishName,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
namespace: string;
|
||||
publishName: string;
|
||||
}) => {
|
||||
const viewMeta = useLiveQuery(async () => {
|
||||
const name = `${namespace}_${publishName}`;
|
||||
|
||||
return db.view_metas.get(name);
|
||||
});
|
||||
const service = useContext(AFConfigContext)?.service;
|
||||
const navigate = useNavigate();
|
||||
const toView = useCallback(
|
||||
async (viewId: string) => {
|
||||
try {
|
||||
const res = await service?.getPublishInfo(viewId);
|
||||
|
||||
if (!res) {
|
||||
throw new Error('Not found');
|
||||
}
|
||||
|
||||
const { namespace, publishName } = res;
|
||||
|
||||
navigate(`/${namespace}/${publishName}`);
|
||||
} catch (e) {
|
||||
notify.error('The view has not been published yet.');
|
||||
return Promise.reject(e);
|
||||
}
|
||||
},
|
||||
[navigate, service]
|
||||
);
|
||||
|
||||
const loadViewMeta = useCallback(
|
||||
async (viewId: string) => {
|
||||
try {
|
||||
const info = await service?.getPublishInfo(viewId);
|
||||
|
||||
if (!info) {
|
||||
throw new Error('View has not been published yet');
|
||||
}
|
||||
|
||||
const res = await service?.getPublishViewMeta(namespace, publishName);
|
||||
|
||||
if (!res) {
|
||||
throw new Error('View has not been published yet');
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
},
|
||||
[namespace, publishName, service]
|
||||
);
|
||||
|
||||
const getViewRowsMap = useCallback(
|
||||
async (viewId: string, rowIds: string[]) => {
|
||||
try {
|
||||
const info = await service?.getPublishInfo(viewId);
|
||||
|
||||
if (!info) {
|
||||
throw new Error('View has not been published yet');
|
||||
}
|
||||
|
||||
const { namespace, publishName } = info;
|
||||
const res = await service?.getPublishDatabaseViewRows(namespace, publishName, rowIds);
|
||||
|
||||
if (!res) {
|
||||
throw new Error('View has not been published yet');
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
},
|
||||
[service]
|
||||
);
|
||||
|
||||
const loadView = useCallback(
|
||||
async (viewId: string) => {
|
||||
try {
|
||||
const res = await service?.getPublishInfo(viewId);
|
||||
|
||||
if (!res) {
|
||||
throw new Error('View has not been published yet');
|
||||
}
|
||||
|
||||
const { namespace, publishName } = res;
|
||||
|
||||
const data = service?.getPublishView(namespace, publishName);
|
||||
|
||||
if (!data) {
|
||||
throw new Error('View has not been published yet');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
},
|
||||
[service]
|
||||
);
|
||||
|
||||
return (
|
||||
<PublishContext.Provider
|
||||
value={{
|
||||
loadView,
|
||||
viewMeta,
|
||||
getViewRowsMap,
|
||||
loadViewMeta,
|
||||
toView,
|
||||
namespace,
|
||||
publishName,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PublishContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function usePublishContext() {
|
||||
return useContext(PublishContext);
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './context';
|
@ -1,310 +0,0 @@
|
||||
import { CollabType } from '@/application/collab.type';
|
||||
import * as Y from 'yjs';
|
||||
import { withTestingYDoc } from '@/application/slate-yjs/__tests__/withTestingYjsEditor';
|
||||
import { expect } from '@jest/globals';
|
||||
import { getCollab, batchCollab, collabTypeToDBType } from '../cache';
|
||||
import { applyYDoc } from '@/application/ydoc/apply';
|
||||
import { getCollabDBName, openCollabDB } from '../cache/db';
|
||||
import { StrategyType } from '../cache/types';
|
||||
|
||||
jest.mock('@/application/ydoc/apply', () => ({
|
||||
applyYDoc: jest.fn(),
|
||||
}));
|
||||
jest.mock('../cache/db', () => ({
|
||||
openCollabDB: jest.fn(),
|
||||
getCollabDBName: jest.fn(),
|
||||
}));
|
||||
|
||||
const emptyDoc = new Y.Doc();
|
||||
const normalDoc = withTestingYDoc('1');
|
||||
const mockFetcher = jest.fn();
|
||||
const mockBatchFetcher = jest.fn();
|
||||
|
||||
describe('Cache functions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getCollab', () => {
|
||||
describe('with CACHE_ONLY strategy', () => {
|
||||
it('should throw error when no cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
await expect(
|
||||
getCollab(
|
||||
mockFetcher,
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
StrategyType.CACHE_ONLY
|
||||
)
|
||||
).rejects.toThrow('No cache found');
|
||||
});
|
||||
it('should fetch collab with CACHE_ONLY strategy and existing cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
const result = await getCollab(
|
||||
mockFetcher,
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
StrategyType.CACHE_ONLY
|
||||
);
|
||||
|
||||
expect(result).toBe(normalDoc);
|
||||
expect(mockFetcher).not.toHaveBeenCalled();
|
||||
expect(applyYDoc).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with CACHE_FIRST strategy', () => {
|
||||
it('should fetch collab with CACHE_FIRST strategy and existing cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
|
||||
|
||||
const result = await getCollab(
|
||||
mockFetcher,
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
StrategyType.CACHE_FIRST
|
||||
);
|
||||
|
||||
expect(result).toBe(normalDoc);
|
||||
expect(mockFetcher).not.toHaveBeenCalled();
|
||||
expect(applyYDoc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch collab with CACHE_FIRST strategy and no cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
|
||||
|
||||
const result = await getCollab(
|
||||
mockFetcher,
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
StrategyType.CACHE_FIRST
|
||||
);
|
||||
|
||||
expect(result).toBe(emptyDoc);
|
||||
expect(mockFetcher).toHaveBeenCalled();
|
||||
expect(applyYDoc).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with CACHE_AND_NETWORK strategy', () => {
|
||||
it('should fetch collab with CACHE_AND_NETWORK strategy and existing cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
|
||||
|
||||
const result = await getCollab(
|
||||
mockFetcher,
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
expect(result).toBe(normalDoc);
|
||||
expect(mockFetcher).toHaveBeenCalled();
|
||||
expect(applyYDoc).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch collab with CACHE_AND_NETWORK strategy and no cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
|
||||
|
||||
const result = await getCollab(
|
||||
mockFetcher,
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
expect(result).toBe(emptyDoc);
|
||||
expect(mockFetcher).toHaveBeenCalled();
|
||||
expect(applyYDoc).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with default strategy', () => {
|
||||
it('should fetch collab with default strategy', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
|
||||
|
||||
const result = await getCollab(
|
||||
mockFetcher,
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
StrategyType.NETWORK_ONLY
|
||||
);
|
||||
|
||||
expect(result).toBe(normalDoc);
|
||||
expect(mockFetcher).toHaveBeenCalled();
|
||||
expect(applyYDoc).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchCollab', () => {
|
||||
describe('with CACHE_ONLY strategy', () => {
|
||||
it('should batch fetch collabs with CACHE_ONLY strategy and no cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
|
||||
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
await expect(
|
||||
batchCollab(
|
||||
mockBatchFetcher,
|
||||
[
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
],
|
||||
StrategyType.CACHE_ONLY
|
||||
)
|
||||
).rejects.toThrow('No cache found');
|
||||
});
|
||||
|
||||
it('should batch fetch collabs with CACHE_ONLY strategy and existing cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
await batchCollab(
|
||||
mockBatchFetcher,
|
||||
[
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
],
|
||||
StrategyType.CACHE_ONLY
|
||||
);
|
||||
|
||||
expect(mockBatchFetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with CACHE_FIRST strategy', () => {
|
||||
it('should batch fetch collabs with CACHE_FIRST strategy and existing cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
await batchCollab(
|
||||
mockBatchFetcher,
|
||||
[
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
],
|
||||
StrategyType.CACHE_FIRST
|
||||
);
|
||||
|
||||
expect(mockBatchFetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should batch fetch collabs with CACHE_FIRST strategy and no cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
|
||||
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
mockBatchFetcher.mockResolvedValue({ id1: [1, 2, 3] });
|
||||
|
||||
await batchCollab(
|
||||
mockBatchFetcher,
|
||||
[
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
],
|
||||
StrategyType.CACHE_FIRST
|
||||
);
|
||||
|
||||
expect(mockBatchFetcher).toHaveBeenCalled();
|
||||
expect(applyYDoc).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with CACHE_AND_NETWORK strategy', () => {
|
||||
it('should batch fetch collabs with CACHE_AND_NETWORK strategy', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
mockBatchFetcher.mockResolvedValue({ id1: [1, 2, 3] });
|
||||
|
||||
await batchCollab(
|
||||
mockBatchFetcher,
|
||||
[
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
],
|
||||
StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
expect(mockBatchFetcher).toHaveBeenCalled();
|
||||
expect(applyYDoc).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should batch fetch collabs with CACHE_AND_NETWORK strategy and no cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
|
||||
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
mockBatchFetcher.mockResolvedValue({ id1: [1, 2, 3] });
|
||||
|
||||
await batchCollab(
|
||||
mockBatchFetcher,
|
||||
[
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
],
|
||||
StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
expect(mockBatchFetcher).toHaveBeenCalled();
|
||||
expect(applyYDoc).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('collabTypeToDBType', () => {
|
||||
it('should return correct DB type', () => {
|
||||
expect(collabTypeToDBType(CollabType.Document)).toBe('document');
|
||||
expect(collabTypeToDBType(CollabType.Folder)).toBe('folder');
|
||||
expect(collabTypeToDBType(CollabType.Database)).toBe('database');
|
||||
expect(collabTypeToDBType(CollabType.WorkspaceDatabase)).toBe('databases');
|
||||
expect(collabTypeToDBType(CollabType.DatabaseRow)).toBe('database_row');
|
||||
expect(collabTypeToDBType(CollabType.UserAwareness)).toBe('user_awareness');
|
||||
expect(collabTypeToDBType(CollabType.Empty)).toBe('');
|
||||
});
|
||||
});
|
@ -1,13 +1,13 @@
|
||||
import { expect } from '@jest/globals';
|
||||
import { fetchCollab, batchFetchCollab } from '../fetch';
|
||||
import { CollabType } from '@/application/collab.type';
|
||||
import { fetchPublishView, fetchPublishViewMeta, fetchViewInfo } from '../fetch';
|
||||
import { APIService } from '@/application/services/js-services/wasm';
|
||||
|
||||
jest.mock('@/application/services/js-services/wasm', () => {
|
||||
return {
|
||||
APIService: {
|
||||
getCollab: jest.fn(),
|
||||
batchGetCollab: jest.fn(),
|
||||
getPublishView: jest.fn(),
|
||||
getPublishViewMeta: jest.fn(),
|
||||
getPublishInfoWithViewId: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@ -17,41 +17,100 @@ describe('Collab fetch functions with deduplication', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('fetchCollab', () => {
|
||||
it('should fetch collab without duplicating requests', async () => {
|
||||
const workspaceId = 'workspace1';
|
||||
const id = 'id1';
|
||||
const type = CollabType.Document;
|
||||
describe('fetchPublishView', () => {
|
||||
it('should fetch publish view without duplicating requests', async () => {
|
||||
const namespace = 'namespace1';
|
||||
const publishName = 'publish1';
|
||||
const mockResponse = { data: 'mockData' };
|
||||
|
||||
(APIService.getCollab as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(APIService.getPublishView as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result1 = fetchCollab(workspaceId, id, type);
|
||||
const result2 = fetchCollab(workspaceId, id, type);
|
||||
const result1 = fetchPublishView(namespace, publishName);
|
||||
const result2 = fetchPublishView(namespace, publishName);
|
||||
|
||||
expect(result1).toBe(result2);
|
||||
await expect(result1).resolves.toEqual(mockResponse);
|
||||
expect(APIService.getCollab).toHaveBeenCalledTimes(1);
|
||||
expect(APIService.getPublishView).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should fetch publish view with different params', async () => {
|
||||
const namespace = 'namespace1';
|
||||
const publishName = 'publish1';
|
||||
const mockResponse = { data: 'mockData' };
|
||||
|
||||
(APIService.getPublishView as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result1 = fetchPublishView(namespace, publishName);
|
||||
const result2 = fetchPublishView(namespace, 'publish2');
|
||||
|
||||
expect(result1).not.toBe(result2);
|
||||
await expect(result1).resolves.toEqual(mockResponse);
|
||||
await expect(result2).resolves.toEqual(mockResponse);
|
||||
expect(APIService.getPublishView).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchFetchCollab', () => {
|
||||
it('should batch fetch collabs without duplicating requests', async () => {
|
||||
const workspaceId = 'workspace1';
|
||||
const params = [
|
||||
{ collabId: 'id1', collabType: CollabType.Document },
|
||||
{ collabId: 'id2', collabType: CollabType.Folder },
|
||||
];
|
||||
describe('fetchViewInfo', () => {
|
||||
it('should fetch view info without duplicating requests', async () => {
|
||||
const viewId = 'view1';
|
||||
const mockResponse = { data: 'mockData' };
|
||||
|
||||
(APIService.batchGetCollab as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(APIService.getPublishInfoWithViewId as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result1 = batchFetchCollab(workspaceId, params);
|
||||
const result2 = batchFetchCollab(workspaceId, params);
|
||||
const result1 = fetchViewInfo(viewId);
|
||||
const result2 = fetchViewInfo(viewId);
|
||||
|
||||
expect(result1).toBe(result2);
|
||||
await expect(result1).resolves.toEqual(mockResponse);
|
||||
expect(APIService.batchGetCollab).toHaveBeenCalledTimes(1);
|
||||
expect(APIService.getPublishInfoWithViewId).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should fetch view info with different params', async () => {
|
||||
const viewId = 'view1';
|
||||
const mockResponse = { data: 'mockData' };
|
||||
|
||||
(APIService.getPublishInfoWithViewId as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result1 = fetchViewInfo(viewId);
|
||||
const result2 = fetchViewInfo('view2');
|
||||
|
||||
expect(result1).not.toBe(result2);
|
||||
await expect(result1).resolves.toEqual(mockResponse);
|
||||
await expect(result2).resolves.toEqual(mockResponse);
|
||||
expect(APIService.getPublishInfoWithViewId).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPublishViewMeta', () => {
|
||||
it('should fetch publish view meta without duplicating requests', async () => {
|
||||
const namespace = 'namespace1';
|
||||
const publishName = 'publish1';
|
||||
const mockResponse = { data: 'mockData' };
|
||||
|
||||
(APIService.getPublishViewMeta as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result1 = fetchPublishViewMeta(namespace, publishName);
|
||||
const result2 = fetchPublishViewMeta(namespace, publishName);
|
||||
|
||||
expect(result1).toBe(result2);
|
||||
await expect(result1).resolves.toEqual(mockResponse);
|
||||
expect(APIService.getPublishViewMeta).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should fetch publish view meta with different params', async () => {
|
||||
const namespace = 'namespace1';
|
||||
const publishName = 'publish1';
|
||||
const mockResponse = { data: 'mockData' };
|
||||
|
||||
(APIService.getPublishViewMeta as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result1 = fetchPublishViewMeta(namespace, publishName);
|
||||
const result2 = fetchPublishViewMeta(namespace, 'publish2');
|
||||
|
||||
expect(result1).not.toBe(result2);
|
||||
await expect(result1).resolves.toEqual(mockResponse);
|
||||
await expect(result2).resolves.toEqual(mockResponse);
|
||||
expect(APIService.getPublishViewMeta).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -0,0 +1,128 @@
|
||||
import { withTestingYDoc } from '@/application/slate-yjs/__tests__/withTestingYjsEditor';
|
||||
import { AFClientService } from '../index';
|
||||
import { fetchViewInfo } from '@/application/services/js-services/fetch';
|
||||
import { expect, jest } from '@jest/globals';
|
||||
import { getBatchCollabs, getPublishView, getPublishViewMeta } from '@/application/services/js-services/cache';
|
||||
|
||||
jest.mock('@/application/services/js-services/wasm/client_api', () => {
|
||||
return {
|
||||
initAPIService: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('nanoid', () => {
|
||||
return {
|
||||
nanoid: jest.fn().mockReturnValue('12345678'),
|
||||
};
|
||||
});
|
||||
jest.mock('@/application/services/js-services/fetch', () => {
|
||||
return {
|
||||
fetchPublishView: jest.fn(),
|
||||
fetchPublishViewMeta: jest.fn(),
|
||||
fetchViewInfo: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@/application/services/js-services/cache', () => {
|
||||
return {
|
||||
getPublishView: jest.fn(),
|
||||
getPublishViewMeta: jest.fn(),
|
||||
getBatchCollabs: jest.fn(),
|
||||
};
|
||||
});
|
||||
describe('AFClientService', () => {
|
||||
let service: AFClientService;
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new AFClientService({
|
||||
cloudConfig: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
gotrueURL: 'http://localhost:3000',
|
||||
wsURL: 'ws://localhost:3000',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should get view meta', async () => {
|
||||
const namespace = 'namespace';
|
||||
const publishName = 'publishName';
|
||||
const mockResponse = {
|
||||
view_id: 'view_id',
|
||||
publish_name: publishName,
|
||||
metadata: {
|
||||
view: {
|
||||
name: 'viewName',
|
||||
view_id: 'view_id',
|
||||
},
|
||||
child_views: [],
|
||||
ancestor_views: [],
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
(getPublishViewMeta as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.getPublishViewMeta(namespace, publishName);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should get view', async () => {
|
||||
const namespace = 'namespace';
|
||||
const publishName = 'publishName';
|
||||
const mockResponse = {
|
||||
data: [1, 2, 3],
|
||||
meta: {
|
||||
metadata: {
|
||||
view: {
|
||||
name: 'viewName',
|
||||
view_id: 'view_id',
|
||||
},
|
||||
child_views: [],
|
||||
ancestor_views: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
(getPublishView as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.getPublishView(namespace, publishName);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should get view info', async () => {
|
||||
const viewId = 'viewId';
|
||||
const mockResponse = {
|
||||
namespace: 'namespace',
|
||||
publish_name: 'publishName',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
(fetchViewInfo as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.getPublishInfo(viewId);
|
||||
|
||||
expect(result).toEqual({
|
||||
namespace: 'namespace',
|
||||
publishName: 'publishName',
|
||||
});
|
||||
});
|
||||
|
||||
it('getPublishDatabaseViewRows', async () => {
|
||||
const namespace = 'namespace';
|
||||
const publishName = 'publishName';
|
||||
const rowIds = ['1', '2', '3'];
|
||||
const mockResponse = [withTestingYDoc('1'), withTestingYDoc('2'), withTestingYDoc('3')];
|
||||
|
||||
// @ts-ignore
|
||||
(getBatchCollabs as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.getPublishDatabaseViewRows(namespace, publishName, rowIds);
|
||||
|
||||
expect(result).toEqual({
|
||||
rows: expect.any(Object),
|
||||
destroy: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
@ -1,39 +0,0 @@
|
||||
import { AuthService } from '@/application/services/services.type';
|
||||
import { ProviderType, SignUpWithEmailPasswordParams } from '@/application/user.type';
|
||||
import { APIService } from 'src/application/services/js-services/wasm';
|
||||
import { signInSuccess } from '@/application/services/js-services/session/auth';
|
||||
import { invalidToken } from 'src/application/services/js-services/session';
|
||||
import { afterSignInDecorator } from '@/application/services/js-services/decorator';
|
||||
|
||||
export class JSAuthService implements AuthService {
|
||||
constructor() {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
getOAuthURL = async (_provider: ProviderType): Promise<string> => {
|
||||
return Promise.reject('Not implemented');
|
||||
};
|
||||
|
||||
@afterSignInDecorator(signInSuccess)
|
||||
async signInWithOAuth(_: { uri: string }): Promise<void> {
|
||||
return Promise.reject('Not implemented');
|
||||
}
|
||||
|
||||
signupWithEmailPassword = async (_params: SignUpWithEmailPasswordParams): Promise<void> => {
|
||||
return Promise.reject('Not implemented');
|
||||
};
|
||||
|
||||
@afterSignInDecorator(signInSuccess)
|
||||
async signinWithEmailPassword(email: string, password: string): Promise<void> {
|
||||
try {
|
||||
return APIService.signIn(email, password);
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
signOut = async (): Promise<void> => {
|
||||
invalidToken();
|
||||
return APIService.logout();
|
||||
};
|
||||
}
|
145
frontend/appflowy_web_app/src/application/services/js-services/cache/__tests__/cache.test.ts
vendored
Normal file
145
frontend/appflowy_web_app/src/application/services/js-services/cache/__tests__/cache.test.ts
vendored
Normal file
@ -0,0 +1,145 @@
|
||||
import { CollabType } from '@/application/collab.type';
|
||||
import { withTestingYDoc } from '@/application/slate-yjs/__tests__/withTestingYjsEditor';
|
||||
import { expect } from '@jest/globals';
|
||||
import {
|
||||
collabTypeToDBType,
|
||||
getPublishView,
|
||||
getPublishViewMeta,
|
||||
getBatchCollabs,
|
||||
} from '@/application/services/js-services/cache';
|
||||
import { openCollabDB, db } from '@/application/db';
|
||||
import { StrategyType } from '@/application/services/js-services/cache/types';
|
||||
|
||||
jest.mock('@/application/ydoc/apply', () => ({
|
||||
applyYDoc: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/application/db', () => ({
|
||||
openCollabDB: jest.fn(),
|
||||
db: {
|
||||
view_metas: {
|
||||
get: jest.fn(),
|
||||
put: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const normalDoc = withTestingYDoc('1');
|
||||
const mockFetcher = jest.fn();
|
||||
|
||||
async function runTestWithStrategy(strategy: StrategyType) {
|
||||
return getPublishView(
|
||||
mockFetcher,
|
||||
{
|
||||
namespace: 'appflowy',
|
||||
publishName: 'test',
|
||||
},
|
||||
strategy
|
||||
);
|
||||
}
|
||||
|
||||
async function runGetPublishViewMetaWithStrategy(strategy: StrategyType) {
|
||||
return getPublishViewMeta(
|
||||
mockFetcher,
|
||||
{
|
||||
namespace: 'appflowy',
|
||||
publishName: 'test',
|
||||
},
|
||||
strategy
|
||||
);
|
||||
}
|
||||
|
||||
describe('Cache functions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetcher.mockClear();
|
||||
(openCollabDB as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe('getPublishView', () => {
|
||||
it('should call fetcher when no cache found', async () => {
|
||||
mockFetcher.mockResolvedValue({ data: [1, 2, 3], meta: { metadata: { view: { id: '1' } } } });
|
||||
(db.view_metas.get as jest.Mock).mockResolvedValue(undefined);
|
||||
await runTestWithStrategy(StrategyType.CACHE_FIRST);
|
||||
expect(mockFetcher).toBeCalledTimes(1);
|
||||
|
||||
await runTestWithStrategy(StrategyType.CACHE_AND_NETWORK);
|
||||
expect(mockFetcher).toBeCalledTimes(2);
|
||||
await expect(runTestWithStrategy(StrategyType.CACHE_ONLY)).rejects.toThrow('No cache found');
|
||||
});
|
||||
it('should call fetcher when cache is invalid or strategy is CACHE_AND_NETWORK', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
(db.view_metas.get as jest.Mock).mockResolvedValue({ view_id: '1' });
|
||||
mockFetcher.mockResolvedValue({ data: [1, 2, 3], meta: { metadata: { view: { id: '1' } } } });
|
||||
await runTestWithStrategy(StrategyType.CACHE_ONLY);
|
||||
expect(openCollabDB).toBeCalledTimes(1);
|
||||
|
||||
await runTestWithStrategy(StrategyType.CACHE_FIRST);
|
||||
expect(openCollabDB).toBeCalledTimes(2);
|
||||
expect(mockFetcher).toBeCalledTimes(0);
|
||||
|
||||
await runTestWithStrategy(StrategyType.CACHE_AND_NETWORK);
|
||||
expect(openCollabDB).toBeCalledTimes(3);
|
||||
expect(mockFetcher).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublishViewMeta', () => {
|
||||
it('should call fetcher when no cache found', async () => {
|
||||
mockFetcher.mockResolvedValue({ metadata: { view: { id: '1' }, child_views: [], ancestor_views: [] } });
|
||||
(db.view_metas.get as jest.Mock).mockResolvedValue(undefined);
|
||||
await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_FIRST);
|
||||
expect(mockFetcher).toBeCalledTimes(1);
|
||||
|
||||
await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_AND_NETWORK);
|
||||
expect(mockFetcher).toBeCalledTimes(2);
|
||||
|
||||
await expect(runGetPublishViewMetaWithStrategy(StrategyType.CACHE_ONLY)).rejects.toThrow('No cache found');
|
||||
});
|
||||
|
||||
it('should call fetcher when cache is invalid or strategy is CACHE_AND_NETWORK', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
(db.view_metas.get as jest.Mock).mockResolvedValue({ view_id: '1' });
|
||||
|
||||
mockFetcher.mockResolvedValue({ metadata: { view: { id: '1' }, child_views: [], ancestor_views: [] } });
|
||||
const meta = await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_ONLY);
|
||||
expect(openCollabDB).toBeCalledTimes(0);
|
||||
expect(meta).toBeDefined();
|
||||
|
||||
await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_FIRST);
|
||||
expect(openCollabDB).toBeCalledTimes(0);
|
||||
expect(mockFetcher).toBeCalledTimes(0);
|
||||
|
||||
await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_AND_NETWORK);
|
||||
expect(openCollabDB).toBeCalledTimes(0);
|
||||
expect(mockFetcher).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBatchCollabs', () => {
|
||||
it('should return empty array when no cache found', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(undefined);
|
||||
const collabs = await getBatchCollabs(['1', '2', '3']);
|
||||
expect(collabs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return collabs when cache found', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
(db.view_metas.get as jest.Mock).mockResolvedValue({ view_id: '1' });
|
||||
const collabs = await getBatchCollabs(['1', '2', '3']);
|
||||
expect(collabs).toEqual([normalDoc, normalDoc, normalDoc]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('collabTypeToDBType', () => {
|
||||
it('should return correct DB type', () => {
|
||||
expect(collabTypeToDBType(CollabType.Document)).toBe('document');
|
||||
expect(collabTypeToDBType(CollabType.Folder)).toBe('folder');
|
||||
expect(collabTypeToDBType(CollabType.Database)).toBe('database');
|
||||
expect(collabTypeToDBType(CollabType.WorkspaceDatabase)).toBe('databases');
|
||||
expect(collabTypeToDBType(CollabType.DatabaseRow)).toBe('database_row');
|
||||
expect(collabTypeToDBType(CollabType.UserAwareness)).toBe('user_awareness');
|
||||
expect(collabTypeToDBType(CollabType.Empty)).toBe('');
|
||||
});
|
||||
});
|
@ -1,7 +1,8 @@
|
||||
import { MetaData } from '@/application/db/tables/view_metas';
|
||||
import { CollabType, YDoc, YjsEditorKey, YSharedRoot } from '@/application/collab.type';
|
||||
import { applyYDoc } from '@/application/ydoc/apply';
|
||||
import { getCollabDBName, openCollabDB } from './db';
|
||||
import { Fetcher, StrategyType } from './types';
|
||||
import { db, openCollabDB } from '@/application/db';
|
||||
import { Fetcher, StrategyType } from '@/application/services/js-services/cache/types';
|
||||
|
||||
export function collabTypeToDBType(type: CollabType) {
|
||||
switch (type) {
|
||||
@ -32,30 +33,42 @@ const collabSharedRootKeyMap = {
|
||||
[CollabType.Empty]: YjsEditorKey.empty,
|
||||
};
|
||||
|
||||
export function hasCache(doc: YDoc, type: CollabType) {
|
||||
export function hasCollabCache(doc: YDoc) {
|
||||
const data = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
|
||||
|
||||
return data.has(collabSharedRootKeyMap[type] as string);
|
||||
return Object.values(collabSharedRootKeyMap).some((key) => {
|
||||
return data.has(key);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCollab(
|
||||
fetcher: Fetcher<{
|
||||
state: Uint8Array;
|
||||
}>,
|
||||
export async function hasViewMetaCache(name: string) {
|
||||
const data = await db.view_metas.get(name);
|
||||
|
||||
return !!data;
|
||||
}
|
||||
|
||||
export async function getPublishViewMeta<
|
||||
T extends {
|
||||
metadata: {
|
||||
view: MetaData;
|
||||
child_views: MetaData[];
|
||||
ancestor_views: MetaData[];
|
||||
};
|
||||
}
|
||||
>(
|
||||
fetcher: Fetcher<T>,
|
||||
{
|
||||
collabId,
|
||||
collabType,
|
||||
uuid,
|
||||
namespace,
|
||||
publishName,
|
||||
}: {
|
||||
uuid?: string;
|
||||
collabId: string;
|
||||
collabType: CollabType;
|
||||
namespace: string;
|
||||
publishName: string;
|
||||
},
|
||||
strategy: StrategyType = StrategyType.CACHE_AND_NETWORK
|
||||
) {
|
||||
const name = getCollabDBName(collabId, collabTypeToDBType(collabType), uuid);
|
||||
const collab = await openCollabDB(name);
|
||||
const exist = hasCache(collab, collabType);
|
||||
const name = `${namespace}_${publishName}`;
|
||||
const exist = await hasViewMetaCache(name);
|
||||
const meta = await db.view_metas.get(name);
|
||||
|
||||
switch (strategy) {
|
||||
case StrategyType.CACHE_ONLY: {
|
||||
@ -63,103 +76,155 @@ export async function getCollab(
|
||||
throw new Error('No cache found');
|
||||
}
|
||||
|
||||
return collab;
|
||||
return meta;
|
||||
}
|
||||
|
||||
case StrategyType.CACHE_FIRST: {
|
||||
if (!exist) {
|
||||
await revalidateCollab(fetcher, collab);
|
||||
return revalidatePublishViewMeta(name, fetcher);
|
||||
}
|
||||
|
||||
return collab;
|
||||
return meta;
|
||||
}
|
||||
|
||||
case StrategyType.CACHE_AND_NETWORK: {
|
||||
if (!exist) {
|
||||
await revalidateCollab(fetcher, collab);
|
||||
return revalidatePublishViewMeta(name, fetcher);
|
||||
} else {
|
||||
void revalidateCollab(fetcher, collab);
|
||||
void revalidatePublishViewMeta(name, fetcher);
|
||||
}
|
||||
|
||||
return collab;
|
||||
return meta;
|
||||
}
|
||||
|
||||
default: {
|
||||
await revalidateCollab(fetcher, collab);
|
||||
|
||||
return collab;
|
||||
return revalidatePublishViewMeta(name, fetcher);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function revalidateCollab(
|
||||
fetcher: Fetcher<{
|
||||
state: Uint8Array;
|
||||
}>,
|
||||
collab: YDoc
|
||||
export async function getPublishView<
|
||||
T extends {
|
||||
data: number[];
|
||||
meta: {
|
||||
metadata: {
|
||||
view: MetaData;
|
||||
child_views: MetaData[];
|
||||
ancestor_views: MetaData[];
|
||||
};
|
||||
};
|
||||
}
|
||||
>(
|
||||
fetcher: Fetcher<T>,
|
||||
{
|
||||
namespace,
|
||||
publishName,
|
||||
}: {
|
||||
namespace: string;
|
||||
publishName: string;
|
||||
},
|
||||
strategy: StrategyType = StrategyType.CACHE_AND_NETWORK
|
||||
) {
|
||||
const { state } = await fetcher();
|
||||
const name = `${namespace}_${publishName}`;
|
||||
const doc = await openCollabDB(name);
|
||||
const exist = (await hasViewMetaCache(name)) && hasCollabCache(doc);
|
||||
|
||||
switch (strategy) {
|
||||
case StrategyType.CACHE_ONLY: {
|
||||
if (!exist) {
|
||||
throw new Error('No cache found');
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case StrategyType.CACHE_FIRST: {
|
||||
if (!exist) {
|
||||
await revalidatePublishView(name, fetcher, doc);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case StrategyType.CACHE_AND_NETWORK: {
|
||||
if (!exist) {
|
||||
await revalidatePublishView(name, fetcher, doc);
|
||||
} else {
|
||||
void revalidatePublishView(name, fetcher, doc);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
await revalidatePublishView(name, fetcher, doc);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
export async function revalidatePublishViewMeta<
|
||||
T extends {
|
||||
metadata: {
|
||||
view: MetaData;
|
||||
child_views: MetaData[];
|
||||
ancestor_views: MetaData[];
|
||||
};
|
||||
}
|
||||
>(name: string, fetcher: Fetcher<T>) {
|
||||
const { metadata } = await fetcher();
|
||||
|
||||
await db.view_metas.put(
|
||||
{
|
||||
publish_name: name,
|
||||
...metadata.view,
|
||||
child_views: metadata.child_views,
|
||||
ancestor_views: metadata.ancestor_views,
|
||||
},
|
||||
name
|
||||
);
|
||||
}
|
||||
|
||||
export async function revalidatePublishView<
|
||||
T extends {
|
||||
data: number[];
|
||||
rows?: Record<string, number[]>;
|
||||
meta: {
|
||||
metadata: {
|
||||
view: MetaData;
|
||||
child_views: MetaData[];
|
||||
ancestor_views: MetaData[];
|
||||
};
|
||||
};
|
||||
}
|
||||
>(name: string, fetcher: Fetcher<T>, collab: YDoc) {
|
||||
const { data, meta, rows } = await fetcher();
|
||||
|
||||
await db.view_metas.put(
|
||||
{
|
||||
publish_name: name,
|
||||
...meta.metadata.view,
|
||||
child_views: meta.metadata.child_views,
|
||||
ancestor_views: meta.metadata.ancestor_views,
|
||||
},
|
||||
name
|
||||
);
|
||||
|
||||
for (const [key, value] of Object.entries(rows ?? {})) {
|
||||
const row = await openCollabDB(`${name}_${key}`);
|
||||
|
||||
applyYDoc(row, new Uint8Array(value));
|
||||
}
|
||||
|
||||
const state = new Uint8Array(data);
|
||||
|
||||
applyYDoc(collab, state);
|
||||
}
|
||||
|
||||
export async function batchCollab(
|
||||
batchFetcher: Fetcher<Record<string, number[]>>,
|
||||
collabs: {
|
||||
collabId: string;
|
||||
collabType: CollabType;
|
||||
uuid?: string;
|
||||
}[],
|
||||
strategy: StrategyType = StrategyType.CACHE_AND_NETWORK,
|
||||
itemCallback?: (id: string, doc: YDoc) => void
|
||||
) {
|
||||
const collabMap = new Map<string, YDoc>();
|
||||
export async function getBatchCollabs(names: string[]) {
|
||||
const collabs = await Promise.all(names.map((name) => openCollabDB(name)));
|
||||
|
||||
for (const { collabId, collabType, uuid } of collabs) {
|
||||
const name = getCollabDBName(collabId, collabTypeToDBType(collabType), uuid);
|
||||
const collab = await openCollabDB(name);
|
||||
const exist = hasCache(collab, collabType);
|
||||
|
||||
collabMap.set(collabId, collab);
|
||||
if (exist) {
|
||||
itemCallback?.(collabId, collab);
|
||||
}
|
||||
}
|
||||
|
||||
const notCacheIds = collabs.filter(({ collabId, collabType }) => {
|
||||
const id = collabMap.get(collabId);
|
||||
|
||||
if (!id) return false;
|
||||
|
||||
return !hasCache(id, collabType);
|
||||
});
|
||||
|
||||
if (strategy === StrategyType.CACHE_ONLY) {
|
||||
if (notCacheIds.length > 0) {
|
||||
throw new Error('No cache found');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (strategy === StrategyType.CACHE_FIRST && notCacheIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const states = await batchFetcher();
|
||||
|
||||
for (const [collabId, data] of Object.entries(states)) {
|
||||
const info = collabs.find((item) => item.collabId === collabId);
|
||||
const collab = collabMap.get(collabId);
|
||||
|
||||
if (!info || !collab) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = new Uint8Array(data);
|
||||
|
||||
applyYDoc(collab, state);
|
||||
|
||||
itemCallback?.(collabId, collab);
|
||||
}
|
||||
return collabs;
|
||||
}
|
||||
|
@ -1,157 +0,0 @@
|
||||
import { CollabType, YDatabase, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
|
||||
import { batchCollab, getCollab } from '@/application/services/js-services/cache';
|
||||
import { StrategyType } from '@/application/services/js-services/cache/types';
|
||||
import { batchFetchCollab, fetchCollab } from '@/application/services/js-services/fetch';
|
||||
import { getCurrentWorkspace } from 'src/application/services/js-services/session';
|
||||
import { DatabaseService } from '@/application/services/services.type';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export class JSDatabaseService implements DatabaseService {
|
||||
private loadedDatabaseId: Set<string> = new Set();
|
||||
|
||||
private loadedWorkspaceId: Set<string> = new Set();
|
||||
|
||||
private cacheDatabaseRowDocMap: Map<string, Y.Doc> = new Map();
|
||||
|
||||
constructor() {
|
||||
//
|
||||
}
|
||||
|
||||
currentWorkspace() {
|
||||
return getCurrentWorkspace();
|
||||
}
|
||||
|
||||
async getWorkspaceDatabases(): Promise<{ views: string[]; database_id: string }[]> {
|
||||
const workspace = await this.currentWorkspace();
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace database not found');
|
||||
}
|
||||
|
||||
const isLoaded = this.loadedWorkspaceId.has(workspace.id);
|
||||
|
||||
const workspaceDatabase = await getCollab(
|
||||
() => {
|
||||
return fetchCollab(workspace.id, workspace.workspaceDatabaseId, CollabType.WorkspaceDatabase);
|
||||
},
|
||||
{
|
||||
collabId: workspace.workspaceDatabaseId,
|
||||
collabType: CollabType.WorkspaceDatabase,
|
||||
},
|
||||
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
if (!isLoaded) {
|
||||
this.loadedWorkspaceId.add(workspace.id);
|
||||
}
|
||||
|
||||
return workspaceDatabase.getMap(YjsEditorKey.data_section).get(YjsEditorKey.workspace_database).toJSON() as {
|
||||
views: string[];
|
||||
database_id: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
async openDatabase(databaseId: string): Promise<{
|
||||
databaseDoc: YDoc;
|
||||
rows: Y.Map<YDoc>;
|
||||
}> {
|
||||
const workspace = await this.currentWorkspace();
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace database not found');
|
||||
}
|
||||
|
||||
const workspaceId = workspace.id;
|
||||
const isLoaded = this.loadedDatabaseId.has(databaseId);
|
||||
|
||||
const rootRowsDoc =
|
||||
this.cacheDatabaseRowDocMap.get(databaseId) ??
|
||||
new Y.Doc({
|
||||
guid: databaseId,
|
||||
});
|
||||
|
||||
if (!this.cacheDatabaseRowDocMap.has(databaseId)) {
|
||||
this.cacheDatabaseRowDocMap.set(databaseId, rootRowsDoc);
|
||||
}
|
||||
|
||||
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
|
||||
|
||||
const databaseDoc = await getCollab(
|
||||
() => {
|
||||
return fetchCollab(workspaceId, databaseId, CollabType.Database);
|
||||
},
|
||||
{
|
||||
collabId: databaseId,
|
||||
collabType: CollabType.Database,
|
||||
},
|
||||
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
if (!isLoaded) this.loadedDatabaseId.add(databaseId);
|
||||
|
||||
const database = databaseDoc.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database) as YDatabase;
|
||||
const viewId = database.get(YjsDatabaseKey.metas)?.get(YjsDatabaseKey.iid)?.toString();
|
||||
const rowOrders = database.get(YjsDatabaseKey.views)?.get(viewId)?.get(YjsDatabaseKey.row_orders);
|
||||
const rowOrdersIds = rowOrders.toJSON() as {
|
||||
id: string;
|
||||
}[];
|
||||
|
||||
if (!rowOrdersIds) {
|
||||
throw new Error('Database rows not found');
|
||||
}
|
||||
|
||||
const rowsParams = rowOrdersIds.map((item) => ({
|
||||
collabId: item.id,
|
||||
collabType: CollabType.DatabaseRow,
|
||||
}));
|
||||
|
||||
void batchCollab(
|
||||
() => {
|
||||
return batchFetchCollab(workspaceId, rowsParams);
|
||||
},
|
||||
rowsParams,
|
||||
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK,
|
||||
(id: string, doc: YDoc) => {
|
||||
if (!rowsFolder.has(id)) {
|
||||
rowsFolder.set(id, doc);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update rows if there are new rows added after the database has been loaded
|
||||
rowOrders?.observe((event) => {
|
||||
if (event.changes.added.size > 0) {
|
||||
const rowIds = rowOrders.toJSON() as {
|
||||
id: string;
|
||||
}[];
|
||||
|
||||
const params = rowIds.map((item) => ({
|
||||
collabId: item.id,
|
||||
collabType: CollabType.DatabaseRow,
|
||||
}));
|
||||
|
||||
void batchCollab(
|
||||
() => {
|
||||
return batchFetchCollab(workspaceId, params);
|
||||
},
|
||||
params,
|
||||
StrategyType.CACHE_AND_NETWORK,
|
||||
(id: string, doc: YDoc) => {
|
||||
if (!rowsFolder.has(id)) {
|
||||
rowsFolder.set(id, doc);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
databaseDoc,
|
||||
rows: rowsFolder,
|
||||
};
|
||||
}
|
||||
|
||||
async closeDatabase(databaseId: string) {
|
||||
this.cacheDatabaseRowDocMap.delete(databaseId);
|
||||
}
|
||||
}
|
@ -1,60 +0,0 @@
|
||||
/**
|
||||
* @description:
|
||||
* * This is a decorator that can be used to read data from storage and fetch data from the server.
|
||||
* * If the data is already in storage, it will return the data from storage and fetch the data from the server in the background.
|
||||
*
|
||||
* @param getStorage A function that returns the data from storage. eg. `() => Promise<T | undefined>`
|
||||
*
|
||||
* @param setStorage A function that saves the data to storage. eg. `(data: T) => Promise<void>`
|
||||
*
|
||||
* @param fetchFunction A function that fetches the data from the server. eg. `(params: P) => Promise<T | undefined>`
|
||||
*
|
||||
* @returns: A function that returns the data from storage and fetches the data from the server in the background.
|
||||
*/
|
||||
export function asyncDataDecorator<P, T>(
|
||||
getStorage: () => Promise<T | undefined>,
|
||||
setStorage: (data: T) => Promise<void>,
|
||||
fetchFunction: (params: P) => Promise<T | undefined>
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
async function fetchData(params: P) {
|
||||
const data = await fetchFunction(params);
|
||||
|
||||
if (!data) return;
|
||||
await setStorage(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function (params: P) {
|
||||
const data = await getStorage();
|
||||
|
||||
await originalMethod.apply(this, [params]);
|
||||
if (data) {
|
||||
void fetchData(params);
|
||||
return data;
|
||||
} else {
|
||||
return fetchData(params);
|
||||
}
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
export function afterSignInDecorator(successCallback: () => Promise<void>) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
await originalMethod.apply(this, args);
|
||||
await successCallback();
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type';
|
||||
import { getCollab } from '@/application/services/js-services/cache';
|
||||
import { StrategyType } from '@/application/services/js-services/cache/types';
|
||||
import { fetchCollab } from '@/application/services/js-services/fetch';
|
||||
import { getCurrentWorkspace } from 'src/application/services/js-services/session';
|
||||
import { DocumentService } from '@/application/services/services.type';
|
||||
|
||||
export class JSDocumentService implements DocumentService {
|
||||
private loaded: Set<string> = new Set();
|
||||
|
||||
constructor() {
|
||||
//
|
||||
}
|
||||
|
||||
async openDocument(docId: string): Promise<YDoc> {
|
||||
const workspace = await getCurrentWorkspace();
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace database not found');
|
||||
}
|
||||
|
||||
const isLoaded = this.loaded.has(docId);
|
||||
|
||||
const doc = await getCollab(
|
||||
() => {
|
||||
return fetchCollab(workspace.id, docId, CollabType.Document);
|
||||
},
|
||||
{
|
||||
collabId: docId,
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
if (!isLoaded) this.loaded.add(docId);
|
||||
const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => {
|
||||
if (origin === CollabOrigin.LocalSync) {
|
||||
// Send the update to the server
|
||||
console.log('update', update);
|
||||
}
|
||||
};
|
||||
|
||||
doc.on('update', handleUpdate);
|
||||
|
||||
return doc;
|
||||
}
|
||||
}
|
@ -1,4 +1,3 @@
|
||||
import { CollabType } from '@/application/collab.type';
|
||||
import { APIService } from '@/application/services/js-services/wasm';
|
||||
|
||||
const pendingRequests = new Map();
|
||||
@ -31,36 +30,20 @@ function fetchWithDeduplication<Req, Res>(url: string, params: Req, fetchFunctio
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch collab
|
||||
* @param workspaceId
|
||||
* @param id
|
||||
* @param type [CollabType]
|
||||
*/
|
||||
export function fetchCollab(workspaceId: string, id: string, type: CollabType) {
|
||||
const fetchFunction = () => APIService.getCollab(workspaceId, id, type);
|
||||
export function fetchPublishView(namespace: string, publishName: string) {
|
||||
const fetchFunction = () => APIService.getPublishView(namespace, publishName);
|
||||
|
||||
return fetchWithDeduplication(`fetchCollab_${workspaceId}`, { id, type }, fetchFunction);
|
||||
return fetchWithDeduplication(`fetchPublishView_${namespace}`, { publishName }, fetchFunction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch fetch collab
|
||||
* Usage:
|
||||
* // load database rows
|
||||
* const rows = await batchFetchCollab(workspaceId, databaseRows.map((row) => ({ collabId: row.id, collabType: CollabType.DatabaseRow })));
|
||||
*
|
||||
* @param workspaceId
|
||||
* @param params [{ collabId: string; collabType: CollabType }]
|
||||
*/
|
||||
export function batchFetchCollab(workspaceId: string, params: { collabId: string; collabType: CollabType }[]) {
|
||||
const fetchFunction = () =>
|
||||
APIService.batchGetCollab(
|
||||
workspaceId,
|
||||
params.map(({ collabId, collabType }) => ({
|
||||
object_id: collabId,
|
||||
collab_type: collabType,
|
||||
}))
|
||||
);
|
||||
export function fetchViewInfo(viewId: string) {
|
||||
const fetchFunction = () => APIService.getPublishInfoWithViewId(viewId);
|
||||
|
||||
return fetchWithDeduplication(`batchFetchCollab_${workspaceId}`, params, fetchFunction);
|
||||
return fetchWithDeduplication(`fetchViewInfo`, { viewId }, fetchFunction);
|
||||
}
|
||||
|
||||
export function fetchPublishViewMeta(namespace: string, publishName: string) {
|
||||
const fetchFunction = () => APIService.getPublishViewMeta(namespace, publishName);
|
||||
|
||||
return fetchWithDeduplication(`fetchPublishViewMeta_${namespace}`, { publishName }, fetchFunction);
|
||||
}
|
||||
|
@ -1,39 +0,0 @@
|
||||
import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type';
|
||||
import { getCollab } from '@/application/services/js-services/cache';
|
||||
import { StrategyType } from '@/application/services/js-services/cache/types';
|
||||
import { fetchCollab } from '@/application/services/js-services/fetch';
|
||||
import { FolderService } from '@/application/services/services.type';
|
||||
|
||||
export class JSFolderService implements FolderService {
|
||||
private loaded: Set<string> = new Set();
|
||||
|
||||
constructor() {
|
||||
//
|
||||
}
|
||||
|
||||
async openWorkspace(workspaceId: string): Promise<YDoc> {
|
||||
const isLoaded = this.loaded.has(workspaceId);
|
||||
const doc = await getCollab(
|
||||
() => {
|
||||
return fetchCollab(workspaceId, workspaceId, CollabType.Folder);
|
||||
},
|
||||
{
|
||||
collabId: workspaceId,
|
||||
collabType: CollabType.Folder,
|
||||
},
|
||||
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
if (!isLoaded) this.loaded.add(workspaceId);
|
||||
const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => {
|
||||
if (origin === CollabOrigin.LocalSync) {
|
||||
// Send the update to the server
|
||||
console.log('update', update);
|
||||
}
|
||||
};
|
||||
|
||||
doc.on('update', handleUpdate);
|
||||
|
||||
return doc;
|
||||
}
|
||||
}
|
@ -1,42 +1,28 @@
|
||||
import { JSDatabaseService } from '@/application/services/js-services/database.service';
|
||||
import {
|
||||
AFService,
|
||||
AFServiceConfig,
|
||||
AuthService,
|
||||
DatabaseService,
|
||||
DocumentService,
|
||||
FolderService,
|
||||
UserService,
|
||||
} from '@/application/services/services.type';
|
||||
import { JSUserService } from '@/application/services/js-services/user.service';
|
||||
import { JSAuthService } from '@/application/services/js-services/auth.service';
|
||||
import { JSFolderService } from '@/application/services/js-services/folder.service';
|
||||
import { JSDocumentService } from '@/application/services/js-services/document.service';
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { getBatchCollabs, getPublishView, getPublishViewMeta } from '@/application/services/js-services/cache';
|
||||
import { StrategyType } from '@/application/services/js-services/cache/types';
|
||||
import { fetchPublishView, fetchPublishViewMeta, fetchViewInfo } from '@/application/services/js-services/fetch';
|
||||
import { AFService, AFServiceConfig } from '@/application/services/services.type';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { initAPIService } from '@/application/services/js-services/wasm/client_api';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export class AFClientService implements AFService {
|
||||
authService: AuthService;
|
||||
|
||||
userService: UserService;
|
||||
|
||||
documentService: DocumentService;
|
||||
|
||||
folderService: FolderService;
|
||||
|
||||
databaseService: DatabaseService;
|
||||
|
||||
private deviceId: string = nanoid(8);
|
||||
|
||||
private clientId: string = 'web';
|
||||
|
||||
getDeviceID = (): string => {
|
||||
return this.deviceId;
|
||||
};
|
||||
private publishViewLoaded: Set<string> = new Set();
|
||||
|
||||
getClientID = (): string => {
|
||||
return this.clientId;
|
||||
};
|
||||
private publishViewInfo: Map<
|
||||
string,
|
||||
{
|
||||
namespace: string;
|
||||
publishName: string;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
private cacheDatabaseRowDocMap: Map<string, Y.Doc> = new Map();
|
||||
|
||||
constructor(config: AFServiceConfig) {
|
||||
initAPIService({
|
||||
@ -44,11 +30,104 @@ export class AFClientService implements AFService {
|
||||
deviceId: this.deviceId,
|
||||
clientId: this.clientId,
|
||||
});
|
||||
}
|
||||
|
||||
this.authService = new JSAuthService();
|
||||
this.userService = new JSUserService();
|
||||
this.documentService = new JSDocumentService();
|
||||
this.folderService = new JSFolderService();
|
||||
this.databaseService = new JSDatabaseService();
|
||||
async getPublishViewMeta(namespace: string, publishName: string) {
|
||||
const viewMeta = await getPublishViewMeta(
|
||||
() => {
|
||||
return fetchPublishViewMeta(namespace, publishName);
|
||||
},
|
||||
{
|
||||
namespace,
|
||||
publishName,
|
||||
},
|
||||
StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
if (!viewMeta) {
|
||||
return Promise.reject(new Error('View has not been published yet'));
|
||||
}
|
||||
|
||||
return viewMeta;
|
||||
}
|
||||
|
||||
async getPublishView(namespace: string, publishName: string) {
|
||||
const name = `${namespace}_${publishName}`;
|
||||
const isLoaded = this.publishViewLoaded.has(name);
|
||||
const doc = await getPublishView(
|
||||
() => {
|
||||
return fetchPublishView(namespace, publishName);
|
||||
},
|
||||
{
|
||||
namespace,
|
||||
publishName,
|
||||
},
|
||||
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
if (!isLoaded) {
|
||||
this.publishViewLoaded.add(name);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
async getPublishDatabaseViewRows(namespace: string, publishName: string, rowIds: string[]) {
|
||||
const name = `${namespace}_${publishName}`;
|
||||
|
||||
if (!this.publishViewLoaded.has(name)) {
|
||||
await this.getPublishView(namespace, publishName);
|
||||
}
|
||||
|
||||
const rootRowsDoc =
|
||||
this.cacheDatabaseRowDocMap.get(name) ??
|
||||
new Y.Doc({
|
||||
guid: name,
|
||||
});
|
||||
|
||||
if (!this.cacheDatabaseRowDocMap.has(name)) {
|
||||
this.cacheDatabaseRowDocMap.set(name, rootRowsDoc);
|
||||
}
|
||||
|
||||
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
|
||||
const docs = await getBatchCollabs(rowIds);
|
||||
|
||||
docs.forEach((doc, index) => {
|
||||
rowsFolder.set(rowIds[index], doc);
|
||||
});
|
||||
|
||||
return {
|
||||
rows: rowsFolder,
|
||||
destroy: () => {
|
||||
this.cacheDatabaseRowDocMap.delete(name);
|
||||
rootRowsDoc.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getPublishInfo(viewId: string) {
|
||||
if (this.publishViewInfo.has(viewId)) {
|
||||
return this.publishViewInfo.get(viewId) as {
|
||||
namespace: string;
|
||||
publishName: string;
|
||||
};
|
||||
}
|
||||
|
||||
const info = await fetchViewInfo(viewId);
|
||||
|
||||
const namespace = info.namespace;
|
||||
|
||||
if (!namespace) {
|
||||
return Promise.reject(new Error('View not found'));
|
||||
}
|
||||
|
||||
const data = {
|
||||
namespace,
|
||||
publishName: info.publish_name,
|
||||
};
|
||||
|
||||
this.publishViewInfo.set(viewId, data);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +0,0 @@
|
||||
export async function signInSuccess() {
|
||||
// Do nothing
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
export * from './token';
|
||||
export * from './user';
|
||||
export * from './auth';
|
@ -1,37 +0,0 @@
|
||||
import { notify } from '@/components/_shared/notify';
|
||||
|
||||
const tokenKey = 'token';
|
||||
|
||||
export function readTokenStr() {
|
||||
return sessionStorage.getItem(tokenKey);
|
||||
}
|
||||
|
||||
export function getAuthInfo() {
|
||||
const token = readTokenStr() || '';
|
||||
|
||||
try {
|
||||
const info = JSON.parse(token);
|
||||
|
||||
return {
|
||||
uuid: info.user.id,
|
||||
access_token: info.access_token,
|
||||
email: info.user.email,
|
||||
};
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeToken(token: string) {
|
||||
if (!token) {
|
||||
invalidToken();
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.setItem(tokenKey, token);
|
||||
}
|
||||
|
||||
export function invalidToken() {
|
||||
sessionStorage.removeItem(tokenKey);
|
||||
notify.error('Invalid token, please login again');
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
import { UserProfile, UserWorkspace, Workspace } from '@/application/user.type';
|
||||
|
||||
const userKey = 'user';
|
||||
const workspaceKey = 'workspace';
|
||||
|
||||
export async function getSignInUser(): Promise<UserProfile | undefined> {
|
||||
const userStr = localStorage.getItem(userKey);
|
||||
|
||||
try {
|
||||
return userStr ? JSON.parse(userStr) : undefined;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSignInUser(profile: UserProfile) {
|
||||
const userStr = JSON.stringify(profile);
|
||||
|
||||
localStorage.setItem(userKey, userStr);
|
||||
}
|
||||
|
||||
export async function getUserWorkspace(): Promise<UserWorkspace | undefined> {
|
||||
const str = localStorage.getItem(workspaceKey);
|
||||
|
||||
try {
|
||||
return str ? JSON.parse(str) : undefined;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setUserWorkspace(workspace: UserWorkspace) {
|
||||
const str = JSON.stringify(workspace);
|
||||
|
||||
localStorage.setItem(workspaceKey, str);
|
||||
}
|
||||
|
||||
export async function getCurrentWorkspace(): Promise<Workspace | undefined> {
|
||||
const userProfile = await getSignInUser();
|
||||
const userWorkspace = await getUserWorkspace();
|
||||
|
||||
return userWorkspace?.workspaces.find((workspace) => workspace.id === userProfile?.workspaceId);
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
import { UserService } from '@/application/services/services.type';
|
||||
import { UserProfile, UserWorkspace } from '@/application/user.type';
|
||||
import { APIService } from 'src/application/services/js-services/wasm';
|
||||
import {
|
||||
getAuthInfo,
|
||||
getSignInUser,
|
||||
getUserWorkspace,
|
||||
invalidToken,
|
||||
setSignInUser,
|
||||
setUserWorkspace,
|
||||
} from 'src/application/services/js-services/session';
|
||||
import { asyncDataDecorator } from '@/application/services/js-services/decorator';
|
||||
|
||||
async function getUser() {
|
||||
try {
|
||||
const user = await APIService.getUser();
|
||||
|
||||
return user;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
invalidToken();
|
||||
}
|
||||
}
|
||||
|
||||
export class JSUserService implements UserService {
|
||||
@asyncDataDecorator<void, UserProfile>(getSignInUser, setSignInUser, getUser)
|
||||
async getUserProfile(): Promise<UserProfile> {
|
||||
if (!getAuthInfo()) {
|
||||
return Promise.reject('Not authenticated');
|
||||
}
|
||||
|
||||
await this.getUserWorkspace();
|
||||
|
||||
return null!;
|
||||
}
|
||||
|
||||
async checkUser(): Promise<boolean> {
|
||||
return (await getSignInUser()) !== undefined;
|
||||
}
|
||||
|
||||
@asyncDataDecorator<void, UserWorkspace>(getUserWorkspace, setUserWorkspace, APIService.getUserWorkspace)
|
||||
async getUserWorkspace(): Promise<UserWorkspace> {
|
||||
return null!;
|
||||
}
|
||||
}
|
@ -1,8 +1,5 @@
|
||||
import { CollabType } from '@/application/collab.type';
|
||||
import { ClientAPI } from '@appflowyinc/client-api-wasm';
|
||||
import { UserProfile, UserWorkspace } from '@/application/user.type';
|
||||
import { AFCloudConfig } from '@/application/services/services.type';
|
||||
import { invalidToken, readTokenStr, writeToken } from 'src/application/services/js-services/session';
|
||||
|
||||
let client: ClientAPI;
|
||||
|
||||
@ -12,8 +9,14 @@ export function initAPIService(
|
||||
clientId: string;
|
||||
}
|
||||
) {
|
||||
window.refresh_token = writeToken;
|
||||
window.invalid_token = invalidToken;
|
||||
window.refresh_token = () => {
|
||||
//
|
||||
};
|
||||
|
||||
window.invalid_token = () => {
|
||||
// invalidToken();
|
||||
};
|
||||
|
||||
client = ClientAPI.new({
|
||||
base_url: config.baseURL,
|
||||
ws_addr: config.wsURL,
|
||||
@ -26,96 +29,17 @@ export function initAPIService(
|
||||
},
|
||||
});
|
||||
|
||||
const token = readTokenStr();
|
||||
|
||||
if (token) {
|
||||
client.restore_token(token);
|
||||
}
|
||||
|
||||
client.subscribe();
|
||||
}
|
||||
|
||||
export function signIn(email: string, password: string) {
|
||||
return client.login(email, password);
|
||||
export async function getPublishView(publishNamespace: string, publishName: string) {
|
||||
return client.get_publish_view(publishNamespace, publishName);
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return client.logout();
|
||||
export async function getPublishInfoWithViewId(viewId: string) {
|
||||
return client.get_publish_info(viewId);
|
||||
}
|
||||
|
||||
export async function getUser(): Promise<UserProfile> {
|
||||
try {
|
||||
const user = await client.get_user();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('No user found');
|
||||
}
|
||||
|
||||
return {
|
||||
uid: parseInt(user.uid),
|
||||
uuid: user.uuid || undefined,
|
||||
email: user.email || undefined,
|
||||
name: user.name || undefined,
|
||||
workspaceId: user.latest_workspace_id,
|
||||
iconUrl: user.icon_url || undefined,
|
||||
};
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCollab(workspaceId: string, object_id: string, collabType: CollabType) {
|
||||
const res = await client.get_collab({
|
||||
workspace_id: workspaceId,
|
||||
object_id: object_id,
|
||||
collab_type: Number(collabType) as 0 | 1 | 2 | 3 | 4 | 5,
|
||||
});
|
||||
|
||||
const state = new Uint8Array(res.doc_state);
|
||||
|
||||
return {
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
export async function batchGetCollab(
|
||||
workspaceId: string,
|
||||
params: {
|
||||
object_id: string;
|
||||
collab_type: CollabType;
|
||||
}[]
|
||||
) {
|
||||
const res = (await client.batch_get_collab(
|
||||
workspaceId,
|
||||
params.map((param) => ({
|
||||
object_id: param.object_id,
|
||||
collab_type: Number(param.collab_type) as 0 | 1 | 2 | 3 | 4 | 5,
|
||||
}))
|
||||
)) as unknown as Map<string, { doc_state: number[] }>;
|
||||
|
||||
const result: Record<string, number[]> = {};
|
||||
|
||||
res.forEach((value, key) => {
|
||||
result[key] = value.doc_state;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getUserWorkspace(): Promise<UserWorkspace> {
|
||||
const res = await client.get_user_workspace();
|
||||
|
||||
return {
|
||||
visitingWorkspaceId: res.visiting_workspace_id,
|
||||
workspaces: res.workspaces.map((workspace) => ({
|
||||
id: workspace.workspace_id,
|
||||
name: workspace.workspace_name,
|
||||
icon: workspace.icon,
|
||||
owner: {
|
||||
id: Number(workspace.owner_uid),
|
||||
name: workspace.owner_name,
|
||||
},
|
||||
type: workspace.workspace_type,
|
||||
workspaceDatabaseId: workspace.database_storage_id,
|
||||
})),
|
||||
};
|
||||
export async function getPublishViewMeta(publishNamespace: string, publishName: string) {
|
||||
return client.get_publish_view_meta(publishNamespace, publishName);
|
||||
}
|
||||
|
@ -1,16 +1,8 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type';
|
||||
import { ViewMeta } from '@/application/db/tables/view_metas';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export interface AFService {
|
||||
getDeviceID: () => string;
|
||||
getClientID: () => string;
|
||||
authService: AuthService;
|
||||
userService: UserService;
|
||||
documentService: DocumentService;
|
||||
folderService: FolderService;
|
||||
databaseService: DatabaseService;
|
||||
}
|
||||
export type AFService = PublishService;
|
||||
|
||||
export interface AFServiceConfig {
|
||||
cloudConfig: AFCloudConfig;
|
||||
@ -22,35 +14,16 @@ export interface AFCloudConfig {
|
||||
wsURL: string;
|
||||
}
|
||||
|
||||
export interface AuthService {
|
||||
getOAuthURL: (provider: ProviderType) => Promise<string>;
|
||||
signInWithOAuth: (params: { uri: string }) => Promise<void>;
|
||||
signupWithEmailPassword: (params: SignUpWithEmailPasswordParams) => Promise<void>;
|
||||
signinWithEmailPassword: (email: string, password: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DocumentService {
|
||||
openDocument: (docId: string) => Promise<YDoc>;
|
||||
}
|
||||
|
||||
export interface DatabaseService {
|
||||
getWorkspaceDatabases: () => Promise<{ views: string[]; database_id: string }[]>;
|
||||
openDatabase: (
|
||||
databaseId: string,
|
||||
rowIds?: string[]
|
||||
export interface PublishService {
|
||||
getPublishViewMeta: (namespace: string, publishName: string) => Promise<ViewMeta>;
|
||||
getPublishView: (namespace: string, publishName: string) => Promise<YDoc>;
|
||||
getPublishInfo: (viewId: string) => Promise<{ namespace: string; publishName: string }>;
|
||||
getPublishDatabaseViewRows: (
|
||||
namespace: string,
|
||||
publishName: string,
|
||||
rowIds: string[]
|
||||
) => Promise<{
|
||||
databaseDoc: YDoc;
|
||||
rows: Y.Map<YDoc>;
|
||||
destroy: () => void;
|
||||
}>;
|
||||
closeDatabase: (databaseId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UserService {
|
||||
getUserProfile: () => Promise<UserProfile | null>;
|
||||
checkUser: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface FolderService {
|
||||
openWorkspace: (workspaceId: string) => Promise<YDoc>;
|
||||
}
|
||||
|
@ -1,114 +0,0 @@
|
||||
import { AFCloudConfig, AuthService } from '@/application/services/services.type';
|
||||
import {
|
||||
AuthenticatorPB,
|
||||
OauthProviderPB,
|
||||
OauthSignInPB,
|
||||
SignInPayloadPB,
|
||||
SignUpPayloadPB,
|
||||
UserEventGetOauthURLWithProvider,
|
||||
UserEventOauthSignIn,
|
||||
UserEventSignInWithEmailPassword,
|
||||
UserEventSignOut,
|
||||
UserEventSignUp,
|
||||
UserProfilePB,
|
||||
} from './backend/events/flowy-user';
|
||||
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type';
|
||||
|
||||
export class TauriAuthService implements AuthService {
|
||||
|
||||
constructor (private cloudConfig: AFCloudConfig, private clientConfig: {
|
||||
deviceId: string;
|
||||
clientId: string;
|
||||
|
||||
}) {}
|
||||
|
||||
getDeviceID = (): string => {
|
||||
return this.clientConfig.deviceId;
|
||||
};
|
||||
getOAuthURL = async (provider: ProviderType): Promise<string> => {
|
||||
const providerDataRes = await UserEventGetOauthURLWithProvider(
|
||||
OauthProviderPB.fromObject({
|
||||
provider: provider as number,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!providerDataRes.ok) {
|
||||
throw new Error(providerDataRes.val.msg);
|
||||
}
|
||||
|
||||
const providerData = providerDataRes.val;
|
||||
|
||||
return providerData.oauth_url;
|
||||
};
|
||||
|
||||
signInWithOAuth = async ({ uri }: { uri: string }): Promise<void> => {
|
||||
const payload = OauthSignInPB.fromObject({
|
||||
authenticator: AuthenticatorPB.AppFlowyCloud,
|
||||
map: {
|
||||
sign_in_url: uri,
|
||||
device_id: this.getDeviceID(),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await UserEventOauthSignIn(payload);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.val.msg);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
signinWithEmailPassword = async (email: string, password: string): Promise<void> => {
|
||||
const payload = SignInPayloadPB.fromObject({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
const res = await UserEventSignInWithEmailPassword(payload);
|
||||
|
||||
if (!res.ok) {
|
||||
return Promise.reject(res.val.msg);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
signupWithEmailPassword = async (params: SignUpWithEmailPasswordParams): Promise<void> => {
|
||||
const payload = SignUpPayloadPB.fromObject({
|
||||
name: params.name,
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
device_id: this.getDeviceID(),
|
||||
});
|
||||
|
||||
const res = await UserEventSignUp(payload);
|
||||
|
||||
if (!res.ok) {
|
||||
return Promise.reject(res.val.msg);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
signOut = async () => {
|
||||
const res = await UserEventSignOut();
|
||||
|
||||
if (!res.ok) {
|
||||
return Promise.reject(res.val.msg);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUserProfileFrom (userPB: UserProfilePB): UserProfile {
|
||||
const user = userPB.toObject();
|
||||
|
||||
return {
|
||||
uid: user.id as number,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
iconUrl: user.icon_url,
|
||||
workspaceId: user.workspace_id,
|
||||
};
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { DatabaseService } from '@/application/services/services.type';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export class TauriDatabaseService implements DatabaseService {
|
||||
constructor() {
|
||||
//
|
||||
}
|
||||
|
||||
async getWorkspaceDatabases(): Promise<{ views: string[]; database_id: string }[]> {
|
||||
return Promise.reject('Not implemented');
|
||||
}
|
||||
|
||||
async closeDatabase(_databaseId: string) {
|
||||
return Promise.reject('Not implemented');
|
||||
}
|
||||
|
||||
async openDatabase(_viewId: string): Promise<{
|
||||
databaseDoc: YDoc;
|
||||
rows: Y.Map<YDoc>;
|
||||
}> {
|
||||
return Promise.reject('Not implemented');
|
||||
}
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
import { DocumentService } from '@/application/services/services.type';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export class TauriDocumentService implements DocumentService {
|
||||
async openDocument(_id: string): Promise<Y.Doc> {
|
||||
return Promise.reject('Not implemented');
|
||||
}
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { FolderService } from '@/application/services/services.type';
|
||||
|
||||
export class TauriFolderService implements FolderService {
|
||||
constructor() {
|
||||
//
|
||||
}
|
||||
|
||||
async openWorkspace(_workspaceId: string): Promise<YDoc> {
|
||||
return Promise.reject('Not implemented');
|
||||
}
|
||||
}
|
@ -1,50 +1,24 @@
|
||||
import {
|
||||
AFService,
|
||||
AFServiceConfig,
|
||||
AuthService,
|
||||
DatabaseService,
|
||||
DocumentService,
|
||||
FolderService,
|
||||
UserService,
|
||||
} from '@/application/services/services.type';
|
||||
import { TauriAuthService } from '@/application/services/tauri-services/auth.service';
|
||||
import { TauriDatabaseService } from '@/application/services/tauri-services/database.service';
|
||||
import { TauriFolderService } from '@/application/services/tauri-services/folder.service';
|
||||
import { TauriUserService } from '@/application/services/tauri-services/user.service';
|
||||
import { TauriDocumentService } from '@/application/services/tauri-services/document.service';
|
||||
import { AFService } from '@/application/services/services.type';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export class AFClientService implements AFService {
|
||||
authService: AuthService;
|
||||
|
||||
userService: UserService;
|
||||
|
||||
documentService: DocumentService;
|
||||
|
||||
folderService: FolderService;
|
||||
|
||||
databaseService: DatabaseService;
|
||||
|
||||
private deviceId: string = nanoid(8);
|
||||
|
||||
private clientId: string = 'web';
|
||||
private clientId: string = 'tauri';
|
||||
|
||||
getDeviceID = (): string => {
|
||||
return this.deviceId;
|
||||
};
|
||||
async getPublishView(_namespace: string, _publishName: string) {
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
|
||||
getClientID = (): string => {
|
||||
return this.clientId;
|
||||
};
|
||||
async getPublishInfo(_viewId: string) {
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
|
||||
constructor(config: AFServiceConfig) {
|
||||
this.authService = new TauriAuthService(config.cloudConfig, {
|
||||
deviceId: this.deviceId,
|
||||
clientId: this.clientId,
|
||||
});
|
||||
this.userService = new TauriUserService();
|
||||
this.documentService = new TauriDocumentService();
|
||||
this.folderService = new TauriFolderService();
|
||||
this.databaseService = new TauriDatabaseService();
|
||||
async getPublishViewMeta(_namespace: string, _publishName: string) {
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
|
||||
async getPublishDatabaseViewRows(_namespace: string, _publishName: string, _rowIds: string[]) {
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
}
|
||||
|
@ -1,20 +0,0 @@
|
||||
import { UserService } from '@/application/services/services.type';
|
||||
import { UserProfile } from '@/application/user.type';
|
||||
import { UserEventGetUserProfile } from './backend/events/flowy-user';
|
||||
import { parseUserProfileFrom } from '@/application/services/tauri-services/auth.service';
|
||||
|
||||
export class TauriUserService implements UserService {
|
||||
async getUserProfile(): Promise<UserProfile | null> {
|
||||
const res = await UserEventGetUserProfile();
|
||||
|
||||
if (res.ok) {
|
||||
return parseUserProfileFrom(res.val);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async checkUser(): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
export enum Authenticator {
|
||||
Local = 0,
|
||||
Supabase = 1,
|
||||
AppFlowyCloud = 2,
|
||||
}
|
||||
|
||||
export enum EncryptionType {
|
||||
NoEncryption = 0,
|
||||
Symmetric = 1,
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
uid: number;
|
||||
uuid?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
iconUrl?: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export interface UserWorkspace {
|
||||
visitingWorkspaceId: string;
|
||||
workspaces: Workspace[];
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
owner: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
type: number;
|
||||
workspaceDatabaseId: string;
|
||||
}
|
||||
|
||||
export interface SignUpWithEmailPasswordParams {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export enum ProviderType {
|
||||
Apple = 0,
|
||||
Azure = 1,
|
||||
Bitbucket = 2,
|
||||
Discord = 3,
|
||||
Facebook = 4,
|
||||
Figma = 5,
|
||||
Github = 6,
|
||||
Gitlab = 7,
|
||||
Google = 8,
|
||||
Keycloak = 9,
|
||||
Kakao = 10,
|
||||
Linkedin = 11,
|
||||
Notion = 12,
|
||||
Spotify = 13,
|
||||
Slack = 14,
|
||||
Workos = 15,
|
||||
Twitch = 16,
|
||||
Twitter = 17,
|
||||
Email = 18,
|
||||
Phone = 19,
|
||||
Zoom = 20,
|
||||
}
|
||||
|
||||
export interface UserSetting {
|
||||
workspaceId: string;
|
||||
latestView?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
hasLatestView: boolean;
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
import { YFolder } from '@/application/collab.type';
|
||||
import { Crumb, FolderContext } from '@/application/folder-yjs';
|
||||
|
||||
export const FolderProvider: React.FC<{
|
||||
folder: YFolder | null;
|
||||
children?: React.ReactNode;
|
||||
onNavigateToView?: (viewId: string) => void;
|
||||
crumbs?: Crumb[];
|
||||
setCrumbs?: React.Dispatch<React.SetStateAction<Crumb[]>>;
|
||||
}> = ({ folder, children, onNavigateToView, crumbs, setCrumbs }) => {
|
||||
return (
|
||||
<FolderContext.Provider
|
||||
value={{
|
||||
folder,
|
||||
onNavigateToView,
|
||||
crumbs,
|
||||
setCrumbs,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FolderContext.Provider>
|
||||
);
|
||||
};
|
@ -1,17 +0,0 @@
|
||||
import { useContext, createContext } from 'react';
|
||||
|
||||
export const IdContext = createContext<IdProviderProps | null>(null);
|
||||
|
||||
interface IdProviderProps {
|
||||
objectId: string;
|
||||
}
|
||||
|
||||
export const IdProvider = ({ children, ...props }: IdProviderProps & { children: React.ReactNode }) => {
|
||||
return <IdContext.Provider value={props}>{children}</IdContext.Provider>;
|
||||
};
|
||||
|
||||
const defaultIdValue = {} as IdProviderProps;
|
||||
|
||||
export function useId() {
|
||||
return useContext(IdContext) || defaultIdValue;
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
import { getCurrentWorkspace } from 'src/application/services/js-services/session';
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export function RecordNotFound({ open, title }: { open: boolean; title?: string }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogTitle>Oops.. something went wrong</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id='alert-dialog-description'>
|
||||
{title ? title : 'The record you are looking for does not exist.'}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions className={'flex w-full items-center justify-center'}>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const workspace = await getCurrentWorkspace();
|
||||
|
||||
if (!workspace) return;
|
||||
navigate(`/view/${workspace.id}`);
|
||||
}}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecordNotFound;
|
@ -1 +0,0 @@
|
||||
export * from './RecordNotFound';
|
@ -1,27 +1,14 @@
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const commonOptions = {
|
||||
style: {
|
||||
background: 'var(--bg-base)',
|
||||
color: 'var(--text-title)',
|
||||
shadows: 'var(--shadow)',
|
||||
},
|
||||
};
|
||||
|
||||
export const notify = {
|
||||
success: (message: string) => {
|
||||
toast.success(message, commonOptions);
|
||||
window.toast.success(message);
|
||||
},
|
||||
error: (message: string) => {
|
||||
toast.error(message, commonOptions);
|
||||
},
|
||||
loading: (message: string) => {
|
||||
toast.loading(message, commonOptions);
|
||||
window.toast.error(message);
|
||||
},
|
||||
info: (message: string) => {
|
||||
toast(message, commonOptions);
|
||||
window.toast.info(message);
|
||||
},
|
||||
clear: () => {
|
||||
toast.dismiss();
|
||||
window.toast.clear();
|
||||
},
|
||||
};
|
||||
|
@ -1,30 +0,0 @@
|
||||
import { YView } from '@/application/collab.type';
|
||||
import { usePageInfo } from '@/components/_shared/page/usePageInfo';
|
||||
import React from 'react';
|
||||
|
||||
export function Page({
|
||||
id,
|
||||
onClick,
|
||||
...props
|
||||
}: {
|
||||
id: string;
|
||||
onClick?: (view: YView) => void;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const { view, icon, name } = usePageInfo(id);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
onClick && view && onClick(view);
|
||||
}}
|
||||
className={'flex items-center justify-center gap-2 overflow-hidden'}
|
||||
{...props}
|
||||
>
|
||||
<div>{icon}</div>
|
||||
<div className={'flex-1 truncate'}>{name}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
@ -1 +0,0 @@
|
||||
export * from './Page';
|
@ -1,92 +0,0 @@
|
||||
import { FontLayout, LineHeightLayout, ViewLayout, YjsFolderKey, YView } from '@/application/collab.type';
|
||||
import { useViewSelector } from '@/application/folder-yjs';
|
||||
import { CoverType } from '@/application/folder-yjs/folder.type';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { ReactComponent as DocumentSvg } from '$icons/16x/document.svg';
|
||||
import { ReactComponent as GridSvg } from '$icons/16x/grid.svg';
|
||||
import { ReactComponent as BoardSvg } from '$icons/16x/board.svg';
|
||||
import { ReactComponent as CalendarSvg } from '$icons/16x/date.svg';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface PageCover {
|
||||
type: CoverType;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PageExtra {
|
||||
cover: PageCover | null;
|
||||
fontLayout: FontLayout;
|
||||
lineHeightLayout: LineHeightLayout;
|
||||
font?: string;
|
||||
}
|
||||
|
||||
function parseExtra(extra: string): PageExtra {
|
||||
let extraObj;
|
||||
|
||||
try {
|
||||
extraObj = JSON.parse(extra);
|
||||
} catch (e) {
|
||||
extraObj = {};
|
||||
}
|
||||
|
||||
return {
|
||||
cover: extraObj.cover
|
||||
? {
|
||||
type: extraObj.cover.type,
|
||||
value: extraObj.cover.value,
|
||||
}
|
||||
: null,
|
||||
fontLayout: extraObj.font_layout || FontLayout.normal,
|
||||
lineHeightLayout: extraObj.line_height_layout || LineHeightLayout.normal,
|
||||
font: extraObj.font,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePageInfo(id: string) {
|
||||
const { view } = useViewSelector(id);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const layout = view?.get(YjsFolderKey.layout);
|
||||
const icon = view?.get(YjsFolderKey.icon);
|
||||
const extra = view?.get(YjsFolderKey.extra);
|
||||
const name = view?.get(YjsFolderKey.name) || '';
|
||||
const iconObj = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(icon || '');
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}, [icon]);
|
||||
|
||||
const extraObj = useMemo(() => {
|
||||
return parseExtra(extra || '');
|
||||
}, [extra]);
|
||||
|
||||
const defaultIcon = useMemo(() => {
|
||||
switch (parseInt(layout ?? '0')) {
|
||||
case ViewLayout.Document:
|
||||
return <DocumentSvg />;
|
||||
case ViewLayout.Grid:
|
||||
return <GridSvg />;
|
||||
case ViewLayout.Board:
|
||||
return <BoardSvg />;
|
||||
case ViewLayout.Calendar:
|
||||
return <CalendarSvg />;
|
||||
default:
|
||||
return <DocumentSvg />;
|
||||
}
|
||||
}, [layout]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(!view);
|
||||
}, [view]);
|
||||
return {
|
||||
icon: iconObj?.value || defaultIcon,
|
||||
name: name || t('menuAppHeader.defaultNewPageName'),
|
||||
view: view as YView,
|
||||
loading,
|
||||
extra: extraObj,
|
||||
};
|
||||
}
|
@ -1,18 +1,12 @@
|
||||
import FolderPage from '@/pages/FolderPage';
|
||||
import PublishPage from '@/pages/PublishPage';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import ProtectedRoutes from '@/components/auth/ProtectedRoutes';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
import ProductPage from '@/pages/ProductPage';
|
||||
import withAppWrapper from '@/components/app/withAppWrapper';
|
||||
import '@/styles/app.scss';
|
||||
|
||||
const AppMain = withAppWrapper(() => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path={'/'} element={<ProtectedRoutes />}>
|
||||
<Route path={'/view/:workspaceId'} element={<FolderPage />} />
|
||||
<Route path={'/view/:workspaceId/:objectId'} element={<ProductPage />} />
|
||||
</Route>
|
||||
<Route path={'/login'} element={<LoginPage />} />
|
||||
<Route path={'/:namespace/:publishName'} element={<PublishPage />} />
|
||||
</Routes>
|
||||
);
|
||||
});
|
||||
|
@ -1,8 +1,27 @@
|
||||
import { useAppLanguage } from '@/components/app/useAppLanguage';
|
||||
import React, { createContext, useEffect, useMemo, useState } from 'react';
|
||||
import { AFService } from '@/application/services/services.type';
|
||||
import React, { createContext, useEffect, useState } from 'react';
|
||||
import { AFService, AFServiceConfig } from '@/application/services/services.type';
|
||||
import { getService } from '@/application/services';
|
||||
import { useAppSelector } from '@/stores/store';
|
||||
|
||||
const defaultConfig: AFServiceConfig = {
|
||||
cloudConfig: {
|
||||
baseURL: import.meta.env.AF_BASE_URL
|
||||
? import.meta.env.AF_BASE_URL
|
||||
: import.meta.env.DEV
|
||||
? 'https://test.appflowy.cloud'
|
||||
: 'https://beta.appflowy.cloud',
|
||||
gotrueURL: import.meta.env.AF_GOTRUE_URL
|
||||
? import.meta.env.AF_GOTRUE_URL
|
||||
: import.meta.env.DEV
|
||||
? 'https://test.appflowy.cloud/gotrue'
|
||||
: 'https://beta.appflowy.cloud/gotrue',
|
||||
wsURL: import.meta.env.AF_WS_URL
|
||||
? import.meta.env.AF_WS_URL
|
||||
: import.meta.env.DEV
|
||||
? 'wss://test.appflowy.cloud/ws/v1'
|
||||
: 'wss://beta.appflowy.cloud/ws/v1',
|
||||
},
|
||||
};
|
||||
|
||||
export const AFConfigContext = createContext<
|
||||
| {
|
||||
@ -12,7 +31,7 @@ export const AFConfigContext = createContext<
|
||||
>(undefined);
|
||||
|
||||
function AppConfig({ children }: { children: React.ReactNode }) {
|
||||
const appConfig = useAppSelector((state) => state.app.appConfig);
|
||||
const [appConfig] = useState<AFServiceConfig>(defaultConfig);
|
||||
const [service, setService] = useState<AFService>();
|
||||
|
||||
useAppLanguage();
|
||||
@ -24,14 +43,15 @@ function AppConfig({ children }: { children: React.ReactNode }) {
|
||||
})();
|
||||
}, [appConfig]);
|
||||
|
||||
const config = useMemo(
|
||||
() => ({
|
||||
service,
|
||||
}),
|
||||
[service]
|
||||
return (
|
||||
<AFConfigContext.Provider
|
||||
value={{
|
||||
service,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AFConfigContext.Provider>
|
||||
);
|
||||
|
||||
return <AFConfigContext.Provider value={config}>{children}</AFConfigContext.Provider>;
|
||||
}
|
||||
|
||||
export default AppConfig;
|
||||
|
@ -1,27 +1,52 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from 'src/stores/store';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { ErrorHandlerPage } from 'src/components/error/ErrorHandlerPage';
|
||||
import AppTheme from '@/components/app/AppTheme';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import AppConfig from '@/components/app/AppConfig';
|
||||
import { Suspense } from 'react';
|
||||
import { Suspense, useEffect } from 'react';
|
||||
import { SnackbarProvider, useSnackbar } from 'notistack';
|
||||
|
||||
export default function withAppWrapper (Component: React.FC): React.FC {
|
||||
return function AppWrapper (): JSX.Element {
|
||||
export default function withAppWrapper(Component: React.FC): React.FC {
|
||||
return function AppWrapper(): JSX.Element {
|
||||
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
|
||||
|
||||
useEffect(() => {
|
||||
window.toast = {
|
||||
success: (message: string) => {
|
||||
enqueueSnackbar(message, { variant: 'success' });
|
||||
},
|
||||
error: (message: string) => {
|
||||
enqueueSnackbar(message, { variant: 'error' });
|
||||
},
|
||||
warning: (message: string) => {
|
||||
enqueueSnackbar(message, { variant: 'warning' });
|
||||
},
|
||||
info: (message: string) => {
|
||||
enqueueSnackbar(message, { variant: 'info' });
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
closeSnackbar();
|
||||
},
|
||||
};
|
||||
}, [closeSnackbar, enqueueSnackbar]);
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<AppTheme>
|
||||
<ErrorBoundary FallbackComponent={ErrorHandlerPage}>
|
||||
<AppTheme>
|
||||
<ErrorBoundary FallbackComponent={ErrorHandlerPage}>
|
||||
<SnackbarProvider
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
preventDuplicate
|
||||
>
|
||||
<AppConfig>
|
||||
<Suspense>
|
||||
<Component />
|
||||
<Toaster />
|
||||
</Suspense>
|
||||
</AppConfig>
|
||||
</ErrorBoundary>
|
||||
</AppTheme>
|
||||
</Provider>
|
||||
</SnackbarProvider>
|
||||
</ErrorBoundary>
|
||||
</AppTheme>
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,70 +0,0 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import GoogleIcon from '@/assets/settings/google.png';
|
||||
import GithubIcon from '@/assets/settings/github.png';
|
||||
import DiscordIcon from '@/assets/settings/discord.png';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from './auth.hooks';
|
||||
import { ProviderType } from '@/application/user.type';
|
||||
import { useState } from 'react';
|
||||
import EmailOutlined from '@mui/icons-material/EmailOutlined';
|
||||
import SignInWithEmail from './SignInWithEmail';
|
||||
|
||||
export const LoginButtonGroup = () => {
|
||||
const { t } = useTranslation();
|
||||
const [openSignInWithEmail, setOpenSignInWithEmail] = useState(false);
|
||||
const { signInWithProvider } = useAuth();
|
||||
|
||||
return (
|
||||
<div className={'flex w-full flex-col items-center gap-4'}>
|
||||
<Button
|
||||
data-cy={'signInWithEmail'}
|
||||
onClick={() => {
|
||||
setOpenSignInWithEmail(true);
|
||||
}}
|
||||
className={'w-full rounded-lg border-text-title py-3 text-sm'}
|
||||
color={'inherit'}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<EmailOutlined className={'mr-2 h-6 w-6'} />
|
||||
{t('signIn.signInWithEmail')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled
|
||||
onClick={() => {
|
||||
void signInWithProvider(ProviderType.Google);
|
||||
}}
|
||||
className={'w-full rounded-lg border-text-title py-3 text-sm'}
|
||||
color={'inherit'}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<img src={GoogleIcon} alt={'Google'} className={'mr-2 h-6 w-6'} />
|
||||
{t('button.signInGoogle')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled
|
||||
onClick={() => {
|
||||
void signInWithProvider(ProviderType.Github);
|
||||
}}
|
||||
className={'w-full rounded-lg border-text-title py-3 text-sm'}
|
||||
color={'inherit'}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<img src={GithubIcon} alt={'Github'} className={'mr-2 h-6 w-6'} />
|
||||
{t('button.signInGithub')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled
|
||||
onClick={() => {
|
||||
void signInWithProvider(ProviderType.Discord);
|
||||
}}
|
||||
className={'w-full rounded-lg border-text-title py-3 text-sm'}
|
||||
color={'inherit'}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<img src={DiscordIcon} alt={'Discord'} className={'mr-2 h-6 w-6'} />
|
||||
{t('button.signInDiscord')}
|
||||
</Button>
|
||||
<SignInWithEmail open={openSignInWithEmail} onClose={() => setOpenSignInWithEmail(false)} />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,97 +0,0 @@
|
||||
import React, { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAuth } from '@/components/auth/auth.hooks';
|
||||
import { currentUserActions, LoginState } from '@/stores/currentUser/slice';
|
||||
import { useAppDispatch } from '@/stores/store';
|
||||
import { getPlatform } from '@/utils/platform';
|
||||
import SplashScreen from '@/components/auth/SplashScreen';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Portal from '@mui/material/Portal';
|
||||
import { ReactComponent as Logo } from '@/assets/logo.svg';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const TauriAuth = lazy(() => import('@/components/tauri/TauriAuth'));
|
||||
|
||||
function ProtectedRoutes() {
|
||||
const { currentUser, checkUser, isReady } = useAuth();
|
||||
|
||||
const isLoading = currentUser?.loginState === LoginState.LOADING;
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
const checkUserStatus = useCallback(async () => {
|
||||
if (!isReady) return;
|
||||
setChecked(false);
|
||||
try {
|
||||
if (!currentUser.isAuthenticated) {
|
||||
await checkUser();
|
||||
}
|
||||
} finally {
|
||||
setChecked(true);
|
||||
}
|
||||
}, [checkUser, isReady, currentUser.isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
void checkUserStatus();
|
||||
}, [checkUserStatus]);
|
||||
|
||||
const platform = useMemo(() => getPlatform(), []);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (checked && !currentUser.isAuthenticated && window.location.pathname !== '/login') {
|
||||
navigate(`/login?redirect=${encodeURIComponent(window.location.pathname)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentUser.user?.workspaceId && (window.location.pathname === '/' || window.location.pathname === '')) {
|
||||
navigate(`/view/${currentUser.user.workspaceId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={'relative h-screen w-screen bg-bg-body'}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{checked ? (
|
||||
<SplashScreen />
|
||||
) : (
|
||||
<div className={'flex h-screen w-screen items-center justify-center'}>
|
||||
<Logo className={'h-20 w-20'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <StartLoading />}
|
||||
{platform.isTauri && <TauriAuth />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProtectedRoutes;
|
||||
|
||||
const StartLoading = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
const preventDefault = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
dispatch(currentUserActions.resetLoginState());
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', preventDefault, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', preventDefault, true);
|
||||
};
|
||||
}, [dispatch]);
|
||||
return (
|
||||
<Portal>
|
||||
<div className={'bg-bg-mask fixed inset-0 z-[1400] flex h-full w-full items-center justify-center bg-opacity-50'}>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
};
|
@ -1,83 +0,0 @@
|
||||
import { Button, CircularProgress, Dialog, DialogActions, DialogContent, TextField } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '@/components/auth/auth.hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function SignInWithEmail({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const { signInWithEmailPassword } = useAuth();
|
||||
|
||||
const handleSignIn = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await signInWithEmailPassword(email, password);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
zIndex: 1500,
|
||||
}}
|
||||
data-cy={'signInWithEmailDialog'}
|
||||
PaperProps={{
|
||||
className: 'w-[400px]',
|
||||
}}
|
||||
keepMounted={false}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void handleSignIn();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className={'mt-2 flex w-full flex-col gap-3'}>
|
||||
<TextField
|
||||
label={'Email'}
|
||||
size={'small'}
|
||||
data-cy={'email'}
|
||||
required={true}
|
||||
placeholder={'name@gmail.com'}
|
||||
type={'email'}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
size={'small'}
|
||||
data-cy={'password'}
|
||||
required={true}
|
||||
label={'Password'}
|
||||
placeholder={'Password'}
|
||||
type={'password'}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions className={'mb-4 flex w-full gap-2 px-6'}>
|
||||
<Button variant={'outlined'} className={'flex-1'} color={'inherit'} onClick={onClose}>
|
||||
{t('button.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
data-cy={'submit'}
|
||||
disabled={loading}
|
||||
className={'justify-content flex h-[33px] flex-1 items-center gap-2'}
|
||||
variant={'contained'}
|
||||
onClick={handleSignIn}
|
||||
>
|
||||
{loading && <CircularProgress size={20} />}
|
||||
{t('button.signIn')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignInWithEmail;
|
@ -1,14 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Layout from '@/components/layout/Layout';
|
||||
|
||||
function SplashScreen () {
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Outlet/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default SplashScreen;
|
@ -1,35 +0,0 @@
|
||||
import React from 'react';
|
||||
import Welcome from './Welcome';
|
||||
import withAppWrapper from '@/components/app/withAppWrapper';
|
||||
|
||||
describe('<Welcome />', () => {
|
||||
beforeEach(() => {
|
||||
cy.mockAPI();
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
const AppWrapper = withAppWrapper(Welcome);
|
||||
|
||||
cy.mount(<AppWrapper />);
|
||||
});
|
||||
|
||||
it('should handle login success', () => {
|
||||
const AppWrapper = withAppWrapper(Welcome);
|
||||
|
||||
cy.mount(<AppWrapper />);
|
||||
|
||||
cy.get('[data-cy=signInWithEmail]').click();
|
||||
|
||||
cy.wait(100);
|
||||
|
||||
cy.get('[data-cy=signInWithEmailDialog]').as('dialog').should('be.visible');
|
||||
cy.get('[data-cy=email]').type('fakeEmail123');
|
||||
cy.get('[data-cy=password]').type('fakePassword123');
|
||||
cy.get('[data-cy=submit]').click();
|
||||
cy.wait('@loginSuccess');
|
||||
cy.wait('@verifyToken');
|
||||
cy.wait('@getUserProfile');
|
||||
cy.wait('@getUserWorkspace');
|
||||
cy.get('@dialog').should('not.exist');
|
||||
});
|
||||
});
|
@ -1,39 +0,0 @@
|
||||
import { ReactComponent as AppflowyLogo } from '@/assets/logo.svg';
|
||||
import { Stack } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LoginButtonGroup } from './LoginButtonGroup';
|
||||
import { getPlatform } from '@/utils/platform';
|
||||
import { lazy } from 'react';
|
||||
|
||||
const SignInAsAnonymous = lazy(() => import('@/components/tauri/SignInAsAnonymous'));
|
||||
|
||||
export const Welcome = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={(e) => e.preventDefault()} method='POST'>
|
||||
<Stack className='relative flex h-screen w-screen flex-col items-center justify-center gap-12 bg-bg-body text-center text-text-title'>
|
||||
<div className='flex justify-center' id='appflowy'>
|
||||
<AppflowyLogo className={'h-16 w-16'} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className='text-2xl font-semibold leading-9'>
|
||||
{t('welcomeTo')} {t('appName')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div id='Get-Started' className='flex w-[340px] flex-col gap-4 ' aria-label='Get-Started'>
|
||||
{getPlatform().isTauri && <SignInAsAnonymous />}
|
||||
<div className={'w-w-full flex items-center justify-center gap-2 text-sm'}>
|
||||
<LoginButtonGroup />
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Welcome;
|
@ -1,192 +0,0 @@
|
||||
import { useAppDispatch, useAppSelector } from '@/stores/store';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { open } from '@tauri-apps/api/shell';
|
||||
import { ProviderType, UserProfile } from '@/application/user.type';
|
||||
import { currentUserActions } from '@/stores/currentUser/slice';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import { notify } from '@/components/_shared/notify';
|
||||
|
||||
export const useAuth = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const AFConfig = useContext(AFConfigContext);
|
||||
const currentUser = useAppSelector((state) => state.currentUser);
|
||||
const isReady = !!AFConfig?.service;
|
||||
|
||||
const handleSuccess = useCallback(() => {
|
||||
notify.clear();
|
||||
dispatch(currentUserActions.loginSuccess());
|
||||
}, [dispatch]);
|
||||
|
||||
const setUser = useCallback(
|
||||
async (userProfile: UserProfile) => {
|
||||
handleSuccess();
|
||||
dispatch(currentUserActions.updateUser(userProfile));
|
||||
},
|
||||
[dispatch, handleSuccess]
|
||||
);
|
||||
|
||||
const handleStart = useCallback(() => {
|
||||
notify.clear();
|
||||
notify.loading('Loading...');
|
||||
dispatch(currentUserActions.loginStart());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleError = useCallback(
|
||||
({ message }: { message: string }) => {
|
||||
notify.clear();
|
||||
notify.error(message);
|
||||
dispatch(currentUserActions.loginError());
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Check if the user is authenticated
|
||||
const checkUser = useCallback(async () => {
|
||||
try {
|
||||
const userHasSignIn = await AFConfig?.service?.userService.checkUser();
|
||||
|
||||
if (!userHasSignIn) {
|
||||
throw new Error('Failed to check user');
|
||||
}
|
||||
|
||||
const userProfile = await AFConfig?.service?.userService.getUserProfile();
|
||||
|
||||
if (!userProfile) {
|
||||
throw new Error('Failed to check user');
|
||||
}
|
||||
|
||||
console.log('userProfile', userProfile);
|
||||
await setUser(userProfile);
|
||||
|
||||
return userProfile;
|
||||
} catch (e) {
|
||||
return Promise.reject('Failed to check user');
|
||||
}
|
||||
}, [AFConfig?.service?.userService, setUser]);
|
||||
|
||||
const register = useCallback(
|
||||
async (email: string, password: string, name: string): Promise<UserProfile | null> => {
|
||||
handleStart();
|
||||
try {
|
||||
const userProfile = await AFConfig?.service?.authService.signupWithEmailPassword({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
});
|
||||
|
||||
if (!userProfile) {
|
||||
throw new Error('Failed to register');
|
||||
}
|
||||
|
||||
await setUser(userProfile);
|
||||
|
||||
return userProfile;
|
||||
} catch (e) {
|
||||
handleError({
|
||||
message: 'Failed to register',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[handleStart, AFConfig?.service?.authService, setUser, handleError]
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await AFConfig?.service?.authService.signOut();
|
||||
dispatch(currentUserActions.logout());
|
||||
} catch (e) {
|
||||
handleError({
|
||||
message: 'Failed to logout',
|
||||
});
|
||||
}
|
||||
}, [AFConfig?.service?.authService, dispatch, handleError]);
|
||||
|
||||
const signInAsAnonymous = useCallback(async () => {
|
||||
const fakeEmail = nanoid(8) + '@appflowy.io';
|
||||
const fakePassword = 'AppFlowy123@';
|
||||
const fakeName = 'Me';
|
||||
|
||||
await register(fakeEmail, fakePassword, fakeName);
|
||||
}, [register]);
|
||||
|
||||
const signInWithProvider = useCallback(
|
||||
async (provider: ProviderType) => {
|
||||
handleStart();
|
||||
try {
|
||||
const url = await AFConfig?.service?.authService.getOAuthURL(provider);
|
||||
|
||||
if (!url) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
await open(url);
|
||||
} catch {
|
||||
handleError({
|
||||
message: 'Failed to sign in',
|
||||
});
|
||||
}
|
||||
},
|
||||
[AFConfig?.service?.authService, handleError, handleStart]
|
||||
);
|
||||
|
||||
const signInWithOAuth = useCallback(
|
||||
async (uri: string) => {
|
||||
handleStart();
|
||||
try {
|
||||
await AFConfig?.service?.authService.signInWithOAuth({ uri });
|
||||
const userProfile = await AFConfig?.service?.userService.getUserProfile();
|
||||
|
||||
if (!userProfile) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
await setUser(userProfile);
|
||||
|
||||
return userProfile;
|
||||
} catch (e) {
|
||||
handleError({
|
||||
message: 'Failed to sign in',
|
||||
});
|
||||
}
|
||||
},
|
||||
[AFConfig?.service?.authService, AFConfig?.service?.userService, handleError, handleStart, setUser]
|
||||
);
|
||||
|
||||
const signInWithEmailPassword = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
handleStart();
|
||||
try {
|
||||
await AFConfig?.service?.authService.signinWithEmailPassword(email, password);
|
||||
|
||||
const userProfile = await AFConfig?.service?.userService.getUserProfile();
|
||||
|
||||
if (!userProfile) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
await setUser(userProfile);
|
||||
|
||||
return userProfile;
|
||||
} catch (e) {
|
||||
handleError({
|
||||
message: 'Failed to sign in',
|
||||
});
|
||||
}
|
||||
},
|
||||
[AFConfig?.service?.authService, AFConfig?.service?.userService, handleError, handleStart, setUser]
|
||||
);
|
||||
|
||||
return {
|
||||
isReady,
|
||||
currentUser,
|
||||
checkUser,
|
||||
register,
|
||||
logout,
|
||||
signInWithProvider,
|
||||
signInAsAnonymous,
|
||||
signInWithOAuth,
|
||||
signInWithEmailPassword,
|
||||
};
|
||||
};
|
@ -1,89 +0,0 @@
|
||||
import { YDoc, YjsEditorKey } from '@/application/collab.type';
|
||||
import { DatabaseContextState } from '@/application/database-yjs';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import { Log } from '@/utils/log';
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
export function useGetDatabaseId(iidIndex: string) {
|
||||
const [databaseId, setDatabaseId] = useState<string>();
|
||||
const databaseService = useContext(AFConfigContext)?.service?.databaseService;
|
||||
|
||||
const loadDatabaseId = useCallback(async () => {
|
||||
if (!databaseService) return;
|
||||
const databases = await databaseService.getWorkspaceDatabases();
|
||||
|
||||
console.log('databses', databases);
|
||||
const id = databases.find((item) => item.views.includes(iidIndex))?.database_id;
|
||||
|
||||
if (!id) return;
|
||||
setDatabaseId(id);
|
||||
}, [iidIndex, databaseService]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDatabaseId();
|
||||
}, [loadDatabaseId]);
|
||||
return databaseId;
|
||||
}
|
||||
|
||||
export function useGetDatabaseDispatch() {
|
||||
const databaseService = useContext(AFConfigContext)?.service?.databaseService;
|
||||
const onOpenDatabase = useCallback(
|
||||
async ({ databaseId, rowIds }: { databaseId: string; rowIds?: string[] }) => {
|
||||
if (!databaseService) return Promise.reject();
|
||||
return databaseService.openDatabase(databaseId, rowIds);
|
||||
},
|
||||
[databaseService]
|
||||
);
|
||||
|
||||
const onCloseDatabase = useCallback(
|
||||
(databaseId: string) => {
|
||||
if (!databaseService) return;
|
||||
void databaseService.closeDatabase(databaseId);
|
||||
},
|
||||
[databaseService]
|
||||
);
|
||||
|
||||
return {
|
||||
onOpenDatabase,
|
||||
onCloseDatabase,
|
||||
};
|
||||
}
|
||||
|
||||
export function useLoadDatabase({ databaseId, rowIds }: { databaseId?: string; rowIds?: string[] }) {
|
||||
const [doc, setDoc] = useState<YDoc | null>(null);
|
||||
const [rows, setRows] = useState<DatabaseContextState['rowDocMap'] | null>(null); // Map<rowId, YDoc
|
||||
const [notFound, setNotFound] = useState<boolean>(false);
|
||||
const { onOpenDatabase, onCloseDatabase } = useGetDatabaseDispatch();
|
||||
|
||||
const handleOpenDatabase = useCallback(
|
||||
async (databaseId: string, rowIds?: string[]) => {
|
||||
try {
|
||||
setDoc(null);
|
||||
const { databaseDoc, rows } = await onOpenDatabase({
|
||||
databaseId,
|
||||
rowIds,
|
||||
});
|
||||
|
||||
console.log('databaseDoc', databaseDoc.getMap(YjsEditorKey.data_section).toJSON());
|
||||
console.log('rows', rows);
|
||||
|
||||
setDoc(databaseDoc);
|
||||
setRows(rows);
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
setNotFound(true);
|
||||
}
|
||||
},
|
||||
[onOpenDatabase]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!databaseId) return;
|
||||
void handleOpenDatabase(databaseId, rowIds);
|
||||
return () => {
|
||||
onCloseDatabase(databaseId);
|
||||
};
|
||||
}, [handleOpenDatabase, databaseId, rowIds, onCloseDatabase]);
|
||||
|
||||
return { doc, rows, notFound };
|
||||
}
|
@ -1,23 +1,103 @@
|
||||
import { YDatabase, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
|
||||
import { ViewMeta } from '@/application/db/tables/view_metas';
|
||||
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
|
||||
import DatabaseRow from '@/components/database/DatabaseRow';
|
||||
import DatabaseViews from '@/components/database/DatabaseViews';
|
||||
import { ViewMetaPreview, ViewMetaProps } from '@/components/view-meta/ViewMetaPreview';
|
||||
import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import * as Y from 'yjs';
|
||||
import { DatabaseContextProvider } from './DatabaseContext';
|
||||
|
||||
import React, { memo } from 'react';
|
||||
export interface Database2Props extends ViewMetaProps {
|
||||
doc: YDoc;
|
||||
getViewRowsMap?: (viewId: string, rowIds: string[]) => Promise<{ rows: Y.Map<YDoc>; destroy: () => void }>;
|
||||
loadView?: (viewId: string) => Promise<YDoc>;
|
||||
navigateToView?: (viewId: string) => Promise<void>;
|
||||
loadViewMeta?: (viewId: string) => Promise<ViewMeta>;
|
||||
}
|
||||
|
||||
export const Database = memo(
|
||||
({
|
||||
viewId,
|
||||
onNavigateToView,
|
||||
iidIndex,
|
||||
}: {
|
||||
iidIndex: string;
|
||||
viewId: string;
|
||||
onNavigateToView: (viewId: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className='appflowy-database relative flex w-full flex-1 select-text flex-col overflow-y-hidden'>
|
||||
<DatabaseViews iidIndex={iidIndex} onChangeView={onNavigateToView} viewId={viewId} />
|
||||
</div>
|
||||
);
|
||||
function Database({ doc, getViewRowsMap, navigateToView, loadViewMeta, loadView, ...viewMeta }: Database2Props) {
|
||||
const [search, setSearch] = useSearchParams();
|
||||
|
||||
const viewId = search.get('v') || viewMeta.viewId;
|
||||
|
||||
const rowIds = useMemo(() => {
|
||||
if (!viewId) return [];
|
||||
const database = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database) as YDatabase;
|
||||
const rows = database.get(YjsDatabaseKey.views).get(viewId).get(YjsDatabaseKey.row_orders);
|
||||
|
||||
return rows.toArray().map((row) => row.get(YjsDatabaseKey.id));
|
||||
}, [doc, viewId]);
|
||||
|
||||
const iidIndex = useMemo(() => {
|
||||
const database = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database) as YDatabase;
|
||||
|
||||
return database.get(YjsDatabaseKey.metas).get(YjsDatabaseKey.iid);
|
||||
}, [doc]);
|
||||
|
||||
const [rowDocMap, setRowDocMap] = useState<Y.Map<YDoc> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getViewRowsMap || !rowIds.length || !iidIndex) return;
|
||||
|
||||
void (async () => {
|
||||
const { rows, destroy } = await getViewRowsMap(iidIndex, rowIds);
|
||||
|
||||
setRowDocMap(rows);
|
||||
return destroy;
|
||||
})();
|
||||
}, [getViewRowsMap, rowIds, iidIndex]);
|
||||
|
||||
const rowId = search.get('r');
|
||||
|
||||
const handleChangeView = useCallback(
|
||||
(viewId: string) => {
|
||||
setSearch({ v: viewId });
|
||||
},
|
||||
[setSearch]
|
||||
);
|
||||
|
||||
const handleNavigateToRow = useCallback(
|
||||
(rowId: string) => {
|
||||
setSearch({ r: rowId });
|
||||
},
|
||||
[setSearch]
|
||||
);
|
||||
|
||||
if (!rowDocMap || !viewId) {
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={'flex w-full justify-center'}>
|
||||
<Suspense fallback={<ComponentLoading />}>
|
||||
<DatabaseContextProvider
|
||||
isDatabaseRowPage={!!rowId}
|
||||
navigateToRow={handleNavigateToRow}
|
||||
viewId={viewId}
|
||||
databaseDoc={doc}
|
||||
rowDocMap={rowDocMap}
|
||||
readOnly={true}
|
||||
loadView={loadView}
|
||||
navigateToView={navigateToView}
|
||||
loadViewMeta={loadViewMeta}
|
||||
>
|
||||
{rowId ? (
|
||||
<DatabaseRow rowId={rowId} />
|
||||
) : (
|
||||
<div className={'relative flex h-full w-full flex-col'}>
|
||||
{viewMeta && <ViewMetaPreview {...viewMeta} />}
|
||||
|
||||
<div className='appflowy-database relative flex w-full flex-1 select-text flex-col overflow-y-hidden'>
|
||||
<DatabaseViews iidIndex={iidIndex} onChangeView={handleChangeView} viewId={viewId} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DatabaseContextProvider>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Database;
|
||||
|
@ -1,19 +0,0 @@
|
||||
import { usePageInfo } from '@/components/_shared/page/usePageInfo';
|
||||
import React from 'react';
|
||||
|
||||
function DatabaseTitle({ viewId }: { viewId: string }) {
|
||||
const { name, icon } = usePageInfo(viewId);
|
||||
|
||||
return (
|
||||
<div className={'flex w-full flex-col py-4'}>
|
||||
<div className={'flex w-full items-center px-16 max-md:px-4'}>
|
||||
<div className={'flex items-center gap-2 text-3xl'}>
|
||||
<div>{icon}</div>
|
||||
<div className={'font-bold'}>{name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DatabaseTitle;
|
@ -1,11 +1,10 @@
|
||||
import { renderDatabase } from '@/components/database/__tests__/withTestingDatabase';
|
||||
import '@/components/layout/layout.scss';
|
||||
import '@/styles/app.scss';
|
||||
|
||||
describe('<Database />', () => {
|
||||
beforeEach(() => {
|
||||
cy.viewport(1280, 720);
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US' });
|
||||
cy.mockDatabase();
|
||||
});
|
||||
|
||||
it('renders with a database', () => {
|
||||
@ -18,7 +17,7 @@ describe('<Database />', () => {
|
||||
onNavigateToView,
|
||||
},
|
||||
() => {
|
||||
cy.get('[data-testid^=view-tab-]').should('have.length', 4);
|
||||
cy.get('[data-testid^=view-tab-]').should('have.length', 10);
|
||||
cy.get('.database-grid').should('exist');
|
||||
|
||||
cy.get('[data-testid=view-tab-e410747b-5f2f-45a0-b2f7-890ad3001355]').click();
|
||||
@ -27,11 +26,13 @@ describe('<Database />', () => {
|
||||
|
||||
cy.wait(800);
|
||||
cy.get('[data-testid=view-tab-7d2148fc-cace-4452-9c5c-96e52e6bf8b5]').click();
|
||||
cy.wait(800);
|
||||
cy.get('.database-grid').should('exist');
|
||||
cy.wrap(onNavigateToView).should('have.been.calledWith', '7d2148fc-cace-4452-9c5c-96e52e6bf8b5');
|
||||
|
||||
cy.wait(800);
|
||||
cy.get('[data-testid=view-tab-2143e95d-5dcb-4e0f-bb2c-50944e6e019f]').click();
|
||||
cy.wait(800);
|
||||
cy.get('.database-calendar').should('exist');
|
||||
cy.wrap(onNavigateToView).should('have.been.calledWith', '2143e95d-5dcb-4e0f-bb2c-50944e6e019f');
|
||||
}
|
||||
|
@ -1,46 +1,40 @@
|
||||
import { YDoc, YFolder, YjsEditorKey } from '@/application/collab.type';
|
||||
import { withTestingYDoc } from '@/application/slate-yjs/__tests__/withTestingYjsEditor';
|
||||
import { applyYDoc } from '@/application/ydoc/apply';
|
||||
import { FolderProvider } from '@/components/_shared/context-provider/FolderProvider';
|
||||
import { IdProvider } from '@/components/_shared/context-provider/IdProvider';
|
||||
import withAppWrapper from '@/components/app/withAppWrapper';
|
||||
import { DatabaseRow } from 'src/components/database/DatabaseRow';
|
||||
import { DatabaseContextProvider } from 'src/components/database/DatabaseContext';
|
||||
import * as Y from 'yjs';
|
||||
import '@/components/layout/layout.scss';
|
||||
import '@/styles/app.scss';
|
||||
|
||||
describe('<DatabaseRow />', () => {
|
||||
beforeEach(() => {
|
||||
cy.viewport(1280, 720);
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US' });
|
||||
Object.defineProperty(window.navigator, 'languages', { value: ['en-US'] });
|
||||
cy.mockDatabase();
|
||||
cy.mockDocument('f56bdf0f-90c8-53fb-97d9-ad5860d2b7a0');
|
||||
});
|
||||
|
||||
it('renders with a row', () => {
|
||||
cy.wait(1000);
|
||||
cy.fixture('folder').then((folderJson) => {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US' });
|
||||
cy.fixture('database/4c658817-20db-4f56-b7f9-0637a22dfeb6').then((database) => {
|
||||
const doc = new Y.Doc();
|
||||
const state = new Uint8Array(folderJson.data.doc_state);
|
||||
const databaseState = new Uint8Array(database.data.doc_state);
|
||||
|
||||
applyYDoc(doc, state);
|
||||
const folder = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.folder) as YFolder;
|
||||
applyYDoc(doc, databaseState);
|
||||
|
||||
cy.fixture('database/4c658817-20db-4f56-b7f9-0637a22dfeb6').then((database) => {
|
||||
const doc = new Y.Doc();
|
||||
const databaseState = new Uint8Array(database.data.doc_state);
|
||||
cy.fixture('database/rows/4c658817-20db-4f56-b7f9-0637a22dfeb6').then((rows) => {
|
||||
const rootRowsDoc = new Y.Doc();
|
||||
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
|
||||
const data = rows['2f944220-9f45-40d9-96b5-e8c0888daf7c'];
|
||||
const rowDoc = new Y.Doc();
|
||||
|
||||
applyYDoc(doc, databaseState);
|
||||
applyYDoc(rowDoc, new Uint8Array(data));
|
||||
rowsFolder.set('2f944220-9f45-40d9-96b5-e8c0888daf7c', rowDoc);
|
||||
|
||||
cy.fixture('database/rows/4c658817-20db-4f56-b7f9-0637a22dfeb6').then((rows) => {
|
||||
const rootRowsDoc = new Y.Doc();
|
||||
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
|
||||
const data = rows['2f944220-9f45-40d9-96b5-e8c0888daf7c'];
|
||||
const rowDoc = new Y.Doc();
|
||||
|
||||
applyYDoc(rowDoc, new Uint8Array(data));
|
||||
rowsFolder.set('2f944220-9f45-40d9-96b5-e8c0888daf7c', rowDoc);
|
||||
cy.fixture('simple_doc').then((docJson) => {
|
||||
const subDoc = new Y.Doc();
|
||||
const state = new Uint8Array(docJson.data.doc_state);
|
||||
|
||||
applyYDoc(subDoc, state);
|
||||
const AppWrapper = withAppWrapper(() => {
|
||||
return (
|
||||
<div className={'flex h-screen w-screen flex-col overflow-y-auto py-4'}>
|
||||
@ -48,8 +42,8 @@ describe('<DatabaseRow />', () => {
|
||||
rowId={'2f944220-9f45-40d9-96b5-e8c0888daf7c'}
|
||||
databaseDoc={doc}
|
||||
rows={rowsFolder}
|
||||
folder={folder}
|
||||
viewId={'7d2148fc-cace-4452-9c5c-96e52e6bf8b5'}
|
||||
loadView={() => Promise.resolve(subDoc)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -70,28 +64,25 @@ function TestDatabaseRow({
|
||||
rowId,
|
||||
databaseDoc,
|
||||
rows,
|
||||
folder,
|
||||
viewId,
|
||||
loadView,
|
||||
}: {
|
||||
rowId: string;
|
||||
databaseDoc: YDoc;
|
||||
rows: Y.Map<YDoc>;
|
||||
folder: YFolder;
|
||||
viewId: string;
|
||||
loadView?: (viewId: string) => Promise<YDoc>;
|
||||
}) {
|
||||
return (
|
||||
<FolderProvider folder={folder}>
|
||||
<IdProvider objectId={viewId}>
|
||||
<DatabaseContextProvider
|
||||
viewId={viewId}
|
||||
readOnly={true}
|
||||
isDatabaseRowPage
|
||||
databaseDoc={databaseDoc}
|
||||
rowDocMap={rows}
|
||||
>
|
||||
<DatabaseRow rowId={rowId} />
|
||||
</DatabaseContextProvider>
|
||||
</IdProvider>
|
||||
</FolderProvider>
|
||||
<DatabaseContextProvider
|
||||
viewId={viewId}
|
||||
readOnly={true}
|
||||
isDatabaseRowPage
|
||||
databaseDoc={databaseDoc}
|
||||
rowDocMap={rows}
|
||||
loadView={loadView}
|
||||
>
|
||||
<DatabaseRow rowId={rowId} />
|
||||
</DatabaseContextProvider>
|
||||
);
|
||||
}
|
||||
|
@ -1,11 +1,10 @@
|
||||
import { renderDatabase } from '@/components/database/__tests__/withTestingDatabase';
|
||||
import '@/components/layout/layout.scss';
|
||||
import '@/styles/app.scss';
|
||||
|
||||
describe('<Database /> with filters and sorts', () => {
|
||||
beforeEach(() => {
|
||||
cy.viewport(1280, 720);
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US' });
|
||||
cy.mockDatabase();
|
||||
});
|
||||
|
||||
it('render a database with filters and sorts', () => {
|
||||
|
@ -1,12 +1,10 @@
|
||||
import { YDoc, YFolder, YjsEditorKey } from '@/application/collab.type';
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { applyYDoc } from '@/application/ydoc/apply';
|
||||
import { FolderProvider } from '@/components/_shared/context-provider/FolderProvider';
|
||||
import { IdProvider } from '@/components/_shared/context-provider/IdProvider';
|
||||
import withAppWrapper from '@/components/app/withAppWrapper';
|
||||
import { DatabaseContextProvider } from '@/components/database/DatabaseContext';
|
||||
import DatabaseViews from '@/components/database/DatabaseViews';
|
||||
import { useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
import { Database } from 'src/components/database/Database';
|
||||
|
||||
export function renderDatabase(
|
||||
{
|
||||
@ -20,49 +18,39 @@ export function renderDatabase(
|
||||
},
|
||||
onAfterRender?: () => void
|
||||
) {
|
||||
cy.fixture('folder').then((folderJson) => {
|
||||
const doc = new Y.Doc();
|
||||
const state = new Uint8Array(folderJson.data.doc_state);
|
||||
cy.fixture(`database/${databaseId}`).then((database) => {
|
||||
cy.fixture(`database/rows/${databaseId}`).then((rows) => {
|
||||
const doc = new Y.Doc();
|
||||
const rootRowsDoc = new Y.Doc();
|
||||
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
|
||||
const databaseState = new Uint8Array(database.data.doc_state);
|
||||
|
||||
applyYDoc(doc, state);
|
||||
applyYDoc(doc, databaseState);
|
||||
|
||||
const folder = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.folder) as YFolder;
|
||||
Object.keys(rows).forEach((key) => {
|
||||
const data = rows[key];
|
||||
const rowDoc = new Y.Doc();
|
||||
|
||||
cy.fixture(`database/${databaseId}`).then((database) => {
|
||||
cy.fixture(`database/rows/${databaseId}`).then((rows) => {
|
||||
const doc = new Y.Doc();
|
||||
const rootRowsDoc = new Y.Doc();
|
||||
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
|
||||
const databaseState = new Uint8Array(database.data.doc_state);
|
||||
|
||||
applyYDoc(doc, databaseState);
|
||||
|
||||
Object.keys(rows).forEach((key) => {
|
||||
const data = rows[key];
|
||||
const rowDoc = new Y.Doc();
|
||||
|
||||
applyYDoc(rowDoc, new Uint8Array(data));
|
||||
rowsFolder.set(key, rowDoc);
|
||||
});
|
||||
|
||||
const AppWrapper = withAppWrapper(() => {
|
||||
return (
|
||||
<div className={'flex h-screen w-screen flex-col py-4'}>
|
||||
<TestDatabase
|
||||
databaseDoc={doc}
|
||||
rows={rowsFolder}
|
||||
folder={folder}
|
||||
iidIndex={viewId}
|
||||
initialViewId={viewId}
|
||||
onNavigateToView={onNavigateToView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
cy.mount(<AppWrapper />);
|
||||
onAfterRender?.();
|
||||
applyYDoc(rowDoc, new Uint8Array(data));
|
||||
rowsFolder.set(key, rowDoc);
|
||||
});
|
||||
|
||||
const AppWrapper = withAppWrapper(() => {
|
||||
return (
|
||||
<div className={'flex h-screen w-screen flex-col py-4'}>
|
||||
<TestDatabase
|
||||
databaseDoc={doc}
|
||||
rows={rowsFolder}
|
||||
iidIndex={viewId}
|
||||
initialViewId={viewId}
|
||||
onNavigateToView={onNavigateToView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
cy.mount(<AppWrapper />);
|
||||
onAfterRender?.();
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -70,14 +58,12 @@ export function renderDatabase(
|
||||
export function TestDatabase({
|
||||
databaseDoc,
|
||||
rows,
|
||||
folder,
|
||||
iidIndex,
|
||||
initialViewId,
|
||||
onNavigateToView,
|
||||
}: {
|
||||
databaseDoc: YDoc;
|
||||
rows: Y.Map<YDoc>;
|
||||
folder: YFolder;
|
||||
iidIndex: string;
|
||||
initialViewId: string;
|
||||
onNavigateToView: (viewId: string) => void;
|
||||
@ -90,17 +76,13 @@ export function TestDatabase({
|
||||
};
|
||||
|
||||
return (
|
||||
<FolderProvider folder={folder}>
|
||||
<IdProvider objectId={iidIndex}>
|
||||
<DatabaseContextProvider
|
||||
viewId={activeViewId || iidIndex}
|
||||
databaseDoc={databaseDoc}
|
||||
rowDocMap={rows}
|
||||
readOnly={true}
|
||||
>
|
||||
<Database iidIndex={iidIndex} viewId={activeViewId} onNavigateToView={handleNavigateToView} />
|
||||
</DatabaseContextProvider>
|
||||
</IdProvider>
|
||||
</FolderProvider>
|
||||
<DatabaseContextProvider
|
||||
viewId={activeViewId || iidIndex}
|
||||
databaseDoc={databaseDoc}
|
||||
rowDocMap={rows}
|
||||
readOnly={true}
|
||||
>
|
||||
<DatabaseViews iidIndex={iidIndex} viewId={activeViewId} onChangeView={handleNavigateToView} />
|
||||
</DatabaseContextProvider>
|
||||
);
|
||||
}
|
||||
|
@ -1,54 +1,28 @@
|
||||
import { YDatabaseField, YDatabaseFields, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
|
||||
import {
|
||||
DatabaseContextState,
|
||||
parseRelationTypeOption,
|
||||
useDatabase,
|
||||
useFieldSelector,
|
||||
useNavigateToRow,
|
||||
} from '@/application/database-yjs';
|
||||
import { YjsDatabaseKey } from '@/application/collab.type';
|
||||
import { DatabaseContext, DatabaseContextState, useDatabase, useNavigateToRow } from '@/application/database-yjs';
|
||||
import { RelationCell, RelationCellData } from '@/application/database-yjs/cell.type';
|
||||
import { RelationPrimaryValue } from '@/components/database/components/cell/relation/RelationPrimaryValue';
|
||||
import { useGetDatabaseDispatch } from '@/components/database/Database.hooks';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
function RelationItems({ style, cell, fieldId }: { cell: RelationCell; fieldId: string; style?: React.CSSProperties }) {
|
||||
const { field } = useFieldSelector(fieldId);
|
||||
const currentDatabaseId = useDatabase()?.get(YjsDatabaseKey.id);
|
||||
const { onOpenDatabase, onCloseDatabase } = useGetDatabaseDispatch();
|
||||
function RelationItems({ style, cell }: { cell: RelationCell; fieldId: string; style?: React.CSSProperties }) {
|
||||
const database = useDatabase();
|
||||
const viewId = database.get(YjsDatabaseKey.metas)?.get(YjsDatabaseKey.iid)?.toString();
|
||||
const rowIds = useMemo(() => {
|
||||
return (cell.data?.toJSON() as RelationCellData) ?? [];
|
||||
}, [cell.data]);
|
||||
const databaseId = rowIds.length > 0 && field ? parseRelationTypeOption(field).database_id : undefined;
|
||||
const [databasePrimaryFieldId, setDatabasePrimaryFieldId] = useState<string | undefined>(undefined);
|
||||
const getViewRowsMap = useContext(DatabaseContext)?.getViewRowsMap;
|
||||
|
||||
const [rows, setRows] = useState<DatabaseContextState['rowDocMap'] | null>();
|
||||
|
||||
const navigateToRow = useNavigateToRow();
|
||||
|
||||
useEffect(() => {
|
||||
if (!databaseId || !rowIds.length) return;
|
||||
void onOpenDatabase({ databaseId, rowIds }).then(({ databaseDoc: doc, rows }) => {
|
||||
const fields = doc
|
||||
.getMap(YjsEditorKey.data_section)
|
||||
.get(YjsEditorKey.database)
|
||||
.get(YjsDatabaseKey.fields) as YDatabaseFields;
|
||||
|
||||
fields.forEach((field, fieldId) => {
|
||||
if ((field as YDatabaseField).get(YjsDatabaseKey.is_primary)) {
|
||||
setDatabasePrimaryFieldId(fieldId);
|
||||
}
|
||||
});
|
||||
if (!viewId || !rowIds.length) return;
|
||||
|
||||
void getViewRowsMap?.(viewId, rowIds).then(({ rows }) => {
|
||||
setRows(rows);
|
||||
});
|
||||
}, [onOpenDatabase, databaseId, rowIds, onCloseDatabase]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (currentDatabaseId !== databaseId && databaseId) {
|
||||
onCloseDatabase(databaseId);
|
||||
}
|
||||
};
|
||||
}, [databaseId, currentDatabaseId, onCloseDatabase]);
|
||||
}, [getViewRowsMap, rowIds, viewId]);
|
||||
|
||||
return (
|
||||
<div style={style} className={'relation-cell flex w-full items-center gap-2'}>
|
||||
@ -64,9 +38,7 @@ function RelationItems({ style, cell, fieldId }: { cell: RelationCell; fieldId:
|
||||
}}
|
||||
className={'w-full cursor-pointer underline'}
|
||||
>
|
||||
{rowDoc && databasePrimaryFieldId && (
|
||||
<RelationPrimaryValue rowDoc={rowDoc} fieldId={databasePrimaryFieldId} />
|
||||
)}
|
||||
{rowDoc && <RelationPrimaryValue rowDoc={rowDoc} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { FieldId, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
|
||||
import { FieldId, YDatabaseCell, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
|
||||
import { FieldType } from '@/application/database-yjs';
|
||||
import { parseYDatabaseCellToCell } from '@/application/database-yjs/cell.parse';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export function RelationPrimaryValue({ rowDoc, fieldId }: { rowDoc: YDoc; fieldId: FieldId }) {
|
||||
export function RelationPrimaryValue({ rowDoc, fieldId }: { rowDoc: YDoc; fieldId?: FieldId }) {
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [row, setRow] = useState<YDatabaseRow | null>(null);
|
||||
|
||||
@ -23,18 +24,34 @@ export function RelationPrimaryValue({ rowDoc, fieldId }: { rowDoc: YDoc; fieldI
|
||||
useEffect(() => {
|
||||
if (!row) return;
|
||||
const cells = row.get(YjsDatabaseKey.cells);
|
||||
const primaryCell = cells.get(fieldId);
|
||||
|
||||
if (!primaryCell) return;
|
||||
let primaryCell: YDatabaseCell | undefined;
|
||||
|
||||
if (fieldId) {
|
||||
primaryCell = cells?.get(fieldId);
|
||||
} else {
|
||||
const fieldId = Array.from(cells.keys()).find((key) => {
|
||||
const fieldType = cells.get(key)?.get(YjsDatabaseKey.field_type);
|
||||
|
||||
if (!fieldType) return false;
|
||||
return Number(fieldType) === FieldType.RichText;
|
||||
});
|
||||
|
||||
if (fieldId) {
|
||||
primaryCell = cells?.get(fieldId);
|
||||
}
|
||||
}
|
||||
|
||||
const observeHandler = () => {
|
||||
if (!primaryCell) return;
|
||||
setText(parseYDatabaseCellToCell(primaryCell).data as string);
|
||||
};
|
||||
|
||||
observeHandler();
|
||||
|
||||
primaryCell.observe(observeHandler);
|
||||
primaryCell?.observe(observeHandler);
|
||||
return () => {
|
||||
primaryCell.unobserve(observeHandler);
|
||||
primaryCell?.unobserve(observeHandler);
|
||||
};
|
||||
}, [row, fieldId]);
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { useRowMetaSelector } from '@/application/database-yjs';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import { DatabaseContext, useRowMetaSelector } from '@/application/database-yjs';
|
||||
import { Editor } from '@/components/editor';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
@ -8,17 +7,19 @@ import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
export function DatabaseRowSubDocument({ rowId }: { rowId: string }) {
|
||||
const meta = useRowMetaSelector(rowId);
|
||||
const documentId = meta?.documentId;
|
||||
const loadView = useContext(DatabaseContext)?.loadView;
|
||||
const getViewRowsMap = useContext(DatabaseContext)?.getViewRowsMap;
|
||||
const navigateToView = useContext(DatabaseContext)?.navigateToView;
|
||||
const loadViewMeta = useContext(DatabaseContext)?.loadViewMeta;
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [doc, setDoc] = useState<YDoc | null>(null);
|
||||
|
||||
const documentService = useContext(AFConfigContext)?.service?.documentService;
|
||||
|
||||
const handleOpenDocument = useCallback(async () => {
|
||||
if (!documentService || !documentId) return;
|
||||
if (!loadView || !documentId) return;
|
||||
try {
|
||||
setDoc(null);
|
||||
const doc = await documentService.openDocument(documentId);
|
||||
const doc = await loadView(documentId);
|
||||
|
||||
console.log('doc', doc);
|
||||
setDoc(doc);
|
||||
@ -26,7 +27,7 @@ export function DatabaseRowSubDocument({ rowId }: { rowId: string }) {
|
||||
console.error(e);
|
||||
// haven't created by client, ignore error and show empty
|
||||
}
|
||||
}, [documentService, documentId]);
|
||||
}, [loadView, documentId]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@ -43,7 +44,16 @@ export function DatabaseRowSubDocument({ rowId }: { rowId: string }) {
|
||||
|
||||
if (!doc) return null;
|
||||
|
||||
return <Editor doc={doc} readOnly={true} />;
|
||||
return (
|
||||
<Editor
|
||||
doc={doc}
|
||||
loadViewMeta={loadViewMeta}
|
||||
navigateToView={navigateToView}
|
||||
getViewRowsMap={getViewRowsMap}
|
||||
readOnly={true}
|
||||
loadView={loadView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DatabaseRowSubDocument;
|
||||
|
@ -1,11 +0,0 @@
|
||||
import { usePageInfo } from '@/components/_shared/page/usePageInfo';
|
||||
import Title from './Title';
|
||||
import React from 'react';
|
||||
|
||||
export function DatabaseHeader({ viewId }: { viewId: string }) {
|
||||
const { name, icon } = usePageInfo(viewId);
|
||||
|
||||
return <Title name={name} icon={icon} />;
|
||||
}
|
||||
|
||||
export default DatabaseHeader;
|
@ -1,12 +1,9 @@
|
||||
import { useCellSelector, useDatabaseViewId, usePrimaryFieldId, useRowMetaSelector } from '@/application/database-yjs';
|
||||
import { FolderContext } from '@/application/folder-yjs';
|
||||
import { useCellSelector, usePrimaryFieldId, useRowMetaSelector } from '@/application/database-yjs';
|
||||
import Title from '@/components/database/components/header/Title';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
function DatabaseRowHeader({ rowId }: { rowId: string }) {
|
||||
const fieldId = usePrimaryFieldId() || '';
|
||||
const setCrumbs = useContext(FolderContext)?.setCrumbs;
|
||||
const viewId = useDatabaseViewId();
|
||||
|
||||
const meta = useRowMetaSelector(rowId);
|
||||
const cell = useCellSelector({
|
||||
@ -14,22 +11,6 @@ function DatabaseRowHeader({ rowId }: { rowId: string }) {
|
||||
fieldId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewId) return;
|
||||
setCrumbs?.((prev) => {
|
||||
const lastCrumb = prev[prev.length - 1];
|
||||
const crumb = {
|
||||
viewId,
|
||||
rowId,
|
||||
name: cell?.data as string,
|
||||
icon: meta?.icon || '',
|
||||
};
|
||||
|
||||
if (lastCrumb?.rowId === rowId) return [...prev.slice(0, -1), crumb];
|
||||
return [...prev, crumb];
|
||||
});
|
||||
}, [cell, meta, rowId, setCrumbs, viewId]);
|
||||
|
||||
return <Title icon={meta?.icon} name={cell?.data as string} />;
|
||||
}
|
||||
|
||||
|
@ -1,2 +1 @@
|
||||
export * from './DatabaseHeader';
|
||||
export * from './DatabaseRowHeader';
|
||||
|
@ -1,16 +1,14 @@
|
||||
import { DatabaseViewLayout, ViewLayout, YjsDatabaseKey, YjsFolderKey, YView } from '@/application/collab.type';
|
||||
import { useDatabaseView } from '@/application/database-yjs';
|
||||
import { useFolderContext } from '@/application/folder-yjs';
|
||||
import { DatabaseViewLayout, YDatabaseView, YjsDatabaseKey } from '@/application/collab.type';
|
||||
import { useDatabase, useDatabaseView } from '@/application/database-yjs';
|
||||
import { DatabaseActions } from '@/components/database/components/conditions';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import { forwardRef, FunctionComponent, SVGProps, useCallback, useMemo } from 'react';
|
||||
import { forwardRef, FunctionComponent, SVGProps, useMemo } from 'react';
|
||||
import { ViewTabs, ViewTab } from './ViewTabs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ReactComponent as GridSvg } from '$icons/16x/grid.svg';
|
||||
import { ReactComponent as BoardSvg } from '$icons/16x/board.svg';
|
||||
import { ReactComponent as CalendarSvg } from '$icons/16x/date.svg';
|
||||
import { ReactComponent as DocumentSvg } from '$icons/16x/document.svg';
|
||||
|
||||
export interface DatabaseTabBarProps {
|
||||
viewIds: string[];
|
||||
@ -19,33 +17,24 @@ export interface DatabaseTabBarProps {
|
||||
}
|
||||
|
||||
const DatabaseIcons: {
|
||||
[key in ViewLayout]: FunctionComponent<SVGProps<SVGSVGElement> & { title?: string | undefined }>;
|
||||
[key in DatabaseViewLayout]: FunctionComponent<SVGProps<SVGSVGElement> & { title?: string | undefined }>;
|
||||
} = {
|
||||
[ViewLayout.Document]: DocumentSvg,
|
||||
[ViewLayout.Grid]: GridSvg,
|
||||
[ViewLayout.Board]: BoardSvg,
|
||||
[ViewLayout.Calendar]: CalendarSvg,
|
||||
[DatabaseViewLayout.Grid]: GridSvg,
|
||||
[DatabaseViewLayout.Board]: BoardSvg,
|
||||
[DatabaseViewLayout.Calendar]: CalendarSvg,
|
||||
};
|
||||
|
||||
export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
|
||||
({ viewIds, selectedViewId, setSelectedViewId }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const folder = useFolderContext();
|
||||
const view = useDatabaseView();
|
||||
const views = useDatabase().get(YjsDatabaseKey.views);
|
||||
const layout = Number(view?.get(YjsDatabaseKey.layout)) as DatabaseViewLayout;
|
||||
|
||||
const handleChange = (_: React.SyntheticEvent, newValue: string) => {
|
||||
setSelectedViewId?.(newValue);
|
||||
};
|
||||
|
||||
const getFolderView = useCallback(
|
||||
(viewId: string) => {
|
||||
if (!folder) return null;
|
||||
return folder.get(YjsFolderKey.views)?.get(viewId) as YView | null;
|
||||
},
|
||||
[folder]
|
||||
);
|
||||
|
||||
const className = useMemo(() => {
|
||||
const classList = [
|
||||
'mx-16 -mb-[0.5px] flex items-center overflow-hidden border-line-divider text-text-title max-md:mx-4',
|
||||
@ -75,12 +64,12 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
|
||||
onChange={handleChange}
|
||||
>
|
||||
{viewIds.map((viewId) => {
|
||||
const view = getFolderView(viewId);
|
||||
const view = views?.get(viewId) as YDatabaseView | null;
|
||||
|
||||
if (!view) return null;
|
||||
const layout = Number(view.get(YjsFolderKey.layout)) as ViewLayout;
|
||||
const layout = Number(view.get(YjsDatabaseKey.layout)) as DatabaseViewLayout;
|
||||
const Icon = DatabaseIcons[layout];
|
||||
const name = view.get(YjsFolderKey.name);
|
||||
const name = view.get(YjsDatabaseKey.name);
|
||||
|
||||
return (
|
||||
<ViewTab
|
||||
|
@ -1,112 +1,43 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { useId } from '@/components/_shared/context-provider/IdProvider';
|
||||
import { usePageInfo } from '@/components/_shared/page/usePageInfo';
|
||||
import { ViewMeta } from '@/application/db/tables/view_metas';
|
||||
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import { DocumentHeader } from '@/components/document/document_header';
|
||||
import { Editor } from '@/components/editor';
|
||||
import { EditorLayoutStyle } from '@/components/editor/EditorContext';
|
||||
import { Log } from '@/utils/log';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import React, { Suspense, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import RecordNotFound from 'src/components/_shared/not-found/RecordNotFound';
|
||||
import React, { Suspense } from 'react';
|
||||
import ViewMetaPreview, { ViewMetaProps } from '@/components/view-meta/ViewMetaPreview';
|
||||
import Y from 'yjs';
|
||||
|
||||
export const Document = () => {
|
||||
const { objectId: documentId } = useId() || {};
|
||||
const [doc, setDoc] = useState<YDoc | null>(null);
|
||||
const [notFound, setNotFound] = useState<boolean>(false);
|
||||
const extra = usePageInfo(documentId).extra;
|
||||
|
||||
const layoutStyle: EditorLayoutStyle = useMemo(() => {
|
||||
return {
|
||||
font: extra?.font || '',
|
||||
fontLayout: extra?.fontLayout,
|
||||
lineHeightLayout: extra?.lineHeightLayout,
|
||||
};
|
||||
}, [extra]);
|
||||
const documentService = useContext(AFConfigContext)?.service?.documentService;
|
||||
|
||||
const handleOpenDocument = useCallback(async () => {
|
||||
if (!documentService || !documentId) return;
|
||||
try {
|
||||
setDoc(null);
|
||||
const doc = await documentService.openDocument(documentId);
|
||||
|
||||
setDoc(doc);
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
setNotFound(true);
|
||||
}
|
||||
}, [documentService, documentId]);
|
||||
|
||||
useEffect(() => {
|
||||
setNotFound(false);
|
||||
void handleOpenDocument();
|
||||
}, [handleOpenDocument]);
|
||||
|
||||
const style = useMemo(() => {
|
||||
const fontSizeMap = {
|
||||
small: '14px',
|
||||
normal: '16px',
|
||||
large: '20px',
|
||||
};
|
||||
|
||||
return {
|
||||
fontFamily: layoutStyle.font,
|
||||
fontSize: fontSizeMap[layoutStyle.fontLayout],
|
||||
};
|
||||
}, [layoutStyle]);
|
||||
|
||||
const layoutClassName = useMemo(() => {
|
||||
const classList = [];
|
||||
|
||||
if (layoutStyle.fontLayout === 'large') {
|
||||
classList.push('font-large');
|
||||
} else if (layoutStyle.fontLayout === 'small') {
|
||||
classList.push('font-small');
|
||||
}
|
||||
|
||||
if (layoutStyle.lineHeightLayout === 'large') {
|
||||
classList.push('line-height-large');
|
||||
} else if (layoutStyle.lineHeightLayout === 'small') {
|
||||
classList.push('line-height-small');
|
||||
}
|
||||
|
||||
return classList.join(' ');
|
||||
}, [layoutStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutStyle.font) return;
|
||||
void window.WebFont?.load({
|
||||
google: {
|
||||
families: [layoutStyle.font],
|
||||
},
|
||||
});
|
||||
}, [layoutStyle.font]);
|
||||
|
||||
if (!documentId) return null;
|
||||
export interface DocumentProps extends ViewMetaProps {
|
||||
doc: YDoc;
|
||||
navigateToView?: (viewId: string) => Promise<void>;
|
||||
loadViewMeta?: (viewId: string) => Promise<ViewMeta>;
|
||||
loadView?: (viewId: string) => Promise<YDoc>;
|
||||
getViewRowsMap?: (viewId: string, rowIds: string[]) => Promise<{ rows: Y.Map<YDoc>; destroy: () => void }>;
|
||||
}
|
||||
|
||||
export const Document = ({
|
||||
doc,
|
||||
loadView,
|
||||
navigateToView,
|
||||
loadViewMeta,
|
||||
getViewRowsMap,
|
||||
...viewMeta
|
||||
}: DocumentProps) => {
|
||||
return (
|
||||
<>
|
||||
{doc ? (
|
||||
<div style={style} className={`relative w-full ${layoutClassName}`}>
|
||||
<DocumentHeader doc={doc} viewId={documentId} />
|
||||
<div className={'flex w-full justify-center'}>
|
||||
<Suspense fallback={<ComponentLoading />}>
|
||||
<div className={'max-w-screen w-[964px] min-w-0'}>
|
||||
<Editor doc={doc} readOnly={true} layoutStyle={layoutStyle} />
|
||||
</div>
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className={'flex w-full justify-center'}>
|
||||
<ViewMetaPreview {...viewMeta} />
|
||||
<Suspense fallback={<ComponentLoading />}>
|
||||
<div className={'max-w-screen w-[964px] min-w-0'}>
|
||||
<Editor
|
||||
loadView={loadView}
|
||||
loadViewMeta={loadViewMeta}
|
||||
navigateToView={navigateToView}
|
||||
getViewRowsMap={getViewRowsMap}
|
||||
doc={doc}
|
||||
readOnly={true}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={'flex h-full w-full items-center justify-center'}>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RecordNotFound open={notFound} />
|
||||
</>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,56 +0,0 @@
|
||||
import { showColorsForImage } from '@/components/document/document_header/utils';
|
||||
import { renderColor } from '@/utils/color';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
function DocumentCover({
|
||||
coverValue,
|
||||
coverType,
|
||||
onTextColor,
|
||||
}: {
|
||||
coverValue?: string;
|
||||
coverType?: string;
|
||||
onTextColor: (color: string) => void;
|
||||
}) {
|
||||
const renderCoverColor = useCallback((color: string) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: renderColor(color),
|
||||
}}
|
||||
className={`h-full w-full`}
|
||||
/>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const renderCoverImage = useCallback(
|
||||
(url: string) => {
|
||||
return (
|
||||
<img
|
||||
onLoad={(e) => {
|
||||
void showColorsForImage(e.currentTarget).then((res) => {
|
||||
onTextColor(res);
|
||||
});
|
||||
}}
|
||||
draggable={false}
|
||||
src={url}
|
||||
alt={''}
|
||||
className={'h-full w-full object-cover'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[onTextColor]
|
||||
);
|
||||
|
||||
if (!coverType || !coverValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'relative flex h-[255px] w-full max-sm:h-[180px]'}>
|
||||
{coverType === 'color' && renderCoverColor(coverValue)}
|
||||
{(coverType === 'custom' || coverType === 'built_in') && renderCoverImage(coverValue)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentCover;
|
@ -1,100 +0,0 @@
|
||||
import { DocCoverType, YDoc, YjsFolderKey } from '@/application/collab.type';
|
||||
import { useViewSelector } from '@/application/folder-yjs';
|
||||
import { CoverType } from '@/application/folder-yjs/folder.type';
|
||||
import { usePageInfo } from '@/components/_shared/page/usePageInfo';
|
||||
import DocumentCover from '@/components/document/document_header/DocumentCover';
|
||||
import { useBlockCover } from '@/components/document/document_header/useBlockCover';
|
||||
import React, { memo, useMemo, useRef, useState } from 'react';
|
||||
import BuiltInImage1 from '@/assets/cover/m_cover_image_1.png';
|
||||
import BuiltInImage2 from '@/assets/cover/m_cover_image_2.png';
|
||||
import BuiltInImage3 from '@/assets/cover/m_cover_image_3.png';
|
||||
import BuiltInImage4 from '@/assets/cover/m_cover_image_4.png';
|
||||
import BuiltInImage5 from '@/assets/cover/m_cover_image_5.png';
|
||||
import BuiltInImage6 from '@/assets/cover/m_cover_image_6.png';
|
||||
|
||||
export function DocumentHeader({ viewId, doc }: { viewId: string; doc: YDoc }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { view } = useViewSelector(viewId);
|
||||
const [textColor, setTextColor] = useState<string>('var(--text-title)');
|
||||
const icon = view?.get(YjsFolderKey.icon);
|
||||
const iconObject = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(icon || '');
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}, [icon]);
|
||||
|
||||
const { extra } = usePageInfo(viewId);
|
||||
|
||||
const pageCover = extra.cover;
|
||||
const { cover } = useBlockCover(doc);
|
||||
|
||||
const coverType = useMemo(() => {
|
||||
if (
|
||||
(pageCover && [CoverType.NormalColor, CoverType.GradientColor].includes(pageCover.type)) ||
|
||||
cover?.cover_selection_type === DocCoverType.Color
|
||||
) {
|
||||
return 'color';
|
||||
}
|
||||
|
||||
if (CoverType.BuildInImage === pageCover?.type || cover?.cover_selection_type === DocCoverType.Asset) {
|
||||
return 'built_in';
|
||||
}
|
||||
|
||||
if (
|
||||
(pageCover && [CoverType.CustomImage, CoverType.UpsplashImage].includes(pageCover.type)) ||
|
||||
cover?.cover_selection_type === DocCoverType.Image
|
||||
) {
|
||||
return 'custom';
|
||||
}
|
||||
}, [cover?.cover_selection_type, pageCover]);
|
||||
|
||||
const coverValue = useMemo(() => {
|
||||
if (coverType === 'built_in') {
|
||||
return {
|
||||
1: BuiltInImage1,
|
||||
2: BuiltInImage2,
|
||||
3: BuiltInImage3,
|
||||
4: BuiltInImage4,
|
||||
5: BuiltInImage5,
|
||||
6: BuiltInImage6,
|
||||
}[pageCover?.value as string];
|
||||
}
|
||||
|
||||
return pageCover?.value || cover?.cover_selection;
|
||||
}, [coverType, cover?.cover_selection, pageCover]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={'document-header mb-[10px] select-none'}>
|
||||
<div className={'view-banner relative flex w-full flex-col overflow-hidden'}>
|
||||
<DocumentCover onTextColor={setTextColor} coverType={coverType} coverValue={coverValue} />
|
||||
|
||||
<div className={`relative mx-16 w-[964px] min-w-0 max-w-full overflow-visible max-md:mx-4`}>
|
||||
<div
|
||||
style={{
|
||||
position: coverValue ? 'absolute' : 'relative',
|
||||
bottom: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
className={'flex items-center gap-2 px-14 py-8 text-4xl max-md:px-2 max-sm:text-[7vw]'}
|
||||
>
|
||||
<div className={`view-icon`}>{iconObject?.value}</div>
|
||||
<div className={'flex flex-1 items-center gap-2 overflow-hidden'}>
|
||||
<div
|
||||
style={{
|
||||
color: textColor,
|
||||
}}
|
||||
className={'font-bold leading-[1.5em]'}
|
||||
>
|
||||
{view?.get(YjsFolderKey.name)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DocumentHeader);
|
@ -1 +0,0 @@
|
||||
export * from './DocumentHeader';
|
@ -1,36 +0,0 @@
|
||||
import { DocCover, YBlocks, YDoc, YDocument, YjsEditorKey } from '@/application/collab.type';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export function useBlockCover(doc: YDoc) {
|
||||
const [cover, setCover] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!doc) return;
|
||||
|
||||
const document = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.document) as YDocument;
|
||||
const pageId = document.get(YjsEditorKey.page_id) as string;
|
||||
const blocks = document.get(YjsEditorKey.blocks) as YBlocks;
|
||||
const root = blocks.get(pageId);
|
||||
|
||||
setCover(root.toJSON().data || null);
|
||||
const observerEvent = () => setCover(root.toJSON().data || null);
|
||||
|
||||
root.observe(observerEvent);
|
||||
|
||||
return () => {
|
||||
root.unobserve(observerEvent);
|
||||
};
|
||||
}, [doc]);
|
||||
|
||||
const coverObj: DocCover = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(cover || '');
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}, [cover]);
|
||||
|
||||
return {
|
||||
cover: coverObj,
|
||||
};
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
import ColorThief from 'colorthief';
|
||||
|
||||
const colorThief = new ColorThief();
|
||||
|
||||
export function calculateTextColor(rgb: [number, number, number]): string {
|
||||
const [r, g, b] = rgb;
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
|
||||
return brightness > 125 ? 'black' : 'white';
|
||||
}
|
||||
|
||||
export async function showColorsForImage(image: HTMLImageElement) {
|
||||
const img = new Image();
|
||||
|
||||
img.crossOrigin = 'Anonymous'; // Handle CORS
|
||||
img.src = image.src;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
});
|
||||
|
||||
const dominantColor = colorThief.getColor(img);
|
||||
|
||||
return calculateTextColor(dominantColor);
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
import { YDoc, YFolder, YjsEditorKey } from '@/application/collab.type';
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { DocumentTest } from '@/../cypress/support/document';
|
||||
import { applyYDoc } from '@/application/ydoc/apply';
|
||||
import { FolderProvider } from '@/components/_shared/context-provider/FolderProvider';
|
||||
import React from 'react';
|
||||
import * as Y from 'yjs';
|
||||
import { Editor } from './Editor';
|
||||
@ -20,39 +19,23 @@ describe('<Editor />', () => {
|
||||
});
|
||||
|
||||
it('renders with a full document', () => {
|
||||
cy.mockDatabase();
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US' });
|
||||
Object.defineProperty(window.navigator, 'languages', { value: ['en-US'] });
|
||||
cy.fixture('folder').then((folderJson) => {
|
||||
cy.fixture('full_doc').then((docJson) => {
|
||||
const doc = new Y.Doc();
|
||||
const state = new Uint8Array(folderJson.data.doc_state);
|
||||
const state = new Uint8Array(docJson.data.doc_state);
|
||||
|
||||
applyYDoc(doc, state);
|
||||
|
||||
const folder = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.folder) as YFolder;
|
||||
|
||||
cy.fixture('full_doc').then((docJson) => {
|
||||
const doc = new Y.Doc();
|
||||
const state = new Uint8Array(docJson.data.doc_state);
|
||||
|
||||
applyYDoc(doc, state);
|
||||
renderEditor(doc, folder);
|
||||
});
|
||||
renderEditor(doc);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function renderEditor(doc: YDoc, folder?: YFolder) {
|
||||
function renderEditor(doc: YDoc) {
|
||||
const AppWrapper = withAppWrapper(() => {
|
||||
return (
|
||||
<div className={'h-screen w-screen overflow-y-auto'}>
|
||||
{folder ? (
|
||||
<FolderProvider folder={folder}>
|
||||
<Editor doc={doc} readOnly />
|
||||
</FolderProvider>
|
||||
) : (
|
||||
<Editor doc={doc} readOnly />
|
||||
)}
|
||||
<Editor doc={doc} readOnly />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -1,18 +1,16 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import CollaborativeEditor from '@/components/editor/CollaborativeEditor';
|
||||
import { defaultLayoutStyle, EditorContextProvider, EditorLayoutStyle } from '@/components/editor/EditorContext';
|
||||
import { defaultLayoutStyle, EditorContextProvider, EditorContextState } from '@/components/editor/EditorContext';
|
||||
import React, { memo } from 'react';
|
||||
import './editor.scss';
|
||||
|
||||
export interface EditorProps {
|
||||
readOnly: boolean;
|
||||
export interface EditorProps extends EditorContextState {
|
||||
doc: YDoc;
|
||||
layoutStyle?: EditorLayoutStyle;
|
||||
}
|
||||
|
||||
export const Editor = memo(({ readOnly, doc, layoutStyle = defaultLayoutStyle }: EditorProps) => {
|
||||
export const Editor = memo(({ doc, layoutStyle = defaultLayoutStyle, ...props }: EditorProps) => {
|
||||
return (
|
||||
<EditorContextProvider layoutStyle={layoutStyle} readOnly={readOnly}>
|
||||
<EditorContextProvider {...props} layoutStyle={layoutStyle}>
|
||||
<CollaborativeEditor doc={doc} />
|
||||
</EditorContextProvider>
|
||||
);
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { FontLayout, LineHeightLayout } from '@/application/collab.type';
|
||||
import { FontLayout, LineHeightLayout, YDoc } from '@/application/collab.type';
|
||||
import { ViewMeta } from '@/application/db/tables/view_metas';
|
||||
import { createContext, useContext } from 'react';
|
||||
import Y from 'yjs';
|
||||
|
||||
export interface EditorLayoutStyle {
|
||||
fontLayout: FontLayout;
|
||||
@ -13,9 +15,13 @@ export const defaultLayoutStyle: EditorLayoutStyle = {
|
||||
lineHeightLayout: LineHeightLayout.normal,
|
||||
};
|
||||
|
||||
interface EditorContextState {
|
||||
export interface EditorContextState {
|
||||
readOnly: boolean;
|
||||
layoutStyle: EditorLayoutStyle;
|
||||
layoutStyle?: EditorLayoutStyle;
|
||||
navigateToView?: (viewId: string) => Promise<void>;
|
||||
loadViewMeta?: (viewId: string) => Promise<ViewMeta>;
|
||||
loadView?: (viewId: string) => Promise<YDoc>;
|
||||
getViewRowsMap?: (viewId: string, rowIds: string[]) => Promise<{ rows: Y.Map<YDoc>; destroy: () => void }>;
|
||||
}
|
||||
|
||||
export const EditorContext = createContext<EditorContextState>({
|
||||
|
@ -1,25 +1,26 @@
|
||||
import { ReactComponent as ExpandMoreIcon } from '$icons/16x/full_view.svg';
|
||||
import { useNavigateToView } from '@/application/folder-yjs';
|
||||
import { getCurrentWorkspace } from 'src/application/services/js-services/session';
|
||||
import { IdProvider } from '@/components/_shared/context-provider/IdProvider';
|
||||
import { useEditorContext } from '@/components/editor/EditorContext';
|
||||
import { Database } from '@/components/database';
|
||||
import { useGetDatabaseId, useLoadDatabase } from '@/components/database/Database.hooks';
|
||||
import { DatabaseContextProvider } from '@/components/database/DatabaseContext';
|
||||
import { DatabaseNode, EditorElementProps } from '@/components/editor/editor.type';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import React, { forwardRef, memo, useCallback, useMemo, useState } from 'react';
|
||||
import React, { forwardRef, memo, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BlockType } from '@/application/collab.type';
|
||||
import { BlockType, YDoc } from '@/application/collab.type';
|
||||
|
||||
export const DatabaseBlock = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<DatabaseNode>>(({ node, children, ...attributes }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const viewId = node.data.view_id;
|
||||
const type = node.type;
|
||||
const navigateToView = useNavigateToView();
|
||||
const navigateToView = useEditorContext()?.navigateToView;
|
||||
const loadView = useEditorContext()?.loadView;
|
||||
const getViewRowsMap = useEditorContext()?.getViewRowsMap;
|
||||
const loadViewMeta = useEditorContext()?.loadViewMeta;
|
||||
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [doc, setDoc] = useState<YDoc | null>(null);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [databaseViewId, setDatabaseViewId] = useState<string | undefined>(viewId);
|
||||
const style = useMemo(() => {
|
||||
const style = {};
|
||||
|
||||
@ -39,23 +40,22 @@ export const DatabaseBlock = memo(
|
||||
return style;
|
||||
}, [type]);
|
||||
|
||||
const handleNavigateToRow = useCallback(
|
||||
async (rowId: string) => {
|
||||
const workspace = await getCurrentWorkspace();
|
||||
useEffect(() => {
|
||||
if (!viewId) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const view = await loadView?.(viewId);
|
||||
|
||||
if (!workspace) return;
|
||||
if (!view) {
|
||||
throw new Error('View not found');
|
||||
}
|
||||
|
||||
const url = `/view/${workspace.id}/${databaseViewId}?r=${rowId}`;
|
||||
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
[databaseViewId]
|
||||
);
|
||||
const databaseId = useGetDatabaseId(viewId);
|
||||
|
||||
const { doc, rows, notFound } = useLoadDatabase({
|
||||
databaseId,
|
||||
});
|
||||
setDoc(view);
|
||||
} catch (e) {
|
||||
setNotFound(true);
|
||||
}
|
||||
})();
|
||||
}, [viewId, loadView]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -69,17 +69,15 @@ export const DatabaseBlock = memo(
|
||||
{children}
|
||||
</div>
|
||||
<div contentEditable={false} style={style} className={`container-bg relative flex w-full flex-col px-3`}>
|
||||
{viewId && doc && rows ? (
|
||||
<IdProvider objectId={viewId}>
|
||||
<DatabaseContextProvider
|
||||
navigateToRow={handleNavigateToRow}
|
||||
viewId={databaseViewId || viewId}
|
||||
databaseDoc={doc}
|
||||
rowDocMap={rows}
|
||||
readOnly={true}
|
||||
>
|
||||
<Database iidIndex={viewId} viewId={databaseViewId || viewId} onNavigateToView={setDatabaseViewId} />
|
||||
</DatabaseContextProvider>
|
||||
{viewId && doc ? (
|
||||
<>
|
||||
<Database
|
||||
doc={doc}
|
||||
getViewRowsMap={getViewRowsMap}
|
||||
loadView={loadView}
|
||||
navigateToView={navigateToView}
|
||||
loadViewMeta={loadViewMeta}
|
||||
/>
|
||||
{isHovering && (
|
||||
<div className={'absolute right-4 top-1'}>
|
||||
<Tooltip placement={'bottom'} title={t('tooltip.openAsPage')}>
|
||||
@ -87,7 +85,7 @@ export const DatabaseBlock = memo(
|
||||
color={'primary'}
|
||||
className={'rounded border border-line-divider bg-bg-body p-1 hover:bg-fill-list-hover'}
|
||||
onClick={() => {
|
||||
navigateToView?.(viewId);
|
||||
void navigateToView?.(viewId);
|
||||
}}
|
||||
>
|
||||
<ExpandMoreIcon />
|
||||
@ -95,15 +93,14 @@ export const DatabaseBlock = memo(
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</IdProvider>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={'mt-[10%] flex h-full w-full flex-col items-center gap-2 px-16 text-text-caption max-md:px-4'}
|
||||
>
|
||||
{notFound ? (
|
||||
<>
|
||||
<div className={'text-sm font-medium'}>{t('document.plugins.database.noDataSource')}</div>
|
||||
<div className={'text-xs'}>{t('grid.relation.noDatabaseSelected')}</div>
|
||||
<div className={'text-sm font-medium'}>{t('publish.hasNotBeenPublished')}</div>
|
||||
</>
|
||||
) : (
|
||||
<CircularProgress />
|
||||
|
@ -2,6 +2,7 @@ import { BlockData, BlockType, InlineBlockType, YjsEditorKey } from '@/applicati
|
||||
import { BulletedList } from '@/components/editor/components/blocks/bulleted-list';
|
||||
import { Callout } from '@/components/editor/components/blocks/callout';
|
||||
import { CodeBlock } from '@/components/editor/components/blocks/code';
|
||||
import { DatabaseBlock } from '@/components/editor/components/blocks/database';
|
||||
import { DividerNode } from '@/components/editor/components/blocks/divider';
|
||||
import { Heading } from '@/components/editor/components/blocks/heading';
|
||||
import { ImageBlock } from '@/components/editor/components/blocks/image';
|
||||
@ -25,7 +26,6 @@ import { EditorElementProps, TextNode } from '@/components/editor/editor.type';
|
||||
import { renderColor } from '@/utils/color';
|
||||
import React, { FC, memo, Suspense, useMemo } from 'react';
|
||||
import { RenderElementProps } from 'slate-react';
|
||||
import { DatabaseBlock } from 'src/components/editor/components/blocks/database';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
|
||||
export const Element = memo(
|
||||
|
@ -1,22 +1,81 @@
|
||||
import { useNavigateToView } from '@/application/folder-yjs';
|
||||
import { usePageInfo } from '@/components/_shared/page/usePageInfo';
|
||||
import React from 'react';
|
||||
import { ReactComponent as DocumentSvg } from '$icons/16x/document.svg';
|
||||
import { ReactComponent as GridSvg } from '$icons/16x/grid.svg';
|
||||
import { ReactComponent as BoardSvg } from '$icons/16x/board.svg';
|
||||
import { ReactComponent as CalendarSvg } from '$icons/16x/date.svg';
|
||||
import { ViewLayout } from '@/application/collab.type';
|
||||
import { ViewMeta } from '@/application/db/tables/view_metas';
|
||||
import { useEditorContext } from '@/components/editor/EditorContext';
|
||||
import { ViewMetaIcon } from '@/components/view-meta';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function MentionPage({ pageId }: { pageId: string }) {
|
||||
const onNavigateToView = useNavigateToView();
|
||||
const { icon, name } = usePageInfo(pageId);
|
||||
const context = useEditorContext();
|
||||
const { navigateToView, loadViewMeta } = context;
|
||||
const [unPublished, setUnPublished] = useState(false);
|
||||
const [meta, setMeta] = useState<ViewMeta | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
if (loadViewMeta) {
|
||||
setUnPublished(false);
|
||||
try {
|
||||
const meta = await loadViewMeta(pageId);
|
||||
|
||||
setMeta(meta);
|
||||
} catch (e) {
|
||||
setUnPublished(true);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [loadViewMeta, pageId]);
|
||||
|
||||
const icon = useMemo(() => {
|
||||
if (meta?.icon) {
|
||||
try {
|
||||
return JSON.parse(meta.icon) as ViewMetaIcon;
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}, [meta?.icon]);
|
||||
|
||||
const defaultIcon = useMemo(() => {
|
||||
switch (meta?.layout) {
|
||||
case ViewLayout.Document:
|
||||
return <DocumentSvg />;
|
||||
case ViewLayout.Grid:
|
||||
return <GridSvg />;
|
||||
case ViewLayout.Board:
|
||||
return <BoardSvg />;
|
||||
case ViewLayout.Calendar:
|
||||
return <CalendarSvg />;
|
||||
default:
|
||||
return <DocumentSvg />;
|
||||
}
|
||||
}, [meta?.layout]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={() => {
|
||||
onNavigateToView?.(pageId);
|
||||
void navigateToView?.(pageId);
|
||||
}}
|
||||
className={`mention-inline px-1 underline`}
|
||||
contentEditable={false}
|
||||
>
|
||||
<span className={'mention-icon'}>{icon}</span>
|
||||
{unPublished ? (
|
||||
<span className={'mention-unpublished font-semibold text-text-caption'}>No Access</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={'mention-icon'}>{icon?.value || defaultIcon}</span>
|
||||
|
||||
<span className={'mention-content'}>{name}</span>
|
||||
<span className={'mention-content'}>{meta?.name || t('menuAppHeader.defaultNewPageName')}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@ -1,39 +0,0 @@
|
||||
import { useAppDispatch, useAppSelector } from '@/stores/store';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {errorActions} from "@/stores/error/slice";
|
||||
|
||||
export const useError = (e: Error) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const error = useAppSelector((state) => state.error);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [displayError, setDisplayError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayError(error.display);
|
||||
setErrorMessage(error.message);
|
||||
}, [error]);
|
||||
|
||||
const showError = useCallback(
|
||||
(msg: string) => {
|
||||
dispatch(errorActions.showError(msg));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (e) {
|
||||
showError(e.message);
|
||||
}
|
||||
}, [e, showError]);
|
||||
|
||||
const hideError = () => {
|
||||
dispatch(errorActions.hideError());
|
||||
};
|
||||
|
||||
return {
|
||||
showError,
|
||||
hideError,
|
||||
errorMessage,
|
||||
displayError,
|
||||
};
|
||||
};
|
@ -1,8 +1,26 @@
|
||||
import { useError } from './Error.hooks';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ErrorModal } from './ErrorModal';
|
||||
|
||||
export const ErrorHandlerPage = ({ error }: { error: Error }) => {
|
||||
const { hideError, errorMessage, displayError } = useError(error);
|
||||
const [displayError, setDisplayError] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState(error.message);
|
||||
|
||||
const hideError = () => {
|
||||
setDisplayError(false);
|
||||
};
|
||||
|
||||
const showError = useCallback((msg: string) => {
|
||||
setErrorMessage(msg);
|
||||
setDisplayError(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
showError(error.message);
|
||||
} else {
|
||||
setDisplayError(false);
|
||||
}
|
||||
}, [error, showError]);
|
||||
|
||||
return displayError ? <ErrorModal message={errorMessage} onClose={hideError}></ErrorModal> : <></>;
|
||||
};
|
||||
|
@ -1,17 +0,0 @@
|
||||
import { useViewsIdSelector } from '@/application/folder-yjs';
|
||||
import ViewItem from '@/components/folder/ViewItem';
|
||||
import React from 'react';
|
||||
|
||||
export function Folder() {
|
||||
const { viewsId } = useViewsIdSelector();
|
||||
|
||||
return (
|
||||
<div className={'m-10 p-10'}>
|
||||
{viewsId.map((viewId) => {
|
||||
return <ViewItem key={viewId} id={viewId} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Folder;
|
@ -1,20 +0,0 @@
|
||||
import { useNavigateToView } from '@/application/folder-yjs';
|
||||
import React from 'react';
|
||||
import Page from '@/components/_shared/page/Page';
|
||||
|
||||
function ViewItem({ id }: { id: string }) {
|
||||
const onNavigateToView = useNavigateToView();
|
||||
|
||||
return (
|
||||
<div className={'cursor-pointer border-b border-line-border py-4 px-2'}>
|
||||
<Page
|
||||
onClick={() => {
|
||||
onNavigateToView?.(id);
|
||||
}}
|
||||
id={id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewItem;
|
@ -1 +0,0 @@
|
||||
export * from './Folder';
|
@ -1,74 +0,0 @@
|
||||
import { downloadPage, openAppFlowySchema, openUrl } from '@/utils/url';
|
||||
import { Button } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ReactComponent as Logo } from '@/assets/logo.svg';
|
||||
import Popover, { PopoverOrigin } from '@mui/material/Popover';
|
||||
import Breadcrumb from 'src/components/layout/breadcrumb/Breadcrumb';
|
||||
|
||||
const popoverOrigin: {
|
||||
anchorOrigin: PopoverOrigin;
|
||||
transformOrigin: PopoverOrigin;
|
||||
} = {
|
||||
anchorOrigin: {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
},
|
||||
transformOrigin: {
|
||||
vertical: -10,
|
||||
horizontal: 'right',
|
||||
},
|
||||
};
|
||||
|
||||
function Header() {
|
||||
const { t } = useTranslation();
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
|
||||
|
||||
return (
|
||||
<div className={'appflowy-top-bar flex h-[64px] p-4'}>
|
||||
<div className={'flex w-full items-center justify-between overflow-hidden'}>
|
||||
<Breadcrumb />
|
||||
|
||||
<Button
|
||||
className={'border-line-border'}
|
||||
onClick={(e) => {
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
variant={'outlined'}
|
||||
color={'inherit'}
|
||||
endIcon={<Logo />}
|
||||
>
|
||||
Built with
|
||||
</Button>
|
||||
</div>
|
||||
<Popover open={Boolean(anchorEl)} anchorEl={anchorEl} {...popoverOrigin} onClose={() => setAnchorEl(null)}>
|
||||
<div className={'flex w-fit flex-col gap-2 p-4'}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void openUrl(openAppFlowySchema);
|
||||
}}
|
||||
className={'w-full'}
|
||||
variant={'outlined'}
|
||||
>
|
||||
{`🥳 Open AppFlowy`}
|
||||
</Button>
|
||||
<div className={'flex w-full items-center justify-center gap-2 text-xs text-text-caption'}>
|
||||
<div className={'h-px flex-1 bg-line-divider'} />
|
||||
{t('signIn.or')}
|
||||
<div className={'h-px flex-1 bg-line-divider'} />
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void openUrl(downloadPage, '_blank');
|
||||
}}
|
||||
variant={'contained'}
|
||||
>
|
||||
{`Download AppFlowy`}
|
||||
</Button>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
@ -1,96 +0,0 @@
|
||||
import { YFolder, YjsEditorKey, YjsFolderKey } from '@/application/collab.type';
|
||||
import { Crumb } from '@/application/folder-yjs';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
|
||||
export function useLayout() {
|
||||
const { workspaceId, objectId } = useParams();
|
||||
const [search] = useSearchParams();
|
||||
const folderService = useContext(AFConfigContext)?.service?.folderService;
|
||||
const [folder, setFolder] = useState<YFolder | null>(null);
|
||||
const views = folder?.get(YjsFolderKey.views);
|
||||
const view = objectId ? views?.get(objectId) : null;
|
||||
const [crumbs, setCrumbs] = useState<Crumb[]>([]);
|
||||
|
||||
const getFolder = useCallback(
|
||||
async (workspaceId: string) => {
|
||||
const folder = (await folderService?.openWorkspace(workspaceId))
|
||||
?.getMap(YjsEditorKey.data_section)
|
||||
.get(YjsEditorKey.folder);
|
||||
|
||||
if (!folder) return;
|
||||
|
||||
console.log(folder.toJSON());
|
||||
setFolder(folder);
|
||||
},
|
||||
[folderService]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) return;
|
||||
|
||||
void getFolder(workspaceId);
|
||||
}, [getFolder, workspaceId]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleNavigateToView = useCallback(
|
||||
(viewId: string) => {
|
||||
const view = folder?.get(YjsFolderKey.views)?.get(viewId);
|
||||
|
||||
if (!view) return;
|
||||
navigate(`/view/${workspaceId}/${viewId}`);
|
||||
},
|
||||
[folder, navigate, workspaceId]
|
||||
);
|
||||
|
||||
const onChangeBreadcrumb = useCallback(() => {
|
||||
if (!view) return;
|
||||
const queue = [view];
|
||||
let parentId = view.get(YjsFolderKey.bid);
|
||||
|
||||
while (parentId) {
|
||||
const parent = views?.get(parentId);
|
||||
|
||||
if (!parent) break;
|
||||
|
||||
queue.unshift(parent);
|
||||
parentId = parent?.get(YjsFolderKey.bid);
|
||||
}
|
||||
|
||||
setCrumbs(
|
||||
queue
|
||||
.map((view) => {
|
||||
let icon = view.get(YjsFolderKey.icon);
|
||||
|
||||
try {
|
||||
icon = JSON.parse(icon || '')?.value;
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return {
|
||||
viewId: view.get(YjsFolderKey.id),
|
||||
name: view.get(YjsFolderKey.name),
|
||||
icon: icon || view.get(YjsFolderKey.layout),
|
||||
};
|
||||
})
|
||||
.slice(1)
|
||||
);
|
||||
}, [view, views]);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeBreadcrumb();
|
||||
|
||||
view?.observe(onChangeBreadcrumb);
|
||||
views?.observe(onChangeBreadcrumb);
|
||||
|
||||
return () => {
|
||||
view?.unobserve(onChangeBreadcrumb);
|
||||
views?.unobserve(onChangeBreadcrumb);
|
||||
};
|
||||
}, [search, onChangeBreadcrumb, view, views]);
|
||||
|
||||
return { folder, handleNavigateToView, crumbs, setCrumbs };
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
import { FolderProvider } from '@/components/_shared/context-provider/FolderProvider';
|
||||
import Header from '@/components/layout/Header';
|
||||
import { AFScroller } from '@/components/_shared/scroller';
|
||||
import { useLayout } from '@/components/layout/Layout.hooks';
|
||||
import React from 'react';
|
||||
import './layout.scss';
|
||||
import { ReactComponent as Logo } from '@/assets/logo.svg';
|
||||
|
||||
function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { folder, handleNavigateToView, crumbs, setCrumbs } = useLayout();
|
||||
|
||||
if (!folder)
|
||||
return (
|
||||
<div className={'flex h-screen w-screen items-center justify-center'}>
|
||||
<Logo className={'h-20 w-20'} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<FolderProvider setCrumbs={setCrumbs} crumbs={crumbs} onNavigateToView={handleNavigateToView} folder={folder}>
|
||||
<Header />
|
||||
<AFScroller
|
||||
overflowXHidden
|
||||
style={{
|
||||
height: 'calc(100vh - 64px)',
|
||||
}}
|
||||
className={'appflowy-layout appflowy-scroll-container'}
|
||||
>
|
||||
{children}
|
||||
</AFScroller>
|
||||
</FolderProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layout;
|
@ -1 +0,0 @@
|
||||
export * from './Breadcrumb';
|
@ -0,0 +1,65 @@
|
||||
import { ViewLayout, YDoc } from '@/application/collab.type';
|
||||
import { ViewMeta } from '@/application/db/tables/view_metas';
|
||||
import { usePublishContext } from '@/application/publish';
|
||||
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
|
||||
import { Database } from '@/components/database';
|
||||
import { useViewMeta } from '@/components/publish/useViewMeta';
|
||||
import { ViewMetaProps } from 'src/components/view-meta';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Document } from '@/components/document';
|
||||
import Y from 'yjs';
|
||||
|
||||
export interface CollabViewProps {
|
||||
doc?: YDoc;
|
||||
}
|
||||
|
||||
function CollabView({ doc }: CollabViewProps) {
|
||||
const { viewId, layout, icon, cover, layoutClassName, style } = useViewMeta();
|
||||
|
||||
const View = useMemo(() => {
|
||||
switch (layout) {
|
||||
case ViewLayout.Document:
|
||||
return Document;
|
||||
case ViewLayout.Grid:
|
||||
case ViewLayout.Board:
|
||||
case ViewLayout.Calendar:
|
||||
return Database;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [layout]) as React.FC<
|
||||
{
|
||||
doc: YDoc;
|
||||
navigateToView?: (viewId: string) => Promise<void>;
|
||||
loadViewMeta?: (viewId: string) => Promise<ViewMeta>;
|
||||
getViewRowsMap?: (rowIds: string[]) => Promise<{ rows: Y.Map<YDoc>; destroy: () => void }>;
|
||||
loadView?: (id: string) => Promise<YDoc>;
|
||||
} & ViewMetaProps
|
||||
>;
|
||||
|
||||
const navigateToView = usePublishContext()?.toView;
|
||||
const loadViewMeta = usePublishContext()?.loadViewMeta;
|
||||
const getViewRowsMap = usePublishContext()?.getViewRowsMap;
|
||||
const loadView = usePublishContext()?.loadView;
|
||||
|
||||
if (!doc) {
|
||||
return <ComponentLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={style} className={`relative w-full ${layoutClassName}`}>
|
||||
<View
|
||||
doc={doc}
|
||||
loadViewMeta={loadViewMeta}
|
||||
getViewRowsMap={getViewRowsMap}
|
||||
navigateToView={navigateToView}
|
||||
loadView={loadView}
|
||||
icon={icon}
|
||||
cover={cover}
|
||||
viewId={viewId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CollabView;
|
@ -0,0 +1,61 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { PublishProvider } from '@/application/publish';
|
||||
import { AFScroller } from '@/components/_shared/scroller';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import CollabView from '@/components/publish/CollabView';
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { PublishViewHeader } from 'src/components/publish/header';
|
||||
|
||||
export interface PublishViewProps {
|
||||
namespace: string;
|
||||
publishName: string;
|
||||
}
|
||||
|
||||
export function PublishView({ namespace, publishName }: PublishViewProps) {
|
||||
const [doc, setDoc] = useState<YDoc | undefined>();
|
||||
const [notFound, setNotFound] = useState<boolean>(false);
|
||||
const service = useContext(AFConfigContext)?.service;
|
||||
const openPublishView = useCallback(async () => {
|
||||
let doc;
|
||||
|
||||
try {
|
||||
doc = await service?.getPublishView(namespace, publishName);
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
if (!doc) {
|
||||
setNotFound(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setDoc(doc);
|
||||
}, [namespace, publishName, service]);
|
||||
|
||||
useEffect(() => {
|
||||
void openPublishView();
|
||||
}, [openPublishView]);
|
||||
|
||||
if (notFound) {
|
||||
return <div className={'flex h-full w-full items-center justify-center'}>Not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<PublishProvider namespace={namespace} publishName={publishName}>
|
||||
<div className={'h-screen w-screen'}>
|
||||
<PublishViewHeader />
|
||||
<AFScroller
|
||||
overflowXHidden
|
||||
style={{
|
||||
height: 'calc(100vh - 64px)',
|
||||
}}
|
||||
className={'appflowy-layout appflowy-scroll-container'}
|
||||
>
|
||||
<CollabView doc={doc} />
|
||||
</AFScroller>
|
||||
</div>
|
||||
</PublishProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublishView;
|
@ -1,10 +1,8 @@
|
||||
import { useCrumbs } from '@/application/folder-yjs';
|
||||
import Item from '@/components/layout/breadcrumb/Item';
|
||||
import BreadcrumbItem, { Crumb } from 'src/components/publish/header/BreadcrumbItem';
|
||||
import React, { useMemo } from 'react';
|
||||
import { ReactComponent as RightIcon } from '$icons/16x/right.svg';
|
||||
|
||||
export function Breadcrumb() {
|
||||
const crumbs = useCrumbs();
|
||||
|
||||
export function Breadcrumb({ crumbs }: { crumbs: Crumb[] }) {
|
||||
const renderCrumb = useMemo(() => {
|
||||
return crumbs?.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1;
|
||||
@ -12,8 +10,8 @@ export function Breadcrumb() {
|
||||
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<Item crumb={crumb} disableClick={isLast} />
|
||||
{!isLast && <span>/</span>}
|
||||
<BreadcrumbItem crumb={crumb} disableClick={isLast} />
|
||||
{!isLast && <RightIcon className={'h-4 w-4'} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
@ -1,11 +1,11 @@
|
||||
import { ViewLayout } from '@/application/collab.type';
|
||||
import { Crumb, useNavigateToView } from '@/application/folder-yjs';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ReactComponent as DocumentSvg } from '$icons/16x/document.svg';
|
||||
import { ReactComponent as GridSvg } from '$icons/16x/grid.svg';
|
||||
import { ReactComponent as BoardSvg } from '$icons/16x/board.svg';
|
||||
import { ReactComponent as CalendarSvg } from '$icons/16x/date.svg';
|
||||
import { ReactComponent as DocumentSvg } from '$icons/16x/document.svg';
|
||||
import { ReactComponent as GridSvg } from '$icons/16x/grid.svg';
|
||||
import { ViewLayout } from '@/application/collab.type';
|
||||
import { usePublishContext } from '@/application/publish';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const renderCrumbIcon = (icon: string) => {
|
||||
if (Number(icon) === ViewLayout.Grid) {
|
||||
@ -27,11 +27,18 @@ const renderCrumbIcon = (icon: string) => {
|
||||
return icon;
|
||||
};
|
||||
|
||||
function Item({ crumb, disableClick = false }: { crumb: Crumb; disableClick?: boolean }) {
|
||||
export interface Crumb {
|
||||
viewId: string;
|
||||
rowId?: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ crumb, disableClick = false }: { crumb: Crumb; disableClick?: boolean }) {
|
||||
const { viewId, icon, name } = crumb;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const onNavigateToView = useNavigateToView();
|
||||
const onNavigateToView = usePublishContext()?.toView;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -51,4 +58,4 @@ function Item({ crumb, disableClick = false }: { crumb: Crumb; disableClick?: bo
|
||||
);
|
||||
}
|
||||
|
||||
export default Item;
|
||||
export default BreadcrumbItem;
|
@ -0,0 +1,26 @@
|
||||
import { usePublishContext } from '@/application/publish';
|
||||
import React, { useMemo } from 'react';
|
||||
import Breadcrumb from './Breadcrumb';
|
||||
|
||||
export function PublishViewHeader() {
|
||||
const viewMeta = usePublishContext()?.viewMeta;
|
||||
const crumbs = useMemo(() => {
|
||||
const ancestors = viewMeta?.ancestor_views || [];
|
||||
|
||||
return ancestors.map((ancestor) => ({
|
||||
viewId: ancestor.view_id,
|
||||
name: ancestor.name,
|
||||
icon: ancestor.icon || String(viewMeta?.layout),
|
||||
}));
|
||||
}, [viewMeta]);
|
||||
|
||||
return (
|
||||
<div className={'appflowy-top-bar flex h-[64px] p-4'}>
|
||||
<div className={'flex w-full items-center justify-between overflow-hidden'}>
|
||||
<Breadcrumb crumbs={crumbs} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublishViewHeader;
|
@ -0,0 +1,2 @@
|
||||
export * from './PublishViewHeader';
|
||||
export { Crumb } from '@/components/publish/header/BreadcrumbItem';
|
@ -0,0 +1 @@
|
||||
export * from './PublishView';
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user