Parcourir la source

chore: add tauri database group test (#1924)

* chore: add tauri database group test

* chore: add more tests

* chore: enable run all tests

* chore: rename test folder
Nathan.fooo il y a 2 ans
Parent
commit
7e7cee4bf4
32 fichiers modifiés avec 1187 ajouts et 581 suppressions
  1. 2 2
      frontend/appflowy_flutter/lib/plugins/database_view/application/database_service.dart
  2. 1 1
      frontend/appflowy_flutter/lib/plugins/database_view/application/defines.dart
  3. 1 1
      frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_service.dart
  4. 1 1
      frontend/appflowy_flutter/lib/plugins/database_view/application/filter/filter_service.dart
  5. 1 1
      frontend/appflowy_flutter/lib/plugins/database_view/application/row/row_service.dart
  6. 1 1
      frontend/appflowy_flutter/lib/plugins/database_view/application/setting/setting_service.dart
  7. 1 1
      frontend/appflowy_flutter/lib/plugins/database_view/application/sort/sort_service.dart
  8. 1 25
      frontend/appflowy_flutter/lib/plugins/database_view/board/application/group_controller.dart
  9. 1 1
      frontend/appflowy_tauri/src/appflowy_app/App.tsx
  10. 0 334
      frontend/appflowy_tauri/src/appflowy_app/components/TestApiButton/TestGrid.tsx
  11. 44 1
      frontend/appflowy_tauri/src/appflowy_app/components/tests/DatabaseTestHelper.ts
  12. 18 1
      frontend/appflowy_tauri/src/appflowy_app/components/tests/TestAPI.tsx
  13. 0 0
      frontend/appflowy_tauri/src/appflowy_app/components/tests/TestApiButton.tsx
  14. 349 0
      frontend/appflowy_tauri/src/appflowy_app/components/tests/TestGrid.tsx
  15. 150 0
      frontend/appflowy_tauri/src/appflowy_app/components/tests/TestGroup.tsx
  16. 44 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/database_bd_svc.ts
  17. 113 18
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/database_controller.ts
  18. 8 8
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_controller.ts
  19. 6 8
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_observer.ts
  20. 149 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/group/group_controller.ts
  21. 58 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/group/group_observer.ts
  22. 5 3
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/row/row_cache.ts
  23. 8 8
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/view/database_view_cache.ts
  24. 181 0
      frontend/rust-lib/flowy-database/src/entities/database_entities.rs
  25. 0 155
      frontend/rust-lib/flowy-database/src/entities/grid_entities.rs
  26. 1 2
      frontend/rust-lib/flowy-database/src/entities/mod.rs
  27. 11 0
      frontend/rust-lib/flowy-database/src/event_handler.rs
  28. 6 2
      frontend/rust-lib/flowy-database/src/event_map.rs
  29. 4 4
      frontend/rust-lib/flowy-database/src/manager.rs
  30. 5 0
      frontend/rust-lib/flowy-database/src/services/database/database_editor.rs
  31. 10 2
      frontend/rust-lib/flowy-database/src/services/database_view/editor.rs
  32. 7 1
      frontend/rust-lib/flowy-database/src/services/database_view/editor_manager.rs

+ 2 - 2
frontend/appflowy_flutter/lib/plugins/database_view/application/database_service.dart

@@ -1,9 +1,9 @@
+import 'package:appflowy_backend/protobuf/flowy-database/database_entities.pb.dart';
 import 'package:dartz/dartz.dart';
 import 'package:appflowy_backend/dispatch/dispatch.dart';
 import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/field_entities.pb.dart';
-import 'package:appflowy_backend/protobuf/flowy-database/grid_entities.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/group.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/row_entities.pb.dart';
 
@@ -60,6 +60,6 @@ class DatabaseBackendService {
 
   Future<Either<RepeatedGroupPB, FlowyError>> loadGroups() {
     final payload = DatabaseViewIdPB(value: viewId);
-    return DatabaseEventGetGroup(payload).send();
+    return DatabaseEventGetGroups(payload).send();
   }
 }

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/database_view/application/defines.dart

@@ -1,6 +1,6 @@
 import 'dart:collection';
 
-import 'package:appflowy_backend/protobuf/flowy-database/grid_entities.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-database/database_entities.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
 
 import '../grid/presentation/widgets/filter/filter_info.dart';

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_service.dart

@@ -1,8 +1,8 @@
+import 'package:appflowy_backend/protobuf/flowy-database/database_entities.pb.dart';
 import 'package:dartz/dartz.dart';
 import 'package:appflowy_backend/dispatch/dispatch.dart';
 import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/field_entities.pb.dart';
-import 'package:appflowy_backend/protobuf/flowy-database/grid_entities.pb.dart';
 import 'package:freezed_annotation/freezed_annotation.dart';
 
 part 'field_service.freezed.dart';

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/database_view/application/filter/filter_service.dart

@@ -1,3 +1,4 @@
+import 'package:appflowy_backend/protobuf/flowy-database/database_entities.pb.dart';
 import 'package:dartz/dartz.dart';
 import 'package:appflowy_backend/dispatch/dispatch.dart';
 import 'package:appflowy_backend/log.dart';
@@ -6,7 +7,6 @@ import 'package:appflowy_backend/protobuf/flowy-database/checkbox_filter.pbserve
 import 'package:appflowy_backend/protobuf/flowy-database/checklist_filter.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/date_filter.pbserver.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/field_entities.pb.dart';
-import 'package:appflowy_backend/protobuf/flowy-database/grid_entities.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/number_filter.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/select_option_filter.pbserver.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/setting_entities.pb.dart';

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/database_view/application/row/row_service.dart

@@ -1,7 +1,7 @@
+import 'package:appflowy_backend/protobuf/flowy-database/database_entities.pb.dart';
 import 'package:dartz/dartz.dart';
 import 'package:appflowy_backend/dispatch/dispatch.dart';
 import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
-import 'package:appflowy_backend/protobuf/flowy-database/grid_entities.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/group_changeset.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/row_entities.pb.dart';
 

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/database_view/application/setting/setting_service.dart

@@ -1,8 +1,8 @@
+import 'package:appflowy_backend/protobuf/flowy-database/database_entities.pb.dart';
 import 'package:dartz/dartz.dart';
 import 'package:appflowy_backend/dispatch/dispatch.dart';
 import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/field_entities.pb.dart';
-import 'package:appflowy_backend/protobuf/flowy-database/grid_entities.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/group.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/setting_entities.pb.dart';
 

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/database_view/application/sort/sort_service.dart

@@ -1,9 +1,9 @@
+import 'package:appflowy_backend/protobuf/flowy-database/database_entities.pb.dart';
 import 'package:dartz/dartz.dart';
 import 'package:appflowy_backend/dispatch/dispatch.dart';
 import 'package:appflowy_backend/log.dart';
 import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/field_entities.pb.dart';
-import 'package:appflowy_backend/protobuf/flowy-database/grid_entities.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/setting_entities.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/sort_entities.pb.dart';
 

+ 1 - 25
frontend/appflowy_flutter/lib/plugins/database_view/board/application/group_controller.dart

@@ -68,9 +68,8 @@ class GroupController {
 
             if (index != -1) {
               group.rows[index] = updatedRow;
+              delegate.updateRow(group, updatedRow);
             }
-
-            delegate.updateRow(group, updatedRow);
           }
         },
         (err) => Log.error(err),
@@ -78,29 +77,6 @@ class GroupController {
     });
   }
 
-  // GroupChangesetPB _transformChangeset(GroupChangesetPB changeset) {
-  //   final insertedRows = changeset.insertedRows
-  //       .where(
-  //         (delete) => !changeset.deletedRows.contains(delete.row.id),
-  //       )
-  //       .toList();
-
-  //   final deletedRows = changeset.deletedRows
-  //       .where((deletedRowId) =>
-  //           changeset.insertedRows
-  //               .indexWhere((insert) => insert.row.id == deletedRowId) ==
-  //           -1)
-  //       .toList();
-
-  //   return changeset.rebuild((rebuildChangeset) {
-  //     rebuildChangeset.insertedRows.clear();
-  //     rebuildChangeset.insertedRows.addAll(insertedRows);
-
-  //     rebuildChangeset.deletedRows.clear();
-  //     rebuildChangeset.deletedRows.addAll(deletedRows);
-  //   });
-  // }
-
   Future<void> dispose() async {
     _listener.stop();
   }

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/App.tsx

@@ -12,7 +12,7 @@ import { SignUpPage } from './views/SignUpPage';
 import { ConfirmAccountPage } from './views/ConfirmAccountPage';
 import { ErrorHandlerPage } from './components/error/ErrorHandlerPage';
 import initializeI18n from './stores/i18n/initializeI18n';
-import { TestAPI } from './components/TestApiButton/TestAPI';
+import { TestAPI } from './components/tests/TestAPI';
 import { GetStarted } from './components/auth/GetStarted/GetStarted';
 
 initializeI18n();

+ 0 - 334
frontend/appflowy_tauri/src/appflowy_app/components/TestApiButton/TestGrid.tsx

