feat: add new group (#3854)

* feat: implement backend logic

* fix: did_create_row not working properly

* fix: did_delete_group not working properly

* fix: test

* chore: fix clippy

* fix: new card not editable and in wrong position

* feat: imlement UI for add new stack

* test: add integration test

* chore: i18n

* chore: remove debug message

* chore: merge conflict

---------

Co-authored-by: nathan <nathan@appflowy.io>
This commit is contained in:
Richard Shiue 2023-11-06 16:17:05 +08:00 committed by GitHub
parent 4d82bb5322
commit c4fc60612f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 674 additions and 138 deletions

View File

@ -1,11 +1,14 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/widgets/card/card.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_board/appflowy_board.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../util/util.dart';
import '../util/database_test_op.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@ -46,5 +49,56 @@ void main() {
await tester.tapButtonWithName(LocaleKeys.button_duplicate.tr());
expect(find.textContaining(name, findRichText: true), findsNWidgets(2));
});
testWidgets('add new group', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.createNewPageWithName(layout: ViewLayoutPB.Board);
// assert number of groups
tester.assertNumberOfGroups(4);
// scroll the board horizontally to ensure add new group button appears
await tester.scrollBoardToEnd();
// assert and click on add new group button
tester.assertNewGroupTextField(false);
await tester.tapNewGroupButton();
tester.assertNewGroupTextField(true);
// enter new group name and submit
await tester.enterNewGroupName('needs design', submit: true);
// assert number of groups has increased
tester.assertNumberOfGroups(5);
// assert text field has disappeared
await tester.scrollBoardToEnd();
tester.assertNewGroupTextField(false);
// click on add new group button
await tester.tapNewGroupButton();
tester.assertNewGroupTextField(true);
// type some things
await tester.enterNewGroupName('needs planning', submit: false);
// click on clear button and assert empty contents
await tester.clearNewGroupTextField();
// press escape to cancel
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
tester.assertNewGroupTextField(false);
// click on add new group button
await tester.tapNewGroupButton();
tester.assertNewGroupTextField(true);
// press elsewhere to cancel
await tester.tap(find.byType(AppFlowyBoard));
await tester.pumpAndSettle();
tester.assertNewGroupTextField(false);
});
});
}

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database_view/board/presentation/widgets/board_column_header.dart';
import 'package:appflowy/plugins/database_view/calendar/application/calendar_bloc.dart';
import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_day.dart';
import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_event_card.dart';
@ -59,6 +60,7 @@ import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_board/appflowy_board.dart';
import 'package:calendar_view/calendar_view.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@ -1390,6 +1392,84 @@ extension AppFlowyDatabaseTest on WidgetTester {
await tapButton(findCreateButton);
}
void assertNumberOfGroups(int number) {
final groups = find.byType(BoardColumnHeader, skipOffstage: false);
expect(groups, findsNWidgets(number));
}
Future<void> scrollBoardToEnd() async {
final scrollable = find
.descendant(
of: find.byType(AppFlowyBoard),
matching: find.byWidgetPredicate(
(widget) => widget is Scrollable && widget.axis == Axis.horizontal,
),
)
.first;
await scrollUntilVisible(
find.byType(BoardTrailing),
300,
scrollable: scrollable,
);
}
Future<void> tapNewGroupButton() async {
final button = find.descendant(
of: find.byType(BoardTrailing),
matching: find.byWidgetPredicate(
(widget) => widget is FlowySvg && widget.svg == FlowySvgs.add_s,
),
);
expect(button, findsOneWidget);
await tapButton(button);
}
void assertNewGroupTextField(bool isVisible) {
final textField = find.descendant(
of: find.byType(BoardTrailing),
matching: find.byType(TextField),
);
if (isVisible) {
expect(textField, findsOneWidget);
} else {
expect(textField, findsNothing);
}
}
Future<void> enterNewGroupName(String name, {required bool submit}) async {
final textField = find.descendant(
of: find.byType(BoardTrailing),
matching: find.byType(TextField),
);
await enterText(textField, name);
await pumpAndSettle();
if (submit) {
await testTextInput.receiveAction(TextInputAction.done);
await pumpAndSettle();
}
}
Future<void> clearNewGroupTextField() async {
final textField = find.descendant(
of: find.byType(BoardTrailing),
matching: find.byType(TextField),
);
await tapButton(
find.descendant(
of: textField,
matching: find.byWidgetPredicate(
(widget) =>
widget is FlowySvg && widget.svg == FlowySvgs.close_filled_m,
),
),
);
final textFieldWidget = widget<TextField>(textField);
assert(
textFieldWidget.controller != null &&
textFieldWidget.controller!.text.isEmpty,
);
}
Future<void> tapTabBarLinkedViewByViewName(String name) async {
final viewButton = findTabBarLinkViewByViewName(name);
await tapButton(viewButton);

View File

@ -37,4 +37,15 @@ class GroupBackendService {
}
return DatabaseEventUpdateGroup(payload).send();
}
Future<Either<Unit, FlowyError>> createGroup({
required String name,
String groupConfigId = "",
}) {
final payload = CreateGroupPayloadPB.create()
..viewId = viewId
..name = name;
return DatabaseEventCreateGroup(payload).send();
}
}

View File

