Pārlūkot izejas kodu

Feat/tauri database effects (#1863)

* feat: config database view effects

* chore: add tests

* chore: config jest

* chore: config jest windows

* ci: wanrings

* chore: config folder effect
Nathan.fooo 2 gadi atpakaļ
vecāks
revīzija
8a2f5fe789
60 mainītis faili ar 2003 papildinājumiem un 109 dzēšanām
  1. 0 1
      frontend/app_flowy/lib/plugins/blank/blank.dart
  2. 6 6
      frontend/app_flowy/lib/plugins/grid/application/field/field_controller.dart
  3. 0 1
      frontend/app_flowy/lib/plugins/grid/application/grid_data_controller.dart
  4. 0 2
      frontend/app_flowy/lib/plugins/grid/application/row/row_cache.dart
  5. 0 17
      frontend/app_flowy/lib/plugins/grid/application/row/row_list.dart
  6. 0 1
      frontend/app_flowy/lib/plugins/grid/application/view/grid_view_cache.dart
  7. 1 1
      frontend/app_flowy/lib/user/application/auth_service.dart
  8. 1 1
      frontend/app_flowy/lib/user/presentation/skip_log_in_screen.dart
  9. 1 1
      frontend/app_flowy/lib/user/presentation/splash_screen.dart
  10. 0 1
      frontend/app_flowy/lib/workspace/application/app/app_bloc.dart
  11. 0 3
      frontend/app_flowy/lib/workspace/application/app/app_service.dart
  12. 0 5
      frontend/app_flowy/lib/workspace/application/view/view_service.dart
  13. 0 1
      frontend/app_flowy/test/bloc_test/board_test/util.dart
  14. 0 1
      frontend/app_flowy/test/bloc_test/grid_test/filter/filter_util.dart
  15. 0 1
      frontend/app_flowy/test/bloc_test/grid_test/util.dart
  16. 6 3
      frontend/appflowy_tauri/.eslintignore
  17. 5 0
      frontend/appflowy_tauri/.eslintrc.cjs
  18. 8 0
      frontend/appflowy_tauri/jest.config.cjs
  19. 9 2
      frontend/appflowy_tauri/package.json
  20. 20 3
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/FolderItem.hooks.ts
  21. 4 3
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/PageItem.tsx
  22. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/user/application/notifications/user_listener.ts
  23. 50 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/backend_service.ts
  24. 36 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/backend_service.ts
  25. 45 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cache.ts
  26. 40 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_observer.ts
  27. 123 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/controller.ts
  28. 141 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/controller_builder.ts
  29. 70 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/data_parser.ts
  30. 46 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/data_persistence.ts
  31. 51 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/controller.ts
  32. 87 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/backend_service.ts
  33. 129 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/controller.ts
  34. 41 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_observer.ts
  35. 17 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/notifications/observer.ts
  36. 0 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/notifications/parser.ts
  37. 314 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/row/cache.ts
  38. 55 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/view/cache.ts
  39. 71 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/view/row_observer.ts
  40. 38 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/app/app_observer.ts
  41. 98 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/app/backend_service.ts
  42. 17 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/notifications/observer.ts
  43. 26 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/notifications/parser.ts
  44. 32 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/view/backend_service.ts
  45. 74 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/view/view_observer.ts
  46. 57 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/workspace/backend_service.ts
  47. 46 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/workspace/workspace_observer.ts
  48. 82 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/user/backend_service.ts
  49. 1 4
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/folders/slice.ts
  50. 3 8
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts
  51. 17 0
      frontend/appflowy_tauri/src/appflowy_app/utils/change_notifier.ts
  52. 21 0
      frontend/appflowy_tauri/src/appflowy_app/utils/log.ts
  53. 2 2
      frontend/appflowy_tauri/src/services/backend/notifications/index.ts
  54. 0 29
      frontend/appflowy_tauri/src/services/backend/notifications/listener.ts
  55. 26 0
      frontend/appflowy_tauri/src/services/backend/notifications/observer.ts
  56. 14 10
      frontend/appflowy_tauri/src/services/backend/notifications/parser.ts
  57. 1 0
      frontend/appflowy_tauri/src/tests/helpers/init.ts
  58. 42 0
      frontend/appflowy_tauri/src/tests/user.test.ts
  59. 14 0
      frontend/appflowy_tauri/test/specs/example.e2e.ts
  60. 13 0
      frontend/appflowy_tauri/test/tsconfig.json

+ 0 - 1
frontend/app_flowy/lib/plugins/blank/blank.dart

@@ -2,7 +2,6 @@ import 'package:app_flowy/workspace/presentation/home/home_stack.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flowy_infra_ui/style_widget/text.dart';
 import 'package:flutter/material.dart';
-
 import 'package:app_flowy/generated/locale_keys.g.dart';
 import 'package:app_flowy/startup/plugin/plugin.dart';
 

+ 6 - 6
frontend/app_flowy/lib/plugins/grid/application/field/field_controller.dart

@@ -615,16 +615,16 @@ class GridFieldController {
     if (insertedFields.isEmpty) {
       return;
     }
-    final List<FieldInfo> newFields = fieldInfos;
+    final List<FieldInfo> newFieldInfos = fieldInfos;
     for (final indexField in insertedFields) {
-      final gridField = FieldInfo(field: indexField.field_1);
-      if (newFields.length > indexField.index) {
-        newFields.insert(indexField.index, gridField);
+      final fieldInfo = FieldInfo(field: indexField.field_1);
+      if (newFieldInfos.length > indexField.index) {
+        newFieldInfos.insert(indexField.index, fieldInfo);
       } else {
-        newFields.add(gridField);
+        newFieldInfos.add(fieldInfo);
       }
     }
-    _fieldNotifier?.fieldInfos = newFields;
+    _fieldNotifier?.fieldInfos = newFieldInfos;
   }
 
   List<FieldInfo> _updateFields(List<FieldPB> updatedFieldPBs) {

+ 0 - 1
frontend/app_flowy/lib/plugins/grid/application/grid_data_controller.dart

@@ -58,7 +58,6 @@ class GridController {
     );
   }
 
-  // Loads the rows from each block
   Future<Either<Unit, FlowyError>> openGrid() async {
     return _gridFFIService.openGrid().then((result) {
       return result.fold(

+ 0 - 2
frontend/app_flowy/lib/plugins/grid/application/row/row_cache.dart

@@ -29,7 +29,6 @@ abstract class RowCacheDelegate {
 
 class GridRowCache {
   final String databaseId;
-  final List<RowPB> rows;
 
   /// _rows containers the current block's rows
   /// Use List to reverse the order of the GridRow.
@@ -48,7 +47,6 @@ class GridRowCache {
 
   GridRowCache({
     required this.databaseId,
-    required this.rows,
     required RowChangesetNotifierForward notifier,
     required RowCacheDelegate delegate,
   })  : _cellCache = GridCellCache(databaseId: databaseId),

+ 0 - 17
frontend/app_flowy/lib/plugins/grid/application/row/row_list.dart

@@ -1,7 +1,5 @@
 import 'dart:collection';
-
 import 'package:appflowy_backend/protobuf/flowy-database/row_entities.pb.dart';
-
 import 'row_cache.dart';
 
 class RowList {
@@ -134,21 +132,6 @@ class RowList {
     return updatedIndexs;
   }
 
-  List<DeletedIndex> markRowsAsInvisible(List<String> rowIds) {
-    final List<DeletedIndex> deletedRows = [];
-
-    for (final rowId in rowIds) {
-      final rowInfo = _rowInfoByRowId[rowId];
-      if (rowInfo != null) {
-        final index = _rowInfos.indexOf(rowInfo);
-        if (index != -1) {
-          deletedRows.add(DeletedIndex(index: index, rowInfo: rowInfo));
-        }
-      }
-    }
-    return deletedRows;
-  }
-
   void reorderWithRowIds(List<String> rowIds) {
     _rowInfos.clear();
 

+ 0 - 1
frontend/app_flowy/lib/plugins/grid/application/view/grid_view_cache.dart

@@ -21,7 +21,6 @@ class DatabaseViewCache {
     final delegate = GridRowFieldNotifierImpl(fieldController);
     _rowCache = GridRowCache(
       databaseId: databaseId,
-      rows: [],
       notifier: delegate,
       delegate: delegate,
     );

+ 1 - 1
frontend/app_flowy/lib/user/application/auth_service.dart

@@ -47,7 +47,7 @@ class AuthService {
     return UserEventSignOut().send();
   }
 
-  Future<Either<UserProfilePB, FlowyError>> signUpWithRandomUser() {
+  Future<Either<UserProfilePB, FlowyError>> autoSignUp() {
     const password = "AppFlowy123@";
     final uid = uuid();
     final userEmail = "[email protected]";

+ 1 - 1
frontend/app_flowy/lib/user/presentation/skip_log_in_screen.dart

@@ -118,7 +118,7 @@ class _SkipLogInScreenState extends State<SkipLogInScreen> {
   }
 
   Future<void> _autoRegister(BuildContext context) async {
-    final result = await widget.authService.signUpWithRandomUser();
+    final result = await widget.authService.autoSignUp();
     result.fold(
       (user) {
         FolderEventReadCurrentWorkspace().send().then((result) {

+ 1 - 1
frontend/app_flowy/lib/user/presentation/splash_screen.dart

@@ -90,7 +90,7 @@ class SplashScreen extends StatelessWidget {
   Future<void> _registerIfNeeded() async {
     final result = await UserEventCheckUser().send();
     if (!result.isLeft()) {
-      await getIt<AuthService>().signUpWithRandomUser();
+      await getIt<AuthService>().autoSignUp();
     }
   }
 }

+ 0 - 1
frontend/app_flowy/lib/workspace/application/app/app_bloc.dart

@@ -100,7 +100,6 @@ class AppBloc extends Bloc<AppEvent, AppState> {
       name: value.name,
       desc: value.desc ?? "",
       dataFormatType: value.pluginBuilder.dataFormatType,
-      pluginType: value.pluginBuilder.pluginType,
       layoutType: value.pluginBuilder.layoutType!,
       initialData: value.initialData,
     );

+ 0 - 3
frontend/app_flowy/lib/workspace/application/app/app_service.dart

@@ -8,8 +8,6 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder/app.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
 
-import 'package:app_flowy/startup/plugin/plugin.dart';
-
 class AppService {
   Future<Either<AppPB, FlowyError>> readApp({required String appId}) {
     final payload = AppIdPB.create()..value = appId;
@@ -22,7 +20,6 @@ class AppService {
     required String name,
     String? desc,
     required ViewDataFormatPB dataFormatType,
-    required PluginType pluginType,
     required ViewLayoutTypePB layoutType,
 
     /// The initial data should be the JSON of the doucment

+ 0 - 5
frontend/app_flowy/lib/workspace/application/view/view_service.dart

@@ -5,11 +5,6 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
 
 class ViewService {
-  Future<Either<ViewPB, FlowyError>> readView({required String viewId}) {
-    final request = ViewIdPB(value: viewId);
-    return FolderEventReadView(request).send();
-  }
-
   Future<Either<ViewPB, FlowyError>> updateView(
       {required String viewId, String? name, String? desc}) {
     final request = UpdateViewPayloadPB.create()..viewId = viewId;

+ 0 - 1
frontend/app_flowy/test/bloc_test/board_test/util.dart

@@ -33,7 +33,6 @@ class AppFlowyBoardTest {
       appId: app.id,
       name: "Test Board",
       dataFormatType: builder.dataFormatType,
-      pluginType: builder.pluginType,
       layoutType: builder.layoutType!,
     )
         .then((result) {

+ 0 - 1
frontend/app_flowy/test/bloc_test/grid_test/filter/filter_util.dart

@@ -12,7 +12,6 @@ Future<GridTestContext> createTestFilterGrid(AppFlowyGridTest gridTest) async {
     appId: app.id,
     name: "Filter Grid",
     dataFormatType: builder.dataFormatType,
-    pluginType: builder.pluginType,
     layoutType: builder.layoutType!,
   )
       .then((result) {

+ 0 - 1
frontend/app_flowy/test/bloc_test/grid_test/util.dart

@@ -168,7 +168,6 @@ class AppFlowyGridTest {
       appId: app.id,
       name: "Test Grid",
       dataFormatType: builder.dataFormatType,
-      pluginType: builder.pluginType,
       layoutType: builder.layoutType!,
     )
         .then((result) {

+ 6 - 3
frontend/appflowy_tauri/.eslintignore

@@ -1,4 +1,7 @@
-/src/services
-/src/styles
+src/services
+src/styles
+node_modules/
+dist/
+src-tauri/
 .eslintrc.cjs
-node_modules
+tsconfig.json

+ 5 - 0
frontend/appflowy_tauri/.eslintrc.cjs

@@ -1,4 +1,5 @@
 module.exports = {
+  // https://eslint.org/docs/latest/use/configure/configuration-files
   env: {
     browser: true,
     es6: true,
@@ -9,6 +10,7 @@ module.exports = {
   parserOptions: {
     project: 'tsconfig.json',
     sourceType: 'module',
+    tsconfigRootDir: __dirname,
   },
   plugins: ['@typescript-eslint'],
   rules: {
@@ -22,6 +24,8 @@ module.exports = {
     '@typescript-eslint/prefer-for-of': 'warn',
     '@typescript-eslint/triple-slash-reference': 'error',
     '@typescript-eslint/unified-signatures': 'warn',
+    'no-shadow': 'off',
+    '@typescript-eslint/no-shadow': 'warn',
     'constructor-super': 'error',
     eqeqeq: ['error', 'always'],
     'no-cond-assign': 'error',
@@ -51,4 +55,5 @@ module.exports = {
     'no-void': 'off',
     'prefer-const': 'warn',
   },
+  ignorePatterns: ['src/**/*.test.ts'],
 };

+ 8 - 0
frontend/appflowy_tauri/jest.config.cjs

@@ -0,0 +1,8 @@
+/** @type {import('ts-jest').JestConfigWithTsJest} */
+module.exports = {
+  preset: 'ts-jest',
+  testEnvironment: 'node',
+  globals: {
+    window: {},
+  },
+};

+ 9 - 2
frontend/appflowy_tauri/package.json

@@ -10,12 +10,14 @@
     "format": "prettier --write .",
     "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
     "test:prettier": "yarn prettier --list-different src",
-    "tauri:dev": "tauri dev"
+    "tauri:dev": "tauri dev",
+    "test": "jest"
   },
   "dependencies": {
     "@reduxjs/toolkit": "^1.9.2",
     "@tauri-apps/api": "^1.2.0",
     "google-protobuf": "^3.21.2",
+    "jest": "^29.4.3",
     "nanoid": "^4.0.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
@@ -23,14 +25,18 @@
     "react-router-dom": "^6.8.0",
     "react18-input-otp": "^1.1.2",
     "redux": "^4.2.1",
-    "ts-results": "^3.3.0"
+    "rxjs": "^7.8.0",
+    "ts-results": "^3.3.0",
+    "utf8": "^3.0.0"
   },
   "devDependencies": {
     "@tauri-apps/cli": "^1.2.2",
     "@types/google-protobuf": "^3.15.6",
+    "@types/jest": "^29.4.0",
     "@types/node": "^18.7.10",
     "@types/react": "^18.0.15",
     "@types/react-dom": "^18.0.6",
+    "@types/utf8": "^3.0.1",
     "@typescript-eslint/eslint-plugin": "^5.51.0",
     "@typescript-eslint/parser": "^5.51.0",
     "@vitejs/plugin-react": "^3.0.0",
@@ -41,6 +47,7 @@
     "prettier": "^2.8.3",
     "prettier-plugin-tailwindcss": "^0.2.2",
     "tailwindcss": "^3.2.4",
+    "ts-jest": "^29.0.5",
     "typescript": "^4.6.4",
     "vite": "^4.0.0"
   }

+ 20 - 3
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/FolderItem.hooks.ts

@@ -3,6 +3,7 @@ import { useState } from 'react';
 import { useAppDispatch } from '../../../stores/store';
 import { nanoid } from 'nanoid';
 import { pagesActions } from '../../../stores/reducers/pages/slice';
+import { ViewLayoutTypePB } from '../../../../services/backend';
 
 export const useFolderEvents = (folder: IFolder) => {
   const appDispatch = useAppDispatch();
@@ -54,17 +55,33 @@ export const useFolderEvents = (folder: IFolder) => {
 
   const onAddNewDocumentPage = () => {
     closePopup();
-    appDispatch(pagesActions.addPage({ folderId: folder.id, pageType: 'document', title: 'New Page 1', id: nanoid(6) }));
+    appDispatch(
+      pagesActions.addPage({
+        folderId: folder.id,
+        pageType: ViewLayoutTypePB.Document,
+        title: 'New Page 1',
+        id: nanoid(6),
+      })
+    );
   };
 
   const onAddNewBoardPage = () => {
     closePopup();
-    appDispatch(pagesActions.addPage({ folderId: folder.id, pageType: 'board', title: 'New Board 1', id: nanoid(6) }));
+    appDispatch(
+      pagesActions.addPage({
+        folderId: folder.id,
+        pageType: ViewLayoutTypePB.Board,
+        title: 'New Board 1',
+        id: nanoid(6),
+      })
+    );
   };
 
   const onAddNewGridPage = () => {
     closePopup();
-    appDispatch(pagesActions.addPage({ folderId: folder.id, pageType: 'grid', title: 'New Grid 1', id: nanoid(6) }));
+    appDispatch(
+      pagesActions.addPage({ folderId: folder.id, pageType: ViewLayoutTypePB.Grid, title: 'New Grid 1', id: nanoid(6) })
+    );
   };
 
   return {

+ 4 - 3
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/PageItem.tsx

@@ -7,6 +7,7 @@ import { IPage } from '../../../stores/reducers/pages/slice';
 import { Button } from '../../_shared/Button';
 import { usePageEvents } from './PageItem.hooks';
 import { RenamePopup } from './RenamePopup';
+import { ViewLayoutTypePB } from '../../../../services/backend/models/flowy-folder/view';
 
 export const PageItem = ({ page, onPageClick }: { page: IPage; onPageClick: () => void }) => {
   const {
@@ -29,9 +30,9 @@ export const PageItem = ({ page, onPageClick }: { page: IPage; onPageClick: () =
       >
         <div className={'flex min-w-0 flex-1 items-center'}>
           <div className={'ml-1 mr-1 h-[16px] w-[16px]'}>
-            {page.pageType === 'document' && <DocumentSvg></DocumentSvg>}
-            {page.pageType === 'board' && <BoardSvg></BoardSvg>}
-            {page.pageType === 'grid' && <GridSvg></GridSvg>}
+            {page.pageType === ViewLayoutTypePB.Document && <DocumentSvg></DocumentSvg>}
+            {page.pageType === ViewLayoutTypePB.Board && <BoardSvg></BoardSvg>}
+            {page.pageType === ViewLayoutTypePB.Grid && <GridSvg></GridSvg>}
           </div>
           <span className={'ml-2 min-w-0 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap'}>{page.title}</span>
         </div>

+ 2 - 2
frontend/appflowy_tauri/src/appflowy_app/components/user/application/notifications/user_listener.ts

@@ -1,11 +1,11 @@
 import { UserNotification, UserProfilePB } from '../../../../../services/backend';
-import { AFNotificationListener, OnNotificationError } from '../../../../../services/backend/notifications';
+import { AFNotificationObserver, OnNotificationError } from '../../../../../services/backend/notifications';
 import { UserNotificationParser } from './parser';
 
 declare type OnUserProfileUpdate = (userProfile: UserProfilePB) => void;
 declare type OnUserSignIn = (userProfile: UserProfilePB) => void;
 
-export class UserNotificationListener extends AFNotificationListener<UserNotification> {
+export class UserNotificationListener extends AFNotificationObserver<UserNotification> {
   onProfileUpdate?: OnUserProfileUpdate;
   onUserSignIn?: OnUserSignIn;
 

+ 50 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/backend_service.ts

@@ -0,0 +1,50 @@
+import {
+  DatabaseEventCreateRow,
+  DatabaseEventGetDatabase,
+  DatabaseEventGetFields,
+} from '../../../../services/backend/events/flowy-database/event';
+import { DatabaseIdPB } from '../../../../services/backend/models/flowy-database';
+import { CreateRowPayloadPB } from '../../../../services/backend/models/flowy-database/row_entities';
+import {
+  GetFieldPayloadPB,
+  RepeatedFieldIdPB,
+  FieldIdPB,
+} from '../../../../services/backend/models/flowy-database/field_entities';
+import { ViewIdPB } from '../../../../services/backend/models/flowy-folder/view';
+import { FolderEventCloseView } from '../../../../services/backend/events/flowy-folder';
+
+export class DatabaseBackendService {
+  viewId: string;
+
+  constructor(viewId: string) {
+    this.viewId = viewId;
+  }
+
+  openDatabase = async () => {
+    const payload = DatabaseIdPB.fromObject({
+      value: this.viewId,
+    });
+    return DatabaseEventGetDatabase(payload);
+  };
+
+  closeDatabase = async () => {
+    const payload = ViewIdPB.fromObject({ value: this.viewId });
+    return FolderEventCloseView(payload);
+  };
+
+  createRow = async (rowId?: string) => {
+    const props = { database_id: this.viewId, start_row_id: rowId ?? undefined };
+    const payload = CreateRowPayloadPB.fromObject(props);
+    return DatabaseEventCreateRow(payload);
+  };
+
+  getFields = async (fieldIds?: FieldIdPB[]) => {
+    const payload = GetFieldPayloadPB.fromObject({ database_id: this.viewId });
+
+    if (!fieldIds) {
+      payload.field_ids = RepeatedFieldIdPB.fromObject({ items: fieldIds });
+    }
+
+    return DatabaseEventGetFields(payload).then((result) => result.map((value) => value.items));
+  };
+}

+ 36 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/backend_service.ts

@@ -0,0 +1,36 @@
+import { DatabaseEventGetCell, DatabaseEventUpdateCell } from '../../../../../services/backend/events/flowy-database';
+import { CellChangesetPB, CellIdPB } from '../../../../../services/backend/models/flowy-database/cell_entities';
+import { FieldType } from '../../../../../services/backend/models/flowy-database/field_entities';
+
+class CellIdentifier {
+  constructor(
+    public readonly viewId: string,
+    public readonly rowId: string,
+    public readonly fieldId: string,
+    public readonly fieldType: FieldType
+  ) {}
+}
+
+class CellBackendService {
+  static updateCell = async (cellId: CellIdentifier, data: string) => {
+    const payload = CellChangesetPB.fromObject({
+      database_id: cellId.viewId,
+      field_id: cellId.fieldId,
+      row_id: cellId.rowId,
+      type_cell_data: data,
+    });
+    return DatabaseEventUpdateCell(payload);
+  };
+
+  getCell = async (cellId: CellIdentifier) => {
+    const payload = CellIdPB.fromObject({
+      database_id: cellId.viewId,
+      field_id: cellId.fieldId,
+      row_id: cellId.rowId,
+    });
+
+    return DatabaseEventGetCell(payload);
+  };
+}
+
+export { CellBackendService, CellIdentifier };

+ 45 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cache.ts

@@ -0,0 +1,45 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+export class CellCacheKey {
+  constructor(public readonly fieldId: string, public readonly rowId: string) {}
+}
+
+export class CellCache {
+  _cellDataByFieldId = new Map<string, Map<string, any>>();
+
+  constructor(public readonly databaseId: string) {}
+
+  remove = (key: CellCacheKey) => {
+    const inner = this._cellDataByFieldId.get(key.fieldId);
+    if (inner !== undefined) {
+      inner.delete(key.rowId);
+    }
+  };
+
+  removeWithFieldId = (fieldId: string) => {
+    this._cellDataByFieldId.delete(fieldId);
+  };
+
+  insert = (key: CellCacheKey, value: any) => {
+    let inner = this._cellDataByFieldId.get(key.fieldId);
+    if (inner === undefined) {
+      inner = this._cellDataByFieldId.set(key.fieldId, new Map());
+    }
+    inner.set(key.rowId, value);
+  };
+
+  get<T>(key: CellCacheKey): T | null {
+    const inner = this._cellDataByFieldId.get(key.fieldId);
+    if (inner === undefined) {
+      return null;
+    } else {
+      const value = inner.get(key.rowId);
+      if (typeof value === typeof undefined || typeof value === typeof null) {
+        return null;
+      }
+      if (value satisfies T) {
+        return value as T;
+      }
+      return null;
+    }
+  }
+}

+ 40 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_observer.ts

@@ -0,0 +1,40 @@
+import { Err, Ok, Result } from 'ts-results';
+import { ChangeNotifier } from '../../../../utils/change_notifier';
+import { DatabaseNotificationObserver } from '../notifications/observer';
+import { FlowyError } from '../../../../../services/backend/models/flowy-error';
+import { DatabaseNotification } from '../../../../../services/backend';
+
+type UpdateCellNotifiedValue = Result<void, FlowyError>;
+
+export type CellListenerCallback = (value: UpdateCellNotifiedValue) => void;
+
+export class CellObserver {
+  _notifier?: ChangeNotifier<UpdateCellNotifiedValue>;
+  _listener?: DatabaseNotificationObserver;
+  constructor(public readonly rowId: string, public readonly fieldId: string) {}
+
+  subscribe = (callbacks: { onCellChanged: CellListenerCallback }) => {
+    this._notifier = new ChangeNotifier();
+    this._notifier?.observer.subscribe(callbacks.onCellChanged);
+
+    this._listener = new DatabaseNotificationObserver({
+      viewId: this.rowId + ':' + this.fieldId,
+      parserHandler: (notification) => {
+        switch (notification) {
+          case DatabaseNotification.DidUpdateCell:
+            this._notifier?.notify(Ok.EMPTY);
+            return;
+          default:
+            break;
+        }
+      },
+      onError: (error) => this._notifier?.notify(Err(error)),
+    });
+    return undefined;
+  };
+
+  unsubscribe = async () => {
+    this._notifier?.unsubscribe();
+    await this._listener?.stop();
+  };
+}

+ 123 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/controller.ts

@@ -0,0 +1,123 @@
+import { CellIdentifier } from './backend_service';
+import { CellCache, CellCacheKey } from './cache';
+import { FieldController } from '../field/controller';
+import { CellDataLoader } from './data_parser';
+import { CellDataPersistence } from './data_persistence';
+import { FieldBackendService, TypeOptionParser } from '../field/backend_service';
+import { ChangeNotifier } from '../../../../utils/change_notifier';
+import { CellObserver } from './cell_observer';
+import { Log } from '../../../../utils/log';
+import { Err, Ok } from 'ts-results';
+
+export abstract class CellFieldNotifier {
+  abstract subscribeOnFieldChanged(callback: () => void): void;
+}
+
+export class CellController<T, D> {
+  _fieldBackendService: FieldBackendService;
+  _cellDataNotifier: CellDataNotifier<T | null>;
+  _cellObserver: CellObserver;
+  _cacheKey: CellCacheKey;
+
+  constructor(
+    public readonly cellIdentifier: CellIdentifier,
+    private readonly cellCache: CellCache,
+    private readonly fieldNotifier: CellFieldNotifier,
+    private readonly cellDataLoader: CellDataLoader<T>,
+    private readonly cellDataPersistence: CellDataPersistence<D>
+  ) {
+    this._fieldBackendService = new FieldBackendService(cellIdentifier.viewId, cellIdentifier.fieldId);
+
+    this._cacheKey = new CellCacheKey(cellIdentifier.rowId, cellIdentifier.fieldId);
+
+    this._cellDataNotifier = new CellDataNotifier(cellCache.get(this._cacheKey));
+
+    this._cellObserver = new CellObserver(cellIdentifier.rowId, cellIdentifier.fieldId);
+  }
+
+  subscribeChanged = (callbacks: { onCellChanged: (value: T | null) => void; onFieldChanged?: () => void }) => {
+    this._cellObserver.subscribe({
+      /// 1.Listen on user edit event and load the new cell data if needed.
+      /// For example:
+      ///  user input: 12
+      ///  cell display: $12
+      onCellChanged: async () => {
+        this.cellCache.remove(this._cacheKey);
+        await this._loadCellData();
+      },
+    });
+
+    /// 2.Listen on the field event and load the cell data if needed.
+    this.fieldNotifier.subscribeOnFieldChanged(async () => {
+      //
+      callbacks.onFieldChanged?.();
+
+      /// reloadOnFieldChanged should be true if you need to load the data when the corresponding field is changed
+      /// For example:
+      ///   ¥12 -> $12
+      if (this.cellDataLoader.reloadOnFieldChanged) {
+        await this._loadCellData();
+      }
+    });
+
+    this._cellDataNotifier.observer.subscribe((cellData) => {
+      callbacks.onCellChanged(cellData);
+    });
+  };
+
+  getTypeOption = async <P extends TypeOptionParser<PD>, PD>(parser: P) => {
+    const result = await this._fieldBackendService.getTypeOptionData(this.cellIdentifier.fieldType);
+    if (result.ok) {
+      return Ok(parser.fromBuffer(result.val.type_option_data));
+    } else {
+      return Err(result.val);
+    }
+  };
+
+  saveCellData = async (data: D) => {
+    const result = await this.cellDataPersistence.save(data);
+    if (result.err) {
+      Log.error(result.val);
+    }
+  };
+
+  _loadCellData = () => {
+    return this.cellDataLoader.loadData().then((result) => {
+      if (result.ok && result.val !== undefined) {
+        this.cellCache.insert(this._cacheKey, result.val);
+        this._cellDataNotifier.cellData = result.val;
+      } else {
+        this.cellCache.remove(this._cacheKey);
+        this._cellDataNotifier.cellData = null;
+      }
+    });
+  };
+}
+
+export class CellFieldNotifierImpl extends CellFieldNotifier {
+  constructor(private readonly fieldController: FieldController) {
+    super();
+  }
+  subscribeOnFieldChanged(callback: () => void): void {
+    this.fieldController.subscribeOnFieldsChanged(callback);
+  }
+}
+
+class CellDataNotifier<T> extends ChangeNotifier<T | null> {
+  _cellData: T | null;
+  constructor(cellData: T) {
+    super();
+    this._cellData = cellData;
+  }
+
+  set cellData(data: T | null) {
+    if (this._cellData !== data) {
+      this._cellData = data;
+      this.notify(this._cellData);
+    }
+  }
+
+  get cellData(): T | null {
+    return this._cellData;
+  }
+}

+ 141 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/controller_builder.ts

@@ -0,0 +1,141 @@
+import {
+  DateCellDataPB,
+  FieldType,
+  SelectOptionCellDataPB,
+  URLCellDataPB,
+} from '../../../../../services/backend/models/flowy-database';
+import { CellIdentifier } from './backend_service';
+import { CellController, CellFieldNotifierImpl } from './controller';
+import {
+  CellDataLoader,
+  DateCellDataParser,
+  SelectOptionCellDataParser,
+  StringCellDataParser,
+  URLCellDataParser,
+} from './data_parser';
+import { CellCache } from './cache';
+import { FieldController } from '../field/controller';
+import { DateCellDataPersistence, TextCellDataPersistence } from './data_persistence';
+export type TextCellController = CellController<string, string>;
+
+export type CheckboxCellController = CellController<string, string>;
+
+export type NumberCellController = CellController<string, string>;
+
+export type SelectOptionCellController = CellController<SelectOptionCellDataPB, string>;
+
+export type ChecklistCellController = CellController<SelectOptionCellDataPB, string>;
+
+export type DateCellController = CellController<DateCellDataPB, CalendarData>;
+export class CalendarData {
+  constructor(public readonly date: Date, public readonly time?: string) {}
+}
+
+export type URLCellController = CellController<URLCellDataPB, string>;
+
+export class CellControllerBuilder {
+  _fieldNotifier: CellFieldNotifierImpl;
+  constructor(
+    public readonly cellIdentifier: CellIdentifier,
+    public readonly cellCache: CellCache,
+    public readonly fieldController: FieldController
+  ) {
+    this._fieldNotifier = new CellFieldNotifierImpl(this.fieldController);
+  }
+  build = () => {
+    switch (this.cellIdentifier.fieldType) {
+      case FieldType.Checkbox:
+        return this.makeCheckboxCellController();
+      case FieldType.RichText:
+        return this.makeTextCellController();
+      case FieldType.Number:
+        return this.makeNumberCellController();
+      case FieldType.DateTime:
+        return this.makeDateCellController();
+      case FieldType.URL:
+        return this.makeURLCellController();
+      case FieldType.SingleSelect:
+      case FieldType.MultiSelect:
+      case FieldType.Checklist:
+        return this.makeSelectOptionCellController();
+    }
+  };
+
+  makeSelectOptionCellController = (): SelectOptionCellController => {
+    const loader = new CellDataLoader(this.cellIdentifier, new SelectOptionCellDataParser(), true);
+    const persistence = new TextCellDataPersistence(this.cellIdentifier);
+
+    return new CellController<SelectOptionCellDataPB, string>(
+      this.cellIdentifier,
+      this.cellCache,
+      this._fieldNotifier,
+      loader,
+      persistence
+    );
+  };
+
+  makeURLCellController = (): URLCellController => {
+    const loader = new CellDataLoader(this.cellIdentifier, new URLCellDataParser());
+    const persistence = new TextCellDataPersistence(this.cellIdentifier);
+
+    return new CellController<URLCellDataPB, string>(
+      this.cellIdentifier,
+      this.cellCache,
+      this._fieldNotifier,
+      loader,
+      persistence
+    );
+  };
+
+  makeDateCellController = (): DateCellController => {
+    const loader = new CellDataLoader(this.cellIdentifier, new DateCellDataParser(), true);
+    const persistence = new DateCellDataPersistence(this.cellIdentifier);
+
+    return new CellController<DateCellDataPB, CalendarData>(
+      this.cellIdentifier,
+      this.cellCache,
+      this._fieldNotifier,
+      loader,
+      persistence
+    );
+  };
+
+  makeNumberCellController = (): NumberCellController => {
+    const loader = new CellDataLoader(this.cellIdentifier, new StringCellDataParser(), true);
+    const persistence = new TextCellDataPersistence(this.cellIdentifier);
+
+    return new CellController<string, string>(
+      this.cellIdentifier,
+      this.cellCache,
+      this._fieldNotifier,
+      loader,
+      persistence
+    );
+  };
+
+  makeTextCellController = (): TextCellController => {
+    const loader = new CellDataLoader(this.cellIdentifier, new StringCellDataParser());
+    const persistence = new TextCellDataPersistence(this.cellIdentifier);
+
+    return new CellController<string, string>(
+      this.cellIdentifier,
+      this.cellCache,
+      this._fieldNotifier,
+      loader,
+      persistence
+    );
+  };
+
+  makeCheckboxCellController = (): CheckboxCellController => {
+    const loader = new CellDataLoader(this.cellIdentifier, new StringCellDataParser());
+    const persistence = new TextCellDataPersistence(this.cellIdentifier);
+
+    return new CellController<string, string>(
+      this.cellIdentifier,
+      this.cellCache,
+      this._fieldNotifier,
+      loader,
+      persistence
+    );
+  };
+}

+ 70 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/data_parser.ts

@@ -0,0 +1,70 @@
+import utf8 from 'utf8';
+import { CellBackendService, CellIdentifier } from './backend_service';
+import { DateCellDataPB } from '../../../../../services/backend/models/flowy-database/date_type_option_entities';
+import { SelectOptionCellDataPB } from '../../../../../services/backend/models/flowy-database/select_type_option';
+import { URLCellDataPB } from '../../../../../services/backend/models/flowy-database/url_type_option_entities';
+import { Err, Ok } from 'ts-results';
+import { Log } from '../../../../utils/log';
+
+abstract class CellDataParser<T> {
+  abstract parserData(data: Uint8Array): T | undefined;
+}
+
+class CellDataLoader<T> {
+  _service = new CellBackendService();
+
+  constructor(
+    readonly cellId: CellIdentifier,
+    readonly parser: CellDataParser<T>,
+    public readonly reloadOnFieldChanged: boolean = false
+  ) {}
+
+  loadData = async () => {
+    const result = await this._service.getCell(this.cellId);
+    if (result.ok) {
+      return Ok(this.parser.parserData(result.val.data));
+    } else {
+      Log.error(result.err);
+      return Err(result.err);
+    }
+  };
+}
+
+class StringCellDataParser extends CellDataParser<string> {
+  parserData(data: Uint8Array): string {
+    return utf8.decode(data.toString());
+  }
+}
+
+class DateCellDataParser extends CellDataParser<DateCellDataPB> {
+  parserData(data: Uint8Array): DateCellDataPB {
+    return DateCellDataPB.deserializeBinary(data);
+  }
+}
+
+class SelectOptionCellDataParser extends CellDataParser<SelectOptionCellDataPB | undefined> {
+  parserData(data: Uint8Array): SelectOptionCellDataPB | undefined {
+    if (data.length === 0) {
+      return undefined;
+    }
+    return SelectOptionCellDataPB.deserializeBinary(data);
+  }
+}
+
+class URLCellDataParser extends CellDataParser<URLCellDataPB | undefined> {
+  parserData(data: Uint8Array): URLCellDataPB | undefined {
+    if (data.length === 0) {
+      return undefined;
+    }
+    return URLCellDataPB.deserializeBinary(data);
+  }
+}
+
+export {
+  StringCellDataParser,
+  DateCellDataParser,
+  SelectOptionCellDataParser,
+  URLCellDataParser,
+  CellDataLoader,
+  CellDataParser,
+};

+ 46 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/data_persistence.ts

@@ -0,0 +1,46 @@
+import { Result } from 'ts-results';
+import { FlowyError } from '../../../../../services/backend/models/flowy-error';
+import { CellBackendService, CellIdentifier } from './backend_service';
+import { CalendarData } from './controller_builder';
+import { DateChangesetPB } from '../../../../../services/backend/models/flowy-database/date_type_option_entities';
+import { CellIdPB } from '../../../../../services/backend/models/flowy-database/cell_entities';
+import { DatabaseEventUpdateDateCell } from '../../../../../services/backend/events/flowy-database';
+
+export abstract class CellDataPersistence<D> {
+  abstract save(data: D): Promise<Result<void, FlowyError>>;
+}
+
+export class TextCellDataPersistence extends CellDataPersistence<string> {
+  constructor(public readonly cellId: CellIdentifier) {
+    super();
+  }
+
+  save(data: string): Promise<Result<void, FlowyError>> {
+    return CellBackendService.updateCell(this.cellId, data);
+  }
+}
+
+export class DateCellDataPersistence extends CellDataPersistence<CalendarData> {
+  constructor(public readonly cellIdentifier: CellIdentifier) {
+    super();
+  }
+  save(data: CalendarData): Promise<Result<void, FlowyError>> {
+    const payload = DateChangesetPB.fromObject({ cell_path: _makeCellPath(this.cellIdentifier) });
+
+    payload.date = data.date.getUTCMilliseconds.toString();
+    payload.is_utc = true;
+
+    if (data.time !== undefined) {
+      payload.time = data.time;
+    }
+    return DatabaseEventUpdateDateCell(payload);
+  }
+}
+
+function _makeCellPath(cellIdentifier: CellIdentifier): CellIdPB {
+  return CellIdPB.fromObject({
+    database_id: cellIdentifier.viewId,
+    field_id: cellIdentifier.fieldId,
+    row_id: cellIdentifier.rowId,
+  });
+}

+ 51 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/controller.ts

@@ -0,0 +1,51 @@
+import { DatabaseBackendService } from './backend_service';
+import { FieldController, FieldInfo } from './field/controller';
+import { DatabaseViewCache } from './view/cache';
+import { DatabasePB } from '../../../../services/backend/models/flowy-database/grid_entities';
+import { RowChangedReason, RowInfo } from './row/cache';
+import { Err } from 'ts-results';
+
+export type SubscribeCallback = {
+  onViewChanged: (data: DatabasePB) => void;
+  onRowsChanged: (rowInfos: RowInfo[], reason: RowChangedReason) => void;
+  onFieldsChanged: (fieldInfos: FieldInfo[]) => void;
+};
+
+export class DatabaseController {
+  _backendService: DatabaseBackendService;
+  _fieldController: FieldController;
+  _databaseViewCache: DatabaseViewCache;
+  _callback?: SubscribeCallback;
+
+  constructor(public readonly viewId: string) {
+    this._backendService = new DatabaseBackendService(viewId);
+    this._fieldController = new FieldController(viewId);
+    this._databaseViewCache = new DatabaseViewCache(viewId, this._fieldController);
+  }
+
+  subscribe = (callbacks: SubscribeCallback) => {
+    this._callback = callbacks;
+    this._fieldController.subscribeOnFieldsChanged(callbacks.onFieldsChanged);
+  };
+
+  open = async () => {
+    const result = await this._backendService.openDatabase();
+    if (result.ok) {
+      const database: DatabasePB = result.val;
+      this._callback?.onViewChanged(database);
+      this._databaseViewCache.initializeWithRows(database.rows);
+      return await this._fieldController.loadFields(database.fields);
+    } else {
+      return Err(result.val);
+    }
+  };
+
+  createRow = async () => {
+    return this._backendService.createRow();
+  };
+
+  dispose = async () => {
+    await this._backendService.closeDatabase();
+    await this._fieldController.dispose();
+  };
+}

+ 87 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/backend_service.ts

@@ -0,0 +1,87 @@
+import {
+  DeleteFieldPayloadPB,
+  DuplicateFieldPayloadPB,
+  FieldChangesetPB,
+  FieldType,
+  TypeOptionChangesetPB,
+  TypeOptionPathPB,
+} from '../../../../../services/backend/models/flowy-database/field_entities';
+import {
+  DatabaseEventDeleteField,
+  DatabaseEventDuplicateField,
+  DatabaseEventGetTypeOption,
+  DatabaseEventUpdateField,
+  DatabaseEventUpdateFieldTypeOption,
+} from '../../../../../services/backend/events/flowy-database';
+
+export abstract class TypeOptionParser<T> {
+  abstract fromBuffer(buffer: Uint8Array): T;
+}
+
+export class FieldBackendService {
+  constructor(public readonly databaseId: string, public readonly fieldId: string) {}
+
+  updateField = (data: {
+    name?: string;
+    fieldType: FieldType;
+    frozen?: boolean;
+    visibility?: boolean;
+    width?: number;
+  }) => {
+    const payload = FieldChangesetPB.fromObject({ database_id: this.databaseId, field_id: this.fieldId });
+
+    if (data.name !== undefined) {
+      payload.name = data.name;
+    }
+
+    if (data.fieldType !== undefined) {
+      payload.field_type = data.fieldType;
+    }
+
+    if (data.frozen !== undefined) {
+      payload.frozen = data.frozen;
+    }
+
+    if (data.visibility !== undefined) {
+      payload.visibility = data.visibility;
+    }
+
+    if (data.width !== undefined) {
+      payload.width = data.width;
+    }
+
+    return DatabaseEventUpdateField(payload);
+  };
+
+  updateTypeOption = (typeOptionData: Uint8Array) => {
+    const payload = TypeOptionChangesetPB.fromObject({
+      database_id: this.databaseId,
+      field_id: this.fieldId,
+      type_option_data: typeOptionData,
+    });
+
+    return DatabaseEventUpdateFieldTypeOption(payload);
+  };
+
+  deleteField = () => {
+    const payload = DeleteFieldPayloadPB.fromObject({ database_id: this.databaseId, field_id: this.fieldId });
+
+    return DatabaseEventDeleteField(payload);
+  };
+
+  duplicateField = () => {
+    const payload = DuplicateFieldPayloadPB.fromObject({ database_id: this.databaseId, field_id: this.fieldId });
+
+    return DatabaseEventDuplicateField(payload);
+  };
+
+  getTypeOptionData = (fieldType: FieldType) => {
+    const payload = TypeOptionPathPB.fromObject({
+      database_id: this.databaseId,
+      field_id: this.fieldId,
+      field_type: fieldType,
+    });
+
+    return DatabaseEventGetTypeOption(payload);
+  };
+}

+ 129 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/controller.ts

@@ -0,0 +1,129 @@
+import { Log } from '../../../../utils/log';
+import { DatabaseBackendService } from '../backend_service';
+import { DatabaseFieldObserver } from './field_observer';
+import { FieldIdPB, FieldPB, IndexFieldPB } from '../../../../../services/backend/models/flowy-database/field_entities';
+import { ChangeNotifier } from '../../../../utils/change_notifier';
+
+export class FieldController {
+  _fieldListener: DatabaseFieldObserver;
+  _backendService: DatabaseBackendService;
+  _fieldNotifier = new FieldNotifier([]);
+
+  constructor(public readonly viewId: string) {
+    this._backendService = new DatabaseBackendService(viewId);
+    this._fieldListener = new DatabaseFieldObserver(viewId);
+
+    this._listenOnFieldChanges();
+  }
+
+  dispose = async () => {
+    this._fieldNotifier.unsubscribe();
+    await this._fieldListener.unsubscribe();
+  };
+
+  get fieldInfos(): readonly FieldInfo[] {
+    return this._fieldNotifier.fieldInfos;
+  }
+
+  getField = (fieldId: string): FieldInfo | undefined => {
+    return this._fieldNotifier.fieldInfos.find((element) => element.field.id === fieldId);
+  };
+
+  loadFields = async (fieldIds: FieldIdPB[]) => {
+    const result = await this._backendService.getFields(fieldIds);
+    if (result.ok) {
+      this._fieldNotifier.fieldInfos = result.val.map((field) => new FieldInfo(field));
+    }
+  };
+
+  subscribeOnFieldsChanged = (callback: (fieldInfos: FieldInfo[]) => void) => {
+    return this._fieldNotifier.observer.subscribe((fieldInfos) => {
+      callback(fieldInfos);
+    });
+  };
+
+  _listenOnFieldChanges = () => {
+    this._fieldListener.subscribe({
+      onFieldsChanged: (result) => {
+        if (result.ok) {
+          const changeset = result.val;
+          this._deleteFields(changeset.deleted_fields);
+          this._insertFields(changeset.inserted_fields);
+          this._updateFields(changeset.updated_fields);
+        } else {
+          Log.error(result.val);
+        }
+      },
+    });
+  };
+
+  _deleteFields = (deletedFields: FieldIdPB[]) => {
+    if (deletedFields.length === 0) {
+      return;
+    }
+
+    const deletedFieldIds = deletedFields.map((field) => field.field_id);
+    const predicate = (element: FieldInfo) => {
+      !deletedFieldIds.includes(element.field.id);
+    };
+    const newFieldInfos = [...this.fieldInfos];
+    newFieldInfos.filter(predicate);
+    this._fieldNotifier.fieldInfos = newFieldInfos;
+  };
+
+  _insertFields = (insertedFields: IndexFieldPB[]) => {
+    if (insertedFields.length === 0) {
+      return;
+    }
+    const newFieldInfos = [...this.fieldInfos];
+    insertedFields.forEach((insertedField) => {
+      const fieldInfo = new FieldInfo(insertedField.field);
+      if (newFieldInfos.length > insertedField.index) {
+        newFieldInfos.splice(insertedField.index, 0, fieldInfo);
+      } else {
+        newFieldInfos.push(fieldInfo);
+      }
+    });
+    this._fieldNotifier.fieldInfos = newFieldInfos;
+  };
+
+  _updateFields = (updatedFields: FieldPB[]) => {
+    if (updatedFields.length === 0) {
+      return;
+    }
+
+    const newFieldInfos = [...this.fieldInfos];
+    updatedFields.forEach((updatedField) => {
+      newFieldInfos.map((element) => {
+        if (element.field.id === updatedField.id) {
+          return updatedField;
+        } else {
+          return element;
+        }
+      });
+    });
+    this._fieldNotifier.fieldInfos = newFieldInfos;
+  };
+}
+
+class FieldNotifier extends ChangeNotifier<FieldInfo[]> {
+  constructor(private _fieldInfos: FieldInfo[]) {
+    super();
+  }
+
+  set fieldInfos(newFieldInfos: FieldInfo[]) {
+    if (this._fieldInfos !== newFieldInfos) {
+      this._fieldInfos = newFieldInfos;
+      this.notify(this._fieldInfos);
+    }
+  }
+
+  /// Return a readonly list
+  get fieldInfos(): FieldInfo[] {
+    return this._fieldInfos;
+  }
+}
+
+export class FieldInfo {
+  constructor(public readonly field: FieldPB) {}
+}

+ 41 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_observer.ts

@@ -0,0 +1,41 @@
+import { Err, Ok, Result } from 'ts-results';
+import { DatabaseNotification } from '../../../../../services/backend';
+import { DatabaseFieldChangesetPB } from '../../../../../services/backend/models/flowy-database/field_entities';
+import { FlowyError } from '../../../../../services/backend/models/flowy-error';
+import { ChangeNotifier } from '../../../../utils/change_notifier';
+import { DatabaseNotificationObserver } from '../notifications/observer';
+
+type UpdateFieldNotifiedValue = Result<DatabaseFieldChangesetPB, FlowyError>;
+export type DatabaseNotificationCallback = (value: UpdateFieldNotifiedValue) => void;
+
+export class DatabaseFieldObserver {
+  _notifier?: ChangeNotifier<UpdateFieldNotifiedValue>;
+  _listener?: DatabaseNotificationObserver;
+
+  constructor(public readonly databaseId: string) {}
+
+  subscribe = (callbacks: { onFieldsChanged: DatabaseNotificationCallback }) => {
+    this._notifier = new ChangeNotifier();
+    this._notifier?.observer.subscribe(callbacks.onFieldsChanged);
+
+    this._listener = new DatabaseNotificationObserver({
+      viewId: this.databaseId,
+      parserHandler: (notification, payload) => {
+        switch (notification) {
+          case DatabaseNotification.DidUpdateFields:
+            this._notifier?.notify(Ok(DatabaseFieldChangesetPB.deserializeBinary(payload)));
+            return;
+          default:
+            break;
+        }
+      },
+      onError: (error) => this._notifier?.notify(Err(error)),
+    });
+    return undefined;
+  };
+
+  unsubscribe = async () => {
+    this._notifier?.unsubscribe();
+    await this._listener?.stop();
+  };
+}

+ 17 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/notifications/observer.ts

@@ -0,0 +1,17 @@
+import { DatabaseNotification } from '../../../../../services/backend/models/flowy-database/notification';
+import { OnNotificationError } from '../../../../../services/backend/notifications';
+import { AFNotificationObserver } from '../../../../../services/backend/notifications/observer';
+import { DatabaseNotificationParser } from './parser';
+
+export type ParserHandler = (notification: DatabaseNotification, payload: Uint8Array) => void;
+
+export class DatabaseNotificationObserver extends AFNotificationObserver<DatabaseNotification> {
+  constructor(params: { viewId?: string; parserHandler: ParserHandler; onError?: OnNotificationError }) {
+    const parser = new DatabaseNotificationParser({
+      callback: params.parserHandler,
+      id: params.viewId,
+      onError: params.onError,
+    });
+    super(parser);
+  }
+}

+ 0 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/grid/notifications/parser.ts → frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/notifications/parser.ts


+ 314 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/row/cache.ts

@@ -0,0 +1,314 @@
+import { RowPB, InsertedRowPB, UpdatedRowPB } from '../../../../../services/backend/models/flowy-database/row_entities';
+import { ChangeNotifier } from '../../../../utils/change_notifier';
+import { FieldInfo } from '../field/controller';
+import { CellCache, CellCacheKey } from '../cell/cache';
+import {
+  ViewRowsChangesetPB,
+  ViewRowsVisibilityChangesetPB,
+} from '../../../../../services/backend/models/flowy-database/view_entities';
+import { CellIdentifier } from '../cell/backend_service';
+import { ReorderSingleRowPB } from '../../../../../services/backend/models/flowy-database/sort_entities';
+
+export class RowCache {
+  _rowList: RowList;
+  _cellCache: CellCache;
+  _notifier: RowChangeNotifier;
+
+  constructor(public readonly viewId: string, private readonly getFieldInfos: () => readonly FieldInfo[]) {
+    this._rowList = new RowList();
+    this._cellCache = new CellCache(viewId);
+    this._notifier = new RowChangeNotifier();
+  }
+
+  get rows(): readonly RowInfo[] {
+    return this._rowList.rows;
+  }
+
+  subscribeOnRowsChanged = (callback: (reason: RowChangedReason, cellMap?: Map<string, CellIdentifier>) => void) => {
+    return this._notifier.observer.subscribe((change) => {
+      if (change.rowId !== undefined) {
+        callback(change.reason, this._toCellMap(change.rowId, this.getFieldInfos()));
+      } else {
+        callback(change.reason);
+      }
+    });
+  };
+
+  onFieldUpdated = (fieldInfo: FieldInfo) => {
+    // Remove the cell data if the corresponding field was changed
+    this._cellCache.removeWithFieldId(fieldInfo.field.id);
+  };
+
+  onNumberOfFieldsUpdated = () => {
+    this._notifier.withChange(RowChangedReason.FieldDidChanged);
+  };
+
+  initializeRows = (rows: RowPB[]) => {
+    rows.forEach((rowPB) => {
+      this._rowList.push(this._toRowInfo(rowPB));
+    });
+  };
+
+  applyRowsChanged = (changeset: ViewRowsChangesetPB) => {
+    this._deleteRows(changeset.deleted_rows);
+    this._insertRows(changeset.inserted_rows);
+    this._updateRows(changeset.updated_rows);
+  };
+
+  applyRowsVisibility = (changeset: ViewRowsVisibilityChangesetPB) => {
+    this._hideRows(changeset.invisible_rows);
+    this._displayRows(changeset.visible_rows);
+  };
+
+  applyReorderRows = (rowIds: string[]) => {
+    this._rowList.reorderByRowIds(rowIds);
+    this._notifier.withChange(RowChangedReason.ReorderRows);
+  };
+
+  applyReorderSingleRow = (reorderRow: ReorderSingleRowPB) => {
+    const rowInfo = this._rowList.getRow(reorderRow.row_id);
+    if (rowInfo !== undefined) {
+      this._rowList.move({ rowId: reorderRow.row_id, fromIndex: reorderRow.old_index, toIndex: reorderRow.new_index });
+      this._notifier.withChange(RowChangedReason.ReorderSingleRow, reorderRow.row_id);
+    }
+  };
+
+  _deleteRows = (rowIds: string[]) => {
+    rowIds.forEach((rowId) => {
+      const deletedRow = this._rowList.remove(rowId);
+      if (deletedRow !== undefined) {
+        this._notifier.withChange(RowChangedReason.Delete, deletedRow.rowInfo.row.id);
+      }
+    });
+  };
+
+  _insertRows = (rows: InsertedRowPB[]) => {
+    rows.forEach((insertedRow) => {
+      const rowInfo = this._toRowInfo(insertedRow.row);
+      const insertedIndex = this._rowList.insert(insertedRow.index, rowInfo);
+      if (insertedIndex !== undefined) {
+        this._notifier.withChange(RowChangedReason.Insert, insertedIndex.rowId);
+      }
+    });
+  };
+
+  _updateRows = (updatedRows: UpdatedRowPB[]) => {
+    if (updatedRows.length === 0) {
+      return;
+    }
+
+    const rowInfos: RowInfo[] = [];
+    updatedRows.forEach((updatedRow) => {
+      updatedRow.field_ids.forEach((fieldId) => {
+        const key = new CellCacheKey(fieldId, updatedRow.row.id);
+        this._cellCache.remove(key);
+      });
+
+      rowInfos.push(this._toRowInfo(updatedRow.row));
+    });
+
+    const updatedIndexs = this._rowList.insertRows(rowInfos);
+    updatedIndexs.forEach((row) => {
+      this._notifier.withChange(RowChangedReason.Update, row.rowId);
+    });
+  };
+
+  _hideRows = (rowIds: string[]) => {
+    rowIds.forEach((rowId) => {
+      const deletedRow = this._rowList.remove(rowId);
+      if (deletedRow !== undefined) {
+        this._notifier.withChange(RowChangedReason.Delete, deletedRow.rowInfo.row.id);
+      }
+    });
+  };
+
+  _displayRows = (insertedRows: InsertedRowPB[]) => {
+    insertedRows.forEach((insertedRow) => {
+      const insertedIndex = this._rowList.insert(insertedRow.index, this._toRowInfo(insertedRow.row));
+
+      if (insertedIndex !== undefined) {
+        this._notifier.withChange(RowChangedReason.Insert, insertedIndex.rowId);
+      }
+    });
+  };
+
+  dispose = async () => {
+    this._notifier.dispose();
+  };
+
+  _toRowInfo = (rowPB: RowPB) => {
+    return new RowInfo(this.viewId, this.getFieldInfos(), rowPB);
+  };
+
+  _toCellMap = (rowId: string, fieldInfos: readonly FieldInfo[]): Map<string, CellIdentifier> => {
+    const cellIdentifierByFieldId: Map<string, CellIdentifier> = new Map();
+
+    fieldInfos.forEach((fieldInfo) => {
+      const identifier = new CellIdentifier(this.viewId, rowId, fieldInfo.field.id, fieldInfo.field.field_type);
+      cellIdentifierByFieldId.set(fieldInfo.field.id, identifier);
+    });
+
+    return cellIdentifierByFieldId;
+  };
+}
+
+class RowList {
+  _rowInfos: RowInfo[] = [];
+  _rowInfoByRowId: Map<string, RowInfo> = new Map();
+
+  get rows(): readonly RowInfo[] {
+    return this._rowInfos;
+  }
+
+  getRow = (rowId: string) => {
+    return this._rowInfoByRowId.get(rowId);
+  };
+
+  getRowWithIndex = (rowId: string): { rowInfo: RowInfo; index: number } | undefined => {
+    const rowInfo = this._rowInfoByRowId.get(rowId);
+    if (rowInfo !== undefined) {
+      const index = this._rowInfos.indexOf(rowInfo, 0);
+      return { rowInfo: rowInfo, index: index };
+    }
+    return undefined;
+  };
+
+  indexOfRow = (rowId: string): number => {
+    const rowInfo = this._rowInfoByRowId.get(rowId);
+    if (rowInfo !== undefined) {
+      return this._rowInfos.indexOf(rowInfo, 0);
+    }
+    return -1;
+  };
+
+  push = (rowInfo: RowInfo) => {
+    const index = this.indexOfRow(rowInfo.row.id);
+    if (index !== -1) {
+      this._rowInfos.splice(index, 1, rowInfo);
+    } else {
+      this._rowInfos.push(rowInfo);
+    }
+
+    this._rowInfoByRowId.set(rowInfo.row.id, rowInfo);
+  };
+
+  remove = (rowId: string): DeletedRow | undefined => {
+    const result = this.getRowWithIndex(rowId);
+    if (result !== undefined) {
+      this._rowInfoByRowId.delete(result.rowInfo.row.id);
+      this._rowInfos.splice(result.index, 1);
+      return new DeletedRow(result.index, result.rowInfo);
+    } else {
+      return undefined;
+    }
+  };
+
+  insert = (index: number, newRowInfo: RowInfo): InsertedRow | undefined => {
+    const rowId = newRowInfo.row.id;
+    // Calibrate where to insert
+    let insertedIndex = index;
+    if (this._rowInfos.length <= insertedIndex) {
+      insertedIndex = this._rowInfos.length;
+    }
+    const result = this.getRowWithIndex(rowId);
+
+    if (result !== undefined) {
+      // remove the old row info
+      this._rowInfos.splice(result.index, 1);
+      // insert the new row info to the insertedIndex
+      this._rowInfos.splice(insertedIndex, 0, newRowInfo);
+      this._rowInfoByRowId.set(rowId, newRowInfo);
+      return undefined;
+    } else {
+      this._rowInfos.splice(insertedIndex, 0, newRowInfo);
+      this._rowInfoByRowId.set(rowId, newRowInfo);
+      return new InsertedRow(insertedIndex, rowId);
+    }
+  };
+
+  insertRows = (rowInfos: RowInfo[]) => {
+    const map = new Map<string, InsertedRow>();
+    rowInfos.forEach((rowInfo) => {
+      const index = this.indexOfRow(rowInfo.row.id);
+      if (index !== -1) {
+        this._rowInfos.splice(index, 1, rowInfo);
+        this._rowInfoByRowId.set(rowInfo.row.id, rowInfo);
+
+        map.set(rowInfo.row.id, new InsertedRow(index, rowInfo.row.id));
+      }
+    });
+    return map;
+  };
+
+  move = (params: { rowId: string; fromIndex: number; toIndex: number }) => {
+    const currentIndex = this.indexOfRow(params.rowId);
+    if (currentIndex !== -1 && currentIndex !== params.toIndex) {
+      const rowInfo = this.remove(params.rowId)?.rowInfo;
+      if (rowInfo !== undefined) {
+        this.insert(params.toIndex, rowInfo);
+      }
+    }
+  };
+
+  reorderByRowIds = (rowIds: string[]) => {
+    // remove all the elements
+    this._rowInfos = [];
+    rowIds.forEach((rowId) => {
+      const rowInfo = this._rowInfoByRowId.get(rowId);
+      if (rowInfo !== undefined) {
+        this._rowInfos.push(rowInfo);
+      }
+    });
+  };
+
+  includes = (rowId: string): boolean => {
+    return this._rowInfoByRowId.has(rowId);
+  };
+}
+
+export class RowInfo {
+  constructor(
+    public readonly databaseId: string,
+    public readonly fieldInfos: readonly FieldInfo[],
+    public readonly row: RowPB
+  ) {}
+}
+
+export class DeletedRow {
+  constructor(public readonly index: number, public readonly rowInfo: RowInfo) {}
+}
+
+export class InsertedRow {
+  constructor(public readonly index: number, public readonly rowId: string) {}
+}
+
+export class RowChanged {
+  constructor(public readonly reason: RowChangedReason, public readonly rowId?: string) {}
+}
+
+// eslint-disable-next-line no-shadow
+export enum RowChangedReason {
+  Insert,
+  Delete,
+  Update,
+  Initial,
+  FieldDidChanged,
+  ReorderRows,
+  ReorderSingleRow,
+}
+
+export class RowChangeNotifier extends ChangeNotifier<RowChanged> {
+  _currentChanged = new RowChanged(RowChangedReason.Initial);
+
+  withChange = (reason: RowChangedReason, rowId?: string) => {
+    const newChange = new RowChanged(reason, rowId);
+    if (this._currentChanged !== newChange) {
+      this._currentChanged = newChange;
+      this.notify(this._currentChanged);
+    }
+  };
+
+  dispose = () => {
+    this.unsubscribe();
+  };
+}

+ 55 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/view/cache.ts

@@ -0,0 +1,55 @@
+import { DatabaseViewRowsObserver } from './row_observer';
+import { RowCache, RowChangedReason } from '../row/cache';
+import { FieldController } from '../field/controller';
+import { RowPB } from '../../../../../services/backend/models/flowy-database/row_entities';
+
+export class DatabaseViewCache {
+  _rowsObserver: DatabaseViewRowsObserver;
+  _rowCache: RowCache;
+
+  constructor(public readonly viewId: string, fieldController: FieldController) {
+    this._rowsObserver = new DatabaseViewRowsObserver(viewId);
+    this._rowCache = new RowCache(viewId, () => fieldController.fieldInfos);
+    this._listenOnRowsChanged();
+  }
+
+  initializeWithRows = (rows: RowPB[]) => {
+    this._rowCache.initializeRows(rows);
+  };
+
+  subscribeOnRowsChanged = (onRowsChanged: (reason: RowChangedReason) => void) => {
+    return this._rowCache.subscribeOnRowsChanged((reason) => {
+      onRowsChanged(reason);
+    });
+  };
+
+  dispose = async () => {
+    await this._rowsObserver.unsubscribe();
+    await this._rowCache.dispose();
+  };
+
+  _listenOnRowsChanged = () => {
+    this._rowsObserver.subscribe({
+      onRowsVisibilityChanged: (result) => {
+        if (result.ok) {
+          this._rowCache.applyRowsVisibility(result.val);
+        }
+      },
+      onNumberOfRowsChanged: (result) => {
+        if (result.ok) {
+          this._rowCache.applyRowsChanged(result.val);
+        }
+      },
+      onReorderRows: (result) => {
+        if (result.ok) {
+          this._rowCache.applyReorderRows(result.val);
+        }
+      },
+      onReorderSingleRow: (result) => {
+        if (result.ok) {
+          this._rowCache.applyReorderSingleRow(result.val);
+        }
+      },
+    });
+  };
+}

+ 71 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/view/row_observer.ts

@@ -0,0 +1,71 @@
+import { Ok, Result } from 'ts-results';
+import {
+  DatabaseNotification,
+  ReorderAllRowsPB,
+  ReorderSingleRowPB,
+} from '../../../../../services/backend/events/flowy-database';
+import {
+  ViewRowsChangesetPB,
+  ViewRowsVisibilityChangesetPB,
+} from '../../../../../services/backend/models/flowy-database/view_entities';
+import { FlowyError } from '../../../../../services/backend/models/flowy-error/errors';
+import { ChangeNotifier } from '../../../../utils/change_notifier';
+import { DatabaseNotificationObserver } from '../notifications/observer';
+
+export type RowsVisibilityNotifyValue = Result<ViewRowsVisibilityChangesetPB, FlowyError>;
+export type RowsNotifyValue = Result<ViewRowsChangesetPB, FlowyError>;
+export type ReorderRowsNotifyValue = Result<string[], FlowyError>;
+export type ReorderSingleRowNotifyValue = Result<ReorderSingleRowPB, FlowyError>;
+
+export class DatabaseViewRowsObserver {
+  _rowsVisibilityNotifier = new ChangeNotifier<RowsVisibilityNotifyValue>();
+  _rowsNotifier = new ChangeNotifier<RowsNotifyValue>();
+  _reorderRowsNotifier = new ChangeNotifier<ReorderRowsNotifyValue>();
+  _reorderSingleRowNotifier = new ChangeNotifier<ReorderSingleRowNotifyValue>();
+
+  _listener?: DatabaseNotificationObserver;
+  constructor(public readonly viewId: string) {}
+
+  subscribe = (callbacks: {
+    onRowsVisibilityChanged?: (value: RowsVisibilityNotifyValue) => void;
+    onNumberOfRowsChanged?: (value: RowsNotifyValue) => void;
+    onReorderRows?: (value: ReorderRowsNotifyValue) => void;
+    onReorderSingleRow?: (value: ReorderSingleRowNotifyValue) => void;
+  }) => {
+    //
+    this._rowsVisibilityNotifier.observer.subscribe(callbacks.onRowsVisibilityChanged);
+    this._rowsNotifier.observer.subscribe(callbacks.onNumberOfRowsChanged);
+    this._reorderRowsNotifier.observer.subscribe(callbacks.onReorderRows);
+    this._reorderSingleRowNotifier.observer.subscribe(callbacks.onReorderSingleRow);
+
+    this._listener = new DatabaseNotificationObserver({
+      viewId: this.viewId,
+      parserHandler: (notification, payload) => {
+        switch (notification) {
+          case DatabaseNotification.DidUpdateViewRowsVisibility:
+            this._rowsVisibilityNotifier.notify(Ok(ViewRowsVisibilityChangesetPB.deserializeBinary(payload)));
+            break;
+          case DatabaseNotification.DidUpdateViewRows:
+            this._rowsNotifier.notify(Ok(ViewRowsChangesetPB.deserializeBinary(payload)));
+            break;
+          case DatabaseNotification.DidReorderRows:
+            this._reorderRowsNotifier.notify(Ok(ReorderAllRowsPB.deserializeBinary(payload).row_orders));
+            break;
+          case DatabaseNotification.DidReorderSingleRow:
+            this._reorderSingleRowNotifier.notify(Ok(ReorderSingleRowPB.deserializeBinary(payload)));
+            break;
+          default:
+            break;
+        }
+      },
+    });
+  };
+
+  unsubscribe = async () => {
+    this._rowsVisibilityNotifier.unsubscribe();
+    this._reorderRowsNotifier.unsubscribe();
+    this._rowsNotifier.unsubscribe();
+    this._reorderSingleRowNotifier.unsubscribe();
+    await this._listener?.stop();
+  };
+}

+ 38 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/app/app_observer.ts

@@ -0,0 +1,38 @@
+import { Ok, Result } from 'ts-results';
+import { AppPB, FolderNotification } from '../../../../../services/backend';
+import { FlowyError } from '../../../../../services/backend/models/flowy-error';
+import { ChangeNotifier } from '../../../../utils/change_notifier';
+import { FolderNotificationObserver } from '../notifications/observer';
+
+export type AppUpdateNotifyValue = Result<AppPB, FlowyError>;
+export type AppUpdateNotifyCallback = (value: AppUpdateNotifyValue) => void;
+
+export class WorkspaceObserver {
+  _appNotifier = new ChangeNotifier<AppUpdateNotifyValue>();
+  _listener?: FolderNotificationObserver;
+
+  constructor(public readonly appId: string) {}
+
+  subscribe = (callbacks: { onAppChanged: AppUpdateNotifyCallback }) => {
+    this._appNotifier?.observer.subscribe(callbacks.onAppChanged);
+
+    this._listener = new FolderNotificationObserver({
+      viewId: this.appId,
+      parserHandler: (notification, payload) => {
+        switch (notification) {
+          case FolderNotification.DidUpdateWorkspaceApps:
+            this._appNotifier?.notify(Ok(AppPB.deserializeBinary(payload)));
+            break;
+          default:
+            break;
+        }
+      },
+    });
+    return undefined;
+  };
+
+  unsubscribe = async () => {
+    this._appNotifier.unsubscribe();
+    await this._listener?.stop();
+  };
+}

+ 98 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/app/backend_service.ts

@@ -0,0 +1,98 @@
+import {
+  FolderEventCreateView,
+  FolderEventDeleteApp,
+  FolderEventDeleteView,
+  FolderEventMoveItem,
+  FolderEventReadApp,
+  FolderEventUpdateApp,
+  ViewDataFormatPB,
+  ViewLayoutTypePB,
+} from '../../../../../services/backend/events/flowy-folder';
+import { AppIdPB, UpdateAppPayloadPB } from '../../../../../services/backend/models/flowy-folder/app';
+import {
+  CreateViewPayloadPB,
+  RepeatedViewIdPB,
+  ViewPB,
+  MoveFolderItemPayloadPB,
+  MoveFolderItemType,
+} from '../../../../../services/backend/models/flowy-folder/view';
+import { FlowyError } from '../../../../../services/backend/models/flowy-error/errors';
+import { None, Result, Some } from 'ts-results';
+
+export class AppBackendService {
+  constructor(public readonly appId: string) {}
+
+  getApp = () => {
+    const payload = AppIdPB.fromObject({ value: this.appId });
+    return FolderEventReadApp(payload);
+  };
+
+  createView = (params: {
+    name: string;
+    desc?: string;
+    dataFormatType: ViewDataFormatPB;
+    layoutType: ViewLayoutTypePB;
+    /// The initial data should be the JSON of the doucment
+    /// For example: {"document":{"type":"editor","children":[]}}
+    initialData?: string;
+  }) => {
+    const encoder = new TextEncoder();
+    const payload = CreateViewPayloadPB.fromObject({
+      belong_to_id: this.appId,
+      name: params.name,
+      desc: params.desc || '',
+      data_format: params.dataFormatType,
+      layout: params.layoutType,
+      initial_data: encoder.encode(params.initialData || ''),
+    });
+
+    return FolderEventCreateView(payload);
+  };
+
+  getAllViews = (): Promise<Result<ViewPB[], FlowyError>> => {
+    const payload = AppIdPB.fromObject({ value: this.appId });
+    return FolderEventReadApp(payload).then((result) => {
+      return result.map((app) => app.belongings.items);
+    });
+  };
+
+  getView = async (viewId: string) => {
+    const result = await this.getAllViews();
+    if (result.ok) {
+      const target = result.val.find((view) => view.id === viewId);
+      if (target !== undefined) {
+        return Some(target);
+      } else {
+        return None;
+      }
+    } else {
+      return None;
+    }
+  };
+
+  update = (params: { name: string }) => {
+    const payload = UpdateAppPayloadPB.fromObject({ app_id: this.appId, name: params.name });
+    return FolderEventUpdateApp(payload);
+  };
+
+  delete = () => {
+    const payload = AppIdPB.fromObject({ value: this.appId });
+    return FolderEventDeleteApp(payload);
+  };
+
+  deleteView = (viewId: string) => {
+    const payload = RepeatedViewIdPB.fromObject({ items: [viewId] });
+    return FolderEventDeleteView(payload);
+  };
+
+  moveView = (params: { view_id: string; fromIndex: number; toIndex: number }) => {
+    const payload = MoveFolderItemPayloadPB.fromObject({
+      item_id: params.view_id,
+      from: params.fromIndex,
+      to: params.toIndex,
+      ty: MoveFolderItemType.MoveView,
+    });
+
+    return FolderEventMoveItem(payload);
+  };
+}

+ 17 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/notifications/observer.ts

@@ -0,0 +1,17 @@
+import { OnNotificationError } from '../../../../../services/backend/notifications';
+import { AFNotificationObserver } from '../../../../../services/backend/notifications/observer';
+import { FolderNotificationParser } from './parser';
+import { FolderNotification } from '../../../../../services/backend/models/flowy-folder/notification';
+
+export type ParserHandler = (notification: FolderNotification, payload: Uint8Array) => void;
+
+export class FolderNotificationObserver extends AFNotificationObserver<FolderNotification> {
+  constructor(params: { viewId?: string; parserHandler: ParserHandler; onError?: OnNotificationError }) {
+    const parser = new FolderNotificationParser({
+      callback: params.parserHandler,
+      id: params.viewId,
+      onError: params.onError,
+    });
+    super(parser);
+  }
+}

+ 26 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/notifications/parser.ts

@@ -0,0 +1,26 @@
+import { NotificationParser, OnNotificationError } from '../../../../../services/backend/notifications';
+import { FolderNotification } from '../../../../../services/backend/models/flowy-folder/notification';
+
+declare type FolderNotificationCallback = (ty: FolderNotification, payload: Uint8Array) => void;
+
+export class FolderNotificationParser extends NotificationParser<FolderNotification> {
+  constructor(params: { id?: string; callback: FolderNotificationCallback; onError?: OnNotificationError }) {
+    super(
+      params.callback,
+      (ty) => {
+        const notification = FolderNotification[ty];
+        if (isFolderNotification(notification)) {
+          return FolderNotification[notification];
+        } else {
+          return FolderNotification.Unknown;
+        }
+      },
+      params.id,
+      params.onError
+    );
+  }
+}
+
+const isFolderNotification = (notification: string): notification is keyof typeof FolderNotification => {
+  return Object.values(FolderNotification).indexOf(notification) !== -1;
+};

+ 32 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/view/backend_service.ts

@@ -0,0 +1,32 @@
+import { UpdateViewPayloadPB, RepeatedViewIdPB, ViewPB } from '../../../../../services/backend/models/flowy-folder/view';
+import {
+  FolderEventDeleteView,
+  FolderEventDuplicateView,
+  FolderEventUpdateView,
+} from '../../../../../services/backend/events/flowy-folder';
+
+export class ViewBackendService {
+  constructor(public readonly viewId: string) {}
+
+  update = (params: { name?: string; desc?: string }) => {
+    const payload = UpdateViewPayloadPB.fromObject({ view_id: this.viewId });
+
+    if (params.name !== undefined) {
+      payload.name = params.name;
+    }
+    if (params.desc !== undefined) {
+      payload.desc = params.desc;
+    }
+
+    return FolderEventUpdateView(payload);
+  };
+
+  delete = () => {
+    const payload = RepeatedViewIdPB.fromObject({ items: [this.viewId] });
+    return FolderEventDeleteView(payload);
+  };
+
+  duplicate = (view: ViewPB) => {
+    return FolderEventDuplicateView(view);
+  };
+}

+ 74 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/view/view_observer.ts

@@ -0,0 +1,74 @@
+import { Ok, Result } from 'ts-results';
+import { FlowyError } from '../../../../../services/backend/models/flowy-error/errors';
+import { DeletedViewPB, FolderNotification, ViewPB } from '../../../../../services/backend/models/flowy-folder';
+import { ChangeNotifier } from '../../../../utils/change_notifier';
+import { FolderNotificationObserver } from '../notifications/observer';
+
+type DeleteViewNotifyValue = Result<ViewPB, FlowyError>;
+type UpdateViewNotifyValue = Result<ViewPB, FlowyError>;
+type RestoreViewNotifyValue = Result<ViewPB, FlowyError>;
+type MoveToTrashViewNotifyValue = Result<DeletedViewPB, FlowyError>;
+
+export class ViewObserver {
+  _deleteViewNotifier = new ChangeNotifier<DeleteViewNotifyValue>();
+  _updateViewNotifier = new ChangeNotifier<UpdateViewNotifyValue>();
+  _restoreViewNotifier = new ChangeNotifier<RestoreViewNotifyValue>();
+  _moveToTashNotifier = new ChangeNotifier<MoveToTrashViewNotifyValue>();
+  _listener?: FolderNotificationObserver;
+
+  constructor(public readonly viewId: string) {}
+
+  subscribe = (callbacks: {
+    onViewUpdate?: (value: UpdateViewNotifyValue) => void;
+    onViewDelete?: (value: DeleteViewNotifyValue) => void;
+    onViewRestored?: (value: RestoreViewNotifyValue) => void;
+    onViewMoveToTrash?: (value: MoveToTrashViewNotifyValue) => void;
+  }) => {
+    if (callbacks.onViewDelete !== undefined) {
+      this._deleteViewNotifier.observer.subscribe(callbacks.onViewDelete);
+    }
+
+    if (callbacks.onViewUpdate !== undefined) {
+      this._updateViewNotifier.observer.subscribe(callbacks.onViewUpdate);
+    }
+
+    if (callbacks.onViewRestored !== undefined) {
+      this._restoreViewNotifier.observer.subscribe(callbacks.onViewRestored);
+    }
+
+    if (callbacks.onViewMoveToTrash !== undefined) {
+      this._moveToTashNotifier.observer.subscribe(callbacks.onViewMoveToTrash);
+    }
+
+    this._listener = new FolderNotificationObserver({
+      viewId: this.viewId,
+      parserHandler: (notification, payload) => {
+        switch (notification) {
+          case FolderNotification.DidUpdateView:
+            this._updateViewNotifier.notify(Ok(ViewPB.deserializeBinary(payload)));
+            break;
+          case FolderNotification.DidDeleteView:
+            this._deleteViewNotifier.notify(Ok(ViewPB.deserializeBinary(payload)));
+            break;
+          case FolderNotification.DidRestoreView:
+            this._restoreViewNotifier.notify(Ok(ViewPB.deserializeBinary(payload)));
+            break;
+          case FolderNotification.DidMoveViewToTrash:
+            this._moveToTashNotifier.notify(Ok(DeletedViewPB.deserializeBinary(payload)));
+            break;
+          default:
+            break;
+        }
+      },
+    });
+    return undefined;
+  };
+
+  unsubscribe = async () => {
+    this._deleteViewNotifier.unsubscribe();
+    this._updateViewNotifier.unsubscribe();
+    this._restoreViewNotifier.unsubscribe();
+    this._moveToTashNotifier.unsubscribe();
+    await this._listener?.stop();
+  };
+}

+ 57 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/workspace/backend_service.ts

@@ -0,0 +1,57 @@
+import { Err, Ok } from 'ts-results';
+import {
+  FolderEventCreateApp,
+  FolderEventMoveItem,
+  FolderEventReadWorkspaceApps,
+  FolderEventReadWorkspaces,
+} from '../../../../../services/backend/events/flowy-folder';
+import { CreateAppPayloadPB } from '../../../../../services/backend/models/flowy-folder/app';
+import { WorkspaceIdPB } from '../../../../../services/backend/models/flowy-folder/workspace';
+import assert from 'assert';
+import { FlowyError } from '../../../../../services/backend/models/flowy-error/errors';
+import { MoveFolderItemPayloadPB } from '../../../../../services/backend/models/flowy-folder/view';
+
+export class WorkspaceBackendService {
+  constructor(public readonly workspaceId: string) {}
+
+  createApp = (params: { name: string; desc?: string }) => {
+    const payload = CreateAppPayloadPB.fromObject({
+      workspace_id: this.workspaceId,
+      name: params.name,
+      desc: params.desc || '',
+    });
+
+    return FolderEventCreateApp(payload);
+  };
+
+  getWorkspace = () => {
+    const payload = WorkspaceIdPB.fromObject({ value: this.workspaceId });
+    return FolderEventReadWorkspaces(payload).then((result) => {
+      if (result.ok) {
+        const workspaces = result.val.items;
+        if (workspaces.length === 0) {
+          return Err(FlowyError.fromObject({ msg: 'workspace not found' }));
+        } else {
+          assert(workspaces.length === 1);
+          return Ok(workspaces[0]);
+        }
+      } else {
+        return Err(result.val);
+      }
+    });
+  };
+
+  getApps = () => {
+    const payload = WorkspaceIdPB.fromObject({ value: this.workspaceId });
+    return FolderEventReadWorkspaceApps(payload).then((result) => result.map((val) => val.items));
+  };
+
+  moveApp = (params: { appId: string; fromIndex: number; toIndex: number }) => {
+    const payload = MoveFolderItemPayloadPB.fromObject({
+      item_id: params.appId,
+      from: params.fromIndex,
+      to: params.toIndex,
+    });
+    return FolderEventMoveItem(payload);
+  };
+}

+ 46 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/workspace/workspace_observer.ts

@@ -0,0 +1,46 @@
+import { Ok, Result } from 'ts-results';
+import { AppPB, FolderNotification, RepeatedAppPB, WorkspacePB } from '../../../../../services/backend';
+import { FlowyError } from '../../../../../services/backend/models/flowy-error';
+import { ChangeNotifier } from '../../../../utils/change_notifier';
+import { FolderNotificationObserver } from '../notifications/observer';
+
+export type AppListNotifyValue = Result<AppPB[], FlowyError>;
+export type AppListNotifyCallback = (value: AppListNotifyValue) => void;
+export type WorkspaceNotifyValue = Result<WorkspacePB, FlowyError>;
+export type WorkspaceNotifyCallback = (value: WorkspaceNotifyValue) => void;
+
+export class WorkspaceObserver {
+  _appListNotifier = new ChangeNotifier<AppListNotifyValue>();
+  _workspaceNotifier = new ChangeNotifier<WorkspaceNotifyValue>();
+  _listener?: FolderNotificationObserver;
+
+  constructor(public readonly workspaceId: string) {}
+
+  subscribe = (callbacks: { onAppListChanged: AppListNotifyCallback; onWorkspaceChanged: WorkspaceNotifyCallback }) => {
+    this._appListNotifier?.observer.subscribe(callbacks.onAppListChanged);
+    this._workspaceNotifier?.observer.subscribe(callbacks.onWorkspaceChanged);
+
+    this._listener = new FolderNotificationObserver({
+      viewId: this.workspaceId,
+      parserHandler: (notification, payload) => {
+        switch (notification) {
+          case FolderNotification.DidUpdateWorkspace:
+            this._workspaceNotifier?.notify(Ok(WorkspacePB.deserializeBinary(payload)));
+            break;
+          case FolderNotification.DidUpdateWorkspaceApps:
+            this._appListNotifier?.notify(Ok(RepeatedAppPB.deserializeBinary(payload).items));
+            break;
+          default:
+            break;
+        }
+      },
+    });
+    return undefined;
+  };
+
+  unsubscribe = async () => {
+    this._appListNotifier.unsubscribe();
+    this._workspaceNotifier.unsubscribe();
+    await this._listener?.stop();
+  };
+}

+ 82 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/user/backend_service.ts

@@ -0,0 +1,82 @@
+import { nanoid } from '@reduxjs/toolkit';
+import {
+  UserEventGetUserProfile,
+  UserEventSignIn,
+  UserEventSignOut,
+  UserEventSignUp,
+  UserEventUpdateUserProfile,
+} from '../../../../services/backend/events/flowy-user';
+import { SignInPayloadPB, SignUpPayloadPB } from '../../../../services/backend/models/flowy-user/auth';
+import { UpdateUserProfilePayloadPB } from '../../../../services/backend/models/flowy-user/user_profile';
+import { WorkspaceIdPB, CreateWorkspacePayloadPB } from '../../../../services/backend/models/flowy-folder/workspace';
+import {
+  FolderEventCreateWorkspace,
+  FolderEventOpenWorkspace,
+  FolderEventReadWorkspaces,
+} from '../../../../services/backend/events/flowy-folder';
+
+export class UserBackendService {
+  constructor(public readonly userId: string) {}
+
+  getUserProfile = () => {
+    return UserEventGetUserProfile();
+  };
+
+  updateUserProfile = (params: { name?: string; password?: string; email?: string; openAIKey?: string }) => {
+    const payload = UpdateUserProfilePayloadPB.fromObject({ id: this.userId });
+
+    if (params.name !== undefined) {
+      payload.name = params.name;
+    }
+    if (params.password !== undefined) {
+      payload.password = params.password;
+    }
+    if (params.email !== undefined) {
+      payload.email = params.email;
+    }
+    // if (params.openAIKey !== undefined) {
+    // }
+    return UserEventUpdateUserProfile(payload);
+  };
+
+  getWorkspaces = () => {
+    const payload = WorkspaceIdPB.fromObject({});
+    return FolderEventReadWorkspaces(payload);
+  };
+
+  openWorkspace = (workspaceId: string) => {
+    const payload = WorkspaceIdPB.fromObject({ value: workspaceId });
+    return FolderEventOpenWorkspace(payload);
+  };
+
+  createWorkspace = (params: { name: string; desc: string }) => {
+    const payload = CreateWorkspacePayloadPB.fromObject({ name: params.name, desc: params.desc });
+    return FolderEventCreateWorkspace(payload);
+  };
+
+  signOut = () => {
+    return UserEventSignOut();
+  };
+}
+
+export class AuthBackendService {
+  signIn = (params: { email: string; password: string }) => {
+    const payload = SignInPayloadPB.fromObject({ email: params.email, password: params.password });
+    return UserEventSignIn(payload);
+  };
+
+  signUp = (params: { name: string; email: string; password: string }) => {
+    const payload = SignUpPayloadPB.fromObject({ name: params.name, email: params.email, password: params.password });
+    return UserEventSignUp(payload);
+  };
+
+  signOut = () => {
+    return UserEventSignOut();
+  };
+
+  autoSignUp = () => {
+    const password = 'AppFlowy123@';
+    const email = nanoid(4) + '@appflowy.io';
+    return this.signUp({ name: 'Me', email: email, password: password });
+  };
+}

+ 1 - 4
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/folders/slice.ts

@@ -5,10 +5,7 @@ export interface IFolder {
   title: string;
 }
 
-const initialState: IFolder[] = [
-  { id: 'getting_started', title: 'Getting Started' },
-  { id: 'my_folder', title: 'My Folder' },
-];
+const initialState: IFolder[] = [];
 
 export const foldersSlice = createSlice({
   name: 'folders',

+ 3 - 8
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts

@@ -1,19 +1,14 @@
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
-
-export type PageType = 'document' | 'grid' | 'board';
+import { ViewLayoutTypePB } from '../../../../services/backend';
 
 export interface IPage {
   id: string;
   title: string;
-  pageType: PageType;
+  pageType: ViewLayoutTypePB;
   folderId: string;
 }
 
-const initialState: IPage[] = [
-  { id: 'welcome_page', title: 'Welcome', pageType: 'document', folderId: 'getting_started' },
-  { id: 'first_page', title: 'First Page', pageType: 'document', folderId: 'my_folder' },
-  { id: 'second_page', title: 'Second Page', pageType: 'document', folderId: 'my_folder' },
-];
+const initialState: IPage[] = [];
 
 export const pagesSlice = createSlice({
   name: 'pages',

+ 17 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/change_notifier.ts

@@ -0,0 +1,17 @@
+import { Subject } from 'rxjs';
+
+export class ChangeNotifier<T> {
+  private subject = new Subject<T>();
+
+  notify(value: T) {
+    this.subject.next(value);
+  }
+
+  get observer() {
+    return this.subject.asObservable();
+  }
+
+  unsubscribe = () => {
+    this.subject.unsubscribe();
+  };
+}

+ 21 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/log.ts

@@ -0,0 +1,21 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+export class Log {
+  static error(msg?: any) {
+    console.log(msg);
+  }
+  static info(msg?: any) {
+    console.log(msg);
+  }
+
+  static debug(msg?: any) {
+    console.log(msg);
+  }
+
+  static trace(msg?: any) {
+    console.log(msg);
+  }
+
+  static warn(msg?: any) {
+    console.log(msg);
+  }
+}

+ 2 - 2
frontend/appflowy_tauri/src/services/backend/notifications/index.ts

@@ -1,2 +1,2 @@
-export * from "./listener";
-export * from "./parser";
+export * from './observer';
+export * from './parser';

+ 0 - 29
frontend/appflowy_tauri/src/services/backend/notifications/listener.ts

@@ -1,29 +0,0 @@
-import { listen, UnlistenFn } from "@tauri-apps/api/event";
-import { FlowyError } from "../models/flowy-error";
-import { SubscribeObject } from "../models/flowy-notification";
-import { NotificationParser } from "./parser";
-
-declare type OnError = (error: FlowyError) => void;
-
-export abstract class AFNotificationListener<T> {
-  parser?: NotificationParser<T> | null;
-  private _listener?: UnlistenFn;
-
-  protected constructor(parser?: NotificationParser<T>) {
-    this.parser = parser;
-  }
-
-  async start() {
-    this._listener = await listen("af-notification", (notification) => {
-      let object = SubscribeObject.fromObject(notification.payload as {});
-      this.parser?.parse(object);
-    });
-  }
-
-  async stop() {
-    if (this._listener != null) {
-      this._listener();
-    }
-    this.parser = null;
-  }
-}

+ 26 - 0
frontend/appflowy_tauri/src/services/backend/notifications/observer.ts

@@ -0,0 +1,26 @@
+import { listen, UnlistenFn } from '@tauri-apps/api/event';
+import { SubscribeObject } from '../models/flowy-notification';
+import { NotificationParser } from './parser';
+
+export abstract class AFNotificationObserver<T> {
+  parser?: NotificationParser<T> | null;
+  private _listener?: UnlistenFn;
+
+  protected constructor(parser?: NotificationParser<T>) {
+    this.parser = parser;
+  }
+
+  async start() {
+    this._listener = await listen('af-notification', (notification) => {
+      const object = SubscribeObject.fromObject(notification.payload as {});
+      this.parser?.parse(object);
+    });
+  }
+
+  async stop() {
+    if (this._listener !== undefined) {
+      this._listener = undefined;
+    }
+    this.parser = null;
+  }
+}

+ 14 - 10
frontend/appflowy_tauri/src/services/backend/notifications/parser.ts

@@ -1,6 +1,5 @@
-import { Ok, Err, Result } from "ts-results/result";
-import { FlowyError } from "../models/flowy-error";
-import { SubscribeObject } from "../models/flowy-notification";
+import { FlowyError } from '../models/flowy-error';
+import { SubscribeObject } from '../models/flowy-notification';
 
 export declare type OnNotificationPayload<T> = (ty: T, payload: Uint8Array) => void;
 export declare type OnNotificationError = (error: FlowyError) => void;
@@ -8,12 +7,17 @@ export declare type NotificationTyParser<T> = (num: number) => T | null;
 export declare type ErrParser<E> = (data: Uint8Array) => E;
 
 export abstract class NotificationParser<T> {
-  id?: String;
+  id?: string;
   onPayload: OnNotificationPayload<T>;
   onError?: OnNotificationError;
   tyParser: NotificationTyParser<T>;
 
-  constructor(onPayload: OnNotificationPayload<T>, tyParser: NotificationTyParser<T>, id?: String, onError?: OnNotificationError) {
+  constructor(
+    onPayload: OnNotificationPayload<T>,
+    tyParser: NotificationTyParser<T>,
+    id?: string,
+    onError?: OnNotificationError
+  ) {
     this.id = id;
     this.onPayload = onPayload;
     this.onError = onError;
@@ -21,19 +25,19 @@ export abstract class NotificationParser<T> {
   }
 
   parse(subject: SubscribeObject) {
-    if (typeof this.id !== "undefined" && this.id.length == 0) {
-      if (subject.id != this.id) {
+    if (typeof this.id !== 'undefined' && this.id.length === 0) {
+      if (subject.id !== this.id) {
         return;
       }
     }
 
-    let ty = this.tyParser(subject.ty);
-    if (ty == null) {
+    const ty = this.tyParser(subject.ty);
+    if (ty === null) {
       return;
     }
 
     if (subject.has_error) {
-      let error = FlowyError.deserializeBinary(subject.error);
+      const error = FlowyError.deserializeBinary(subject.error);
       this.onError?.(error);
     } else {
       this.onPayload(ty, subject.payload);

+ 1 - 0
frontend/appflowy_tauri/src/tests/helpers/init.ts

@@ -0,0 +1 @@
+export {};

+ 42 - 0
frontend/appflowy_tauri/src/tests/user.test.ts

@@ -0,0 +1,42 @@
+import { AuthBackendService, UserBackendService } from '../appflowy_app/stores/effects/user/backend_service';
+import { randomFillSync } from 'crypto';
+import { nanoid } from '@reduxjs/toolkit';
+
+beforeAll(() => {
+  //@ts-ignore
+  window.crypto = {
+    // @ts-ignore
+    getRandomValues: function (buffer) {
+      // @ts-ignore
+      return randomFillSync(buffer);
+    },
+  };
+});
+
+describe('User backend service', () => {
+  it('sign up', async () => {
+    const service = new AuthBackendService();
+    const result = await service.autoSignUp();
+    expect(result.ok).toBeTruthy;
+  });
+
+  it('sign in', async () => {
+    const authService = new AuthBackendService();
+    const email = nanoid(4) + '@appflowy.io';
+    const password = nanoid(10);
+    const signUpResult = await authService.signUp({ name: 'nathan', email: email, password: password });
+    expect(signUpResult.ok).toBeTruthy;
+
+    const signInResult = await authService.signIn({ email: email, password: password });
+    expect(signInResult.ok).toBeTruthy;
+  });
+
+  it('get user profile', async () => {
+    const service = new AuthBackendService();
+    const result = await service.autoSignUp();
+    const userProfile = result.unwrap();
+
+    const userService = new UserBackendService(userProfile.id);
+    expect((await userService.getUserProfile()).unwrap()).toBe(userProfile);
+  });
+});

+ 14 - 0
frontend/appflowy_tauri/test/specs/example.e2e.ts

@@ -0,0 +1,14 @@
+describe('My Login application', () => {
+    it('should login with valid credentials', async () => {
+        await browser.url(`https://the-internet.herokuapp.com/login`)
+
+        await $('#username').setValue('tomsmith')
+        await $('#password').setValue('SuperSecretPassword!')
+        await $('button[type="submit"]').click()
+
+        await expect($('#flash')).toBeExisting()
+        await expect($('#flash')).toHaveTextContaining(
+            'You logged into a secure area!')
+    })
+})
+

+ 13 - 0
frontend/appflowy_tauri/test/tsconfig.json

@@ -0,0 +1,13 @@
+{
+    "compilerOptions": {
+        "moduleResolution": "node",
+        "module": "ESNext",
+        "types": [
+            "node",
+            "@wdio/globals/types",
+            "expect-webdriverio",
+            "@wdio/mocha-framework"
+        ],
+        "target": "es2022"
+    }
+}