@@ -1,334 +0,0 @@
-import React from 'react';
-import {
-  FieldType,
-  NumberFormat,
-  NumberTypeOptionPB,
-  SelectOptionCellDataPB,
-  SingleSelectTypeOptionPB,
-  ViewLayoutTypePB,
-} from '../../../services/backend';
-import { Log } from '../../utils/log';
-import {
-  assertFieldName,
-  assertNumberOfFields,
-  assertNumberOfRows,
-  assertTextCell,
-  createTestDatabaseView,
-  editTextCell,
-  findFirstFieldInfoWithFieldType,
-  makeMultiSelectCellController,
-  makeSingleSelectCellController,
-  makeTextCellController,
-  openTestDatabase,
-} from './DatabaseTestHelper';
-import {
-  SelectOptionBackendService,
-  SelectOptionCellBackendService,
-} from '../../stores/effects/database/cell/select_option_bd_svc';
-import { TypeOptionController } from '../../stores/effects/database/field/type_option/type_option_controller';
-import { None, Some } from 'ts-results';
-import { RowBackendService } from '../../stores/effects/database/row/row_bd_svc';
-import {
-  makeNumberTypeOptionContext,
-  makeSingleSelectTypeOptionContext,
-} from '../../stores/effects/database/field/type_option/type_option_context';
-
-export const TestCreateGrid = () => {
-  async function createBuildInGrid() {
-    const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
-    const databaseController = await openTestDatabase(view.id);
-    databaseController.subscribe({
-      onViewChanged: (databasePB) => {
-        Log.debug('Did receive database:' + databasePB);
-      },
-      // onRowsChanged: async (rows) => {
-      //   if (rows.length !== 3) {
-      //     throw Error('Expected number of rows is 3, but receive ' + rows.length);
-      //   }
-      // },
-      onFieldsChanged: (fields) => {
-        if (fields.length !== 3) {
-          throw Error('Expected number of fields is 3, but receive ' + fields.length);
-        }
-      },
-    });
-    await databaseController.open().then((result) => result.unwrap());
-    await databaseController.dispose();
-  }
-
-  return TestButton('Test create build-in grid', createBuildInGrid);
-};
-
-export const TestEditCell = () => {
-  async function testGridRow() {
-    const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
-    const databaseController = await openTestDatabase(view.id);
-    await databaseController.open().then((result) => result.unwrap());
-
-    for (const [index, row] of databaseController.databaseViewCache.rowInfos.entries()) {
-      const cellContent = index.toString();
-      const fieldInfo = findFirstFieldInfoWithFieldType(row, FieldType.RichText).unwrap();
-      await editTextCell(fieldInfo.field.id, row, databaseController, cellContent);
-      await assertTextCell(fieldInfo.field.id, row, databaseController, cellContent);
-    }
-  }
-
-  return TestButton('Test editing cell', testGridRow);
-};
-
-export const TestCreateRow = () => {
-  async function testCreateRow() {
-    const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
-    const databaseController = await openTestDatabase(view.id);
-    await databaseController.open().then((result) => result.unwrap());
-    await assertNumberOfRows(view.id, 3);
-
-    // Create a row from a DatabaseController or create using the RowBackendService
-    await databaseController.createRow();
-    await assertNumberOfRows(view.id, 4);
-    await databaseController.dispose();
-  }
-
-  return TestButton('Test create row', testCreateRow);
-};
-export const TestDeleteRow = () => {
-  async function testDeleteRow() {
-    const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
-    const databaseController = await openTestDatabase(view.id);
-    await databaseController.open().then((result) => result.unwrap());
-
-    const rows = databaseController.databaseViewCache.rowInfos;
-    const svc = new RowBackendService(view.id);
-    await svc.deleteRow(rows[0].row.id);
-    await assertNumberOfRows(view.id, 2);
-
-    // Wait the databaseViewCache get the change notification and
-    // update the rows.
-    await new Promise((resolve) => setTimeout(resolve, 200));
-    if (databaseController.databaseViewCache.rowInfos.length !== 2) {
-      throw Error('The number of rows is not match');
-    }
-    await databaseController.dispose();
-  }
-
-  return TestButton('Test delete row', testDeleteRow);
-};
-export const TestCreateSelectOptionInCell = () => {
-  async function testCreateOptionInCell() {
-    const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
-    const databaseController = await openTestDatabase(view.id);
-    await databaseController.open().then((result) => result.unwrap());
-    for (const [index, row] of databaseController.databaseViewCache.rowInfos.entries()) {
-      if (index === 0) {
-        const fieldInfo = findFirstFieldInfoWithFieldType(row, FieldType.SingleSelect).unwrap();
-        const cellController = await makeSingleSelectCellController(fieldInfo.field.id, row, databaseController).then(
-          (result) => result.unwrap()
-        );
-        await cellController.subscribeChanged({
-          onCellChanged: (value) => {
-            if (value.some) {
-              const option: SelectOptionCellDataPB = value.unwrap();
-              console.log(option);
-            }
-          },
-        });
-        const backendSvc = new SelectOptionCellBackendService(cellController.cellIdentifier);
-        await backendSvc.createOption({ name: 'option' + index });
-        await cellController.dispose();
-      }
-    }
-    await databaseController.dispose();
-  }
-
-  return TestButton('Test create a select option in cell', testCreateOptionInCell);
-};
-
-export const TestGetSingleSelectFieldData = () => {
-  async function testGetSingleSelectFieldData() {
-    const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
-    const databaseController = await openTestDatabase(view.id);
-    await databaseController.open().then((result) => result.unwrap());
-
-    // Find the single select column
-    const singleSelect = databaseController.fieldController.fieldInfos.find(
-      (fieldInfo) => fieldInfo.field.field_type === FieldType.SingleSelect
-    )!;
-    const typeOptionController = new TypeOptionController(view.id, Some(singleSelect));
-    const singleSelectTypeOptionContext = makeSingleSelectTypeOptionContext(typeOptionController);
-
-    // Create options
-    const singleSelectTypeOptionPB: SingleSelectTypeOptionPB = await singleSelectTypeOptionContext
-      .getTypeOption()
-      .then((result) => result.unwrap());
-    const backendSvc = new SelectOptionBackendService(view.id, singleSelect.field.id);
-    const option1 = await backendSvc.createOption({ name: 'Task 1' }).then((result) => result.unwrap());
-    singleSelectTypeOptionPB.options.splice(0, 0, option1);
-    const option2 = await backendSvc.createOption({ name: 'Task 2' }).then((result) => result.unwrap());
-    singleSelectTypeOptionPB.options.splice(0, 0, option2);
-    const option3 = await backendSvc.createOption({ name: 'Task 3' }).then((result) => result.unwrap());
-    singleSelectTypeOptionPB.options.splice(0, 0, option3);
-    await singleSelectTypeOptionContext.setTypeOption(singleSelectTypeOptionPB);
-
-    // Read options
-    const options = singleSelectTypeOptionPB.options;
-    console.log(options);
-
-    await databaseController.dispose();
-  }
-
-  return TestButton('Test get single-select column data', testGetSingleSelectFieldData);
-};
-
-export const TestSwitchFromSingleSelectToNumber = () => {
-  async function testSwitchFromSingleSelectToNumber() {
-    const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
-    const databaseController = await openTestDatabase(view.id);
-    await databaseController.open().then((result) => result.unwrap());
-
-    // Find the single select column
-    const singleSelect = databaseController.fieldController.fieldInfos.find(
-      (fieldInfo) => fieldInfo.field.field_type === FieldType.SingleSelect
-    )!;
-    const typeOptionController = new TypeOptionController(view.id, Some(singleSelect));
-    await typeOptionController.switchToField(FieldType.Number);
-
-    // Check the number type option
-    const numberTypeOptionContext = makeNumberTypeOptionContext(typeOptionController);
-    const numberTypeOption: NumberTypeOptionPB = await numberTypeOptionContext
-      .getTypeOption()
-      .then((result) => result.unwrap());
-    const format: NumberFormat = numberTypeOption.format;
-    if (format !== NumberFormat.Num) {
-      throw Error('The default format should be number');
-    }
-
-    await databaseController.dispose();
-  }
-
-  return TestButton('Test switch from single-select to number column', testSwitchFromSingleSelectToNumber);
-};
-
-export const TestSwitchFromMultiSelectToText = () => {
-  async function testSwitchFromMultiSelectToRichText() {
-    const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
-    const databaseController = await openTestDatabase(view.id);
-    await databaseController.open().then((result) => result.unwrap());
-
-    // Create multi-select field
-    const typeOptionController = new TypeOptionController(view.id, None, FieldType.MultiSelect);
-    await typeOptionController.initialize();
-
-    // Insert options to first row
-    const row = databaseController.databaseViewCache.rowInfos[0];
-    const multiSelectField = typeOptionController.getFieldInfo();
-    // const multiSelectField = findFirstFieldInfoWithFieldType(row, FieldType.MultiSelect).unwrap();
-    const selectOptionCellController = await makeMultiSelectCellController(
-      multiSelectField.field.id,
-      row,
-      databaseController
-    ).then((result) => result.unwrap());
-    const backendSvc = new SelectOptionCellBackendService(selectOptionCellController.cellIdentifier);
-    await backendSvc.createOption({ name: 'A' });
-    await backendSvc.createOption({ name: 'B' });
-    await backendSvc.createOption({ name: 'C' });
-
-    const selectOptionCellData = await selectOptionCellController.getCellData().then((result) => result.unwrap());
-    if (selectOptionCellData.options.length !== 3) {
-      throw Error('The options should equal to 3');
-    }
-
-    if (selectOptionCellData.select_options.length !== 3) {
-      throw Error('The selected options should equal to 3');
-    }
-    await selectOptionCellController.dispose();
-
-    // Switch to RichText field type
-    await typeOptionController.switchToField(FieldType.RichText).then((result) => result.unwrap());
-    if (typeOptionController.fieldType !== FieldType.RichText) {
-      throw Error('The field type should be text');
-    }
-
-    const textCellController = await makeTextCellController(multiSelectField.field.id, row, databaseController).then(
-      (result) => result.unwrap()
-    );
-    const cellContent = await textCellController.getCellData();
-    if (cellContent.unwrap() !== 'A,B,C') {
-      throw Error('The cell content should be A,B,C, but receive: ' + cellContent.unwrap());
-    }
-
-    await databaseController.dispose();
-  }
-
-  return TestButton('Test switch from multi-select to text column', testSwitchFromMultiSelectToRichText);
-};
-
-export const TestEditField = () => {
-  async function testEditField() {
-    const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
-    const databaseController = await openTestDatabase(view.id);
-    await databaseController.open().then((result) => result.unwrap());
-    const fieldInfos = databaseController.fieldController.fieldInfos;
-
-    // Modify the name of the field
-    const firstFieldInfo = fieldInfos[0];
-    const controller = new TypeOptionController(view.id, Some(firstFieldInfo));
-    await controller.initialize();
-    const newName = 'hello world';
-    await controller.setFieldName(newName);
-
-    await assertFieldName(view.id, firstFieldInfo.field.id, firstFieldInfo.field.field_type, newName);
-    await databaseController.dispose();
-  }
-
-  return TestButton('Test edit the column name', testEditField);
-};
-
-export const TestCreateNewField = () => {
-  async function testCreateNewField() {
-    const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
-    const databaseController = await openTestDatabase(view.id);
-    await databaseController.open().then((result) => result.unwrap());
-    await assertNumberOfFields(view.id, 3);
-
-    // Modify the name of the field
-    const controller = new TypeOptionController(view.id, None);
-    await controller.initialize();
-    await assertNumberOfFields(view.id, 4);
-    await databaseController.dispose();
-  }
-
-  return TestButton('Test create a new column', testCreateNewField);
-};
-
-export const TestDeleteField = () => {
-  async function testDeleteField() {
-    const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
-    const databaseController = await openTestDatabase(view.id);
-    await databaseController.open().then((result) => result.unwrap());
-
-    // Modify the name of the field.
-    // The fieldInfos[0] is the primary field by default, we can't delete it.
-    // So let choose the second fieldInfo.
-    const fieldInfo = databaseController.fieldController.fieldInfos[1];
-    const controller = new TypeOptionController(view.id, Some(fieldInfo));
-    await controller.initialize();
-    await assertNumberOfFields(view.id, 3);
-    await controller.deleteField();
-    await assertNumberOfFields(view.id, 2);
-    await databaseController.dispose();
-  }
-
-  return TestButton('Test delete a new column', testDeleteField);
-};
-
-const TestButton = (title: string, onClick: () => void) => {
-  return (
-    <React.Fragment>
-      <div>
-        <button className='rounded-md bg-gray-300 p-4' type='button' onClick={() => onClick()}>
-          {title}
-        </button>
-      </div>
-    </React.Fragment>
-  );
-};

+ 44 - 1
frontend/appflowy_tauri/src/appflowy_app/components/TestApiButton/DatabaseTestHelper.ts → frontend/appflowy_tauri/src/appflowy_app/components/tests/DatabaseTestHelper.ts