@ -98,6 +98,10 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
(err) => Log.error(err),
);
},
createGroup: (name) async {
final result = await groupBackendSvc.createGroup(name: name);
result.fold((_) {}, (err) => Log.error(err));
},
didCreateRow: (group, row, int? index) {
emit(
state.copyWith(
@ -346,6 +350,7 @@ class BoardEvent with _$BoardEvent {
const factory BoardEvent.initial() = _InitialBoard;
const factory BoardEvent.createBottomRow(String groupId) = _CreateBottomRow;
const factory BoardEvent.createHeaderRow(String groupId) = _CreateHeaderRow;
const factory BoardEvent.createGroup(String name) = _CreateGroup;
const factory BoardEvent.startEditingHeader(String groupId) =
_StartEditingHeader;
const factory BoardEvent.endEditingHeader(String groupId, String groupName) =

View File

@ -16,12 +16,12 @@ import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:appflowy_board/appflowy_board.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/flowy_infra_ui_web.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart' hide Card;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../widgets/card/cells/card_cell.dart';
@ -127,6 +127,7 @@ class BoardContent extends StatefulWidget {
class _BoardContentState extends State<BoardContent> {
late AppFlowyBoardScrollController scrollManager;
late final ScrollController scrollController;
final renderHook = RowCardRenderHook<String>();
final config = const AppFlowyBoardConfig(
@ -138,6 +139,7 @@ class _BoardContentState extends State<BoardContent> {
super.initState();
scrollManager = AppFlowyBoardScrollController();
scrollController = ScrollController();
renderHook.addSelectOptionHook((options, groupId, _) {
// The cell should hide if the option id is equal to the groupId.
final isInGroup =
@ -172,7 +174,7 @@ class _BoardContentState extends State<BoardContent> {
Expanded(
child: AppFlowyBoard(
boardScrollController: scrollManager,
scrollController: ScrollController(),
scrollController: scrollController,
controller: context.read<BoardBloc>().boardController,
headerBuilder: (_, groupData) =>
BlocProvider<BoardBloc>.value(
@ -183,6 +185,7 @@ class _BoardContentState extends State<BoardContent> {
),
),
footerBuilder: _buildFooter,
trailing: BoardTrailing(scrollController: scrollController),
cardBuilder: (_, column, columnItem) => _buildCard(
context,
column,
@ -348,3 +351,109 @@ class _BoardContentState extends State<BoardContent> {
);
}
}
class BoardTrailing extends StatefulWidget {
final ScrollController scrollController;
const BoardTrailing({required this.scrollController, super.key});
@override
State<BoardTrailing> createState() => _BoardTrailingState();
}
class _BoardTrailingState extends State<BoardTrailing> {
bool isEditing = false;
late final TextEditingController _textController;
late final FocusNode _focusNode;
void _cancelAddNewGroup() {
_textController.clear();
setState(() {
isEditing = false;
});
}
@override
void initState() {
super.initState();
_textController = TextEditingController();
_focusNode = FocusNode(
onKeyEvent: (node, event) {
if (_focusNode.hasFocus &&
event.logicalKey == LogicalKeyboardKey.escape) {
_cancelAddNewGroup();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
)..addListener(() {
if (!_focusNode.hasFocus) {
_cancelAddNewGroup();
}
});
}
@override
Widget build(BuildContext context) {
// call after every setState
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isEditing) {
_focusNode.requestFocus();
widget.scrollController.jumpTo(
widget.scrollController.position.maxScrollExtent,
);
}
});
return Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Align(
alignment: AlignmentDirectional.topStart,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: isEditing
? SizedBox(
width: 256,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _textController,
focusNode: _focusNode,
decoration: InputDecoration(
suffixIcon: Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8.0),
child: FlowyIconButton(
icon: const FlowySvg(FlowySvgs.close_filled_m),
hoverColor: Colors.transparent,
onPressed: () => _textController.clear(),
),
),
suffixIconConstraints:
BoxConstraints.loose(const Size(20, 24)),
border: const UnderlineInputBorder(),
contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
isDense: true,
),
style: Theme.of(context).textTheme.bodySmall,
maxLines: 1,
onSubmitted: (groupName) => context
.read<BoardBloc>()
.add(BoardEvent.createGroup(groupName)),
),
),
)
: FlowyTooltip(
message: LocaleKeys.board_column_createNewColumn.tr(),
child: FlowyIconButton(
width: 26,
icon: const FlowySvg(FlowySvgs.add_s),
iconColorOnHover: Theme.of(context).colorScheme.onSurface,
onPressed: () => setState(() {
isEditing = true;
}),
),
),
),
),
);
}
}

View File

@ -45,8 +45,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "6aba8dd"
resolved-ref: "6aba8ddd86839ca09b997cb2457f013236e0c337"
ref: "1a329c2"
resolved-ref: "1a329c21921c0d19871bea3237b7d80fe131f2ed"
url: "https://github.com/AppFlowy-IO/appflowy-board.git"
source: git
version: "0.1.0"

View File

@ -43,7 +43,7 @@ dependencies:
# path: packages/appflowy_board
git:
url: https://github.com/AppFlowy-IO/appflowy-board.git
ref: 6aba8dd
ref: 1a329c2
appflowy_editor: ^1.5.1
appflowy_popover:
path: packages/appflowy_popover

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15ZM10.1392 5.14768C10.3361 4.95077 10.6554 4.95077 10.8523 5.14768C11.0492 5.34459 11.0492 5.66385 10.8523 5.86076L8.71303 8L10.8523 10.1392C11.0492 10.3362 11.0492 10.6554 10.8523 10.8523C10.6554 11.0492 10.3361 11.0492 10.1392 10.8523L7.99996 8.71307L5.86076 10.8523C5.66385 11.0492 5.34459 11.0492 5.14768 10.8523C4.95077 10.6554 4.95077 10.3361 5.14768 10.1392L7.28688 8L5.14769 5.8608C4.95078 5.66389 4.95078 5.34464 5.14769 5.14773C5.3446 4.95082 5.66385 4.95082 5.86076 5.14773L7.99996 7.28692L10.1392 5.14768Z" fill="#1F2329" fill-opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 809 B

View File

@ -755,7 +755,8 @@
"board": {
"column": {
"createNewCard": "New",
"renameGroupTooltip": "Press to rename group"
"renameGroupTooltip": "Press to rename group",
"createNewColumn": "Add a new group"
},
"menuName": "Board",
"showUngrouped": "Show ungrouped items",

View File

@ -3,7 +3,7 @@ use event_integration::EventIntegrationTest;
#[tokio::test]
async fn update_group_name_test() {
let test = EventIntegrationTest::new_with_guest_user().await;
let current_workspace = test.get_current_workspace().await.workspace;
let current_workspace = test.get_current_workspace().await;
let board_view = test
.create_board(&current_workspace.id, "my board view".to_owned(), vec![])
.await;

View File

@ -197,3 +197,36 @@ impl From<UpdateGroupParams> for GroupChangeset {
}
}
}
#[derive(Debug, Default, ProtoBuf)]
pub struct CreateGroupPayloadPB {
#[pb(index = 1)]
pub view_id: String,
#[pb(index = 2)]
pub group_config_id: String,
#[pb(index = 3)]
pub name: String,
}
#[derive(Debug, Clone)]
pub struct CreateGroupParams {
pub view_id: String,
pub group_config_id: String,
pub name: String,
}
impl TryFrom<CreateGroupPayloadPB> for CreateGroupParams {
type Error = ErrorCode;
fn try_from(value: CreateGroupPayloadPB) -> Result<Self, Self::Error> {
let view_id = NotEmptyStr::parse(value.view_id).map_err(|_| ErrorCode::ViewIdIsInvalid)?;
let name = NotEmptyStr::parse(value.name).map_err(|_| ErrorCode::ViewIdIsInvalid)?;
Ok(CreateGroupParams {
view_id: view_id.0,
group_config_id: value.group_config_id,
name: name.0,
})
}
}

View File

@ -741,6 +741,20 @@ pub(crate) async fn move_group_row_handler(
Ok(())
}
#[tracing::instrument(level = "debug", skip(manager), err)]
pub(crate) async fn create_group_handler(
data: AFPluginData<CreateGroupPayloadPB>,
manager: AFPluginState<Weak<DatabaseManager>>,
) -> FlowyResult<()> {
let manager = upgrade_manager(manager)?;
let params: CreateGroupParams = data.into_inner().try_into()?;
let database_editor = manager.get_database_with_view_id(&params.view_id).await?;
database_editor
.create_group(&params.view_id, &params.name)
.await?;
Ok(())
}
#[tracing::instrument(level = "debug", skip(manager), err)]
pub(crate) async fn get_databases_handler(
manager: AFPluginState<Weak<DatabaseManager>>,

View File

@ -60,6 +60,7 @@ pub fn init(database_manager: Weak<DatabaseManager>) -> AFPlugin {
.event(DatabaseEvent::GetGroups, get_groups_handler)
.event(DatabaseEvent::GetGroup, get_group_handler)
.event(DatabaseEvent::UpdateGroup, update_group_handler)
.event(DatabaseEvent::CreateGroup, create_group_handler)
// Database
.event(DatabaseEvent::GetDatabases, get_databases_handler)
// Calendar
@ -284,6 +285,9 @@ pub enum DatabaseEvent {
#[event(input = "UpdateGroupPB")]
UpdateGroup = 113,
#[event(input = "CreateGroupPayloadPB")]
CreateGroup = 114,
/// Returns all the databases
#[event(output = "RepeatedDatabaseDescriptionPB")]
GetDatabases = 120,

View File

@ -442,7 +442,7 @@ impl DatabaseEditor {
let row_detail = self.database.lock().get_row_detail(&row_order.id);
if let Some(row_detail) = row_detail {
for view in self.database_views.editors().await {
view.v_did_create_row(&row_detail, &group_id, index).await;
view.v_did_create_row(&row_detail, index).await;
}
return Ok(Some(row_detail));
}
@ -961,6 +961,12 @@ impl DatabaseEditor {
Ok(())
}
pub async fn create_group(&self, view_id: &str, name: &str) -> FlowyResult<()> {
let view_editor = self.database_views.get_view_editor(view_id).await?;
view_editor.v_create_group(name).await?;
Ok(())
}
#[tracing::instrument(level = "trace", skip_all)]
pub async fn set_layout_setting(
&self,

View File

@ -14,8 +14,8 @@ use lib_dispatch::prelude::af_spawn;
use crate::entities::{
CalendarEventPB, DatabaseLayoutMetaPB, DatabaseLayoutSettingPB, DeleteFilterParams,
DeleteGroupParams, DeleteSortParams, FieldType, FieldVisibility, GroupChangesPB, GroupPB,
GroupRowsNotificationPB, InsertedRowPB, LayoutSettingChangeset, LayoutSettingParams, RowMetaPB,
RowsChangePB, SortChangesetNotificationPB, SortPB, UpdateFilterParams, UpdateSortParams,
InsertedRowPB, LayoutSettingChangeset, LayoutSettingParams, RowMetaPB, RowsChangePB,
SortChangesetNotificationPB, SortPB, UpdateFilterParams, UpdateSortParams,
};
use crate::notification::{send_notification, DatabaseNotification};
use crate::services::cell::CellCache;
@ -126,39 +126,22 @@ impl DatabaseViewEditor {
.send();
}
pub async fn v_did_create_row(
&self,
row_detail: &RowDetail,
group_id: &Option<String>,
index: usize,
) {
let changes: RowsChangePB;
pub async fn v_did_create_row(&self, row_detail: &RowDetail, index: usize) {
// Send the group notification if the current view has groups
match group_id.as_ref() {
None => {
let row = InsertedRowPB::new(RowMetaPB::from(row_detail)).with_index(index as i32);
changes = RowsChangePB::from_insert(row);
},
Some(group_id) => {
self
.mut_group_controller(|group_controller, _| {
group_controller.did_create_row(row_detail, group_id);
Ok(())
})
.await;
if let Some(controller) = self.group_controller.write().await.as_mut() {
let changesets = controller.did_create_row(row_detail, index);
let inserted_row = InsertedRowPB {
row_meta: RowMetaPB::from(row_detail),
index: Some(index as i32),
is_new: true,
};
let changeset =
GroupRowsNotificationPB::insert(group_id.clone(), vec![inserted_row.clone()]);
for changeset in changesets {
notify_did_update_group_rows(changeset).await;
changes = RowsChangePB::from_insert(inserted_row);
},
}
}
let inserted_row = InsertedRowPB {
row_meta: RowMetaPB::from(row_detail),
index: Some(index as i32),
is_new: true,
};
let changes = RowsChangePB::from_insert(inserted_row);
send_notification(&self.view_id, DatabaseNotification::DidUpdateViewRows)
.payload(changes)
.send();
@ -168,16 +151,22 @@ impl DatabaseViewEditor {
pub async fn v_did_delete_row(&self, row: &Row) {
// Send the group notification if the current view has groups;
let result = self
.mut_group_controller(|group_controller, field| {
group_controller.did_delete_delete_row(row, &field)
})
.mut_group_controller(|group_controller, _| group_controller.did_delete_row(row))
.await;
if let Some(result) = result {
tracing::trace!("Delete row in view changeset: {:?}", result.row_changesets);
tracing::trace!("Delete row in view changeset: {:?}", result);
for changeset in result.row_changesets {
notify_did_update_group_rows(changeset).await;
}
if let Some(deleted_group) = result.deleted_group {
let payload = GroupChangesPB {
view_id: self.view_id.clone(),
deleted_groups: vec![deleted_group.group_id],
..Default::default()
};
notify_did_update_num_of_groups(&self.view_id, payload).await;
}
}
let changes = RowsChangePB::from_delete(row.id.clone().into_inner());
send_notification(&self.view_id, DatabaseNotification::DidUpdateViewRows)
@ -319,7 +308,7 @@ impl DatabaseViewEditor {
.read()
.await
.as_ref()?
.groups()
.get_all_groups()
.into_iter()
.filter(|group| group.is_visible)
.map(|group_data| GroupPB::from(group_data.clone()))
@ -371,6 +360,36 @@ impl DatabaseViewEditor {
Ok(())
}
pub async fn v_create_group(&self, name: &str) -> FlowyResult<()> {
let mut old_field: Option<Field> = None;
let result = if let Some(controller) = self.group_controller.write().await.as_mut() {
let create_group_results = controller.create_group(name.to_string())?;
old_field = self.delegate.get_field(controller.field_id());
create_group_results
} else {
(None, None)
};
if let Some(old_field) = old_field {
if let (Some(type_option_data), Some(payload)) = result {
self
.delegate
.update_field(&self.view_id, type_option_data, old_field)
.await?;
let group_changes = GroupChangesPB {
view_id: self.view_id.clone(),
inserted_groups: vec![payload],
..Default::default()
};
notify_did_update_num_of_groups(&self.view_id, group_changes).await;
}
}
Ok(())
}
pub async fn v_delete_group(&self, _params: DeleteGroupParams) -> FlowyResult<()> {
Ok(())
}
@ -671,7 +690,7 @@ impl DatabaseViewEditor {
.await?;
let new_groups = new_group_controller
.groups()
.get_all_groups()
.into_iter()
.map(|group| GroupPB::from(group.clone()))
.collect();

View File

@ -25,15 +25,27 @@ pub trait SelectTypeOptionSharedAction: Send + Sync {
/// If the option already exists, it will be updated.
/// If the option does not exist, it will be inserted at the beginning.
fn insert_option(&mut self, new_option: SelectOption) {
self.insert_option_at_index(new_option, None);
}
fn insert_option_at_index(&mut self, new_option: SelectOption, new_index: Option<usize>) {
let options = self.mut_options();
let safe_new_index = new_index.map(|index| {
if index > options.len() {
options.len()
} else {
index
}
});
if let Some(index) = options
.iter()
.position(|option| option.id == new_option.id || option.name == new_option.name)
{
options.remove(index);
options.insert(index, new_option);
options.insert(safe_new_index.unwrap_or(index), new_option);
} else {
options.insert(0, new_option);
options.insert(safe_new_index.unwrap_or(0), new_option);
}
}

View File

@ -54,7 +54,7 @@ pub trait GroupCustomize: Send + Sync {
&mut self,
row: &Row,
cell_data: &<Self::GroupTypeOption as TypeOption>::CellData,
) -> Vec<GroupRowsNotificationPB>;
) -> (Option<GroupPB>, Vec<GroupRowsNotificationPB>);
/// Move row from one group to another
fn move_row(
@ -71,27 +71,71 @@ pub trait GroupCustomize: Send + Sync {
) -> Option<GroupPB> {
None
}
fn generate_new_group(
&mut self,
_name: String,
) -> FlowyResult<(Option<TypeOptionData>, Option<InsertedGroupPB>)> {
Ok((None, None))
}
}
/// Defines the shared actions any group controller can perform.
#[async_trait]
pub trait GroupControllerOperation: Send + Sync {
/// The field that is used for grouping the rows
/// Returns the id of field that is being used to group the rows
fn field_id(&self) -> &str;
/// Returns number of groups the current field has
fn groups(&self) -> Vec<&GroupData>;
/// Returns all of the groups currently managed by the controller
fn get_all_groups(&self) -> Vec<&GroupData>;
/// Returns the index and the group data with group_id
/// Returns the index and the group data with the given group id if it exists.
///
/// * `group_id` - A string slice that is used to match the group
fn get_group(&self, group_id: &str) -> Option<(usize, GroupData)>;
/// Separates the rows into different groups
/// Sort the rows into the different groups.
///
/// * `rows`: rows to be inserted
/// * `field`: reference to the field being sorted (currently unused)
fn fill_groups(&mut self, rows: &[&RowDetail], field: &Field) -> FlowyResult<()>;
/// Remove the group with from_group_id and insert it to the index with to_group_id
/// Create a new group, currently only supports single and multi-select.
///
/// Returns a new type option data for the grouping field if it's altered.
///
/// * `name`: name of the new group
fn create_group(
&mut self,
name: String,
) -> FlowyResult<(Option<TypeOptionData>, Option<InsertedGroupPB>)>;
/// Reorders the group in the group controller.
///
/// * `from_group_id`: id of the group being moved
/// * `to_group_id`: id of the group whose index is the one at which the
/// reordered group will be placed
fn move_group(&mut self, from_group_id: &str, to_group_id: &str) -> FlowyResult<()>;
/// Insert/Remove the row to the group if the corresponding cell data is changed
/// Adds a newly-created row to one or more suitable groups.
///
/// Returns a changeset payload to be sent as a notification.
///
/// * `row_detail`: the newly-created row
fn did_create_row(
&mut self,
row_detail: &RowDetail,
index: usize,
) -> Vec<GroupRowsNotificationPB>;
/// Called after a row's cell data is changed, this moves the row to the
/// correct group. It may also insert a new group and/or remove an old group.
///
/// Returns the inserted and removed groups if necessary for notification.
///
/// * `old_row_detail`:
/// * `row_detail`:
/// * `field`:
fn did_update_group_row(
&mut self,
old_row_detail: &Option<RowDetail>,
@ -99,22 +143,31 @@ pub trait GroupControllerOperation: Send + Sync {
field: &Field,
) -> FlowyResult<DidUpdateGroupRowResult>;
/// Remove the row from the group if the row gets deleted
fn did_delete_delete_row(
&mut self,
row: &Row,
field: &Field,
) -> FlowyResult<DidMoveGroupRowResult>;
/// Called after the row is deleted, this removes the row from the group.
/// A group could be deleted as a result.
///
/// Returns a the removed group when this occurs.
fn did_delete_row(&mut self, row: &Row) -> FlowyResult<DidMoveGroupRowResult>;
/// Move the row from one group to another group
/// Reorders a row within the current group or move the row to another group.
///
/// * `context`: information about the row being moved and its destination
fn move_group_row(&mut self, context: MoveGroupRowContext) -> FlowyResult<DidMoveGroupRowResult>;
/// Update the group if the corresponding field is changed
/// Updates the groups after a field change. (currently never does anything)
///
/// * `field`: new changeset
fn did_update_group_field(&mut self, field: &Field) -> FlowyResult<Option<GroupChangesPB>>;
/// Updates the name and/or visibility of groups.
///
/// Returns a non-empty `TypeOptionData` when the changes require a change
/// in the field type option data.
///
/// * `changesets`: list of changesets to be made to one or more groups
async fn apply_group_changeset(
&mut self,
changeset: &GroupChangesets,
changesets: &GroupChangesets,
) -> FlowyResult<TypeOptionData>;
}

View File

@ -124,6 +124,7 @@ where
/// Returns the no `status` group
///
/// We take the `id` of the `field` as the no status group id
#[allow(dead_code)]
pub(crate) fn get_no_status_group(&self) -> Option<&GroupData> {
self.group_by_id.get(&self.field.id)
}
@ -249,7 +250,7 @@ where
///
/// # Arguments
///
/// * `generated_group_configs`: the generated groups contains a list of [GeneratedGroupConfig].
/// * `generated_groups`: the generated groups contains a list of [GeneratedGroupConfig].
///
/// Each [FieldType] can implement the [GroupGenerator] trait in order to generate different
/// groups. For example, the FieldType::Checkbox has the [CheckboxGroupGenerator] that implements

View File

@ -11,7 +11,7 @@ use serde::Serialize;
use flowy_error::FlowyResult;
use crate::entities::{
FieldType, GroupChangesPB, GroupRowsNotificationPB, InsertedRowPB, RowMetaPB,
FieldType, GroupChangesPB, GroupRowsNotificationPB, InsertedGroupPB, InsertedRowPB, RowMetaPB,
};
use crate::services::cell::{get_cell_protobuf, CellProtobufBlobParser};
use crate::services::field::{default_type_option_data_from_type, TypeOption, TypeOptionCellData};
@ -38,9 +38,6 @@ pub trait GroupController: GroupControllerOperation + Send + Sync {
/// Called before the row was created.
fn will_create_row(&mut self, cells: &mut Cells, field: &Field, group_id: &str);
/// Called after the row was created.
fn did_create_row(&mut self, row_detail: &RowDetail, group_id: &str);
}
#[async_trait]
@ -184,7 +181,7 @@ where
&self.grouping_field_id
}
fn groups(&self) -> Vec<&GroupData> {
fn get_all_groups(&self) -> Vec<&GroupData> {
self.context.groups()
}
@ -233,10 +230,70 @@ where
Ok(())
}
fn create_group(
&mut self,
name: String,
) -> FlowyResult<(Option<TypeOptionData>, Option<InsertedGroupPB>)> {
self.generate_new_group(name)
}
fn move_group(&mut self, from_group_id: &str, to_group_id: &str) -> FlowyResult<()> {
self.context.move_group(from_group_id, to_group_id)
}
fn did_create_row(
&mut self,
row_detail: &RowDetail,
index: usize,
) -> Vec<GroupRowsNotificationPB> {
let cell = match row_detail.row.cells.get(&self.grouping_field_id) {
None => self.placeholder_cell(),
Some(cell) => Some(cell.clone()),
};
let mut changesets: Vec<GroupRowsNotificationPB> = vec![];
if let Some(cell) = cell {
let cell_data = <T as TypeOption>::CellData::from(&cell);
let mut suitable_group_ids = vec![];
for group in self.get_all_groups() {
if self.can_group(&group.filter_content, &cell_data) {
suitable_group_ids.push(group.id.clone());
let changeset = GroupRowsNotificationPB::insert(
group.id.clone(),
vec![InsertedRowPB {
row_meta: row_detail.into(),
index: Some(index as i32),
is_new: true,
}],
);
changesets.push(changeset);
}
}
if !suitable_group_ids.is_empty() {
for group_id in suitable_group_ids.iter() {
if let Some(group) = self.context.get_mut_group(group_id) {
group.add_row(row_detail.clone());
}
}
} else if let Some(no_status_group) = self.context.get_mut_no_status_group() {
no_status_group.add_row(row_detail.clone());
let changeset = GroupRowsNotificationPB::insert(
no_status_group.id.clone(),
vec![InsertedRowPB {
row_meta: row_detail.into(),
index: Some(index as i32),
is_new: true,
}],
);
changesets.push(changeset);
}
}
changesets
}
fn did_update_group_row(
&mut self,
old_row_detail: &Option<RowDetail>,
@ -278,26 +335,21 @@ where
Ok(result)
}
fn did_delete_delete_row(
&mut self,
row: &Row,
_field: &Field,
) -> FlowyResult<DidMoveGroupRowResult> {
// if the cell_rev is none, then the row must in the default group.
fn did_delete_row(&mut self, row: &Row) -> FlowyResult<DidMoveGroupRowResult> {
let mut result = DidMoveGroupRowResult {
deleted_group: None,
row_changesets: vec![],
};
// early return if the row is not in the default group
if let Some(cell) = row.cells.get(&self.grouping_field_id) {
let cell_data = <T as TypeOption>::CellData::from(cell);
if !cell_data.is_cell_empty() {
tracing::error!("did_delete_delete_row {:?}", cell);
result.row_changesets = self.delete_row(row, &cell_data);
(result.deleted_group, result.row_changesets) = self.delete_row(row, &cell_data);
return Ok(result);
}
}
match self.context.get_no_status_group() {
match self.context.get_mut_no_status_group() {
None => {
tracing::error!("Unexpected None value. It should have the no status group");
},
@ -305,6 +357,7 @@ where
if !no_status_group.contains_row(&row.id) {
tracing::error!("The row: {:?} should be in the no status group", row.id);
}
no_status_group.remove_row(&row.id);
result.row_changesets = vec![GroupRowsNotificationPB::delete(
no_status_group.id.clone(),
vec![row.id.clone().into_inner()],

View File

@ -3,7 +3,7 @@ use collab_database::fields::{Field, TypeOptionData};
use collab_database::rows::{new_cell_builder, Cell, Cells, Row, RowDetail};
use serde::{Deserialize, Serialize};
use crate::entities::{FieldType, GroupRowsNotificationPB, InsertedRowPB, RowMetaPB};
use crate::entities::{FieldType, GroupPB, GroupRowsNotificationPB, InsertedRowPB, RowMetaPB};
use crate::services::cell::insert_checkbox_cell;
use crate::services::field::{
CheckboxCellDataParser, CheckboxTypeOption, TypeOption, CHECK, UNCHECK,
@ -109,7 +109,7 @@ impl GroupCustomize for CheckboxGroupController {
&mut self,
row: &Row,
_cell_data: &<Self::GroupTypeOption as TypeOption>::CellData,
) -> Vec<GroupRowsNotificationPB> {
) -> (Option<GroupPB>, Vec<GroupRowsNotificationPB>) {
let mut changesets = vec![];
self.context.iter_mut_groups(|group| {
let mut changeset = GroupRowsNotificationPB::new(group.id.clone());
@ -122,7 +122,7 @@ impl GroupCustomize for CheckboxGroupController {
changesets.push(changeset);
}
});
changesets
(None, changesets)
}
fn move_row(
@ -155,12 +155,6 @@ impl GroupController for CheckboxGroupController {
},
}
}
fn did_create_row(&mut self, row_detail: &RowDetail, group_id: &str) {
if let Some(group) = self.context.get_mut_group(group_id) {
group.add_row(row_detail.clone())
}
}
}
pub struct CheckboxGroupBuilder();

View File

@ -178,8 +178,8 @@ impl GroupCustomize for DateGroupController {
fn delete_row(
&mut self,
row: &Row,
_cell_data: &<Self::GroupTypeOption as TypeOption>::CellData,
) -> Vec<GroupRowsNotificationPB> {
cell_data: &<Self::GroupTypeOption as TypeOption>::CellData,
) -> (Option<GroupPB>, Vec<GroupRowsNotificationPB>) {
let mut changesets = vec![];
self.context.iter_mut_groups(|group| {
let mut changeset = GroupRowsNotificationPB::new(group.id.clone());
@ -192,7 +192,23 @@ impl GroupCustomize for DateGroupController {
changesets.push(changeset);
}
});
changesets
let setting_content = self.context.get_setting_content();
let deleted_group =
match self
.context
.get_group(&group_id(cell_data, &self.type_option, &setting_content))
{
Some((_, group)) if group.rows.len() == 1 => Some(group.clone()),
_ => None,
};
let deleted_group = deleted_group.map(|group| {
let _ = self.context.delete_group(&group.id);
group.into()
});
(deleted_group, changesets)
}
fn move_row(
@ -247,12 +263,6 @@ impl GroupController for DateGroupController {
},
}
}
fn did_create_row(&mut self, row_detail: &RowDetail, group_id: &str) {
if let Some(group) = self.context.get_mut_group(group_id) {
group.add_row(row_detail.clone())
}
}
}
pub struct DateGroupBuilder();

View File

@ -6,7 +6,7 @@ use collab_database::rows::{Cells, Row, RowDetail};
use flowy_error::FlowyResult;
use crate::entities::GroupChangesPB;
use crate::entities::{GroupChangesPB, GroupRowsNotificationPB, InsertedGroupPB, InsertedRowPB};
use crate::services::group::action::{
DidMoveGroupRowResult, DidUpdateGroupRowResult, GroupControllerOperation,
};
@ -44,7 +44,7 @@ impl GroupControllerOperation for DefaultGroupController {
&self.field_id
}
fn groups(&self) -> Vec<&GroupData> {
fn get_all_groups(&self) -> Vec<&GroupData> {
vec![&self.group]
}
@ -59,10 +59,34 @@ impl GroupControllerOperation for DefaultGroupController {
Ok(())
}
fn create_group(
&mut self,
_name: String,
) -> FlowyResult<(Option<TypeOptionData>, Option<InsertedGroupPB>)> {
Ok((None, None))
}
fn move_group(&mut self, _from_group_id: &str, _to_group_id: &str) -> FlowyResult<()> {
Ok(())
}
fn did_create_row(
&mut self,
row_detail: &RowDetail,
index: usize,
) -> Vec<GroupRowsNotificationPB> {
self.group.add_row(row_detail.clone());
vec![GroupRowsNotificationPB::insert(
self.group.id.clone(),
vec![InsertedRowPB {
row_meta: row_detail.into(),
index: Some(index as i32),
is_new: true,
}],
)]
}
fn did_update_group_row(
&mut self,
_old_row_detail: &Option<RowDetail>,
@ -76,14 +100,15 @@ impl GroupControllerOperation for DefaultGroupController {
})
}
fn did_delete_delete_row(
&mut self,
_row: &Row,
_field: &Field,
) -> FlowyResult<DidMoveGroupRowResult> {
fn did_delete_row(&mut self, row: &Row) -> FlowyResult<DidMoveGroupRowResult> {
let mut changeset = GroupRowsNotificationPB::new(self.group.id.clone());
if self.group.contains_row(&row.id) {
self.group.remove_row(&row.id);
changeset.deleted_rows.push(row.id.clone().into_inner());
}
Ok(DidMoveGroupRowResult {
deleted_group: None,
row_changesets: vec![],
row_changesets: vec![changeset],
})
}
@ -115,6 +140,4 @@ impl GroupController for DefaultGroupController {
}
fn will_create_row(&mut self, _cells: &mut Cells, _field: &Field, _group_id: &str) {}
fn did_create_row(&mut self, _row_detail: &RowDetail, _group_id: &str) {}
}

View File

@ -1,9 +1,10 @@
use async_trait::async_trait;
use collab_database::fields::{Field, TypeOptionData};
use collab_database::rows::{new_cell_builder, Cell, Cells, Row, RowDetail};
use flowy_error::FlowyResult;
use serde::{Deserialize, Serialize};
use crate::entities::{FieldType, GroupRowsNotificationPB};
use crate::entities::{FieldType, GroupPB, GroupRowsNotificationPB, InsertedGroupPB};
use crate::services::cell::insert_select_option_cell;
use crate::services::field::{
MultiSelectTypeOption, SelectOption, SelectOptionCellDataParser, SelectTypeOptionSharedAction,
@ -13,7 +14,7 @@ use crate::services::group::action::GroupCustomize;
use crate::services::group::controller::{BaseGroupController, GroupController};
use crate::services::group::{
add_or_remove_select_option_row, generate_select_option_groups, make_no_status_group,
move_group_row, remove_select_option_row, GeneratedGroups, GroupChangeset, GroupContext,
move_group_row, remove_select_option_row, GeneratedGroups, Group, GroupChangeset, GroupContext,
GroupOperationInterceptor, GroupsBuilder, MoveGroupRowContext,
};
@ -69,14 +70,14 @@ impl GroupCustomize for MultiSelectGroupController {
&mut self,
row: &Row,
cell_data: &<Self::GroupTypeOption as TypeOption>::CellData,
) -> Vec<GroupRowsNotificationPB> {
) -> (Option<GroupPB>, Vec<GroupRowsNotificationPB>) {
let mut changesets = vec![];
self.context.iter_mut_status_groups(|group| {
if let Some(changeset) = remove_select_option_row(group, cell_data, row) {
changesets.push(changeset);
}
});
changesets
(None, changesets)
}
fn move_row(
@ -92,6 +93,20 @@ impl GroupCustomize for MultiSelectGroupController {
});
group_changeset
}
fn generate_new_group(
&mut self,
name: String,
) -> FlowyResult<(Option<TypeOptionData>, Option<InsertedGroupPB>)> {
let mut new_type_option = self.type_option.clone();
let new_select_option = self.type_option.create_option(&name);
new_type_option.insert_option(new_select_option.clone());
let new_group = Group::new(new_select_option.id, new_select_option.name);
let inserted_group_pb = self.context.add_new_group(new_group)?;
Ok((Some(new_type_option.into()), Some(inserted_group_pb)))
}
}
impl GroupController for MultiSelectGroupController {
@ -106,12 +121,6 @@ impl GroupController for MultiSelectGroupController {
},
}
}
fn did_create_row(&mut self, row_detail: &RowDetail, group_id: &str) {
if let Some(group) = self.context.get_mut_group(group_id) {
group.add_row(row_detail.clone())
}
}
}
pub struct MultiSelectGroupBuilder;

View File

@ -1,9 +1,10 @@
use async_trait::async_trait;
use collab_database::fields::{Field, TypeOptionData};
use collab_database::rows::{new_cell_builder, Cell, Cells, Row, RowDetail};
use flowy_error::FlowyResult;
use serde::{Deserialize, Serialize};
use crate::entities::{FieldType, GroupRowsNotificationPB};
use crate::entities::{FieldType, GroupPB, GroupRowsNotificationPB, InsertedGroupPB};
use crate::services::cell::insert_select_option_cell;
use crate::services::field::{
SelectOption, SelectOptionCellDataParser, SelectTypeOptionSharedAction, SingleSelectTypeOption,
@ -14,8 +15,8 @@ use crate::services::group::controller::{BaseGroupController, GroupController};
use crate::services::group::controller_impls::select_option_controller::util::*;
use crate::services::group::entities::GroupData;
use crate::services::group::{
make_no_status_group, GeneratedGroups, GroupChangeset, GroupContext, GroupOperationInterceptor,
GroupsBuilder, MoveGroupRowContext,
make_no_status_group, GeneratedGroups, Group, GroupChangeset, GroupContext,
GroupOperationInterceptor, GroupsBuilder, MoveGroupRowContext,
};
#[derive(Default, Serialize, Deserialize)]
@ -70,14 +71,14 @@ impl GroupCustomize for SingleSelectGroupController {
&mut self,
row: &Row,
cell_data: &<Self::GroupTypeOption as TypeOption>::CellData,
) -> Vec<GroupRowsNotificationPB> {
) -> (Option<GroupPB>, Vec<GroupRowsNotificationPB>) {
let mut changesets = vec![];
self.context.iter_mut_status_groups(|group| {
if let Some(changeset) = remove_select_option_row(group, cell_data, row) {
changesets.push(changeset);
}
});
changesets
(None, changesets)
}
fn move_row(
@ -93,6 +94,23 @@ impl GroupCustomize for SingleSelectGroupController {
});
group_changeset
}
fn generate_new_group(
&mut self,
name: String,
) -> FlowyResult<(Option<TypeOptionData>, Option<InsertedGroupPB>)> {
let mut new_type_option = self.type_option.clone();
let new_select_option = self.type_option.create_option(&name);
new_type_option.insert_option_at_index(
new_select_option.clone(),
Some(new_type_option.options.len()),
);
let new_group = Group::new(new_select_option.id, new_select_option.name);
let inserted_group_pb = self.context.add_new_group(new_group)?;
Ok((Some(new_type_option.into()), Some(inserted_group_pb)))
}
}
impl GroupController for SingleSelectGroupController {
@ -108,12 +126,6 @@ impl GroupController for SingleSelectGroupController {
},
}
}
fn did_create_row(&mut self, row_detail: &RowDetail, group_id: &str) {
if let Some(group) = self.context.get_mut_group(group_id) {
group.add_row(row_detail.clone())
}
}
}
pub struct SingleSelectGroupBuilder();

View File

@ -185,6 +185,7 @@ pub fn make_inserted_cell(group_id: &str, field: &Field) -> Option<Cell> {
},
}
}
pub fn generate_select_option_groups(
_field_id: &str,
options: &[SelectOption],

View File

@ -128,8 +128,8 @@ impl GroupCustomize for URLGroupController {
fn delete_row(
&mut self,
row: &Row,
_cell_data: &<Self::GroupTypeOption as TypeOption>::CellData,
) -> Vec<GroupRowsNotificationPB> {
cell_data: &<Self::GroupTypeOption as TypeOption>::CellData,
) -> (Option<GroupPB>, Vec<GroupRowsNotificationPB>) {
let mut changesets = vec![];
self.context.iter_mut_groups(|group| {
let mut changeset = GroupRowsNotificationPB::new(group.id.clone());
@ -142,7 +142,18 @@ impl GroupCustomize for URLGroupController {
changesets.push(changeset);
}
});
changesets
let deleted_group = match self.context.get_group(&cell_data.data) {
Some((_, group)) if group.rows.len() == 1 => Some(group.clone()),
_ => None,
};
let deleted_group = deleted_group.map(|group| {
let _ = self.context.delete_group(&group.id);
group.into()
});
(deleted_group, changesets)
}
fn move_row(
@ -190,12 +201,6 @@ impl GroupController for URLGroupController {
},
}
}
fn did_create_row(&mut self, row_detail: &RowDetail, group_id: &str) {
if let Some(group) = self.context.get_mut_group(group_id) {
group.add_row(row_detail.clone())
}
}
}
pub struct URLGroupGenerator();

View File

@ -67,6 +67,9 @@ pub enum GroupScript {
group_id: String,
group_name: String,
},
CreateGroup {
name: String,
},
}
pub struct DatabaseGroupTest {
@ -269,6 +272,11 @@ impl DatabaseGroupTest {
assert_eq!(group_id, group.group_id, "group index: {}", group_index);
assert_eq!(group_name, group.group_name, "group index: {}", group_index);
},
GroupScript::CreateGroup { name } => self
.editor
.create_group(&self.view_id, &name)
.await
.unwrap(),
}
}

View File

@ -486,3 +486,19 @@ async fn group_group_by_other_field() {
];
test.run_scripts(scripts).await;
}
#[tokio::test]
async fn group_manual_create_new_group() {
let mut test = DatabaseGroupTest::new().await;
let new_group_name = "Resumed";
let scripts = vec![
AssertGroupCount(4),
CreateGroup {
name: new_group_name.to_string(),
},
AssertGroupCount(5),
];
test.run_scripts(scripts).await;
let new_group = test.group_at_index(4).await;
assert_eq!(new_group.group_name, new_group_name);
}