diff --git a/frontend/appflowy_web_app/cypress/support/commands.ts b/frontend/appflowy_web_app/cypress/support/commands.ts index f78768001f..1b5199b01a 100644 --- a/frontend/appflowy_web_app/cypress/support/commands.ts +++ b/frontend/appflowy_web_app/cypress/support/commands.ts @@ -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 = 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 {}; diff --git a/frontend/appflowy_web_app/package.json b/frontend/appflowy_web_app/package.json index f822a6b0f9..21ba02a625 100644 --- a/frontend/appflowy_web_app/package.json +++ b/frontend/appflowy_web_app/package.json @@ -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", diff --git a/frontend/appflowy_web_app/pnpm-lock.yaml b/frontend/appflowy_web_app/pnpm-lock.yaml index 194beaa5dd..9d8de3d4d6 100644 --- a/frontend/appflowy_web_app/pnpm-lock.yaml +++ b/frontend/appflowy_web_app/pnpm-lock.yaml @@ -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 diff --git a/frontend/appflowy_web_app/src/application/database-yjs/__tests__/selector.test.tsx b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/selector.test.tsx index 23c8bc8221..46473fae01 100644 --- a/frontend/appflowy_web_app/src/application/database-yjs/__tests__/selector.test.tsx +++ b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/selector.test.tsx @@ -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) => ({ children }: { children: React.ReactNode }) => { return ( - - - {children} - - + + {children} + ); }; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/context.ts b/frontend/appflowy_web_app/src/application/database-yjs/context.ts index 5d51001976..cbac5bbd45 100644 --- a/frontend/appflowy_web_app/src/application/database-yjs/context.ts +++ b/frontend/appflowy_web_app/src/application/database-yjs/context.ts @@ -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; isDatabaseRowPage?: boolean; navigateToRow?: (rowId: string) => void; + loadView?: (viewId: string) => Promise; + getViewRowsMap?: (viewId: string, rowIds: string[]) => Promise<{ rows: Y.Map; destroy: () => void }>; + loadViewMeta?: (viewId: string) => Promise; + navigateToView?: (viewId: string) => Promise; } export const DatabaseContext = createContext(null); diff --git a/frontend/appflowy_web_app/src/application/database-yjs/selector.ts b/frontend/appflowy_web_app/src/application/database-yjs/selector.ts index 884c58516d..2832a639f9 100644 --- a/frontend/appflowy_web_app/src/application/database-yjs/selector.ts +++ b/frontend/appflowy_web_app/src/application/database-yjs/selector.ts @@ -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([]); @@ -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, diff --git a/frontend/appflowy_web_app/src/application/services/js-services/cache/db.ts b/frontend/appflowy_web_app/src/application/db/index.ts similarity index 68% rename from frontend/appflowy_web_app/src/application/services/js-services/cache/db.ts rename to frontend/appflowy_web_app/src/application/db/index.ts index a4d888498b..425063e89d 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/cache/db.ts +++ b/frontend/appflowy_web_app/src/application/db/index.ts @@ -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 = 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(); @@ -31,11 +42,3 @@ export async function openCollabDB(docName: string): Promise { return doc as YDoc; } - -export function getCollabDBName(id: string, type: string, uuid?: string) { - if (!uuid) { - return `${type}_${id}`; - } - - return `${uuid}_${type}_${id}`; -} diff --git a/frontend/appflowy_web_app/src/application/db/tables/view_metas.ts b/frontend/appflowy_web_app/src/application/db/tables/view_metas.ts new file mode 100644 index 0000000000..12f77a200e --- /dev/null +++ b/frontend/appflowy_web_app/src/application/db/tables/view_metas.ts @@ -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; +}; + +export const viewMetasSchema = { + view_metas: 'publish_name', +}; diff --git a/frontend/appflowy_web_app/src/application/folder-yjs/context.ts b/frontend/appflowy_web_app/src/application/folder-yjs/context.ts deleted file mode 100644 index cb0e1f63ff..0000000000 --- a/frontend/appflowy_web_app/src/application/folder-yjs/context.ts +++ /dev/null @@ -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>; -} | 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; -}; diff --git a/frontend/appflowy_web_app/src/application/folder-yjs/folder.type.ts b/frontend/appflowy_web_app/src/application/folder-yjs/folder.type.ts deleted file mode 100644 index ce9bebcefb..0000000000 --- a/frontend/appflowy_web_app/src/application/folder-yjs/folder.type.ts +++ /dev/null @@ -1,9 +0,0 @@ -export enum CoverType { - NormalColor = 'color', - GradientColor = 'gradient', - BuildInImage = 'built_in', - CustomImage = 'custom', - LocalImage = 'local', - UpsplashImage = 'unsplash', - None = 'none', -} diff --git a/frontend/appflowy_web_app/src/application/folder-yjs/index.ts b/frontend/appflowy_web_app/src/application/folder-yjs/index.ts deleted file mode 100644 index f94cc509da..0000000000 --- a/frontend/appflowy_web_app/src/application/folder-yjs/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './selector'; -export * from './context'; diff --git a/frontend/appflowy_web_app/src/application/folder-yjs/selector.ts b/frontend/appflowy_web_app/src/application/folder-yjs/selector.ts deleted file mode 100644 index 8e43efbf6a..0000000000 --- a/frontend/appflowy_web_app/src/application/folder-yjs/selector.ts +++ /dev/null @@ -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([]); - 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(0); - const [view, setView] = useState(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, - }; -} diff --git a/frontend/appflowy_web_app/src/application/publish/context.tsx b/frontend/appflowy_web_app/src/application/publish/context.tsx new file mode 100644 index 0000000000..db9674f2b3 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/publish/context.tsx @@ -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; + loadViewMeta: (viewId: string) => Promise; + getViewRowsMap?: (viewId: string, rowIds: string[]) => Promise<{ rows: Y.Map; destroy: () => void }>; + + loadView: (viewId: string) => Promise; +} + +export const PublishContext = createContext(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 ( + + {children} + + ); +}; + +export function usePublishContext() { + return useContext(PublishContext); +} diff --git a/frontend/appflowy_web_app/src/application/publish/index.ts b/frontend/appflowy_web_app/src/application/publish/index.ts new file mode 100644 index 0000000000..c38e8e8215 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/publish/index.ts @@ -0,0 +1 @@ +export * from './context'; diff --git a/frontend/appflowy_web_app/src/application/services/js-services/__tests__/cache.test.ts b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/cache.test.ts deleted file mode 100644 index 9a7e3fcb9d..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/__tests__/cache.test.ts +++ /dev/null @@ -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(''); - }); -}); diff --git a/frontend/appflowy_web_app/src/application/services/js-services/__tests__/fetch.test.ts b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/fetch.test.ts index afdc418c2a..575efef159 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/__tests__/fetch.test.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/fetch.test.ts @@ -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); }); }); }); diff --git a/frontend/appflowy_web_app/src/application/services/js-services/__tests__/index.test.ts b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/index.test.ts new file mode 100644 index 0000000000..5dbf0b4f8c --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/index.test.ts @@ -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), + }); + }); +}); diff --git a/frontend/appflowy_web_app/src/application/services/js-services/auth.service.ts b/frontend/appflowy_web_app/src/application/services/js-services/auth.service.ts deleted file mode 100644 index 7f80c9f871..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/auth.service.ts +++ /dev/null @@ -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 => { - return Promise.reject('Not implemented'); - }; - - @afterSignInDecorator(signInSuccess) - async signInWithOAuth(_: { uri: string }): Promise { - return Promise.reject('Not implemented'); - } - - signupWithEmailPassword = async (_params: SignUpWithEmailPasswordParams): Promise => { - return Promise.reject('Not implemented'); - }; - - @afterSignInDecorator(signInSuccess) - async signinWithEmailPassword(email: string, password: string): Promise { - try { - return APIService.signIn(email, password); - } catch (e) { - return Promise.reject(e); - } - } - - signOut = async (): Promise => { - invalidToken(); - return APIService.logout(); - }; -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/cache/__tests__/cache.test.ts b/frontend/appflowy_web_app/src/application/services/js-services/cache/__tests__/cache.test.ts new file mode 100644 index 0000000000..fae533182f --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/cache/__tests__/cache.test.ts @@ -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(''); + }); +}); diff --git a/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts index 1f7fe670f1..7f49b96cca 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts @@ -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, { - 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, + { + 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) { + 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; + meta: { + metadata: { + view: MetaData; + child_views: MetaData[]; + ancestor_views: MetaData[]; + }; + }; + } +>(name: string, fetcher: Fetcher, 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>, - collabs: { - collabId: string; - collabType: CollabType; - uuid?: string; - }[], - strategy: StrategyType = StrategyType.CACHE_AND_NETWORK, - itemCallback?: (id: string, doc: YDoc) => void -) { - const collabMap = new Map(); +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; } diff --git a/frontend/appflowy_web_app/src/application/services/js-services/database.service.ts b/frontend/appflowy_web_app/src/application/services/js-services/database.service.ts deleted file mode 100644 index cf29b221dd..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/database.service.ts +++ /dev/null @@ -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 = new Set(); - - private loadedWorkspaceId: Set = new Set(); - - private cacheDatabaseRowDocMap: Map = 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; - }> { - 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 = 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); - } -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/decorator.ts b/frontend/appflowy_web_app/src/application/services/js-services/decorator.ts deleted file mode 100644 index a6f9cf9ee4..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/decorator.ts +++ /dev/null @@ -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` - * - * @param setStorage A function that saves the data to storage. eg. `(data: T) => Promise` - * - * @param fetchFunction A function that fetches the data from the server. eg. `(params: P) => Promise` - * - * @returns: A function that returns the data from storage and fetches the data from the server in the background. - */ -export function asyncDataDecorator( - getStorage: () => Promise, - setStorage: (data: T) => Promise, - fetchFunction: (params: P) => Promise -) { - // 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) { - // 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; - }; -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/document.service.ts b/frontend/appflowy_web_app/src/application/services/js-services/document.service.ts deleted file mode 100644 index bcf73ae550..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/document.service.ts +++ /dev/null @@ -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 = new Set(); - - constructor() { - // - } - - async openDocument(docId: string): Promise { - 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; - } -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/fetch.ts b/frontend/appflowy_web_app/src/application/services/js-services/fetch.ts index 7ae4dc8902..3f9ca9873b 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/fetch.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/fetch.ts @@ -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(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); } diff --git a/frontend/appflowy_web_app/src/application/services/js-services/folder.service.ts b/frontend/appflowy_web_app/src/application/services/js-services/folder.service.ts deleted file mode 100644 index f145480c18..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/folder.service.ts +++ /dev/null @@ -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 = new Set(); - - constructor() { - // - } - - async openWorkspace(workspaceId: string): Promise { - 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; - } -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/index.ts index d31b7f117a..800b4da089 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/index.ts @@ -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 = new Set(); - getClientID = (): string => { - return this.clientId; - }; + private publishViewInfo: Map< + string, + { + namespace: string; + publishName: string; + } + > = new Map(); + + private cacheDatabaseRowDocMap: Map = 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 = 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; } } diff --git a/frontend/appflowy_web_app/src/application/services/js-services/session/auth.ts b/frontend/appflowy_web_app/src/application/services/js-services/session/auth.ts deleted file mode 100644 index dd8d3d1d99..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/session/auth.ts +++ /dev/null @@ -1,3 +0,0 @@ -export async function signInSuccess() { - // Do nothing -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/session/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/session/index.ts deleted file mode 100644 index c618a85cfd..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/session/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './token'; -export * from './user'; -export * from './auth'; diff --git a/frontend/appflowy_web_app/src/application/services/js-services/session/token.ts b/frontend/appflowy_web_app/src/application/services/js-services/session/token.ts deleted file mode 100644 index e22f980423..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/session/token.ts +++ /dev/null @@ -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'); -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/session/user.ts b/frontend/appflowy_web_app/src/application/services/js-services/session/user.ts deleted file mode 100644 index 6fbab3f390..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/session/user.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { UserProfile, UserWorkspace, Workspace } from '@/application/user.type'; - -const userKey = 'user'; -const workspaceKey = 'workspace'; - -export async function getSignInUser(): Promise { - 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 { - 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 { - const userProfile = await getSignInUser(); - const userWorkspace = await getUserWorkspace(); - - return userWorkspace?.workspaces.find((workspace) => workspace.id === userProfile?.workspaceId); -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/user.service.ts b/frontend/appflowy_web_app/src/application/services/js-services/user.service.ts deleted file mode 100644 index ce912bd50f..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/user.service.ts +++ /dev/null @@ -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(getSignInUser, setSignInUser, getUser) - async getUserProfile(): Promise { - if (!getAuthInfo()) { - return Promise.reject('Not authenticated'); - } - - await this.getUserWorkspace(); - - return null!; - } - - async checkUser(): Promise { - return (await getSignInUser()) !== undefined; - } - - @asyncDataDecorator(getUserWorkspace, setUserWorkspace, APIService.getUserWorkspace) - async getUserWorkspace(): Promise { - return null!; - } -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts b/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts index cd09cb74d1..400dd80865 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts @@ -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 { - 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; - - const result: Record = {}; - - res.forEach((value, key) => { - result[key] = value.doc_state; - }); - return result; -} - -export async function getUserWorkspace(): Promise { - 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); } diff --git a/frontend/appflowy_web_app/src/application/services/services.type.ts b/frontend/appflowy_web_app/src/application/services/services.type.ts index 1e837f1576..99076175a6 100644 --- a/frontend/appflowy_web_app/src/application/services/services.type.ts +++ b/frontend/appflowy_web_app/src/application/services/services.type.ts @@ -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; - signInWithOAuth: (params: { uri: string }) => Promise; - signupWithEmailPassword: (params: SignUpWithEmailPasswordParams) => Promise; - signinWithEmailPassword: (email: string, password: string) => Promise; - signOut: () => Promise; -} - -export interface DocumentService { - openDocument: (docId: string) => Promise; -} - -export interface DatabaseService { - getWorkspaceDatabases: () => Promise<{ views: string[]; database_id: string }[]>; - openDatabase: ( - databaseId: string, - rowIds?: string[] +export interface PublishService { + getPublishViewMeta: (namespace: string, publishName: string) => Promise; + getPublishView: (namespace: string, publishName: string) => Promise; + getPublishInfo: (viewId: string) => Promise<{ namespace: string; publishName: string }>; + getPublishDatabaseViewRows: ( + namespace: string, + publishName: string, + rowIds: string[] ) => Promise<{ - databaseDoc: YDoc; rows: Y.Map; + destroy: () => void; }>; - closeDatabase: (databaseId: string) => Promise; -} - -export interface UserService { - getUserProfile: () => Promise; - checkUser: () => Promise; -} - -export interface FolderService { - openWorkspace: (workspaceId: string) => Promise; } diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/auth.service.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/auth.service.ts deleted file mode 100644 index f039782058..0000000000 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/auth.service.ts +++ /dev/null @@ -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 => { - 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 => { - 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 => { - 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 => { - 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, - }; -} diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/database.service.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/database.service.ts deleted file mode 100644 index d7909679fb..0000000000 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/database.service.ts +++ /dev/null @@ -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; - }> { - return Promise.reject('Not implemented'); - } -} diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/document.service.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/document.service.ts deleted file mode 100644 index 9ae2987350..0000000000 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/document.service.ts +++ /dev/null @@ -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 { - return Promise.reject('Not implemented'); - } -} diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/folder.service.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/folder.service.ts deleted file mode 100644 index 868e6f1391..0000000000 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/folder.service.ts +++ /dev/null @@ -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 { - return Promise.reject('Not implemented'); - } -} diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts index 8908c002ee..8e81f4ed5f 100644 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts @@ -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'); } } diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/user.service.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/user.service.ts deleted file mode 100644 index 383e648052..0000000000 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/user.service.ts +++ /dev/null @@ -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 { - const res = await UserEventGetUserProfile(); - - if (res.ok) { - return parseUserProfileFrom(res.val); - } - - return null; - } - - async checkUser(): Promise { - return Promise.resolve(false); - } -} diff --git a/frontend/appflowy_web_app/src/application/user.type.ts b/frontend/appflowy_web_app/src/application/user.type.ts deleted file mode 100644 index e2c3bcdb43..0000000000 --- a/frontend/appflowy_web_app/src/application/user.type.ts +++ /dev/null @@ -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; -} diff --git a/frontend/appflowy_web_app/src/components/_shared/context-provider/FolderProvider.tsx b/frontend/appflowy_web_app/src/components/_shared/context-provider/FolderProvider.tsx deleted file mode 100644 index 37bb03533b..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/context-provider/FolderProvider.tsx +++ /dev/null @@ -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>; -}> = ({ folder, children, onNavigateToView, crumbs, setCrumbs }) => { - return ( - - {children} - - ); -}; diff --git a/frontend/appflowy_web_app/src/components/_shared/context-provider/IdProvider.tsx b/frontend/appflowy_web_app/src/components/_shared/context-provider/IdProvider.tsx deleted file mode 100644 index 666554ff73..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/context-provider/IdProvider.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { useContext, createContext } from 'react'; - -export const IdContext = createContext(null); - -interface IdProviderProps { - objectId: string; -} - -export const IdProvider = ({ children, ...props }: IdProviderProps & { children: React.ReactNode }) => { - return {children}; -}; - -const defaultIdValue = {} as IdProviderProps; - -export function useId() { - return useContext(IdContext) || defaultIdValue; -} diff --git a/frontend/appflowy_web_app/src/components/_shared/not-found/RecordNotFound.tsx b/frontend/appflowy_web_app/src/components/_shared/not-found/RecordNotFound.tsx deleted file mode 100644 index a7ef1d2684..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/not-found/RecordNotFound.tsx +++ /dev/null @@ -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 ( - - Oops.. something went wrong - - - {title ? title : 'The record you are looking for does not exist.'} - - - - - - - ); -} - -export default RecordNotFound; diff --git a/frontend/appflowy_web_app/src/components/_shared/not-found/index.ts b/frontend/appflowy_web_app/src/components/_shared/not-found/index.ts deleted file mode 100644 index e4f431167c..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/not-found/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './RecordNotFound'; diff --git a/frontend/appflowy_web_app/src/components/_shared/notify/index.ts b/frontend/appflowy_web_app/src/components/_shared/notify/index.ts index 1086cabdfd..7e7a41d074 100644 --- a/frontend/appflowy_web_app/src/components/_shared/notify/index.ts +++ b/frontend/appflowy_web_app/src/components/_shared/notify/index.ts @@ -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(); }, }; diff --git a/frontend/appflowy_web_app/src/components/_shared/page/Page.tsx b/frontend/appflowy_web_app/src/components/_shared/page/Page.tsx deleted file mode 100644 index 090c15d3b2..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/page/Page.tsx +++ /dev/null @@ -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 ( -
{ - onClick && view && onClick(view); - }} - className={'flex items-center justify-center gap-2 overflow-hidden'} - {...props} - > -
{icon}
-
{name}
-
- ); -} - -export default Page; diff --git a/frontend/appflowy_web_app/src/components/_shared/page/index.ts b/frontend/appflowy_web_app/src/components/_shared/page/index.ts deleted file mode 100644 index d9925d7520..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/page/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Page'; diff --git a/frontend/appflowy_web_app/src/components/_shared/page/usePageInfo.tsx b/frontend/appflowy_web_app/src/components/_shared/page/usePageInfo.tsx deleted file mode 100644 index 2418d669b0..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/page/usePageInfo.tsx +++ /dev/null @@ -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 ; - case ViewLayout.Grid: - return ; - case ViewLayout.Board: - return ; - case ViewLayout.Calendar: - return ; - default: - return ; - } - }, [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, - }; -} diff --git a/frontend/appflowy_web_app/src/components/app/App.tsx b/frontend/appflowy_web_app/src/components/app/App.tsx index d7e9037ad5..beb95f80f8 100644 --- a/frontend/appflowy_web_app/src/components/app/App.tsx +++ b/frontend/appflowy_web_app/src/components/app/App.tsx @@ -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 ( - }> - } /> - } /> - - } /> + } /> ); }); diff --git a/frontend/appflowy_web_app/src/components/app/AppConfig.tsx b/frontend/appflowy_web_app/src/components/app/AppConfig.tsx index fe817e7004..c044dd4c54 100644 --- a/frontend/appflowy_web_app/src/components/app/AppConfig.tsx +++ b/frontend/appflowy_web_app/src/components/app/AppConfig.tsx @@ -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(defaultConfig); const [service, setService] = useState(); useAppLanguage(); @@ -24,14 +43,15 @@ function AppConfig({ children }: { children: React.ReactNode }) { })(); }, [appConfig]); - const config = useMemo( - () => ({ - service, - }), - [service] + return ( + + {children} + ); - - return {children}; } export default AppConfig; diff --git a/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx b/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx index ca5bdcd100..f19c144e0d 100644 --- a/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx +++ b/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx @@ -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 ( - - - + + + - - - - + + + ); }; -} \ No newline at end of file +} diff --git a/frontend/appflowy_web_app/src/components/auth/LoginButtonGroup.tsx b/frontend/appflowy_web_app/src/components/auth/LoginButtonGroup.tsx deleted file mode 100644 index 5e437bd0f7..0000000000 --- a/frontend/appflowy_web_app/src/components/auth/LoginButtonGroup.tsx +++ /dev/null @@ -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 ( -
- - - - - setOpenSignInWithEmail(false)} /> -
- ); -}; diff --git a/frontend/appflowy_web_app/src/components/auth/ProtectedRoutes.tsx b/frontend/appflowy_web_app/src/components/auth/ProtectedRoutes.tsx deleted file mode 100644 index 4d6825c1cb..0000000000 --- a/frontend/appflowy_web_app/src/components/auth/ProtectedRoutes.tsx +++ /dev/null @@ -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 ( -
- {checked ? ( - - ) : ( -
- -
- )} - - {isLoading && } - {platform.isTauri && } -
- ); -} - -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 ( - -
- -
-
- ); -}; diff --git a/frontend/appflowy_web_app/src/components/auth/SignInWithEmail.tsx b/frontend/appflowy_web_app/src/components/auth/SignInWithEmail.tsx deleted file mode 100644 index 06d36c2594..0000000000 --- a/frontend/appflowy_web_app/src/components/auth/SignInWithEmail.tsx +++ /dev/null @@ -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 ( - { - if (e.key === 'Enter') { - e.preventDefault(); - void handleSignIn(); - } - }} - > - - setEmail(e.target.value)} - /> - setPassword(e.target.value)} - /> - - - - - - - ); -} - -export default SignInWithEmail; diff --git a/frontend/appflowy_web_app/src/components/auth/SplashScreen.tsx b/frontend/appflowy_web_app/src/components/auth/SplashScreen.tsx deleted file mode 100644 index bf5a5a854d..0000000000 --- a/frontend/appflowy_web_app/src/components/auth/SplashScreen.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import { Outlet } from 'react-router-dom'; -import Layout from '@/components/layout/Layout'; - -function SplashScreen () { - - return ( - - - - ); -} - -export default SplashScreen; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/auth/Welcome.cy.tsx b/frontend/appflowy_web_app/src/components/auth/Welcome.cy.tsx deleted file mode 100644 index 768cf3587b..0000000000 --- a/frontend/appflowy_web_app/src/components/auth/Welcome.cy.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import Welcome from './Welcome'; -import withAppWrapper from '@/components/app/withAppWrapper'; - -describe('', () => { - beforeEach(() => { - cy.mockAPI(); - }); - - it('renders', () => { - const AppWrapper = withAppWrapper(Welcome); - - cy.mount(); - }); - - it('should handle login success', () => { - const AppWrapper = withAppWrapper(Welcome); - - cy.mount(); - - 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'); - }); -}); diff --git a/frontend/appflowy_web_app/src/components/auth/Welcome.tsx b/frontend/appflowy_web_app/src/components/auth/Welcome.tsx deleted file mode 100644 index 1281c3336f..0000000000 --- a/frontend/appflowy_web_app/src/components/auth/Welcome.tsx +++ /dev/null @@ -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 ( - <> -
e.preventDefault()} method='POST'> - -
- -
- -
- - {t('welcomeTo')} {t('appName')} - -
- -
- {getPlatform().isTauri && } -
- -
-
-
-
- - ); -}; - -export default Welcome; diff --git a/frontend/appflowy_web_app/src/components/auth/auth.hooks.ts b/frontend/appflowy_web_app/src/components/auth/auth.hooks.ts deleted file mode 100644 index affe339c81..0000000000 --- a/frontend/appflowy_web_app/src/components/auth/auth.hooks.ts +++ /dev/null @@ -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 => { - 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, - }; -}; diff --git a/frontend/appflowy_web_app/src/components/database/Database.hooks.ts b/frontend/appflowy_web_app/src/components/database/Database.hooks.ts deleted file mode 100644 index a8945cc6ba..0000000000 --- a/frontend/appflowy_web_app/src/components/database/Database.hooks.ts +++ /dev/null @@ -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(); - 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(null); - const [rows, setRows] = useState(null); // Map(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 }; -} diff --git a/frontend/appflowy_web_app/src/components/database/Database.tsx b/frontend/appflowy_web_app/src/components/database/Database.tsx index 0c590462f9..4b3964e240 100644 --- a/frontend/appflowy_web_app/src/components/database/Database.tsx +++ b/frontend/appflowy_web_app/src/components/database/Database.tsx @@ -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; destroy: () => void }>; + loadView?: (viewId: string) => Promise; + navigateToView?: (viewId: string) => Promise; + loadViewMeta?: (viewId: string) => Promise; +} -export const Database = memo( - ({ - viewId, - onNavigateToView, - iidIndex, - }: { - iidIndex: string; - viewId: string; - onNavigateToView: (viewId: string) => void; - }) => { - return ( -
- -
- ); +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 | 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 ( +
+ }> + + {rowId ? ( + + ) : ( +
+ {viewMeta && } + +
+ +
+
+ )} +
+
+
+ ); +} export default Database; diff --git a/frontend/appflowy_web_app/src/components/database/DatabaseTitle.tsx b/frontend/appflowy_web_app/src/components/database/DatabaseTitle.tsx deleted file mode 100644 index fb996978ff..0000000000 --- a/frontend/appflowy_web_app/src/components/database/DatabaseTitle.tsx +++ /dev/null @@ -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 ( -
-
-
-
{icon}
-
{name}
-
-
-
- ); -} - -export default DatabaseTitle; diff --git a/frontend/appflowy_web_app/src/components/database/__tests__/Database.cy.tsx b/frontend/appflowy_web_app/src/components/database/__tests__/Database.cy.tsx index 23793e9227..3bcc1dd215 100644 --- a/frontend/appflowy_web_app/src/components/database/__tests__/Database.cy.tsx +++ b/frontend/appflowy_web_app/src/components/database/__tests__/Database.cy.tsx @@ -1,11 +1,10 @@ import { renderDatabase } from '@/components/database/__tests__/withTestingDatabase'; -import '@/components/layout/layout.scss'; +import '@/styles/app.scss'; describe('', () => { 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('', () => { 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('', () => { 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'); } diff --git a/frontend/appflowy_web_app/src/components/database/__tests__/DatabaseRow.cy.tsx b/frontend/appflowy_web_app/src/components/database/__tests__/DatabaseRow.cy.tsx index 00255c4ed8..15de6c0c01 100644 --- a/frontend/appflowy_web_app/src/components/database/__tests__/DatabaseRow.cy.tsx +++ b/frontend/appflowy_web_app/src/components/database/__tests__/DatabaseRow.cy.tsx @@ -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('', () => { 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 = 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 = 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 (
@@ -48,8 +42,8 @@ describe('', () => { rowId={'2f944220-9f45-40d9-96b5-e8c0888daf7c'} databaseDoc={doc} rows={rowsFolder} - folder={folder} viewId={'7d2148fc-cace-4452-9c5c-96e52e6bf8b5'} + loadView={() => Promise.resolve(subDoc)} />
); @@ -70,28 +64,25 @@ function TestDatabaseRow({ rowId, databaseDoc, rows, - folder, viewId, + loadView, }: { rowId: string; databaseDoc: YDoc; rows: Y.Map; - folder: YFolder; viewId: string; + loadView?: (viewId: string) => Promise; }) { return ( - - - - - - - + + + ); } diff --git a/frontend/appflowy_web_app/src/components/database/__tests__/DatabaseWithFilter.cy.tsx b/frontend/appflowy_web_app/src/components/database/__tests__/DatabaseWithFilter.cy.tsx index 4c63443ad9..a76678db78 100644 --- a/frontend/appflowy_web_app/src/components/database/__tests__/DatabaseWithFilter.cy.tsx +++ b/frontend/appflowy_web_app/src/components/database/__tests__/DatabaseWithFilter.cy.tsx @@ -1,11 +1,10 @@ import { renderDatabase } from '@/components/database/__tests__/withTestingDatabase'; -import '@/components/layout/layout.scss'; +import '@/styles/app.scss'; describe(' 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', () => { diff --git a/frontend/appflowy_web_app/src/components/database/__tests__/withTestingDatabase.tsx b/frontend/appflowy_web_app/src/components/database/__tests__/withTestingDatabase.tsx index d9e79811d1..9b79f9c40a 100644 --- a/frontend/appflowy_web_app/src/components/database/__tests__/withTestingDatabase.tsx +++ b/frontend/appflowy_web_app/src/components/database/__tests__/withTestingDatabase.tsx @@ -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 = 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 = 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 ( -
- -
- ); - }); - - cy.mount(); - onAfterRender?.(); + applyYDoc(rowDoc, new Uint8Array(data)); + rowsFolder.set(key, rowDoc); }); + + const AppWrapper = withAppWrapper(() => { + return ( +
+ +
+ ); + }); + + cy.mount(); + onAfterRender?.(); }); }); } @@ -70,14 +58,12 @@ export function renderDatabase( export function TestDatabase({ databaseDoc, rows, - folder, iidIndex, initialViewId, onNavigateToView, }: { databaseDoc: YDoc; rows: Y.Map; - folder: YFolder; iidIndex: string; initialViewId: string; onNavigateToView: (viewId: string) => void; @@ -90,17 +76,13 @@ export function TestDatabase({ }; return ( - - - - - - - + + + ); } diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/relation/RelationItems.tsx b/frontend/appflowy_web_app/src/components/database/components/cell/relation/RelationItems.tsx index 259447e0ae..7086d70a1b 100644 --- a/frontend/appflowy_web_app/src/components/database/components/cell/relation/RelationItems.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/cell/relation/RelationItems.tsx @@ -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(undefined); + const getViewRowsMap = useContext(DatabaseContext)?.getViewRowsMap; + const [rows, setRows] = useState(); 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 (
@@ -64,9 +38,7 @@ function RelationItems({ style, cell, fieldId }: { cell: RelationCell; fieldId: }} className={'w-full cursor-pointer underline'} > - {rowDoc && databasePrimaryFieldId && ( - - )} + {rowDoc && }
); })} diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/relation/RelationPrimaryValue.tsx b/frontend/appflowy_web_app/src/components/database/components/cell/relation/RelationPrimaryValue.tsx index a6ae613dd5..cb10266e23 100644 --- a/frontend/appflowy_web_app/src/components/database/components/cell/relation/RelationPrimaryValue.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/cell/relation/RelationPrimaryValue.tsx @@ -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(null); const [row, setRow] = useState(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]); diff --git a/frontend/appflowy_web_app/src/components/database/components/database-row/DatabaseRowSubDocument.tsx b/frontend/appflowy_web_app/src/components/database/components/database-row/DatabaseRowSubDocument.tsx index 09339b530e..bf761b5612 100644 --- a/frontend/appflowy_web_app/src/components/database/components/database-row/DatabaseRowSubDocument.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/database-row/DatabaseRowSubDocument.tsx @@ -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(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 ; + return ( + + ); } export default DatabaseRowSubDocument; diff --git a/frontend/appflowy_web_app/src/components/database/components/header/DatabaseHeader.tsx b/frontend/appflowy_web_app/src/components/database/components/header/DatabaseHeader.tsx deleted file mode 100644 index f84da67aa2..0000000000 --- a/frontend/appflowy_web_app/src/components/database/components/header/DatabaseHeader.tsx +++ /dev/null @@ -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 ; -} - -export default DatabaseHeader; diff --git a/frontend/appflowy_web_app/src/components/database/components/header/DatabaseRowHeader.tsx b/frontend/appflowy_web_app/src/components/database/components/header/DatabaseRowHeader.tsx index 330e5ba1cf..1cffe320fd 100644 --- a/frontend/appflowy_web_app/src/components/database/components/header/DatabaseRowHeader.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/header/DatabaseRowHeader.tsx @@ -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} />; } diff --git a/frontend/appflowy_web_app/src/components/database/components/header/index.ts b/frontend/appflowy_web_app/src/components/database/components/header/index.ts index 452eceafe1..53e50ae7af 100644 --- a/frontend/appflowy_web_app/src/components/database/components/header/index.ts +++ b/frontend/appflowy_web_app/src/components/database/components/header/index.ts @@ -1,2 +1 @@ -export * from './DatabaseHeader'; export * from './DatabaseRowHeader'; diff --git a/frontend/appflowy_web_app/src/components/database/components/tabs/DatabaseTabs.tsx b/frontend/appflowy_web_app/src/components/database/components/tabs/DatabaseTabs.tsx index 881f3d91df..7b52d96a84 100644 --- a/frontend/appflowy_web_app/src/components/database/components/tabs/DatabaseTabs.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/tabs/DatabaseTabs.tsx @@ -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 diff --git a/frontend/appflowy_web_app/src/components/document/Document.tsx b/frontend/appflowy_web_app/src/components/document/Document.tsx index 8809cffee3..cb21e8ede7 100644 --- a/frontend/appflowy_web_app/src/components/document/Document.tsx +++ b/frontend/appflowy_web_app/src/components/document/Document.tsx @@ -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> ); }; diff --git a/frontend/appflowy_web_app/src/components/document/document_header/DocumentCover.tsx b/frontend/appflowy_web_app/src/components/document/document_header/DocumentCover.tsx deleted file mode 100644 index 1d01474622..0000000000 --- a/frontend/appflowy_web_app/src/components/document/document_header/DocumentCover.tsx +++ /dev/null @@ -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; diff --git a/frontend/appflowy_web_app/src/components/document/document_header/DocumentHeader.tsx b/frontend/appflowy_web_app/src/components/document/document_header/DocumentHeader.tsx deleted file mode 100644 index 04201f5ce5..0000000000 --- a/frontend/appflowy_web_app/src/components/document/document_header/DocumentHeader.tsx +++ /dev/null @@ -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); diff --git a/frontend/appflowy_web_app/src/components/document/document_header/index.ts b/frontend/appflowy_web_app/src/components/document/document_header/index.ts deleted file mode 100644 index 00f48716bf..0000000000 --- a/frontend/appflowy_web_app/src/components/document/document_header/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './DocumentHeader'; diff --git a/frontend/appflowy_web_app/src/components/document/document_header/useBlockCover.ts b/frontend/appflowy_web_app/src/components/document/document_header/useBlockCover.ts deleted file mode 100644 index ba6226a6e8..0000000000 --- a/frontend/appflowy_web_app/src/components/document/document_header/useBlockCover.ts +++ /dev/null @@ -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, - }; -} diff --git a/frontend/appflowy_web_app/src/components/document/document_header/utils.ts b/frontend/appflowy_web_app/src/components/document/document_header/utils.ts deleted file mode 100644 index fe2c0acbe0..0000000000 --- a/frontend/appflowy_web_app/src/components/document/document_header/utils.ts +++ /dev/null @@ -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); -} diff --git a/frontend/appflowy_web_app/src/components/editor/Editor.cy.tsx b/frontend/appflowy_web_app/src/components/editor/Editor.cy.tsx index 7c6ec9a55e..f3eb62c9d3 100644 --- a/frontend/appflowy_web_app/src/components/editor/Editor.cy.tsx +++ b/frontend/appflowy_web_app/src/components/editor/Editor.cy.tsx @@ -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> ); }); diff --git a/frontend/appflowy_web_app/src/components/editor/Editor.tsx b/frontend/appflowy_web_app/src/components/editor/Editor.tsx index 183aed8918..245e6b0504 100644 --- a/frontend/appflowy_web_app/src/components/editor/Editor.tsx +++ b/frontend/appflowy_web_app/src/components/editor/Editor.tsx @@ -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> ); diff --git a/frontend/appflowy_web_app/src/components/editor/EditorContext.tsx b/frontend/appflowy_web_app/src/components/editor/EditorContext.tsx index c360c969dd..db1276250e 100644 --- a/frontend/appflowy_web_app/src/components/editor/EditorContext.tsx +++ b/frontend/appflowy_web_app/src/components/editor/EditorContext.tsx @@ -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>({ diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/database/DatabaseBlock.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/database/DatabaseBlock.tsx index 1e6bbb151a..a671c6ef43 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/database/DatabaseBlock.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/database/DatabaseBlock.tsx @@ -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 /> diff --git a/frontend/appflowy_web_app/src/components/editor/components/element/Element.tsx b/frontend/appflowy_web_app/src/components/editor/components/element/Element.tsx index d117a14221..2772ecd01b 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/element/Element.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/element/Element.tsx @@ -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( diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionPage.tsx b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionPage.tsx index 40e6d31e23..fb604ec005 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionPage.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionPage.tsx @@ -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> ); } diff --git a/frontend/appflowy_web_app/src/components/error/Error.hooks.ts b/frontend/appflowy_web_app/src/components/error/Error.hooks.ts deleted file mode 100644 index a9da4ed829..0000000000 --- a/frontend/appflowy_web_app/src/components/error/Error.hooks.ts +++ /dev/null @@ -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, - }; -}; diff --git a/frontend/appflowy_web_app/src/components/error/ErrorHandlerPage.tsx b/frontend/appflowy_web_app/src/components/error/ErrorHandlerPage.tsx index 1bb15f2ca3..078650a7e2 100644 --- a/frontend/appflowy_web_app/src/components/error/ErrorHandlerPage.tsx +++ b/frontend/appflowy_web_app/src/components/error/ErrorHandlerPage.tsx @@ -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> : <></>; }; diff --git a/frontend/appflowy_web_app/src/components/folder/Folder.tsx b/frontend/appflowy_web_app/src/components/folder/Folder.tsx deleted file mode 100644 index f3b9641723..0000000000 --- a/frontend/appflowy_web_app/src/components/folder/Folder.tsx +++ /dev/null @@ -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; diff --git a/frontend/appflowy_web_app/src/components/folder/ViewItem.tsx b/frontend/appflowy_web_app/src/components/folder/ViewItem.tsx deleted file mode 100644 index 49feb382e2..0000000000 --- a/frontend/appflowy_web_app/src/components/folder/ViewItem.tsx +++ /dev/null @@ -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; diff --git a/frontend/appflowy_web_app/src/components/folder/index.ts b/frontend/appflowy_web_app/src/components/folder/index.ts deleted file mode 100644 index 569707cd4f..0000000000 --- a/frontend/appflowy_web_app/src/components/folder/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Folder'; diff --git a/frontend/appflowy_web_app/src/components/layout/Header.tsx b/frontend/appflowy_web_app/src/components/layout/Header.tsx deleted file mode 100644 index df87892b42..0000000000 --- a/frontend/appflowy_web_app/src/components/layout/Header.tsx +++ /dev/null @@ -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; diff --git a/frontend/appflowy_web_app/src/components/layout/Layout.hooks.ts b/frontend/appflowy_web_app/src/components/layout/Layout.hooks.ts deleted file mode 100644 index 3ab11d17e9..0000000000 --- a/frontend/appflowy_web_app/src/components/layout/Layout.hooks.ts +++ /dev/null @@ -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 }; -} diff --git a/frontend/appflowy_web_app/src/components/layout/Layout.tsx b/frontend/appflowy_web_app/src/components/layout/Layout.tsx deleted file mode 100644 index dc3f075f69..0000000000 --- a/frontend/appflowy_web_app/src/components/layout/Layout.tsx +++ /dev/null @@ -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; diff --git a/frontend/appflowy_web_app/src/components/layout/breadcrumb/index.ts b/frontend/appflowy_web_app/src/components/layout/breadcrumb/index.ts deleted file mode 100644 index 116446358b..0000000000 --- a/frontend/appflowy_web_app/src/components/layout/breadcrumb/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Breadcrumb'; diff --git a/frontend/appflowy_web_app/src/components/publish/CollabView.tsx b/frontend/appflowy_web_app/src/components/publish/CollabView.tsx new file mode 100644 index 0000000000..e7daf7f465 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/publish/CollabView.tsx @@ -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; diff --git a/frontend/appflowy_web_app/src/components/publish/PublishView.tsx b/frontend/appflowy_web_app/src/components/publish/PublishView.tsx new file mode 100644 index 0000000000..4cb45e2c1c --- /dev/null +++ b/frontend/appflowy_web_app/src/components/publish/PublishView.tsx @@ -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; diff --git a/frontend/appflowy_web_app/src/components/layout/breadcrumb/Breadcrumb.tsx b/frontend/appflowy_web_app/src/components/publish/header/Breadcrumb.tsx similarity index 59% rename from frontend/appflowy_web_app/src/components/layout/breadcrumb/Breadcrumb.tsx rename to frontend/appflowy_web_app/src/components/publish/header/Breadcrumb.tsx index 02e682514e..840a202289 100644 --- a/frontend/appflowy_web_app/src/components/layout/breadcrumb/Breadcrumb.tsx +++ b/frontend/appflowy_web_app/src/components/publish/header/Breadcrumb.tsx @@ -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> ); }); diff --git a/frontend/appflowy_web_app/src/components/layout/breadcrumb/Item.tsx b/frontend/appflowy_web_app/src/components/publish/header/BreadcrumbItem.tsx similarity index 80% rename from frontend/appflowy_web_app/src/components/layout/breadcrumb/Item.tsx rename to frontend/appflowy_web_app/src/components/publish/header/BreadcrumbItem.tsx index 7d857ac893..cb8a3f462d 100644 --- a/frontend/appflowy_web_app/src/components/layout/breadcrumb/Item.tsx +++ b/frontend/appflowy_web_app/src/components/publish/header/BreadcrumbItem.tsx @@ -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; diff --git a/frontend/appflowy_web_app/src/components/publish/header/PublishViewHeader.tsx b/frontend/appflowy_web_app/src/components/publish/header/PublishViewHeader.tsx new file mode 100644 index 0000000000..a5b8eafd33 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/publish/header/PublishViewHeader.tsx @@ -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; diff --git a/frontend/appflowy_web_app/src/components/publish/header/index.ts b/frontend/appflowy_web_app/src/components/publish/header/index.ts new file mode 100644 index 0000000000..7acf67cb92 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/publish/header/index.ts @@ -0,0 +1,2 @@ +export * from './PublishViewHeader'; +export { Crumb } from '@/components/publish/header/BreadcrumbItem'; diff --git a/frontend/appflowy_web_app/src/components/publish/index.ts b/frontend/appflowy_web_app/src/components/publish/index.ts new file mode 100644 index 0000000000..796b67fd63 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/publish/index.ts @@ -0,0 +1 @@ +export * from './PublishView'; diff --git a/frontend/appflowy_web_app/src/components/publish/useViewMeta.ts b/frontend/appflowy_web_app/src/components/publish/useViewMeta.ts new file mode 100644 index 0000000000..adcbf57b03 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/publish/useViewMeta.ts @@ -0,0 +1,108 @@ +import { usePublishContext } from '@/application/publish'; +import { EditorLayoutStyle } from '@/components/editor/EditorContext'; +import { ViewMetaCover, ViewMetaIcon } from '@/components/view-meta'; +import { useEffect, useMemo } from 'react'; + +export function useViewMeta() { + const viewMeta = usePublishContext()?.viewMeta; + + const extra = useMemo(() => { + try { + return viewMeta?.extra ? JSON.parse(viewMeta.extra) : null; + } catch (e) { + return null; + } + }, [viewMeta?.extra]); + + const layoutStyle: EditorLayoutStyle = useMemo(() => { + return { + font: extra?.font || '', + fontLayout: extra?.fontLayout, + lineHeightLayout: extra?.lineHeightLayout, + }; + }, [extra]); + + const layout = useMemo(() => { + if (viewMeta?.layout) { + return viewMeta.layout; + } + + return; + }, [viewMeta?.layout]); + 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]); + + const icon = useMemo(() => { + if (viewMeta?.icon) { + try { + return JSON.parse(viewMeta.icon) as ViewMetaIcon; + } catch (e) { + return; + } + } + + return; + }, [viewMeta?.icon]); + + const cover = useMemo(() => { + if (extra) { + try { + const extraObj = JSON.parse(extra); + + return extraObj.cover as ViewMetaCover; + } catch (e) { + return; + } + } + + return; + }, [extra]); + + const viewId = viewMeta?.view_id; + + return { + icon, + cover, + style, + layoutClassName, + layout, + viewId, + }; +} diff --git a/frontend/appflowy_web_app/src/components/view-meta/ViewCover.tsx b/frontend/appflowy_web_app/src/components/view-meta/ViewCover.tsx new file mode 100644 index 0000000000..907b9468e0 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/view-meta/ViewCover.tsx @@ -0,0 +1,42 @@ +import { renderColor } from '@/utils/color'; +import React, { useCallback } from 'react'; + +function ViewCover({ coverValue, coverType }: { coverValue?: string; coverType?: string }) { + const renderCoverColor = useCallback((color: string) => { + return ( + <div + style={{ + background: renderColor(color), + }} + className={`h-full w-full`} + /> + ); + }, []); + + const renderCoverImage = useCallback((url: string) => { + return <img draggable={false} src={url} alt={''} className={'h-full w-full object-cover'} />; + }, []); + + 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 ViewCover; + +export enum CoverType { + NormalColor = 'color', + GradientColor = 'gradient', + BuildInImage = 'built_in', + CustomImage = 'custom', + LocalImage = 'local', + UpsplashImage = 'unsplash', + None = 'none', +} diff --git a/frontend/appflowy_web_app/src/components/view-meta/ViewMetaPreview.tsx b/frontend/appflowy_web_app/src/components/view-meta/ViewMetaPreview.tsx new file mode 100644 index 0000000000..60716a4454 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/view-meta/ViewMetaPreview.tsx @@ -0,0 +1,83 @@ +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'; +import ViewCover, { CoverType } from '@/components/view-meta/ViewCover'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +export interface ViewMetaIcon { + type: number; + value: string; +} + +export interface ViewMetaCover { + type: CoverType; + value: string; +} + +export interface ViewMetaProps { + icon?: ViewMetaIcon; + cover?: ViewMetaCover; + name?: string; + viewId?: string; +} + +export function ViewMetaPreview({ icon, cover, name }: ViewMetaProps) { + const coverType = useMemo(() => { + if (cover && [CoverType.NormalColor, CoverType.GradientColor].includes(cover.type)) { + return 'color'; + } + + if (CoverType.BuildInImage === cover?.type) { + return 'built_in'; + } + + if (cover && [CoverType.CustomImage, CoverType.UpsplashImage].includes(cover.type)) { + return 'custom'; + } + }, [cover]); + + const coverValue = useMemo(() => { + if (coverType === 'built_in') { + return { + 1: BuiltInImage1, + 2: BuiltInImage2, + 3: BuiltInImage3, + 4: BuiltInImage4, + 5: BuiltInImage5, + 6: BuiltInImage6, + }[cover?.value as string]; + } + + return cover?.value; + }, [coverType, cover?.value]); + const { t } = useTranslation(); + + return ( + <div className={'w-full'}> + {cover && <ViewCover 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`}>{icon?.value}</div> + <div className={'flex flex-1 items-center gap-2 overflow-hidden'}> + <div className={'font-bold leading-[1.5em]'}> + {name || <span className={'text-text-placeholder'}>{t('menuAppHeader.defaultNewPageName')}</span>} + </div> + </div> + </div> + </div> + </div> + ); +} + +export default ViewMetaPreview; diff --git a/frontend/appflowy_web_app/src/components/view-meta/index.ts b/frontend/appflowy_web_app/src/components/view-meta/index.ts new file mode 100644 index 0000000000..73aa5c6c29 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/view-meta/index.ts @@ -0,0 +1 @@ +export * from 'src/components/view-meta/ViewMetaPreview'; diff --git a/frontend/appflowy_web_app/src/pages/DatabasePage.tsx b/frontend/appflowy_web_app/src/pages/DatabasePage.tsx deleted file mode 100644 index 10e9e5c015..0000000000 --- a/frontend/appflowy_web_app/src/pages/DatabasePage.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useId } from '@/components/_shared/context-provider/IdProvider'; -import { DatabaseHeader } from '@/components/database/components/header'; -import { useGetDatabaseId, useLoadDatabase } from '@/components/database/Database.hooks'; -import { DatabaseContextProvider } from '@/components/database/DatabaseContext'; -import CircularProgress from '@mui/material/CircularProgress'; -import React, { useCallback, useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import DatabaseRow from '@/components/database/DatabaseRow'; -import Database from '@/components/database/Database'; -import RecordNotFound from 'src/components/_shared/not-found/RecordNotFound'; - -function DatabasePage() { - const { objectId } = useId() || {}; - const [search, setSearch] = useSearchParams(); - const rowId = search.get('r'); - - const viewId = search.get('v') || undefined; - const handleChangeView = useCallback( - (viewId: string) => { - setSearch({ v: viewId }); - }, - [setSearch] - ); - const handleNavigateToRow = useCallback( - (rowId: string) => { - setSearch({ r: rowId }); - }, - [setSearch] - ); - - const databaseId = useGetDatabaseId(objectId); - const rowIds = useMemo(() => (rowId ? [rowId] : undefined), [rowId]); - - const { doc, rows, notFound } = useLoadDatabase({ - databaseId, - rowIds, - }); - - if (notFound || !objectId) { - return <RecordNotFound open={notFound} />; - } - - if (!rows || !doc) { - return ( - <div className={'flex h-full w-full items-center justify-center'}> - <CircularProgress /> - </div> - ); - } - - return ( - <DatabaseContextProvider - isDatabaseRowPage={!!rowId} - navigateToRow={handleNavigateToRow} - viewId={viewId || objectId} - databaseDoc={doc} - rowDocMap={rows} - readOnly={true} - > - {rowId ? ( - <DatabaseRow rowId={rowId} /> - ) : ( - <div className={'relative flex h-full w-full flex-col'}> - <DatabaseHeader viewId={objectId} /> - <Database iidIndex={objectId} viewId={viewId || objectId} onNavigateToView={handleChangeView} /> - </div> - )} - </DatabaseContextProvider> - ); -} - -export default DatabasePage; diff --git a/frontend/appflowy_web_app/src/pages/DocumentPage.tsx b/frontend/appflowy_web_app/src/pages/DocumentPage.tsx deleted file mode 100644 index 0a9a359afc..0000000000 --- a/frontend/appflowy_web_app/src/pages/DocumentPage.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { Document } from '@/components/document'; -import React from 'react'; - -function DocumentPage() { - return <Document />; -} - -export default DocumentPage; diff --git a/frontend/appflowy_web_app/src/pages/FolderPage.tsx b/frontend/appflowy_web_app/src/pages/FolderPage.tsx deleted file mode 100644 index 6381fe4ace..0000000000 --- a/frontend/appflowy_web_app/src/pages/FolderPage.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; -import { Folder } from 'src/components/folder'; - -function FolderPage() { - return <Folder />; -} - -export default FolderPage; diff --git a/frontend/appflowy_web_app/src/pages/LoginPage.tsx b/frontend/appflowy_web_app/src/pages/LoginPage.tsx deleted file mode 100644 index a4ded1d5e3..0000000000 --- a/frontend/appflowy_web_app/src/pages/LoginPage.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React, { useEffect } from 'react'; -import Welcome from '@/components/auth/Welcome'; -import { useNavigate } from 'react-router-dom'; -import { useAppSelector } from '@/stores/store'; - -function LoginPage() { - const currentUser = useAppSelector((state) => state.currentUser); - const navigate = useNavigate(); - - useEffect(() => { - if (currentUser.isAuthenticated) { - const redirect = new URLSearchParams(window.location.search).get('redirect'); - const workspaceId = currentUser.user?.workspaceId; - - if (!redirect || redirect === '/') { - return navigate(`/view/${workspaceId}`); - } - - navigate(`${redirect}`); - } - }, [currentUser, navigate]); - return <Welcome />; -} - -export default LoginPage; diff --git a/frontend/appflowy_web_app/src/pages/ProductPage.tsx b/frontend/appflowy_web_app/src/pages/ProductPage.tsx deleted file mode 100644 index 1df649b077..0000000000 --- a/frontend/appflowy_web_app/src/pages/ProductPage.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { ViewLayout } from '@/application/collab.type'; -import { useViewLayout } from '@/application/folder-yjs'; -import { IdProvider } from '@/components/_shared/context-provider/IdProvider'; -import React, { lazy, useMemo } from 'react'; -import { useParams } from 'react-router-dom'; -import DocumentPage from '@/pages/DocumentPage'; - -const DatabasePage = lazy(() => import('./DatabasePage')); - -function ProductPage() { - const { workspaceId, objectId } = useParams(); - const type = useViewLayout(); - - const PageComponent = useMemo(() => { - switch (type) { - case ViewLayout.Document: - return DocumentPage; - case ViewLayout.Grid: - case ViewLayout.Board: - case ViewLayout.Calendar: - return DatabasePage; - default: - return null; - } - }, [type]); - - if (!workspaceId || !objectId) return null; - - return <IdProvider objectId={objectId}>{PageComponent && <PageComponent />}</IdProvider>; -} - -export default ProductPage; diff --git a/frontend/appflowy_web_app/src/pages/PublishPage.tsx b/frontend/appflowy_web_app/src/pages/PublishPage.tsx new file mode 100644 index 0000000000..f5323d8c01 --- /dev/null +++ b/frontend/appflowy_web_app/src/pages/PublishPage.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { PublishView } from '@/components/publish'; + +function PublishPage() { + const { namespace, publishName } = useParams(); + + if (!namespace || !publishName) return null; + return <PublishView namespace={namespace} publishName={publishName} />; +} + +export default PublishPage; diff --git a/frontend/appflowy_web_app/src/stores/app/slice.ts b/frontend/appflowy_web_app/src/stores/app/slice.ts deleted file mode 100644 index dee62a6fc7..0000000000 --- a/frontend/appflowy_web_app/src/stores/app/slice.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { AFServiceConfig } from '@/application/services/services.type'; -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; - -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 interface AppState { - appConfig: AFServiceConfig; -} - -const initialState: AppState = { - appConfig: defaultConfig, -}; - -export const slice = createSlice({ - name: 'app', - initialState, - reducers: { - setAppConfig: (state, action: PayloadAction<AFServiceConfig>) => { - state.appConfig = action.payload; - }, - }, -}); - -export const { setAppConfig } = slice.actions; diff --git a/frontend/appflowy_web_app/src/stores/currentUser/slice.ts b/frontend/appflowy_web_app/src/stores/currentUser/slice.ts deleted file mode 100644 index ecd40a433e..0000000000 --- a/frontend/appflowy_web_app/src/stores/currentUser/slice.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { UserProfile, UserSetting } from '@/application/user.type'; - -export enum LoginState { - IDLE = 'idle', - LOADING = 'loading', - SUCCESS = 'success', - ERROR = 'error', -} - -export interface InitialState { - user?: UserProfile; - isAuthenticated: boolean; - userSetting?: UserSetting; - loginState?: LoginState; -} - -const initialState: InitialState = { - isAuthenticated: false, -}; - -export const currentUserSlice = createSlice({ - name: 'currentUser', - initialState: initialState, - reducers: { - updateUser: (state, action: PayloadAction<UserProfile>) => { - state.user = action.payload; - state.isAuthenticated = true; - }, - logout: (state) => { - state.user = undefined; - state.isAuthenticated = false; - }, - setUserSetting: (state, action: PayloadAction<UserSetting>) => { - state.userSetting = action.payload; - }, - loginStart: (state) => { - state.loginState = LoginState.LOADING; - }, - loginSuccess: (state) => { - state.loginState = LoginState.SUCCESS; - }, - loginError: (state) => { - state.loginState = LoginState.ERROR; - }, - resetLoginState: (state) => { - state.loginState = LoginState.IDLE; - }, - - }, -}); - -export const currentUserActions = currentUserSlice.actions; diff --git a/frontend/appflowy_web_app/src/stores/error/slice.ts b/frontend/appflowy_web_app/src/stores/error/slice.ts deleted file mode 100644 index 9b47df7777..0000000000 --- a/frontend/appflowy_web_app/src/stores/error/slice.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; - -export interface IErrorOptions { - display: boolean; - message: string; -} - -const initialState: IErrorOptions = { - display: false, - message: '', -}; - -export const errorSlice = createSlice({ - name: 'error', - initialState: initialState, - reducers: { - showError(state, action: PayloadAction<string>) { - return { - display: true, - message: action.payload, - }; - }, - hideError() { - return { - display: false, - message: '', - }; - }, - }, -}); - -export const errorActions = errorSlice.actions; diff --git a/frontend/appflowy_web_app/src/stores/store.ts b/frontend/appflowy_web_app/src/stores/store.ts deleted file mode 100644 index b75363e911..0000000000 --- a/frontend/appflowy_web_app/src/stores/store.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; -import { - configureStore, - createListenerMiddleware, - TypedStartListening, - TypedAddListener, - ListenerEffectAPI, - addListener, -} from '@reduxjs/toolkit'; -import { errorSlice } from '@/stores/error/slice'; -import { currentUserSlice } from '@/stores/currentUser/slice'; -import { slice as appSlice } from '@/stores/app/slice'; - -const listenerMiddlewareInstance = createListenerMiddleware({ - onError: () => console.error, -}); - -const store = configureStore({ - reducer: { - [appSlice.name]: appSlice.reducer, - [errorSlice.name]: errorSlice.reducer, - [currentUserSlice.name]: currentUserSlice.reducer, - }, - middleware: (gDM) => gDM({ serializableCheck: false }).prepend(listenerMiddlewareInstance.middleware), -}); - -export { store }; - -// Infer the `RootState` and `AppDispatch` types from the store itself -export type RootState = ReturnType<typeof store.getState>; -// @see https://redux-toolkit.js.org/usage/usage-with-typescript#getting-the-dispatch-type -export type AppDispatch = typeof store.dispatch; - -export type AppListenerEffectAPI = ListenerEffectAPI<RootState, AppDispatch>; - -// @see https://redux-toolkit.js.org/api/createListenerMiddleware#typescript-usage -export type AppStartListening = TypedStartListening<RootState, AppDispatch>; -export type AppAddListener = TypedAddListener<RootState, AppDispatch>; - -export const startAppListening = listenerMiddlewareInstance.startListening as AppStartListening; -export const addAppListener = addListener as AppAddListener; - -// Use throughout your app instead of plain `useDispatch` and `useSelector` -export const useAppDispatch = () => useDispatch<AppDispatch>(); -export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector; diff --git a/frontend/appflowy_web_app/src/components/layout/layout.scss b/frontend/appflowy_web_app/src/styles/app.scss similarity index 100% rename from frontend/appflowy_web_app/src/components/layout/layout.scss rename to frontend/appflowy_web_app/src/styles/app.scss diff --git a/frontend/appflowy_web_app/src/vite-env.d.ts b/frontend/appflowy_web_app/src/vite-env.d.ts index 5748ee1aed..91dbccebd2 100644 --- a/frontend/appflowy_web_app/src/vite-env.d.ts +++ b/frontend/appflowy_web_app/src/vite-env.d.ts @@ -3,10 +3,18 @@ /// <reference types="vite-plugin-terminal/client" /> /// <reference types="cypress" /> /// <reference types="cypress-plugin-tab" /> + interface Window { refresh_token: (token: string) => void; invalid_token: () => void; WebFont?: { load: (options: { google: { families: string[] } }) => void; }; + toast: { + success: (message: string) => void; + error: (message: string) => void; + info: (message: string) => void; + clear: () => void; + warning: (message: string) => void; + }; } diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index b831a7f9df..ec42755831 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2017,5 +2017,8 @@ "upgrade": "Update", "upgradeYourSpace": "Create multiple Spaces", "quicklySwitch": "Quickly switch to the next space" + }, + "publish": { + "hasNotBeenPublished": "This page hasn't been published yet" } } \ No newline at end of file