浏览代码

feat: support checkbox filter (#1492)

* feat: support checkbox filter

* fix: unit test

Co-authored-by: nathan <[email protected]>
Nathan.fooo 2 年之前
父节点
当前提交
c47f755155

+ 7 - 0
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",

+ 9 - 3
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;

+ 99 - 0
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<CheckboxFilterEditorEvent, CheckboxFilterEditorState> {
+  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<CheckboxFilterEditorEvent>(
+      (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<void> 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()!,
+    );
+  }
+}

+ 197 - 2
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<CheckboxFilterChoicechip> createState() =>
+      _CheckboxFilterChoicechipState();
+}
+
+class _CheckboxFilterChoicechipState extends State<CheckboxFilterChoicechip> {
+  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 BlocProvider.value(
+      value: bloc,
+      child: BlocBuilder<CheckboxFilterEditorBloc, CheckboxFilterEditorState>(
+        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<CheckboxFilterEditor> createState() => _CheckboxFilterEditorState();
+}
+
+class _CheckboxFilterEditorState extends State<CheckboxFilterEditor> {
+  final popoverMutex = PopoverMutex();
+
   @override
   Widget build(BuildContext context) {
-    return ChoiceChipButton(filterInfo: filterInfo);
+    return BlocProvider.value(
+      value: widget.bloc,
+      child: BlocBuilder<CheckboxFilterEditorBloc, CheckboxFilterEditorState>(
+        builder: (context, state) {
+          final List<Widget> 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<CheckboxFilterEditorBloc>()
+                  .add(CheckboxFilterEditorEvent.updateCondition(condition));
+            },
+          ),
+          const Spacer(),
+          DisclosureButton(
+            popoverMutex: popoverMutex,
+            onAction: (action) {
+              switch (action) {
+                case FilterDisclosureAction.delete:
+                  context
+                      .read<CheckboxFilterEditorBloc>()
+                      .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<ConditionWrapper>(
+      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 "";
+    }
   }
 }

+ 8 - 0
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);
+  }
 }

+ 2 - 2
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 {

+ 52 - 0
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);
 

+ 5 - 7
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
     }
 }

+ 9 - 7
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

+ 3 - 1
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,
         },
     ];

+ 26 - 8
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 {