@@ -1,4 +1,10 @@
-import { FieldType, ViewLayoutTypePB, ViewPB, WorkspaceSettingPB } from '../../../services/backend';
+import {
+  FieldType,
+  SingleSelectTypeOptionPB,
+  ViewLayoutTypePB,
+  ViewPB,
+  WorkspaceSettingPB,
+} from '../../../services/backend';
 import { FolderEventReadCurrentWorkspace } from '../../../services/backend/events/flowy-folder';
 import { AppBackendService } from '../../stores/effects/folder/app/app_bd_svc';
 import { DatabaseController } from '../../stores/effects/database/database_controller';
@@ -14,6 +20,10 @@ import {
 import { None, Option, Some } from 'ts-results';
 import { TypeOptionBackendService } from '../../stores/effects/database/field/type_option/type_option_bd_svc';
 import { DatabaseBackendService } from '../../stores/effects/database/database_bd_svc';
+import { FieldInfo } from '../../stores/effects/database/field/field_controller';
+import { TypeOptionController } from '../../stores/effects/database/field/type_option/type_option_controller';
+import { makeSingleSelectTypeOptionContext } from '../../stores/effects/database/field/type_option/type_option_context';
+import { SelectOptionBackendService } from '../../stores/effects/database/cell/select_option_bd_svc';
 
 // Create a database view for specific layout type
 // Do not use it production code. Just for testing
@@ -168,3 +178,36 @@ export async function assertNumberOfRows(viewId: string, expected: number) {
     throw Error('Expect number of rows:' + expected + 'but receive:' + databasePB.rows.length);
   }
 }
+
+export async function assertNumberOfRowsInGroup(viewId: string, groupId: string, expected: number) {
+  const svc = new DatabaseBackendService(viewId);
+  await svc.openDatabase();
+
+  const group = await svc.getGroup(groupId).then((result) => result.unwrap());
+  if (group.rows.length !== expected) {
+    throw Error('Expect number of rows in group:' + expected + 'but receive:' + group.rows.length);
+  }
+}
+
+export async function createSingleSelectOptions(viewId: string, fieldInfo: FieldInfo, optionNames: string[]) {
+  assert(fieldInfo.field.field_type === FieldType.SingleSelect, 'Only work on single select');
+  const typeOptionController = new TypeOptionController(viewId, Some(fieldInfo));
+  const singleSelectTypeOptionContext = makeSingleSelectTypeOptionContext(typeOptionController);
+  const singleSelectTypeOptionPB: SingleSelectTypeOptionPB = await singleSelectTypeOptionContext
+    .getTypeOption()
+    .then((result) => result.unwrap());
+
+  const backendSvc = new SelectOptionBackendService(viewId, fieldInfo.field.id);
+  for (const optionName of optionNames) {
+    const option = await backendSvc.createOption({ name: optionName }).then((result) => result.unwrap());
+    singleSelectTypeOptionPB.options.splice(0, 0, option);
+  }
+  await singleSelectTypeOptionContext.setTypeOption(singleSelectTypeOptionPB);
+  return singleSelectTypeOptionContext;
+}
+
+export function assert(condition: boolean, msg?: string) {
+  if (!condition) {
+    throw Error(msg);
+  }
+}

