diff --git a/frontend/app_flowy/assets/translations/en.json b/frontend/app_flowy/assets/translations/en.json index 875d7d1ed3..0300be94c9 100644 --- a/frontend/app_flowy/assets/translations/en.json +++ b/frontend/app_flowy/assets/translations/en.json @@ -184,6 +184,13 @@ "isNotEmpty": "is not empty" } }, + "checkboxFilter": { + "isChecked": "Checked", + "isUnchecked": "Unchecked", + "choicechipPrefix": { + "is": "is" + } + }, "field": { "hide": "Hide", "insertLeft": "Insert Left", diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/field_controller.dart b/frontend/app_flowy/lib/plugins/grid/application/field/field_controller.dart index 85885665f8..bfc53281c1 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/field/field_controller.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/field/field_controller.dart @@ -527,9 +527,15 @@ class FieldInfo { bool get canCreateFilter { if (hasFilter) return false; - if (_field.fieldType != FieldType.RichText) return false; - - return true; + switch (_field.fieldType) { + case FieldType.Checkbox: + // case FieldType.MultiSelect: + case FieldType.RichText: + // case FieldType.SingleSelect: + return true; + default: + return false; + } } FieldInfo({required FieldPB field}) : _field = field; diff --git a/frontend/app_flowy/lib/plugins/grid/application/filter/checkbox_filter_editor_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/filter/checkbox_filter_editor_bloc.dart new file mode 100644 index 0000000000..53d8244ec9 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/grid/application/filter/checkbox_filter_editor_bloc.dart @@ -0,0 +1,99 @@ +import 'package:app_flowy/plugins/grid/presentation/widgets/filter/filter_info.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/checkbox_filter.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/util.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'dart:async'; +import 'filter_listener.dart'; +import 'filter_service.dart'; + +part 'checkbox_filter_editor_bloc.freezed.dart'; + +class CheckboxFilterEditorBloc + extends Bloc { + final FilterInfo filterInfo; + final FilterFFIService _ffiService; + final FilterListener _listener; + + CheckboxFilterEditorBloc({required this.filterInfo}) + : _ffiService = FilterFFIService(viewId: filterInfo.viewId), + _listener = FilterListener( + viewId: filterInfo.viewId, + filterId: filterInfo.filter.id, + ), + super(CheckboxFilterEditorState.initial(filterInfo)) { + on( + (event, emit) async { + event.when( + initial: () async { + _startListening(); + }, + updateCondition: (CheckboxFilterCondition condition) { + _ffiService.insertCheckboxFilter( + filterId: filterInfo.filter.id, + fieldId: filterInfo.field.id, + condition: condition, + ); + }, + delete: () { + _ffiService.deleteFilter( + fieldId: filterInfo.field.id, + filterId: filterInfo.filter.id, + fieldType: filterInfo.field.fieldType, + ); + }, + didReceiveFilter: (FilterPB filter) { + final filterInfo = state.filterInfo.copyWith(filter: filter); + final checkboxFilter = filterInfo.checkboxFilter()!; + emit(state.copyWith( + filterInfo: filterInfo, + filter: checkboxFilter, + )); + }, + ); + }, + ); + } + + void _startListening() { + _listener.start( + onDeleted: () { + if (!isClosed) add(const CheckboxFilterEditorEvent.delete()); + }, + onUpdated: (filter) { + if (!isClosed) add(CheckboxFilterEditorEvent.didReceiveFilter(filter)); + }, + ); + } + + @override + Future close() async { + await _listener.stop(); + return super.close(); + } +} + +@freezed +class CheckboxFilterEditorEvent with _$CheckboxFilterEditorEvent { + const factory CheckboxFilterEditorEvent.initial() = _Initial; + const factory CheckboxFilterEditorEvent.didReceiveFilter(FilterPB filter) = + _DidReceiveFilter; + const factory CheckboxFilterEditorEvent.updateCondition( + CheckboxFilterCondition condition) = _UpdateCondition; + const factory CheckboxFilterEditorEvent.delete() = _Delete; +} + +@freezed +class CheckboxFilterEditorState with _$CheckboxFilterEditorState { + const factory CheckboxFilterEditorState({ + required FilterInfo filterInfo, + required CheckboxFilterPB filter, + }) = _GridFilterState; + + factory CheckboxFilterEditorState.initial(FilterInfo filterInfo) { + return CheckboxFilterEditorState( + filterInfo: filterInfo, + filter: filterInfo.checkboxFilter()!, + ); + } +} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/checkbox.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/checkbox.dart index fa8fba31b1..c1d21ff566 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/checkbox.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/checkbox.dart @@ -1,15 +1,210 @@ +import 'package:app_flowy/generated/locale_keys.g.dart'; +import 'package:app_flowy/plugins/grid/application/filter/checkbox_filter_editor_bloc.dart'; +import 'package:app_flowy/plugins/grid/presentation/widgets/filter/condition_button.dart'; +import 'package:app_flowy/plugins/grid/presentation/widgets/filter/disclosure_button.dart'; import 'package:app_flowy/plugins/grid/presentation/widgets/filter/filter_info.dart'; +import 'package:app_flowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/image.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/checkbox_filter.pbenum.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'choicechip.dart'; -class CheckboxFilterChoicechip extends StatelessWidget { +class CheckboxFilterChoicechip extends StatefulWidget { final FilterInfo filterInfo; const CheckboxFilterChoicechip({required this.filterInfo, Key? key}) : super(key: key); + @override + State createState() => + _CheckboxFilterChoicechipState(); +} + +class _CheckboxFilterChoicechipState extends State { + late CheckboxFilterEditorBloc bloc; + + @override + void initState() { + bloc = CheckboxFilterEditorBloc(filterInfo: widget.filterInfo) + ..add(const CheckboxFilterEditorEvent.initial()); + super.initState(); + } + + @override + void dispose() { + bloc.close(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return ChoiceChipButton(filterInfo: filterInfo); + return BlocProvider.value( + value: bloc, + child: BlocBuilder( + builder: (blocContext, state) { + return AppFlowyPopover( + controller: PopoverController(), + constraints: BoxConstraints.loose(const Size(200, 76)), + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: (BuildContext context) { + return CheckboxFilterEditor(bloc: bloc); + }, + child: ChoiceChipButton( + filterInfo: widget.filterInfo, + filterDesc: _makeFilterDesc(state), + ), + ); + }, + ), + ); + } + + String _makeFilterDesc(CheckboxFilterEditorState state) { + final prefix = LocaleKeys.grid_checkboxFilter_choicechipPrefix_is.tr(); + return "$prefix ${state.filter.condition.filterName}"; + } +} + +class CheckboxFilterEditor extends StatefulWidget { + final CheckboxFilterEditorBloc bloc; + const CheckboxFilterEditor({required this.bloc, Key? key}) : super(key: key); + + @override + State createState() => _CheckboxFilterEditorState(); +} + +class _CheckboxFilterEditorState extends State { + final popoverMutex = PopoverMutex(); + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: widget.bloc, + child: BlocBuilder( + builder: (context, state) { + final List children = [ + _buildFilterPannel(context, state), + ]; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: IntrinsicHeight(child: Column(children: children)), + ); + }, + ), + ); + } + + Widget _buildFilterPannel( + BuildContext context, CheckboxFilterEditorState state) { + return SizedBox( + height: 20, + child: Row( + children: [ + FlowyText(state.filterInfo.field.name), + const HSpace(4), + CheckboxFilterConditionList( + filterInfo: state.filterInfo, + popoverMutex: popoverMutex, + onCondition: (condition) { + context + .read() + .add(CheckboxFilterEditorEvent.updateCondition(condition)); + }, + ), + const Spacer(), + DisclosureButton( + popoverMutex: popoverMutex, + onAction: (action) { + switch (action) { + case FilterDisclosureAction.delete: + context + .read() + .add(const CheckboxFilterEditorEvent.delete()); + break; + } + }, + ), + ], + ), + ); + } +} + +class CheckboxFilterConditionList extends StatelessWidget { + final FilterInfo filterInfo; + final PopoverMutex popoverMutex; + final Function(CheckboxFilterCondition) onCondition; + const CheckboxFilterConditionList({ + required this.filterInfo, + required this.popoverMutex, + required this.onCondition, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final checkboxFilter = filterInfo.checkboxFilter()!; + return PopoverActionList( + asBarrier: true, + mutex: popoverMutex, + direction: PopoverDirection.bottomWithCenterAligned, + actions: CheckboxFilterCondition.values + .map( + (action) => ConditionWrapper( + action, + checkboxFilter.condition == action, + ), + ) + .toList(), + buildChild: (controller) { + return ConditionButton( + conditionName: checkboxFilter.condition.filterName, + onTap: () => controller.show(), + ); + }, + onSelected: (action, controller) async { + onCondition(action.inner); + controller.close(); + }, + ); + } +} + +class ConditionWrapper extends ActionCell { + final CheckboxFilterCondition inner; + final bool isSelected; + + ConditionWrapper(this.inner, this.isSelected); + + @override + Widget? rightIcon(Color iconColor) { + if (isSelected) { + return svgWidget("grid/checkmark"); + } else { + return null; + } + } + + @override + String get name => inner.filterName; +} + +extension TextFilterConditionExtension on CheckboxFilterCondition { + String get filterName { + switch (this) { + case CheckboxFilterCondition.IsChecked: + return LocaleKeys.grid_checkboxFilter_isChecked.tr(); + case CheckboxFilterCondition.IsUnChecked: + return LocaleKeys.grid_checkboxFilter_isUnchecked.tr(); + default: + return ""; + } } } diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/filter_info.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/filter_info.dart index c1f11bf998..4cf9c8befd 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/filter_info.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/filter_info.dart @@ -1,4 +1,5 @@ import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/checkbox_filter.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/date_filter.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/text_filter.pb.dart'; @@ -32,4 +33,11 @@ class FilterInfo { } return TextFilterPB.fromBuffer(filter.data); } + + CheckboxFilterPB? checkboxFilter() { + if (filter.fieldType != FieldType.Checkbox) { + return null; + } + return CheckboxFilterPB.fromBuffer(filter.data); + } } diff --git a/frontend/app_flowy/test/bloc_test/grid_test/filter/filter_menu_test.dart b/frontend/app_flowy/test/bloc_test/grid_test/filter/filter_menu_test.dart index 604b09d93d..2ecb6cb313 100644 --- a/frontend/app_flowy/test/bloc_test/grid_test/filter/filter_menu_test.dart +++ b/frontend/app_flowy/test/bloc_test/grid_test/filter/filter_menu_test.dart @@ -17,7 +17,7 @@ void main() { viewId: context.gridView.id, fieldController: context.fieldController) ..add(const GridFilterMenuEvent.initial()); await gridResponseFuture(); - assert(menuBloc.state.creatableFields.length == 1); + assert(menuBloc.state.creatableFields.length == 2); final service = FilterFFIService(viewId: context.gridView.id); final textField = context.textFieldContext(); @@ -26,7 +26,7 @@ void main() { condition: TextFilterCondition.TextIsEmpty, content: ""); await gridResponseFuture(); - assert(menuBloc.state.creatableFields.isEmpty); + assert(menuBloc.state.creatableFields.length == 1); }); test('test filter menu after update existing text filter)', () async { diff --git a/frontend/app_flowy/test/bloc_test/grid_test/filter/filter_rows_test.dart b/frontend/app_flowy/test/bloc_test/grid_test/filter/filter_rows_test.dart index 4644c76ac3..bea99b55e9 100644 --- a/frontend/app_flowy/test/bloc_test/grid_test/filter/filter_rows_test.dart +++ b/frontend/app_flowy/test/bloc_test/grid_test/filter/filter_rows_test.dart @@ -11,6 +11,58 @@ void main() { gridTest = await AppFlowyGridTest.ensureInitialized(); }); + test('filter rows by text is empty condition)', () async { + final context = await createTestFilterGrid(gridTest); + + final service = FilterFFIService(viewId: context.gridView.id); + final textField = context.textFieldContext(); + // create a new filter + await service.insertTextFilter( + fieldId: textField.id, + condition: TextFilterCondition.TextIsEmpty, + content: ""); + await gridResponseFuture(); + assert(context.fieldController.filterInfos.length == 1, + "expect 1 but receive ${context.fieldController.filterInfos.length}"); + assert(context.rowInfos.length == 1, + "expect 1 but receive ${context.rowInfos.length}"); + + // delete the filter + final textFilter = context.fieldController.filterInfos.first; + await service.deleteFilter( + fieldId: textField.id, + filterId: textFilter.filter.id, + fieldType: textField.fieldType, + ); + await gridResponseFuture(); + assert(context.rowInfos.length == 3); + }); + + test('filter rows by text is not empty condition)', () async { + final context = await createTestFilterGrid(gridTest); + + final service = FilterFFIService(viewId: context.gridView.id); + final textField = context.textFieldContext(); + // create a new filter + await service.insertTextFilter( + fieldId: textField.id, + condition: TextFilterCondition.TextIsNotEmpty, + content: ""); + await gridResponseFuture(); + assert(context.rowInfos.length == 2, + "expect 2 but receive ${context.rowInfos.length}"); + + // delete the filter + final textFilter = context.fieldController.filterInfos.first; + await service.deleteFilter( + fieldId: textField.id, + filterId: textFilter.filter.id, + fieldType: textField.fieldType, + ); + await gridResponseFuture(); + assert(context.rowInfos.length == 3); + }); + test('filter rows by text is empty or is not empty condition)', () async { final context = await createTestFilterGrid(gridTest); diff --git a/frontend/rust-lib/flowy-grid/src/services/filter/cache.rs b/frontend/rust-lib/flowy-grid/src/services/filter/cache.rs index 6d0f81d242..ae799289fe 100644 --- a/frontend/rust-lib/flowy-grid/src/services/filter/cache.rs +++ b/frontend/rust-lib/flowy-grid/src/services/filter/cache.rs @@ -95,15 +95,13 @@ pub(crate) struct FilterResult { impl FilterResult { pub(crate) fn is_visible(&self) -> bool { - if self.visible_by_filter_id.is_empty() { - return false; - } - + let mut is_visible = true; for visible in self.visible_by_filter_id.values() { - if visible == &false { - return false; + if !is_visible { + break; } + is_visible = *visible; } - true + is_visible } } diff --git a/frontend/rust-lib/flowy-grid/src/services/filter/controller.rs b/frontend/rust-lib/flowy-grid/src/services/filter/controller.rs index c6d9a90d3c..f1e807f304 100644 --- a/frontend/rust-lib/flowy-grid/src/services/filter/controller.rs +++ b/frontend/rust-lib/flowy-grid/src/services/filter/controller.rs @@ -309,11 +309,13 @@ fn filter_row( let filter_result = result_by_row_id .entry(row_rev.id.clone()) .or_insert_with(FilterResult::default); + let old_is_visible = filter_result.is_visible(); // Iterate each cell of the row to check its visibility for (field_id, field_rev) in field_rev_by_field_id { let filter_type = FilterType::from(field_rev); if !filter_map.has_filter(&filter_type) { + filter_result.visible_by_filter_id.remove(&filter_type); continue; } @@ -321,16 +323,16 @@ fn filter_row( // if the visibility of the cell_rew is changed, which means the visibility of the // row is changed too. if let Some(is_visible) = filter_cell(&filter_type, field_rev, filter_map, cell_rev) { - let old_is_visible = filter_result.visible_by_filter_id.get(&filter_type).cloned(); filter_result.visible_by_filter_id.insert(filter_type, is_visible); - return if old_is_visible != Some(is_visible) { - Some((row_rev.id.clone(), is_visible)) - } else { - None - }; } } - Some((row_rev.id.clone(), true)) + + let is_visible = filter_result.is_visible(); + return if old_is_visible != is_visible { + Some((row_rev.id.clone(), is_visible)) + } else { + None + }; } // Returns None if there is no change in this cell after applying the filter diff --git a/frontend/rust-lib/flowy-grid/tests/grid/filter_test/checkbox_filter_test.rs b/frontend/rust-lib/flowy-grid/tests/grid/filter_test/checkbox_filter_test.rs index c50945a23f..0a28bb5d15 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/filter_test/checkbox_filter_test.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/filter_test/checkbox_filter_test.rs @@ -5,12 +5,14 @@ use flowy_grid::entities::CheckboxFilterCondition; #[tokio::test] async fn grid_filter_checkbox_is_check_test() { let mut test = GridFilterTest::new().await; + // The initial number of unchecked is 3 + // The initial number of checked is 2 let scripts = vec![ CreateCheckboxFilter { condition: CheckboxFilterCondition::IsChecked, }, AssertFilterChanged { - visible_row_len: 2, + visible_row_len: 0, hide_row_len: 3, }, ]; diff --git a/frontend/rust-lib/flowy-grid/tests/grid/filter_test/text_filter_test.rs b/frontend/rust-lib/flowy-grid/tests/grid/filter_test/text_filter_test.rs index 1167a7e6e6..ffb37fcb8a 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/filter_test/text_filter_test.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/filter_test/text_filter_test.rs @@ -13,7 +13,7 @@ async fn grid_filter_text_is_empty_test() { }, AssertFilterCount { count: 1 }, AssertFilterChanged { - visible_row_len: 1, + visible_row_len: 0, hide_row_len: 4, }, ]; @@ -23,6 +23,7 @@ async fn grid_filter_text_is_empty_test() { #[tokio::test] async fn grid_filter_text_is_not_empty_test() { let mut test = GridFilterTest::new().await; + // Only one row's text of the initial rows is "" let scripts = vec![ CreateTextFilter { condition: TextFilterCondition::TextIsNotEmpty, @@ -30,23 +31,38 @@ async fn grid_filter_text_is_not_empty_test() { }, AssertFilterCount { count: 1 }, AssertFilterChanged { - visible_row_len: 4, + visible_row_len: 0, hide_row_len: 1, }, ]; test.run_scripts(scripts).await; + + let filter = test.grid_filters().await.pop().unwrap(); + let field_rev = test.get_field_rev(FieldType::RichText).clone(); + test.run_scripts(vec![ + DeleteFilter { + filter_id: filter.id, + filter_type: FilterType::from(&field_rev), + }, + // AssertFilterChanged { + // visible_row_len: 1, + // hide_row_len: 0, + // }, + ]) + .await; } #[tokio::test] async fn grid_filter_is_text_test() { let mut test = GridFilterTest::new().await; + // Only one row's text of the initial rows is "A" let scripts = vec![ CreateTextFilter { condition: TextFilterCondition::Is, content: "A".to_string(), }, AssertFilterChanged { - visible_row_len: 1, + visible_row_len: 0, hide_row_len: 4, }, ]; @@ -62,7 +78,7 @@ async fn grid_filter_contain_text_test() { content: "A".to_string(), }, AssertFilterChanged { - visible_row_len: 3, + visible_row_len: 0, hide_row_len: 2, }, ]; @@ -78,7 +94,7 @@ async fn grid_filter_contain_text_test2() { content: "A".to_string(), }, AssertFilterChanged { - visible_row_len: 3, + visible_row_len: 0, hide_row_len: 2, }, UpdateTextCell { @@ -96,18 +112,20 @@ async fn grid_filter_contain_text_test2() { #[tokio::test] async fn grid_filter_does_not_contain_text_test() { let mut test = GridFilterTest::new().await; + // None of the initial rows contains the text "AB" let scripts = vec![ CreateTextFilter { condition: TextFilterCondition::DoesNotContain, content: "AB".to_string(), }, AssertFilterChanged { - visible_row_len: 5, + visible_row_len: 0, hide_row_len: 0, }, ]; test.run_scripts(scripts).await; } + #[tokio::test] async fn grid_filter_start_with_text_test() { let mut test = GridFilterTest::new().await; @@ -117,7 +135,7 @@ async fn grid_filter_start_with_text_test() { content: "A".to_string(), }, AssertFilterChanged { - visible_row_len: 2, + visible_row_len: 0, hide_row_len: 3, }, ]; @@ -201,7 +219,7 @@ async fn grid_filter_update_empty_text_cell_test() { }, AssertFilterCount { count: 1 }, AssertFilterChanged { - visible_row_len: 1, + visible_row_len: 0, hide_row_len: 4, }, UpdateTextCell {