feat: support publish interfaces

This commit is contained in:
Kilu 2024-06-25 18:26:53 +08:00
parent 80a2ebc216
commit 4237a73810
117 changed files with 1851 additions and 3771 deletions

View File

@ -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 {};

View File

@ -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",

View File

@ -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

View File

@ -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>
);
};

View File

@ -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);

View File

@ -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,

View File

@ -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}`;
}

View File

@ -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',
};

View File

@ -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;
};

View File

@ -1,9 +0,0 @@
export enum CoverType {
NormalColor = 'color',
GradientColor = 'gradient',
BuildInImage = 'built_in',
CustomImage = 'custom',
LocalImage = 'local',
UpsplashImage = 'unsplash',
None = 'none',
}

View File

@ -1,2 +0,0 @@
export * from './selector';
export * from './context';

View File

@ -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,
};
}

View 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);
}

View File

@ -0,0 +1 @@
export * from './context';

View File

@ -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('');
});
});

View File

@ -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);
});
});
});

View File

@ -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),
});
});
});

View File

@ -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();
};
}

View 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('');
});
});

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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;
};
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -1,3 +0,0 @@
export async function signInSuccess() {
// Do nothing
}

View File

@ -1,3 +0,0 @@
export * from './token';
export * from './user';
export * from './auth';

View File

@ -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');
}

View File

@ -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);
}

View File

@ -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!;
}
}

View File

@ -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);
}

View File

@ -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>;
}

View File

@ -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,
};
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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>
);
};

View File

@ -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;
}

View File

@ -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;

View File

@ -1 +0,0 @@
export * from './RecordNotFound';

View File

@ -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();
},
};

View File

@ -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;

View File

@ -1 +0,0 @@
export * from './Page';

View File

@ -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,
};
}

View File

@ -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>
);
});

View File

@ -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;

View File

@ -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>
);
};
}

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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;

View File

@ -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');
});
});

View File

@ -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;

View File

@ -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,
};
};

View File

@ -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 };
}

View File

@ -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;

View File

@ -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;

View File

@ -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');
}

View File

@ -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>
);
}

View File

@ -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', () => {

View File

@ -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>
);
}

View File

@ -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>
);
})}

View File

@ -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]);

View File

@ -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;

View File

@ -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;

View File

@ -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} />;
}

View File

@ -1,2 +1 @@
export * from './DatabaseHeader';
export * from './DatabaseRowHeader';

View File

@ -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

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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);

View File

@ -1 +0,0 @@
export * from './DocumentHeader';

View File

@ -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,
};
}

View File

@ -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);
}

View File

@ -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>
);
});

View File

@ -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>
);

View File

@ -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>({

View File

@ -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 />

View File

@ -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(

View File

@ -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>
);
}

View File

@ -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,
};
};

View File

@ -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> : <></>;
};

View File

@ -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;

View File

@ -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;

View File

@ -1 +0,0 @@
export * from './Folder';

View File

@ -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;

View File

@ -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 };
}

View File

@ -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;

View File

@ -1 +0,0 @@
export * from './Breadcrumb';

View File

@ -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;

View File

@ -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;

View File

@ -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>
);
});

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,2 @@
export * from './PublishViewHeader';
export { Crumb } from '@/components/publish/header/BreadcrumbItem';

View File

@ -0,0 +1 @@
export * from './PublishView';

Some files were not shown because too many files have changed in this diff Show More