+ 18 - 1
frontend/appflowy_tauri/src/appflowy_app/components/TestApiButton/TestAPI.tsx → frontend/appflowy_tauri/src/appflowy_app/components/tests/TestAPI.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import {
+  RunAllGridTests,
   TestCreateGrid,
   TestCreateNewField,
   TestCreateRow,
@@ -12,12 +13,21 @@ import {
   TestSwitchFromMultiSelectToText,
   TestSwitchFromSingleSelectToNumber,
 } from './TestGrid';
+import {
+  TestCreateKanbanBoard,
+  TestCreateKanbanBoardColumn,
+  TestCreateKanbanBoardRowInNoStatusGroup,
+  TestAllKanbanTests,
+  TestMoveKanbanBoardColumn,
+  TestMoveKanbanBoardRow,
+} from './TestGroup';
 
 export const TestAPI = () => {
   return (
     <React.Fragment>
       <ul className='m-6, space-y-2'>
-        {/*<TestApiButton></TestApiButton>*/}
+        {/*<tests></tests>*/}
+        <RunAllGridTests></RunAllGridTests>
         <TestCreateGrid></TestCreateGrid>
         <TestCreateRow></TestCreateRow>
         <TestDeleteRow></TestDeleteRow>
@@ -29,6 +39,13 @@ export const TestAPI = () => {
         <TestDeleteField></TestDeleteField>
         <TestSwitchFromSingleSelectToNumber></TestSwitchFromSingleSelectToNumber>
         <TestSwitchFromMultiSelectToText></TestSwitchFromMultiSelectToText>
+        {/*kanban board */}
+        <TestAllKanbanTests></TestAllKanbanTests>
+        <TestCreateKanbanBoard></TestCreateKanbanBoard>
+        <TestCreateKanbanBoardRowInNoStatusGroup></TestCreateKanbanBoardRowInNoStatusGroup>
+        <TestMoveKanbanBoardRow></TestMoveKanbanBoardRow>
+        <TestMoveKanbanBoardColumn></TestMoveKanbanBoardColumn>
+        <TestCreateKanbanBoardColumn></TestCreateKanbanBoardColumn>
       </ul>
     </React.Fragment>
   );

+ 0 - 0
frontend/appflowy_tauri/src/appflowy_app/components/TestApiButton/TestApiButton.tsx → frontend/appflowy_tauri/src/appflowy_app/components/tests/TestApiButton.tsx


+ 349 - 0
frontend/appflowy_tauri/src/appflowy_app/components/tests/TestGrid.tsx

@@ -0,0 +1,349 @@
+import React from 'react';
+import {
+  FieldType,
+  NumberFormat,
+  NumberTypeOptionPB,
+  SelectOptionCellDataPB,
+  ViewLayoutTypePB,
+} from '../../../services/backend';
+import { Log } from '../../utils/log';
+import {
+  assertFieldName,
+  assertNumberOfFields,
+  assertNumberOfRows,
+  assertTextCell,
+  createSingleSelectOptions,
+  createTestDatabaseView,
+  editTextCell,
+  findFirstFieldInfoWithFieldType,
+  makeMultiSelectCellController,
+  makeSingleSelectCellController,
+  makeTextCellController,
+  openTestDatabase,
+} from './DatabaseTestHelper';
+import { SelectOptionCellBackendService } from '../../stores/effects/database/cell/select_option_bd_svc';
+import { TypeOptionController } from '../../stores/effects/database/field/type_option/type_option_controller';
+import { None, Some } from 'ts-results';
+import { RowBackendService } from '../../stores/effects/database/row/row_bd_svc';
+import { makeNumberTypeOptionContext } from '../../stores/effects/database/field/type_option/type_option_context';
+
+export const RunAllGridTests = () => {
+  async function run() {
+    await createBuildInGrid();
+    await testEditGridRow();
+    await testCreateRow();
+    await testDeleteRow();
+    await testCreateOptionInCell();
+    await testGetSingleSelectFieldData();
+    await testSwitchFromSingleSelectToNumber();
+    await testSwitchFromMultiSelectToRichText();
+    await testEditField();
+    await testCreateNewField();
+    await testDeleteField();
+  }
+
+  return (
+    <React.Fragment>
+      <div>
+        <button className='rounded-md bg-red-400 p-4' type='button' onClick={() => run()}>
+          Run all grid tests
+        </button>
+      </div>
+    </React.Fragment>
+  );
+};
+
+async function createBuildInGrid() {
+  const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
+  const databaseController = await openTestDatabase(view.id);
+  databaseController.subscribe({
+    onViewChanged: (databasePB) => {
+      Log.debug('Did receive database:' + databasePB);
+    },
+    // onRowsChanged: async (rows) => {
+    //   if (rows.length !== 3) {
+    //     throw Error('Expected number of rows is 3, but receive ' + rows.length);
+    //   }
+    // },
+    onFieldsChanged: (fields) => {
+      if (fields.length !== 3) {
+        throw Error('Expected number of fields is 3, but receive ' + fields.length);
+      }
+    },
+  });
+  await databaseController.open().then((result) => result.unwrap());
+  await databaseController.dispose();
+}
+
+async function testEditGridRow() {
+  const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
+  const databaseController = await openTestDatabase(view.id);
+  await databaseController.open().then((result) => result.unwrap());
+
+  for (const [index, row] of databaseController.databaseViewCache.rowInfos.entries()) {
+    const cellContent = index.toString();
+    const fieldInfo = findFirstFieldInfoWithFieldType(row, FieldType.RichText).unwrap();
+    await editTextCell(fieldInfo.field.id, row, databaseController, cellContent);
+    await assertTextCell(fieldInfo.field.id, row, databaseController, cellContent);
+  }
+}
+
+async function testCreateRow() {
+  const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
+  const databaseController = await openTestDatabase(view.id);
+  await databaseController.open().then((result) => result.unwrap());
+  await assertNumberOfRows(view.id, 3);
+
+  // Create a row from a DatabaseController or create using the RowBackendService
+  await databaseController.createRow();
+  await assertNumberOfRows(view.id, 4);
+  await databaseController.dispose();
+}
+
+async function testDeleteRow() {
+  const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
+  const databaseController = await openTestDatabase(view.id);
+  await databaseController.open().then((result) => result.unwrap());
+
+  const rows = databaseController.databaseViewCache.rowInfos;
+  const svc = new RowBackendService(view.id);
+  await svc.deleteRow(rows[0].row.id);
+  await assertNumberOfRows(view.id, 2);
+
+  // Wait the databaseViewCache get the change notification and
+  // update the rows.
+  await new Promise((resolve) => setTimeout(resolve, 200));
+  if (databaseController.databaseViewCache.rowInfos.length !== 2) {
+    throw Error('The number of rows is not match');
+  }
+  await databaseController.dispose();
+}
+
+async function testCreateOptionInCell() {
+  const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
+  const databaseController = await openTestDatabase(view.id);
+  await databaseController.open().then((result) => result.unwrap());
+  for (const [index, row] of databaseController.databaseViewCache.rowInfos.entries()) {
+    if (index === 0) {
+      const fieldInfo = findFirstFieldInfoWithFieldType(row, FieldType.SingleSelect).unwrap();
+      const cellController = await makeSingleSelectCellController(fieldInfo.field.id, row, databaseController).then(
+        (result) => result.unwrap()
+      );
+      await cellController.subscribeChanged({
+        onCellChanged: (value) => {
+          if (value.some) {
+            const option: SelectOptionCellDataPB = value.unwrap();
+            console.log(option);
+          }
+        },
+      });
+      const backendSvc = new SelectOptionCellBackendService(cellController.cellIdentifier);
+      await backendSvc.createOption({ name: 'option' + index });
+      await cellController.dispose();
+    }
+  }
+  await databaseController.dispose();
+}
+
+async function testGetSingleSelectFieldData() {
+  const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
+  const databaseController = await openTestDatabase(view.id);
+  await databaseController.open().then((result) => result.unwrap());
+
+  // Find the single select column
+  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  const singleSelect = databaseController.fieldController.fieldInfos.find(
+    (fieldInfo) => fieldInfo.field.field_type === FieldType.SingleSelect
+  )!;
+
+  // Create options
+  const singleSelectTypeOptionContext = await createSingleSelectOptions(view.id, singleSelect, [
+    'Task 1',
+    'Task 2',
+    'Task 3',
+  ]);
+
+  // Read options
+  const options = await singleSelectTypeOptionContext.getTypeOption().then((result) => result.unwrap());
+  console.log(options);
+
+  await databaseController.dispose();
+}
+
+async function testSwitchFromSingleSelectToNumber() {
+  const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
+  const databaseController = await openTestDatabase(view.id);
+  await databaseController.open().then((result) => result.unwrap());
+
+  // Find the single select column
+  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  const singleSelect = databaseController.fieldController.fieldInfos.find(
+    (fieldInfo) => fieldInfo.field.field_type === FieldType.SingleSelect
+  )!;
+  const typeOptionController = new TypeOptionController(view.id, Some(singleSelect));
+  await typeOptionController.switchToField(FieldType.Number);
+
+  // Check the number type option
+  const numberTypeOptionContext = makeNumberTypeOptionContext(typeOptionController);
+  const numberTypeOption: NumberTypeOptionPB = await numberTypeOptionContext
+    .getTypeOption()
+    .then((result) => result.unwrap());
+  const format: NumberFormat = numberTypeOption.format;
+  if (format !== NumberFormat.Num) {
+    throw Error('The default format should be number');
+  }
+
+  await databaseController.dispose();
+}
+
+async function testSwitchFromMultiSelectToRichText() {
+  const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
+  const databaseController = await openTestDatabase(view.id);
+  await databaseController.open().then((result) => result.unwrap());
+
+  // Create multi-select field
+  const typeOptionController = new TypeOptionController(view.id, None, FieldType.MultiSelect);
+  await typeOptionController.initialize();
+
+  // Insert options to first row
+  const row = databaseController.databaseViewCache.rowInfos[0];
+  const multiSelectField = typeOptionController.getFieldInfo();
+  // const multiSelectField = findFirstFieldInfoWithFieldType(row, FieldType.MultiSelect).unwrap();
+  const selectOptionCellController = await makeMultiSelectCellController(
+    multiSelectField.field.id,
+    row,
+    databaseController
+  ).then((result) => result.unwrap());
+  const backendSvc = new SelectOptionCellBackendService(selectOptionCellController.cellIdentifier);
+  await backendSvc.createOption({ name: 'A' });
+  await backendSvc.createOption({ name: 'B' });
+  await backendSvc.createOption({ name: 'C' });
+
+  const selectOptionCellData = await selectOptionCellController.getCellData().then((result) => result.unwrap());
+  if (selectOptionCellData.options.length !== 3) {
+    throw Error('The options should equal to 3');
+  }
+
+  if (selectOptionCellData.select_options.length !== 3) {
+    throw Error('The selected options should equal to 3');
+  }
+  await selectOptionCellController.dispose();
+
+  // Switch to RichText field type
+  await typeOptionController.switchToField(FieldType.RichText).then((result) => result.unwrap());
+  if (typeOptionController.fieldType !== FieldType.RichText) {
+    throw Error('The field type should be text');
+  }
+
+  const textCellController = await makeTextCellController(multiSelectField.field.id, row, databaseController).then(
+    (result) => result.unwrap()
+  );
+  const cellContent = await textCellController.getCellData();
+  if (cellContent.unwrap() !== 'A,B,C') {
+    throw Error('The cell content should be A,B,C, but receive: ' + cellContent.unwrap());
+  }
+
+  await databaseController.dispose();
+}
+
+async function testEditField() {
+  const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
+  const databaseController = await openTestDatabase(view.id);
+  await databaseController.open().then((result) => result.unwrap());
+  const fieldInfos = databaseController.fieldController.fieldInfos;
+
+  // Modify the name of the field
+  const firstFieldInfo = fieldInfos[0];
+  const controller = new TypeOptionController(view.id, Some(firstFieldInfo));
+  await controller.initialize();
+  const newName = 'hello world';
+  await controller.setFieldName(newName);
+
+  await new Promise((resolve) => setTimeout(resolve, 200));
+  await assertFieldName(view.id, firstFieldInfo.field.id, firstFieldInfo.field.field_type, newName);
+  await databaseController.dispose();
+}
+
+async function testCreateNewField() {
+  const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
+  const databaseController = await openTestDatabase(view.id);
+  await databaseController.open().then((result) => result.unwrap());
+  await assertNumberOfFields(view.id, 3);
+
+  // Modify the name of the field
+  const controller = new TypeOptionController(view.id, None);
+  await controller.initialize();
+  await assertNumberOfFields(view.id, 4);
+  await databaseController.dispose();
+}
+
+async function testDeleteField() {
+  const view = await createTestDatabaseView(ViewLayoutTypePB.Grid);
+  const databaseController = await openTestDatabase(view.id);
+  await databaseController.open().then((result) => result.unwrap());
+
+  // Modify the name of the field.
+  // The fieldInfos[0] is the primary field by default, we can't delete it.
+  // So let choose the second fieldInfo.
+  const fieldInfo = databaseController.fieldController.fieldInfos[1];
+  const controller = new TypeOptionController(view.id, Some(fieldInfo));
+  await controller.initialize();
+  await assertNumberOfFields(view.id, 3);
+  await controller.deleteField();
+  await assertNumberOfFields(view.id, 2);
+  await databaseController.dispose();
+}
+
+export const TestCreateGrid = () => {
+  return TestButton('Test create build-in grid', createBuildInGrid);
+};
+
+export const TestEditCell = () => {
+  return TestButton('Test editing cell', testEditGridRow);
+};
+
+export const TestCreateRow = () => {
+  return TestButton('Test create row', testCreateRow);
+};
+export const TestDeleteRow = () => {
+  return TestButton('Test delete row', testDeleteRow);
+};
+export const TestCreateSelectOptionInCell = () => {
+  return TestButton('Test create a select option in cell', testCreateOptionInCell);
+};
+
+export const TestGetSingleSelectFieldData = () => {
+  return TestButton('Test get single-select column data', testGetSingleSelectFieldData);
+};
+
+export const TestSwitchFromSingleSelectToNumber = () => {
+  return TestButton('Test switch from single-select to number column', testSwitchFromSingleSelectToNumber);
+};
+
+export const TestSwitchFromMultiSelectToText = () => {
+  return TestButton('Test switch from multi-select to text column', testSwitchFromMultiSelectToRichText);
+};
+
+export const TestEditField = () => {
+  return TestButton('Test edit the column name', testEditField);
+};
+
+export const TestCreateNewField = () => {
+  return TestButton('Test create a new column', testCreateNewField);
+};
+
+export const TestDeleteField = () => {
+  return TestButton('Test delete a new column', testDeleteField);
+};
+
+export const TestButton = (title: string, onClick: () => void) => {
+  return (
+    <React.Fragment>
+      <div>
+        <button className='rounded-md bg-blue-400 p-4' type='button' onClick={() => onClick()}>
+          {title}
+        </button>
+      </div>
+    </React.Fragment>
+  );
+};

+ 150 - 0
frontend/appflowy_tauri/src/appflowy_app/components/tests/TestGroup.tsx

@@ -0,0 +1,150 @@
+import {
+  assert,
+  assertNumberOfRowsInGroup,
+  createSingleSelectOptions,
+  createTestDatabaseView,
+  openTestDatabase,
+} from './DatabaseTestHelper';
+import { FieldType, ViewLayoutTypePB } from '../../../services/backend';
+import React from 'react';
+
+export const TestAllKanbanTests = () => {
+  async function run() {
+    await createBuildInBoard();
+    await createKanbanBoardRow();
+    await moveKanbanBoardRow();
+    await createKanbanBoardColumn();
+    await createColumnInBoard();
+  }
+
+  return (
+    <React.Fragment>
+      <div>
+        <button className='rounded-md bg-red-400 p-4' type='button' onClick={() => run()}>
+          Run all kanban board tests
+        </button>
+      </div>
+    </React.Fragment>
+  );
+};
+
+async function createBuildInBoard() {
+  const view = await createTestDatabaseView(ViewLayoutTypePB.Board);
+  const databaseController = await openTestDatabase(view.id);
+  databaseController.subscribe({
+    onGroupByField: (groups) => {
+      console.log(groups);
+      if (groups.length !== 4) {
+        throw Error('The build-in board should have 4 groups');
+      }
+
+      assert(groups[0].rows.length === 0, 'The no status group should have 0 rows');
+      assert(groups[1].rows.length === 3, 'The first group should have 3 rows');
+      assert(groups[2].rows.length === 0, 'The second group should have 0 rows');
+      assert(groups[3].rows.length === 0, 'The third group should have 0 rows');
+    },
+  });
+  await databaseController.open().then((result) => result.unwrap());
+  await databaseController.dispose();
+}
+
+async function createKanbanBoardRow() {
+  const view = await createTestDatabaseView(ViewLayoutTypePB.Board);
+  const databaseController = await openTestDatabase(view.id);
+  await databaseController.open().then((result) => result.unwrap());
+
+  // Create row in no status group
+  const noStatusGroup = databaseController.groups.getValue()[0];
+  await noStatusGroup.createRow().then((result) => result.unwrap());
+  await assertNumberOfRowsInGroup(view.id, noStatusGroup.groupId, 1);
+
+  await databaseController.dispose();
+}
+
+async function moveKanbanBoardRow() {
+  const view = await createTestDatabaseView(ViewLayoutTypePB.Board);
+  const databaseController = await openTestDatabase(view.id);
+  await databaseController.open().then((result) => result.unwrap());
+
+  // Create row in no status group
+  const firstGroup = databaseController.groups.getValue()[1];
+  const secondGroup = databaseController.groups.getValue()[2];
+  const row = firstGroup.rowAtIndex(0).unwrap();
+  await databaseController.moveRow(row.id, secondGroup.groupId);
+
+  assert(firstGroup.rows.length === 2);
+  await assertNumberOfRowsInGroup(view.id, firstGroup.groupId, 2);
+
+  assert(secondGroup.rows.length === 1);
+  await assertNumberOfRowsInGroup(view.id, secondGroup.groupId, 1);
+
+  await databaseController.dispose();
+}
+
+async function createKanbanBoardColumn() {
+  const view = await createTestDatabaseView(ViewLayoutTypePB.Board);
+  const databaseController = await openTestDatabase(view.id);
+  await databaseController.open().then((result) => result.unwrap());
+
+  // Create row in no status group
+  const firstGroup = databaseController.groups.getValue()[1];
+  const secondGroup = databaseController.groups.getValue()[2];
+  await databaseController.moveGroup(firstGroup.groupId, secondGroup.groupId);
+
+  assert(databaseController.groups.getValue()[1].groupId === secondGroup.groupId);
+  assert(databaseController.groups.getValue()[2].groupId === firstGroup.groupId);
+  await databaseController.dispose();
+}
+
+async function createColumnInBoard() {
+  const view = await createTestDatabaseView(ViewLayoutTypePB.Board);
+  const databaseController = await openTestDatabase(view.id);
+  await databaseController.open().then((result) => result.unwrap());
+
+  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  const singleSelect = databaseController.fieldController.fieldInfos.find(
+    (fieldInfo) => fieldInfo.field.field_type === FieldType.SingleSelect
+  )!;
+
+  // Create a option which will cause creating a new group
+  const name = 'New column';
+  await createSingleSelectOptions(view.id, singleSelect, [name]);
+
+  // Wait the backend posting the notification to update the groups
+  await new Promise((resolve) => setTimeout(resolve, 200));
+  assert(databaseController.groups.value.length === 5, 'expect number of groups is 5');
+  assert(databaseController.groups.value[4].name === name, 'expect the last group name is ' + name);
+  await databaseController.dispose();
+}
+
+export const TestCreateKanbanBoard = () => {
+  return TestButton('Test create build-in board', createBuildInBoard);
+};
+
+export const TestCreateKanbanBoardRowInNoStatusGroup = () => {
+  return TestButton('Test create row in build-in kanban board', createKanbanBoardRow);
+};
+
+export const TestMoveKanbanBoardRow = () => {
+  return TestButton('Test move row in build-in kanban board', moveKanbanBoardRow);
+};
+
+export const TestMoveKanbanBoardColumn = () => {
+  return TestButton('Test move column in build-in kanban board', createKanbanBoardColumn);
+};
+
+export const TestCreateKanbanBoardColumn = () => {
+  return TestButton('Test create column in build-in kanban board', createColumnInBoard);
+};
+
+export const TestButton = (title: string, onClick: () => void) => {
+  return (
+    <React.Fragment>
+      <div>
+        <button className='rounded-md bg-yellow-200 p-4' type='button' onClick={() => onClick()}>
+          {title}
+        </button>
+      </div>
+    </React.Fragment>
+  );
+};

+ 44 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/database_bd_svc.ts

@@ -1,7 +1,16 @@
 import {
+  CreateBoardCardPayloadPB,
+  DatabaseEventCreateBoardCard,
   DatabaseEventCreateRow,
   DatabaseEventGetDatabase,
   DatabaseEventGetFields,
+  DatabaseEventGetGroup,
+  DatabaseEventGetGroups,
+  DatabaseEventMoveGroup,
+  DatabaseEventMoveGroupRow,
+  DatabaseGroupIdPB,
+  MoveGroupPayloadPB,
+  MoveGroupRowPayloadPB,
 } from '../../../../services/backend/events/flowy-database';
 import {
   GetFieldPayloadPB,
@@ -37,6 +46,31 @@ export class DatabaseBackendService {
     return DatabaseEventCreateRow(payload);
   };
 
+  createGroupRow = async (groupId: string, startRowId?: string) => {
+    const payload = CreateBoardCardPayloadPB.fromObject({ view_id: this.viewId, group_id: groupId });
+    if (startRowId !== undefined) {
+      payload.start_row_id = startRowId;
+    }
+    return DatabaseEventCreateBoardCard(payload);
+  };
+
+  moveRow = (rowId: string, groupId?: string) => {
+    const payload = MoveGroupRowPayloadPB.fromObject({ view_id: this.viewId, from_row_id: rowId });
+    if (groupId !== undefined) {
+      payload.to_group_id = groupId;
+    }
+    return DatabaseEventMoveGroupRow(payload);
+  };
+
+  moveGroup = (fromGroupId: string, toGroupId: string) => {
+    const payload = MoveGroupPayloadPB.fromObject({
+      view_id: this.viewId,
+      from_group_id: fromGroupId,
+      to_group_id: toGroupId,
+    });
+    return DatabaseEventMoveGroup(payload);
+  };
+
   getFields = async (fieldIds?: FieldIdPB[]) => {
     const payload = GetFieldPayloadPB.fromObject({ view_id: this.viewId });
 
@@ -46,4 +80,14 @@ export class DatabaseBackendService {
 
     return DatabaseEventGetFields(payload).then((result) => result.map((value) => value.items));
   };
+
+  getGroup = (groupId: string) => {
+    const payload = DatabaseGroupIdPB.fromObject({ view_id: this.viewId, group_id: groupId });
+    return DatabaseEventGetGroup(payload);
+  };
+
+  loadGroups = () => {
+    const payload = DatabaseViewIdPB.fromObject({ value: this.viewId });
+    return DatabaseEventGetGroups(payload);
+  };
 }

+ 113 - 18
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/database_controller.ts

@@ -1,55 +1,150 @@
 import { DatabaseBackendService } from './database_bd_svc';
 import { FieldController, FieldInfo } from './field/field_controller';
 import { DatabaseViewCache } from './view/database_view_cache';
-import { DatabasePB } from '../../../../services/backend';
+import { DatabasePB, GroupPB } from '../../../../services/backend';
 import { RowChangedReason, RowInfo } from './row/row_cache';
-import { Err, Ok } from 'ts-results';
+import { Err } from 'ts-results';
+import { DatabaseGroupController } from './group/group_controller';
+import { BehaviorSubject } from 'rxjs';
+import { DatabaseGroupObserver } from './group/group_observer';
+import { Log } from '../../../utils/log';
 
-export type SubscribeCallbacks = {
+export type DatabaseSubscriberCallbacks = {
   onViewChanged?: (data: DatabasePB) => void;
   onRowsChanged?: (rowInfos: readonly RowInfo[], reason: RowChangedReason) => void;
   onFieldsChanged?: (fieldInfos: readonly FieldInfo[]) => void;
+  onGroupByField?: (groups: GroupPB[]) => void;
+
+  onNumOfGroupChanged?: {
+    onUpdateGroup: (value: GroupPB[]) => void;
+    onDeleteGroup: (value: GroupPB[]) => void;
+    onInsertGroup: (value: GroupPB[]) => void;
+  };
 };
 
 export class DatabaseController {
-  private backendService: DatabaseBackendService;
+  private readonly backendService: DatabaseBackendService;
   fieldController: FieldController;
   databaseViewCache: DatabaseViewCache;
-  private _callback?: SubscribeCallbacks;
+  private _callback?: DatabaseSubscriberCallbacks;
+  public groups: BehaviorSubject<DatabaseGroupController[]>;
+  private groupsObserver: DatabaseGroupObserver;
 
   constructor(public readonly viewId: string) {
     this.backendService = new DatabaseBackendService(viewId);
     this.fieldController = new FieldController(viewId);
     this.databaseViewCache = new DatabaseViewCache(viewId, this.fieldController);
+    this.groups = new BehaviorSubject<DatabaseGroupController[]>([]);
+    this.groupsObserver = new DatabaseGroupObserver(viewId);
   }
 
-  subscribe = (callbacks: SubscribeCallbacks) => {
+  subscribe = (callbacks: DatabaseSubscriberCallbacks) => {
     this._callback = callbacks;
-    this.fieldController.subscribeOnNumOfFieldsChanged(callbacks.onFieldsChanged);
-    this.databaseViewCache.getRowCache().subscribeOnRowsChanged((reason) => {
-      this._callback?.onRowsChanged?.(this.databaseViewCache.rowInfos, reason);
+    this.fieldController.subscribe({ onNumOfFieldsChanged: callbacks.onFieldsChanged });
+    this.databaseViewCache.getRowCache().subscribe({
+      onRowsChanged: (reason) => {
+        this._callback?.onRowsChanged?.(this.databaseViewCache.rowInfos, reason);
+      },
     });
   };
 
   open = async () => {
-    const result = await this.backendService.openDatabase();
-    if (result.ok) {
-      const database: DatabasePB = result.val;
-      this._callback?.onViewChanged?.(database);
+    const openDatabaseResult = await this.backendService.openDatabase();
+    if (openDatabaseResult.ok) {
+      const database: DatabasePB = openDatabaseResult.val;
+      await this.databaseViewCache.initialize();
+      await this.fieldController.initialize();
+
+      // subscriptions
+      await this.subscribeOnGroupsChanged();
+
+      // load database initial data
       await this.fieldController.loadFields(database.fields);
-      await this.databaseViewCache.listenOnRowsChanged();
-      await this.fieldController.listenOnFieldChanges();
+      const loadGroupResult = await this.loadGroup();
+
       this.databaseViewCache.initializeWithRows(database.rows);
-      return Ok.EMPTY;
+
+      this._callback?.onViewChanged?.(database);
+      return loadGroupResult;
     } else {
-      return Err(result.val);
+      return Err(openDatabaseResult.val);
     }
   };
 
-  createRow = async () => {
+  createRow = () => {
     return this.backendService.createRow();
   };
 
+  moveRow = (rowId: string, groupId: string) => {
+    return this.backendService.moveRow(rowId, groupId);
+  };
+
+  moveGroup = (fromGroupId: string, toGroupId: string) => {
+    return this.backendService.moveGroup(fromGroupId, toGroupId);
+  };
+
+  private loadGroup = async () => {
+    const result = await this.backendService.loadGroups();
+    if (result.ok) {
+      const groups = result.val.items;
+      await this.initialGroups(groups);
+    }
+    return result;
+  };
+
+  private initialGroups = async (groups: GroupPB[]) => {
+    this.groups.getValue().forEach((controller) => {
+      void controller.dispose();
+    });
+
+    const controllers: DatabaseGroupController[] = [];
+    for (const groupPB of groups) {
+      const controller = new DatabaseGroupController(groupPB, this.backendService);
+      await controller.initialize();
+      controllers.push(controller);
+    }
+    this.groups.next(controllers);
+    this.groups.value;
+  };
+
+  private subscribeOnGroupsChanged = async () => {
+    await this.groupsObserver.subscribe({
+      onGroupBy: async (result) => {
+        if (result.ok) {
+          await this.initialGroups(result.val);
+        }
+      },
+      onGroupChangeset: (result) => {
+        if (result.err) {
+          Log.error(result.val);
+          return;
+        }
+        const changeset = result.val;
+        let existControllers = [...this.groups.getValue()];
+        for (const deleteId of changeset.deleted_groups) {
+          existControllers = existControllers.filter((c) => c.groupId !== deleteId);
+        }
+
+        for (const update of changeset.update_groups) {
+          const index = existControllers.findIndex((c) => c.groupId === update.group_id);
+          if (index !== -1) {
+            existControllers[index].updateGroup(update);
+          }
+        }
+
+        for (const insert of changeset.inserted_groups) {
+          const controller = new DatabaseGroupController(insert.group, this.backendService);
+          if (insert.index > existControllers.length) {
+            existControllers.push(controller);
+          } else {
+            existControllers.splice(insert.index, 0, controller);
+          }
+        }
+        this.groups.next(existControllers);
+      },
+    });
+  };
+
   dispose = async () => {
     await this.backendService.closeDatabase();
     await this.fieldController.dispose();

+ 8 - 8
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_controller.ts

@@ -6,17 +6,17 @@ import { ChangeNotifier } from '../../../../utils/change_notifier';
 
 export class FieldController {
   private backendService: DatabaseBackendService;
-  private numOfFieldsObserver: DatabaseFieldChangesetObserver;
+  private fieldChangesetObserver: DatabaseFieldChangesetObserver;
   private numOfFieldsNotifier = new NumOfFieldsNotifier([]);
 
   constructor(public readonly viewId: string) {
     this.backendService = new DatabaseBackendService(viewId);
-    this.numOfFieldsObserver = new DatabaseFieldChangesetObserver(viewId);
+    this.fieldChangesetObserver = new DatabaseFieldChangesetObserver(viewId);
   }
 
   dispose = async () => {
     this.numOfFieldsNotifier.unsubscribe();
-    await this.numOfFieldsObserver.unsubscribe();
+    await this.fieldChangesetObserver.unsubscribe();
   };
 
   get fieldInfos(): readonly FieldInfo[] {
@@ -36,14 +36,14 @@ export class FieldController {
     }
   };
 
-  subscribeOnNumOfFieldsChanged = (callback?: (fieldInfos: readonly FieldInfo[]) => void) => {
-    return this.numOfFieldsNotifier.observer.subscribe((fieldInfos) => {
-      callback?.(fieldInfos);
+  subscribe = (callbacks: { onNumOfFieldsChanged?: (fieldInfos: readonly FieldInfo[]) => void}) => {
+     this.numOfFieldsNotifier.observer.subscribe((fieldInfos) => {
+      callbacks.onNumOfFieldsChanged?.(fieldInfos);
     });
   };
 
-  listenOnFieldChanges = async () => {
-    await this.numOfFieldsObserver.subscribe({
+  initialize = async () => {
+    await this.fieldChangesetObserver.subscribe({
       onFieldsChanged: (result) => {
         if (result.ok) {
           const changeset = result.val;

+ 6 - 8
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_observer.ts

@@ -3,16 +3,15 @@ import { DatabaseNotification, DatabaseFieldChangesetPB, FlowyError, FieldPB } f
 import { ChangeNotifier } from '../../../../utils/change_notifier';
 import { DatabaseNotificationObserver } from '../notifications/observer';
 
-type UpdateFieldNotifiedValue = Result<DatabaseFieldChangesetPB, FlowyError>;
-export type DatabaseNotificationCallback = (value: UpdateFieldNotifiedValue) => void;
+export type FieldChangesetSubscribeCallback = (value: Result<DatabaseFieldChangesetPB, FlowyError>) => void;
 
 export class DatabaseFieldChangesetObserver {
-  private notifier?: ChangeNotifier<UpdateFieldNotifiedValue>;
+  private notifier?: ChangeNotifier<Result<DatabaseFieldChangesetPB, FlowyError>>;
   private listener?: DatabaseNotificationObserver;
 
   constructor(public readonly viewId: string) {}
 
-  subscribe = async (callbacks: { onFieldsChanged: DatabaseNotificationCallback }) => {
+  subscribe = async (callbacks: { onFieldsChanged: FieldChangesetSubscribeCallback }) => {
     this.notifier = new ChangeNotifier();
     this.notifier?.observer.subscribe(callbacks.onFieldsChanged);
 
@@ -41,16 +40,15 @@ export class DatabaseFieldChangesetObserver {
   };
 }
 
-type FieldNotifiedValue = Result<FieldPB, FlowyError>;
-export type FieldNotificationCallback = (value: FieldNotifiedValue) => void;
+export type FieldSubscribeCallback = (value: Result<FieldPB, FlowyError>) => void;
 
 export class DatabaseFieldObserver {
-  private _notifier?: ChangeNotifier<FieldNotifiedValue>;
+  private _notifier?: ChangeNotifier<Result<FieldPB, FlowyError>>;
   private _listener?: DatabaseNotificationObserver;
 
   constructor(public readonly fieldId: string) {}
 
-  subscribe = async (callbacks: { onFieldChanged: FieldNotificationCallback }) => {
+  subscribe = async (callbacks: { onFieldChanged: FieldSubscribeCallback }) => {
     this._notifier = new ChangeNotifier();
     this._notifier?.observer.subscribe(callbacks.onFieldChanged);
 

+ 149 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/group/group_controller.ts

@@ -0,0 +1,149 @@
+import {
+  DatabaseNotification,
+  FlowyError,
+  GroupPB,
+  GroupRowsNotificationPB,
+  RowPB,
+} from '../../../../../services/backend';
+import { ChangeNotifier } from '../../../../utils/change_notifier';
+import { None, Ok, Option, Result, Some } from 'ts-results';
+import { DatabaseNotificationObserver } from '../notifications/observer';
+import { Log } from '../../../../utils/log';
+import { DatabaseBackendService } from '../database_bd_svc';
+
+export type GroupDataCallbacks = {
+  onRemoveRow: (groupId: string, rowId: string) => void;
+  onInsertRow: (groupId: string, row: RowPB, index?: number) => void;
+  onUpdateRow: (groupId: string, row: RowPB) => void;
+
+  onCreateRow: (groupId: string, row: RowPB) => void;
+};
+
+export class DatabaseGroupController {
+  private dataObserver: GroupDataObserver;
+  private callbacks?: GroupDataCallbacks;
+
+  constructor(private group: GroupPB, private databaseBackendSvc: DatabaseBackendService) {
+    this.dataObserver = new GroupDataObserver(group.group_id);
+  }
+
+  get groupId() {
+    return this.group.group_id;
+  }
+
+  get rows() {
+    return this.group.rows;
+  }
+
+  get name() {
+    return this.group.desc;
+  }
+
+  updateGroup = (group: GroupPB) => {
+    this.group = group;
+  };
+
+  rowAtIndex = (index: number): Option<RowPB> => {
+    if (this.group.rows.length < index) {
+      return None;
+    }
+    return Some(this.group.rows[index]);
+  };
+
+  initialize = async () => {
+    await this.dataObserver.subscribe({
+      onRowsChanged: (result) => {
+        if (result.ok) {
+          const changeset = result.val;
+          // Delete
+          changeset.deleted_rows.forEach((deletedRowId) => {
+            this.group.rows = this.group.rows.filter((row) => row.id !== deletedRowId);
+            this.callbacks?.onRemoveRow(this.group.group_id, deletedRowId);
+          });
+
+          // Insert
+          changeset.inserted_rows.forEach((insertedRow) => {
+            let index: number | undefined = insertedRow.index;
+            if (insertedRow.has_index && this.group.rows.length > insertedRow.index) {
+              this.group.rows.splice(index, 0, insertedRow.row);
+            } else {
+              index = undefined;
+              this.group.rows.push(insertedRow.row);
+            }
+
+            if (insertedRow.is_new) {
+              this.callbacks?.onCreateRow(this.group.group_id, insertedRow.row);
+            } else {
+              this.callbacks?.onInsertRow(this.group.group_id, insertedRow.row, index);
+            }
+          });
+
+          // Update
+          changeset.updated_rows.forEach((updatedRow) => {
+            const index = this.group.rows.findIndex((row) => row.id === updatedRow.id);
+            if (index !== -1) {
+              this.group.rows[index] = updatedRow;
+              this.callbacks?.onUpdateRow(this.group.group_id, updatedRow);
+            }
+          });
+        } else {
+          Log.error(result.val);
+        }
+      },
+    });
+  };
+
+  createRow = async () => {
+    return this.databaseBackendSvc.createGroupRow(this.group.group_id);
+  };
+
+  subscribe = (callbacks: GroupDataCallbacks) => {
+    this.callbacks = callbacks;
+  };
+
+  unsubscribe = () => {
+    this.callbacks = undefined;
+  };
+
+  dispose = async () => {
+    await this.dataObserver.unsubscribe();
+    this.callbacks = undefined;
+  };
+}
+
+type GroupRowsSubscribeCallback = (value: Result<GroupRowsNotificationPB, FlowyError>) => void;
+
+class GroupDataObserver {
+  private notifier?: ChangeNotifier<Result<GroupRowsNotificationPB, FlowyError>>;
+  private listener?: DatabaseNotificationObserver;
+
+  constructor(public readonly groupId: string) {}
+
+  subscribe = async (callbacks: { onRowsChanged: GroupRowsSubscribeCallback }) => {
+    this.notifier = new ChangeNotifier();
+    this.notifier?.observer.subscribe(callbacks.onRowsChanged);
+
+    this.listener = new DatabaseNotificationObserver({
+      id: this.groupId,
+      parserHandler: (notification, result) => {
+        switch (notification) {
+          case DatabaseNotification.DidUpdateGroupRow:
+            if (result.ok) {
+              this.notifier?.notify(Ok(GroupRowsNotificationPB.deserializeBinary(result.val)));
+            } else {
+              this.notifier?.notify(result);
+            }
+            return;
+          default:
+            break;
+        }
+      },
+    });
+    await this.listener.start();
+  };
+
+  unsubscribe = async () => {
+    await this.listener?.stop();
+    this.notifier?.unsubscribe();
+  };
+}

+ 58 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/group/group_observer.ts

@@ -0,0 +1,58 @@
+import { ChangeNotifier } from '../../../../utils/change_notifier';
+import { Ok, Result } from 'ts-results';
+import { DatabaseNotification, FlowyError, GroupChangesetPB, GroupPB } from '../../../../../services/backend';
+import { DatabaseNotificationObserver } from '../notifications/observer';
+
+export type GroupByFieldCallback = (value: Result<GroupPB[], FlowyError>) => void;
+export type GroupChangesetSubscribeCallback = (value: Result<GroupChangesetPB, FlowyError>) => void;
+
+export class DatabaseGroupObserver {
+  private groupByNotifier?: ChangeNotifier<Result<GroupPB[], FlowyError>>;
+  private groupChangesetNotifier?: ChangeNotifier<Result<GroupChangesetPB, FlowyError>>;
+  private listener?: DatabaseNotificationObserver;
+
+  constructor(public readonly viewId: string) {}
+
+  subscribe = async (callbacks: {
+    onGroupBy: GroupByFieldCallback;
+    onGroupChangeset: GroupChangesetSubscribeCallback;
+  }) => {
+    this.groupByNotifier = new ChangeNotifier();
+    this.groupByNotifier?.observer.subscribe(callbacks.onGroupBy);
+
+    this.groupChangesetNotifier = new ChangeNotifier();
+    this.groupChangesetNotifier?.observer.subscribe(callbacks.onGroupChangeset);
+
+    this.listener = new DatabaseNotificationObserver({
+      id: this.viewId,
+      parserHandler: (notification, result) => {
+        switch (notification) {
+          case DatabaseNotification.DidGroupByField:
+            if (result.ok) {
+              this.groupByNotifier?.notify(Ok(GroupChangesetPB.deserializeBinary(result.val).initial_groups));
+            } else {
+              this.groupByNotifier?.notify(result);
+            }
+            break;
+          case DatabaseNotification.DidUpdateGroups:
+            if (result.ok) {
+              this.groupChangesetNotifier?.notify(Ok(GroupChangesetPB.deserializeBinary(result.val)));
+            } else {
+              this.groupChangesetNotifier?.notify(result);
+            }
+            break;
+          default:
+            break;
+        }
+      },
+    });
+
+    await this.listener.start();
+  };
+
+  unsubscribe = async () => {
+    this.groupByNotifier?.unsubscribe();
+    this.groupChangesetNotifier?.unsubscribe();
+    await this.listener?.stop();
+  };
+}

+ 5 - 3
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/row/row_cache.ts

@@ -53,12 +53,14 @@ export class RowCache {
     }
   };
 
-  subscribeOnRowsChanged = (callback: (reason: RowChangedReason, cellMap?: Map<string, CellIdentifier>) => void) => {
+  subscribe = (callbacks: {
+    onRowsChanged: (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()));
+        callbacks.onRowsChanged(change.reason, this._toCellMap(change.rowId, this.getFieldInfos()));
       } else {
-        callback(change.reason);
+        callbacks.onRowsChanged(change.reason);
       }
     });
   };

+ 8 - 8
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/view/database_view_cache.ts

@@ -7,16 +7,17 @@ import { Subscription } from 'rxjs';
 export class DatabaseViewCache {
   private readonly rowsObserver: DatabaseViewRowsObserver;
   private readonly rowCache: RowCache;
-  private readonly fieldSubscription?: Subscription;
 
   constructor(public readonly viewId: string, fieldController: FieldController) {
     this.rowsObserver = new DatabaseViewRowsObserver(viewId);
     this.rowCache = new RowCache(viewId, () => fieldController.fieldInfos);
-    this.fieldSubscription = fieldController.subscribeOnNumOfFieldsChanged((fieldInfos) => {
-      fieldInfos.forEach((fieldInfo) => {
-        this.rowCache.onFieldUpdated(fieldInfo);
-      });
-      this.rowCache.onNumberOfFieldsUpdated(fieldInfos);
+    fieldController.subscribe({
+      onNumOfFieldsChanged: (fieldInfos) => {
+        fieldInfos.forEach((fieldInfo) => {
+          this.rowCache.onFieldUpdated(fieldInfo);
+        });
+        this.rowCache.onNumberOfFieldsUpdated(fieldInfos);
+      },
     });
   }
 
@@ -33,12 +34,11 @@ export class DatabaseViewCache {
   };
 
   dispose = async () => {
-    this.fieldSubscription?.unsubscribe();
     await this.rowsObserver.unsubscribe();
     await this.rowCache.dispose();
   };
 
-  listenOnRowsChanged = async () => {
+  initialize = async () => {
     await this.rowsObserver.subscribe({
       onRowsVisibilityChanged: (result) => {
         if (result.ok) {

+ 181 - 0
frontend/rust-lib/flowy-database/src/entities/database_entities.rs

@@ -1,4 +1,158 @@
+use crate::entities::parser::NotEmptyStr;
+use crate::entities::{FieldIdPB, RowPB};
 use flowy_derive::ProtoBuf;
+use flowy_error::ErrorCode;
+
+/// [DatabasePB] describes how many fields and blocks the grid has
+#[derive(Debug, Clone, Default, ProtoBuf)]
+pub struct DatabasePB {
+  #[pb(index = 1)]
+  pub id: String,
+
+  #[pb(index = 2)]
+  pub fields: Vec<FieldIdPB>,
+
+  #[pb(index = 3)]
+  pub rows: Vec<RowPB>,
+}
+
+#[derive(ProtoBuf, Default)]
+pub struct CreateDatabasePayloadPB {
+  #[pb(index = 1)]
+  pub name: String,
+}
+
+#[derive(Clone, ProtoBuf, Default, Debug)]
+pub struct DatabaseViewIdPB {
+  #[pb(index = 1)]
+  pub value: String,
+}
+
+impl AsRef<str> for DatabaseViewIdPB {
+  fn as_ref(&self) -> &str {
+    &self.value
+  }
+}
+
+#[derive(Debug, Clone, Default, ProtoBuf)]
+pub struct MoveFieldPayloadPB {
+  #[pb(index = 1)]
+  pub view_id: String,
+
+  #[pb(index = 2)]
+  pub field_id: String,
+
+  #[pb(index = 3)]
+  pub from_index: i32,
+
+  #[pb(index = 4)]
+  pub to_index: i32,
+}
+
+#[derive(Clone)]
+pub struct MoveFieldParams {
+  pub view_id: String,
+  pub field_id: String,
+  pub from_index: i32,
+  pub to_index: i32,
+}
+
+impl TryInto<MoveFieldParams> for MoveFieldPayloadPB {
+  type Error = ErrorCode;
+
+  fn try_into(self) -> Result<MoveFieldParams, Self::Error> {
+    let view_id = NotEmptyStr::parse(self.view_id).map_err(|_| ErrorCode::DatabaseViewIdIsEmpty)?;
+    let item_id = NotEmptyStr::parse(self.field_id).map_err(|_| ErrorCode::InvalidData)?;
+    Ok(MoveFieldParams {
+      view_id: view_id.0,
+      field_id: item_id.0,
+      from_index: self.from_index,
+      to_index: self.to_index,
+    })
+  }
+}
+
+#[derive(Debug, Clone, Default, ProtoBuf)]
+pub struct MoveRowPayloadPB {
+  #[pb(index = 1)]
+  pub view_id: String,
+
+  #[pb(index = 2)]
+  pub from_row_id: String,
+
+  #[pb(index = 4)]
+  pub to_row_id: String,
+}
+
+pub struct MoveRowParams {
+  pub view_id: String,
+  pub from_row_id: String,
+  pub to_row_id: String,
+}
+
+impl TryInto<MoveRowParams> for MoveRowPayloadPB {
+  type Error = ErrorCode;
+
+  fn try_into(self) -> Result<MoveRowParams, Self::Error> {
+    let view_id = NotEmptyStr::parse(self.view_id).map_err(|_| ErrorCode::DatabaseViewIdIsEmpty)?;
+    let from_row_id = NotEmptyStr::parse(self.from_row_id).map_err(|_| ErrorCode::RowIdIsEmpty)?;
+    let to_row_id = NotEmptyStr::parse(self.to_row_id).map_err(|_| ErrorCode::RowIdIsEmpty)?;
+
+    Ok(MoveRowParams {
+      view_id: view_id.0,
+      from_row_id: from_row_id.0,
+      to_row_id: to_row_id.0,
+    })
+  }
+}
+#[derive(Debug, Clone, Default, ProtoBuf)]
+pub struct MoveGroupRowPayloadPB {
+  #[pb(index = 1)]
+  pub view_id: String,
+
+  #[pb(index = 2)]
+  pub from_row_id: String,
+
+  #[pb(index = 3)]
+  pub to_group_id: String,
+
+  #[pb(index = 4, one_of)]
+  pub to_row_id: Option<String>,
+}
+
+pub struct MoveGroupRowParams {
+  pub view_id: String,
+  pub from_row_id: String,
+  pub to_group_id: String,
+  pub to_row_id: Option<String>,
+}
+
+impl TryInto<MoveGroupRowParams> for MoveGroupRowPayloadPB {
+  type Error = ErrorCode;
+
+  fn try_into(self) -> Result<MoveGroupRowParams, Self::Error> {
+    let view_id = NotEmptyStr::parse(self.view_id).map_err(|_| ErrorCode::DatabaseViewIdIsEmpty)?;
+    let from_row_id = NotEmptyStr::parse(self.from_row_id).map_err(|_| ErrorCode::RowIdIsEmpty)?;
+    let to_group_id =
+      NotEmptyStr::parse(self.to_group_id).map_err(|_| ErrorCode::GroupIdIsEmpty)?;
+
+    let to_row_id = match self.to_row_id {
+      None => None,
+      Some(to_row_id) => Some(
+        NotEmptyStr::parse(to_row_id)
+          .map_err(|_| ErrorCode::RowIdIsEmpty)?
+          .0,
+      ),
+    };
+
+    Ok(MoveGroupRowParams {
+      view_id: view_id.0,
+      from_row_id: from_row_id.0,
+      to_group_id: to_group_id.0,
+      to_row_id,
+    })
+  }
+}
 
 #[derive(Debug, Default, ProtoBuf)]
 pub struct DatabaseDescPB {
@@ -14,3 +168,30 @@ pub struct RepeatedDatabaseDescPB {
   #[pb(index = 1)]
   pub items: Vec<DatabaseDescPB>,
 }
+
+#[derive(Debug, Clone, Default, ProtoBuf)]
+pub struct DatabaseGroupIdPB {
+  #[pb(index = 1)]
+  pub view_id: String,
+
+  #[pb(index = 2)]
+  pub group_id: String,
+}
+
+pub struct DatabaseGroupIdParams {
+  pub view_id: String,
+  pub group_id: String,
+}
+
+impl TryInto<DatabaseGroupIdParams> for DatabaseGroupIdPB {
+  type Error = ErrorCode;
+
+  fn try_into(self) -> Result<DatabaseGroupIdParams, Self::Error> {
+    let view_id = NotEmptyStr::parse(self.view_id).map_err(|_| ErrorCode::DatabaseViewIdIsEmpty)?;
+    let group_id = NotEmptyStr::parse(self.group_id).map_err(|_| ErrorCode::GroupIdIsEmpty)?;
+    Ok(DatabaseGroupIdParams {
+      view_id: view_id.0,
+      group_id: group_id.0,
+    })
+  }
+}

+ 0 - 155
frontend/rust-lib/flowy-database/src/entities/grid_entities.rs

@@ -1,155 +0,0 @@
-use crate::entities::parser::NotEmptyStr;
-use crate::entities::{FieldIdPB, RowPB};
-use flowy_derive::ProtoBuf;
-use flowy_error::ErrorCode;
-
-/// [DatabasePB] describes how many fields and blocks the grid has
-#[derive(Debug, Clone, Default, ProtoBuf)]
-pub struct DatabasePB {
-  #[pb(index = 1)]
-  pub id: String,
-
-  #[pb(index = 2)]
-  pub fields: Vec<FieldIdPB>,
-
-  #[pb(index = 3)]
-  pub rows: Vec<RowPB>,
-}
-
-#[derive(ProtoBuf, Default)]
-pub struct CreateDatabasePayloadPB {
-  #[pb(index = 1)]
-  pub name: String,
-}
-
-#[derive(Clone, ProtoBuf, Default, Debug)]
-pub struct DatabaseViewIdPB {
-  #[pb(index = 1)]
-  pub value: String,
-}
-
-impl AsRef<str> for DatabaseViewIdPB {
-  fn as_ref(&self) -> &str {
-    &self.value
-  }
-}
-
-#[derive(Debug, Clone, Default, ProtoBuf)]
-pub struct MoveFieldPayloadPB {
-  #[pb(index = 1)]
-  pub view_id: String,
-
-  #[pb(index = 2)]
-  pub field_id: String,
-
-  #[pb(index = 3)]
-  pub from_index: i32,
-
-  #[pb(index = 4)]
-  pub to_index: i32,
-}
-
-#[derive(Clone)]
-pub struct MoveFieldParams {
-  pub view_id: String,
-  pub field_id: String,
-  pub from_index: i32,
-  pub to_index: i32,
-}
-
-impl TryInto<MoveFieldParams> for MoveFieldPayloadPB {
-  type Error = ErrorCode;
-
-  fn try_into(self) -> Result<MoveFieldParams, Self::Error> {
-    let view_id = NotEmptyStr::parse(self.view_id).map_err(|_| ErrorCode::DatabaseViewIdIsEmpty)?;
-    let item_id = NotEmptyStr::parse(self.field_id).map_err(|_| ErrorCode::InvalidData)?;
-    Ok(MoveFieldParams {
-      view_id: view_id.0,
-      field_id: item_id.0,
-      from_index: self.from_index,
-      to_index: self.to_index,
-    })
-  }
-}
-
-#[derive(Debug, Clone, Default, ProtoBuf)]
-pub struct MoveRowPayloadPB {
-  #[pb(index = 1)]
-  pub view_id: String,
-
-  #[pb(index = 2)]
-  pub from_row_id: String,
-
-  #[pb(index = 4)]
-  pub to_row_id: String,
-}
-
-pub struct MoveRowParams {
-  pub view_id: String,
-  pub from_row_id: String,
-  pub to_row_id: String,
-}
-
-impl TryInto<MoveRowParams> for MoveRowPayloadPB {
-  type Error = ErrorCode;
-
-  fn try_into(self) -> Result<MoveRowParams, Self::Error> {
-    let view_id = NotEmptyStr::parse(self.view_id).map_err(|_| ErrorCode::DatabaseViewIdIsEmpty)?;
-    let from_row_id = NotEmptyStr::parse(self.from_row_id).map_err(|_| ErrorCode::RowIdIsEmpty)?;
-    let to_row_id = NotEmptyStr::parse(self.to_row_id).map_err(|_| ErrorCode::RowIdIsEmpty)?;
-
-    Ok(MoveRowParams {
-      view_id: view_id.0,
-      from_row_id: from_row_id.0,
-      to_row_id: to_row_id.0,
-    })
-  }
-}
-#[derive(Debug, Clone, Default, ProtoBuf)]
-pub struct MoveGroupRowPayloadPB {
-  #[pb(index = 1)]
-  pub view_id: String,
-
-  #[pb(index = 2)]
-  pub from_row_id: String,
-
-  #[pb(index = 3)]
-  pub to_group_id: String,
-
-  #[pb(index = 4, one_of)]
-  pub to_row_id: Option<String>,
-}
-
-pub struct MoveGroupRowParams {
-  pub view_id: String,
-  pub from_row_id: String,
-  pub to_group_id: String,
-  pub to_row_id: Option<String>,
-}
-
-impl TryInto<MoveGroupRowParams> for MoveGroupRowPayloadPB {
-  type Error = ErrorCode;
-
-  fn try_into(self) -> Result<MoveGroupRowParams, Self::Error> {
-    let view_id = NotEmptyStr::parse(self.view_id).map_err(|_| ErrorCode::DatabaseViewIdIsEmpty)?;
-    let from_row_id = NotEmptyStr::parse(self.from_row_id).map_err(|_| ErrorCode::RowIdIsEmpty)?;
-    let to_group_id =
-      NotEmptyStr::parse(self.to_group_id).map_err(|_| ErrorCode::GroupIdIsEmpty)?;
-
-    let to_row_id = match self.to_row_id {
-      None => None,
-      Some(to_row_id) => Some(
-        NotEmptyStr::parse(to_row_id)
-          .map_err(|_| ErrorCode::RowIdIsEmpty)?
-          .0,
-      ),
-    };
-
-    Ok(MoveGroupRowParams {
-      view_id: view_id.0,
-      from_row_id: from_row_id.0,
-      to_group_id: to_group_id.0,
-      to_row_id,
-    })
-  }
-}

+ 1 - 2
frontend/rust-lib/flowy-database/src/entities/mod.rs

@@ -3,7 +3,6 @@ mod cell_entities;
 mod database_entities;
 mod field_entities;
 pub mod filter_entities;
-mod grid_entities;
 mod group_entities;
 pub mod parser;
 mod row_entities;
@@ -14,9 +13,9 @@ mod view_entities;
 pub use calendar_entities::*;
 pub use cell_entities::*;
 pub use database_entities::*;
+pub use database_entities::*;
 pub use field_entities::*;
 pub use filter_entities::*;
-pub use grid_entities::*;
 pub use group_entities::*;
 pub use row_entities::*;
 pub use setting_entities::*;

+ 11 - 0
frontend/rust-lib/flowy-database/src/event_handler.rs

@@ -538,6 +538,17 @@ pub(crate) async fn get_groups_handler(
   data_result_ok(groups)
 }
 
+#[tracing::instrument(level = "trace", skip_all, err)]
+pub(crate) async fn get_group_handler(
+  data: AFPluginData<DatabaseGroupIdPB>,
+  manager: AFPluginState<Arc<DatabaseManager>>,
+) -> DataResult<GroupPB, FlowyError> {
+  let params: DatabaseGroupIdParams = data.into_inner().try_into()?;
+  let editor = manager.get_database_editor(&params.view_id).await?;
+  let group = editor.get_group(&params.view_id, &params.group_id).await?;
+  data_result_ok(group)
+}
+
 #[tracing::instrument(level = "debug", skip(data, manager), err)]
 pub(crate) async fn create_board_card_handler(
   data: AFPluginData<CreateBoardCardPayloadPB>,

+ 6 - 2
frontend/rust-lib/flowy-database/src/event_map.rs

@@ -47,7 +47,8 @@ pub fn init(database_manager: Arc<DatabaseManager>) -> AFPlugin {
         .event(DatabaseEvent::CreateBoardCard, create_board_card_handler)
         .event(DatabaseEvent::MoveGroup, move_group_handler)
         .event(DatabaseEvent::MoveGroupRow, move_group_row_handler)
-        .event(DatabaseEvent::GetGroup, get_groups_handler)
+        .event(DatabaseEvent::GetGroups, get_groups_handler)
+        .event(DatabaseEvent::GetGroup, get_group_handler)
         // Database
         .event(DatabaseEvent::GetDatabases, get_databases_handler)
         // Calendar
@@ -221,7 +222,10 @@ pub enum DatabaseEvent {
   UpdateDateCell = 80,
 
   #[event(input = "DatabaseViewIdPB", output = "RepeatedGroupPB")]
-  GetGroup = 100,
+  GetGroups = 100,
+
+  #[event(input = "DatabaseGroupIdPB", output = "GroupPB")]
+  GetGroup = 101,
 
   #[event(input = "CreateBoardCardPayloadPB", output = "RowPB")]
   CreateBoardCard = 110,

+ 4 - 4
frontend/rust-lib/flowy-database/src/manager.rs

@@ -207,14 +207,14 @@ impl DatabaseManager {
     let create_view_editor = |database_editor: Arc<DatabaseEditor>| async move {
       let user_id = user.user_id()?;
       let (view_pad, view_rev_manager) = make_database_view_revision_pad(view_id, user).await?;
-      return DatabaseViewEditor::from_pad(
+      DatabaseViewEditor::from_pad(
         &user_id,
         database_editor.database_view_data.clone(),
         database_editor.cell_data_cache.clone(),
         view_rev_manager,
         view_pad,
       )
-      .await;
+      .await
     };
 
     let database_editor = self
@@ -224,7 +224,7 @@ impl DatabaseManager {
       .get(database_id)
       .cloned();
 
-    return match database_editor {
+    match database_editor {
       None => {
         let mut editors_by_database_id = self.editors_by_database_id.write().await;
         let db_pool = self.database_user.db_pool()?;
@@ -241,7 +241,7 @@ impl DatabaseManager {
 
         Ok(database_editor)
       },
-    };
+    }
   }
 
   #[tracing::instrument(level = "trace", skip(self, pool), err)]

+ 5 - 0
frontend/rust-lib/flowy-database/src/services/database/database_editor.rs

@@ -924,6 +924,11 @@ impl DatabaseEditor {
     self.database_views.load_groups(view_id).await
   }
 
+  #[tracing::instrument(level = "trace", skip_all, err)]
+  pub async fn get_group(&self, view_id: &str, group_id: &str) -> FlowyResult<GroupPB> {
+    self.database_views.get_group(view_id, group_id).await
+  }
+
   async fn create_row_rev(&self) -> FlowyResult<RowRevision> {
     let field_revs = self.database_pad.read().await.get_field_revs(None)?;
     let block_id = self.block_id().await?;

+ 10 - 2
frontend/rust-lib/flowy-database/src/services/database_view/editor.rs

@@ -24,7 +24,7 @@ use database_model::{
 use flowy_client_sync::client_database::{
   make_database_view_operations, DatabaseViewRevisionChangeset, DatabaseViewRevisionPad,
 };
-use flowy_error::FlowyResult;
+use flowy_error::{FlowyError, FlowyResult};
 use flowy_revision::RevisionManager;
 use flowy_sqlite::ConnectionPool;
 use flowy_task::TaskDispatcher;
@@ -379,7 +379,7 @@ impl DatabaseViewEditor {
       }
     }
   }
-  /// Only call once after grid view editor initialized
+  /// Only call once after database view editor initialized
   #[tracing::instrument(level = "trace", skip(self))]
   pub async fn v_load_groups(&self) -> FlowyResult<Vec<GroupPB>> {
     let groups = self
@@ -394,6 +394,14 @@ impl DatabaseViewEditor {
     Ok(groups.into_iter().map(GroupPB::from).collect())
   }
 
+  #[tracing::instrument(level = "trace", skip(self))]
+  pub async fn v_get_group(&self, group_id: &str) -> FlowyResult<GroupPB> {
+    match self.group_controller.read().await.get_group(group_id) {
+      None => Err(FlowyError::record_not_found().context("Can't find the group")),
+      Some((_, group)) => Ok(GroupPB::from(group)),
+    }
+  }
+
   #[tracing::instrument(level = "trace", skip(self), err)]
   pub async fn v_move_group(&self, params: MoveGroupParams) -> FlowyResult<()> {
     self

+ 7 - 1
frontend/rust-lib/flowy-database/src/services/database_view/editor_manager.rs

@@ -1,7 +1,8 @@
 #![allow(clippy::while_let_loop)]
 use crate::entities::{
   AlterFilterParams, AlterSortParams, CreateRowParams, DatabaseViewSettingPB, DeleteFilterParams,
-  DeleteGroupParams, DeleteSortParams, InsertGroupParams, MoveGroupParams, RepeatedGroupPB, RowPB,
+  DeleteGroupParams, DeleteSortParams, GroupPB, InsertGroupParams, MoveGroupParams,
+  RepeatedGroupPB, RowPB,
 };
 use crate::manager::DatabaseUser;
 use crate::services::cell::AtomicCellDataCache;
@@ -201,6 +202,11 @@ impl DatabaseViews {
     Ok(RepeatedGroupPB { items: groups })
   }
 
+  pub async fn get_group(&self, view_id: &str, group_id: &str) -> FlowyResult<GroupPB> {
+    let view_editor = self.get_view_editor(view_id).await?;
+    view_editor.v_get_group(group_id).await
+  }
+
   pub async fn insert_or_update_group(&self, params: InsertGroupParams) -> FlowyResult<()> {
     let view_editor = self.get_view_editor(&params.view_id).await?;
     view_editor.v_initialize_new_group(params).await