소스 검색

Feat: support single select option filter (#1494)

* feat: support select option filter

* chore: select option filter ui

* chore: support edit single select filter

* chore: add flutter tests

Co-authored-by: nathan <[email protected]>
Nathan.fooo 2 년 전
부모
커밋
bd32ce5543
18개의 변경된 파일814개의 추가작업 그리고 43개의 파일을 삭제
  1. 12 0
      frontend/app_flowy/assets/translations/en.json
  2. 1 1
      frontend/app_flowy/lib/plugins/grid/application/field/field_controller.dart
  3. 4 2
      frontend/app_flowy/lib/plugins/grid/application/filter/filter_create_bloc.dart
  4. 3 22
      frontend/app_flowy/lib/plugins/grid/application/filter/filter_service.dart
  5. 148 0
      frontend/app_flowy/lib/plugins/grid/application/filter/select_option_filter_bloc.dart
  6. 0 0
      frontend/app_flowy/lib/plugins/grid/application/filter/select_option_filter_editor_bloc.dart
  7. 156 0
      frontend/app_flowy/lib/plugins/grid/application/filter/select_option_filter_list_bloc.dart
  8. 0 15
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option.dart
  9. 117 0
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart
  10. 108 0
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart
  11. 159 0
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart
  12. 10 0
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/filter_info.dart
  13. 1 1
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/menu_item.dart
  14. 34 0
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/builder.dart
  15. 2 2
      frontend/app_flowy/test/bloc_test/grid_test/filter/filter_menu_test.dart
  16. 51 0
      frontend/app_flowy/test/bloc_test/grid_test/filter/filter_rows_by_checkbox_test.dart
  17. 0 0
      frontend/app_flowy/test/bloc_test/grid_test/filter/filter_rows_by_text_test.dart
  18. 8 0
      frontend/app_flowy/test/bloc_test/grid_test/util.dart

+ 12 - 0
frontend/app_flowy/assets/translations/en.json

@@ -191,6 +191,18 @@
         "is": "is"
         "is": "is"
       }
       }
     },
     },
+    "singleSelectOptionFilter": {
+      "is": "Is",
+      "isNot": "Is not",
+      "isEmpty": "Is empty",
+      "isNotEmpty": "Is not empty"
+    },
+    "multiSelectOptionFilter": {
+      "contains": "Contains",
+      "doesNotContain": "Does not contain",
+      "isEmpty": "Is empty",
+      "isNotEmpty": "Is not empty"
+    },
     "field": {
     "field": {
       "hide": "Hide",
       "hide": "Hide",
       "insertLeft": "Insert Left",
       "insertLeft": "Insert Left",

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

@@ -531,7 +531,7 @@ class FieldInfo {
       case FieldType.Checkbox:
       case FieldType.Checkbox:
       // case FieldType.MultiSelect:
       // case FieldType.MultiSelect:
       case FieldType.RichText:
       case FieldType.RichText:
-        // case FieldType.SingleSelect:
+      case FieldType.SingleSelect:
         return true;
         return true;
       default:
       default:
         return false;
         return false;

+ 4 - 2
frontend/app_flowy/lib/plugins/grid/application/filter/filter_create_bloc.dart

@@ -99,9 +99,10 @@ class GridCreateFilterBloc
           timestamp: timestamp,
           timestamp: timestamp,
         );
         );
       case FieldType.MultiSelect:
       case FieldType.MultiSelect:
-        return _ffiService.insertSingleSelectFilter(
+        return _ffiService.insertSelectOptionFilter(
           fieldId: fieldId,
           fieldId: fieldId,
           condition: SelectOptionCondition.OptionIs,
           condition: SelectOptionCondition.OptionIs,
+          fieldType: FieldType.MultiSelect,
         );
         );
       case FieldType.Number:
       case FieldType.Number:
         return _ffiService.insertNumberFilter(
         return _ffiService.insertNumberFilter(
@@ -116,9 +117,10 @@ class GridCreateFilterBloc
           content: '',
           content: '',
         );
         );
       case FieldType.SingleSelect:
       case FieldType.SingleSelect:
-        return _ffiService.insertSingleSelectFilter(
+        return _ffiService.insertSelectOptionFilter(
           fieldId: fieldId,
           fieldId: fieldId,
           condition: SelectOptionCondition.OptionIs,
           condition: SelectOptionCondition.OptionIs,
+          fieldType: FieldType.SingleSelect,
         );
         );
       case FieldType.URL:
       case FieldType.URL:
         return _ffiService.insertURLFilter(
         return _ffiService.insertURLFilter(

+ 3 - 22
frontend/app_flowy/lib/plugins/grid/application/filter/filter_service.dart

@@ -126,28 +126,11 @@ class FilterFFIService {
     );
     );
   }
   }
 
 
-  Future<Either<Unit, FlowyError>> insertSingleSelectFilter({
+  Future<Either<Unit, FlowyError>> insertSelectOptionFilter({
     required String fieldId,
     required String fieldId,
-    String? filterId,
+    required FieldType fieldType,
     required SelectOptionCondition condition,
     required SelectOptionCondition condition,
-    List<String> optionIds = const [],
-  }) {
-    final filter = SelectOptionFilterPB()
-      ..condition = condition
-      ..optionIds.addAll(optionIds);
-
-    return insertFilter(
-      fieldId: fieldId,
-      filterId: filterId,
-      fieldType: FieldType.SingleSelect,
-      data: filter.writeToBuffer(),
-    );
-  }
-
-  Future<Either<Unit, FlowyError>> insertMultiSelectFilter({
-    required String fieldId,
     String? filterId,
     String? filterId,
-    required SelectOptionCondition condition,
     List<String> optionIds = const [],
     List<String> optionIds = const [],
   }) {
   }) {
     final filter = SelectOptionFilterPB()
     final filter = SelectOptionFilterPB()
@@ -157,7 +140,7 @@ class FilterFFIService {
     return insertFilter(
     return insertFilter(
       fieldId: fieldId,
       fieldId: fieldId,
       filterId: filterId,
       filterId: filterId,
-      fieldType: FieldType.MultiSelect,
+      fieldType: fieldType,
       data: filter.writeToBuffer(),
       data: filter.writeToBuffer(),
     );
     );
   }
   }
@@ -168,8 +151,6 @@ class FilterFFIService {
     required FieldType fieldType,
     required FieldType fieldType,
     required List<int> data,
     required List<int> data,
   }) {
   }) {
-    TextFilterCondition.DoesNotContain.value;
-
     var insertFilterPayload = AlterFilterPayloadPB.create()
     var insertFilterPayload = AlterFilterPayloadPB.create()
       ..fieldId = fieldId
       ..fieldId = fieldId
       ..fieldType = fieldType
       ..fieldType = fieldType

+ 148 - 0
frontend/app_flowy/lib/plugins/grid/application/filter/select_option_filter_bloc.dart

@@ -0,0 +1,148 @@
+import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart';
+import 'package:app_flowy/plugins/grid/presentation/widgets/filter/filter_info.dart';
+import 'package:app_flowy/plugins/grid/presentation/widgets/header/type_option/builder.dart';
+import 'package:flowy_sdk/log.dart';
+import 'package:flowy_sdk/protobuf/flowy-grid/select_option_filter.pbserver.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 'select_option_filter_bloc.freezed.dart';
+
+class SelectOptionFilterEditorBloc
+    extends Bloc<SelectOptionFilterEditorEvent, SelectOptionFilterEditorState> {
+  final FilterInfo filterInfo;
+  final FilterFFIService _ffiService;
+  final FilterListener _listener;
+  final SingleSelectTypeOptionContext typeOptionContext;
+
+  SelectOptionFilterEditorBloc({required this.filterInfo})
+      : _ffiService = FilterFFIService(viewId: filterInfo.viewId),
+        _listener = FilterListener(
+          viewId: filterInfo.viewId,
+          filterId: filterInfo.filter.id,
+        ),
+        typeOptionContext = makeSingleSelectTypeOptionContext(
+          gridId: filterInfo.viewId,
+          fieldPB: filterInfo.field.field,
+        ),
+        super(SelectOptionFilterEditorState.initial(filterInfo)) {
+    on<SelectOptionFilterEditorEvent>(
+      (event, emit) async {
+        event.when(
+          initial: () async {
+            _startListening();
+            _loadOptions();
+          },
+          updateCondition: (SelectOptionCondition condition) {
+            _ffiService.insertSelectOptionFilter(
+              filterId: filterInfo.filter.id,
+              fieldId: filterInfo.field.id,
+              condition: condition,
+              optionIds: state.filter.optionIds,
+              fieldType: state.filterInfo.field.fieldType,
+            );
+          },
+          updateContent: (List<String> optionIds) {
+            _ffiService.insertSelectOptionFilter(
+              filterId: filterInfo.filter.id,
+              fieldId: filterInfo.field.id,
+              condition: state.filter.condition,
+              optionIds: optionIds,
+              fieldType: state.filterInfo.field.fieldType,
+            );
+          },
+          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 selectOptionFilter = filterInfo.selectOptionFilter()!;
+            emit(state.copyWith(
+              filterInfo: filterInfo,
+              filter: selectOptionFilter,
+            ));
+          },
+          updateFilterDescription: (String desc) {
+            emit(state.copyWith(filterDesc: desc));
+          },
+        );
+      },
+    );
+  }
+
+  void _startListening() {
+    _listener.start(
+      onDeleted: () {
+        if (!isClosed) add(const SelectOptionFilterEditorEvent.delete());
+      },
+      onUpdated: (filter) {
+        if (!isClosed) {
+          add(SelectOptionFilterEditorEvent.didReceiveFilter(filter));
+        }
+      },
+    );
+  }
+
+  void _loadOptions() {
+    typeOptionContext.loadTypeOptionData(
+      onCompleted: (value) {
+        if (!isClosed) {
+          String filterDesc = '';
+          for (final option in value.options) {
+            if (state.filter.optionIds.contains(option.id)) {
+              filterDesc += "${option.name} ";
+            }
+          }
+          add(SelectOptionFilterEditorEvent.updateFilterDescription(
+              filterDesc));
+        }
+      },
+      onError: (error) => Log.error(error),
+    );
+  }
+
+  @override
+  Future<void> close() async {
+    await _listener.stop();
+    return super.close();
+  }
+}
+
+@freezed
+class SelectOptionFilterEditorEvent with _$SelectOptionFilterEditorEvent {
+  const factory SelectOptionFilterEditorEvent.initial() = _Initial;
+  const factory SelectOptionFilterEditorEvent.didReceiveFilter(
+      FilterPB filter) = _DidReceiveFilter;
+  const factory SelectOptionFilterEditorEvent.updateCondition(
+      SelectOptionCondition condition) = _UpdateCondition;
+  const factory SelectOptionFilterEditorEvent.updateContent(
+      List<String> optionIds) = _UpdateContent;
+  const factory SelectOptionFilterEditorEvent.updateFilterDescription(
+      String desc) = _UpdateDesc;
+  const factory SelectOptionFilterEditorEvent.delete() = _Delete;
+}
+
+@freezed
+class SelectOptionFilterEditorState with _$SelectOptionFilterEditorState {
+  const factory SelectOptionFilterEditorState({
+    required FilterInfo filterInfo,
+    required SelectOptionFilterPB filter,
+    required String filterDesc,
+  }) = _GridFilterState;
+
+  factory SelectOptionFilterEditorState.initial(FilterInfo filterInfo) {
+    return SelectOptionFilterEditorState(
+      filterInfo: filterInfo,
+      filter: filterInfo.selectOptionFilter()!,
+      filterDesc: '',
+    );
+  }
+}

+ 0 - 0
frontend/app_flowy/lib/plugins/grid/application/filter/select_option_filter_editor_bloc.dart


+ 156 - 0
frontend/app_flowy/lib/plugins/grid/application/filter/select_option_filter_list_bloc.dart

@@ -0,0 +1,156 @@
+import 'dart:async';
+
+import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart';
+import 'package:app_flowy/plugins/grid/presentation/widgets/header/type_option/builder.dart';
+import 'package:flowy_sdk/log.dart';
+import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart';
+import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'select_option_filter_list_bloc.freezed.dart';
+
+class SelectOptionFilterListBloc<T>
+    extends Bloc<SelectOptionFilterListEvent, SelectOptionFilterListState> {
+  final SingleSelectTypeOptionContext typeOptionContext;
+  SelectOptionFilterListBloc({
+    required String viewId,
+    required FieldPB fieldPB,
+    required List<String> selectedOptionIds,
+  })  : typeOptionContext = makeSingleSelectTypeOptionContext(
+          gridId: viewId,
+          fieldPB: fieldPB,
+        ),
+        super(SelectOptionFilterListState.initial(selectedOptionIds)) {
+    on<SelectOptionFilterListEvent>(
+      (event, emit) async {
+        await event.when(
+          initial: () async {
+            _startListening();
+            _loadOptions();
+          },
+          selectOption: (option) {
+            final selectedOptionIds = Set<String>.from(state.selectedOptionIds);
+            selectedOptionIds.add(option.id);
+
+            _updateSelectOptions(
+              selectedOptionIds: selectedOptionIds,
+              emit: emit,
+            );
+          },
+          unselectOption: (option) {
+            final selectedOptionIds = Set<String>.from(state.selectedOptionIds);
+            selectedOptionIds.remove(option.id);
+
+            _updateSelectOptions(
+              selectedOptionIds: selectedOptionIds,
+              emit: emit,
+            );
+          },
+          didReceiveOptions: (newOptions) {
+            List<SelectOptionPB> options = List.from(newOptions);
+            options.retainWhere(
+                (element) => element.name.contains(state.predicate));
+
+            final visibleOptions = options.map((option) {
+              return VisibleSelectOption(
+                  option, state.selectedOptionIds.contains(option.id));
+            }).toList();
+
+            emit(state.copyWith(
+                options: options, visibleOptions: visibleOptions));
+          },
+          filterOption: (optionName) {
+            _updateSelectOptions(predicate: optionName, emit: emit);
+          },
+        );
+      },
+    );
+  }
+
+  @override
+  Future<void> close() async {
+    return super.close();
+  }
+
+  void _updateSelectOptions({
+    String? predicate,
+    Set<String>? selectedOptionIds,
+    required Emitter<SelectOptionFilterListState> emit,
+  }) {
+    final List<VisibleSelectOption> visibleOptions = _makeVisibleOptions(
+      predicate ?? state.predicate,
+      selectedOptionIds ?? state.selectedOptionIds,
+    );
+
+    emit(state.copyWith(
+      predicate: predicate ?? state.predicate,
+      visibleOptions: visibleOptions,
+      selectedOptionIds: selectedOptionIds ?? state.selectedOptionIds,
+    ));
+  }
+
+  List<VisibleSelectOption> _makeVisibleOptions(
+    String predicate,
+    Set<String> selectedOptionIds,
+  ) {
+    List<SelectOptionPB> options = List.from(state.options);
+    options.retainWhere((element) => element.name.contains(predicate));
+
+    return options.map((option) {
+      return VisibleSelectOption(option, selectedOptionIds.contains(option.id));
+    }).toList();
+  }
+
+  void _loadOptions() {
+    typeOptionContext.loadTypeOptionData(
+      onCompleted: (value) {
+        if (!isClosed) {
+          add(SelectOptionFilterListEvent.didReceiveOptions(value.options));
+        }
+      },
+      onError: (error) => Log.error(error),
+    );
+  }
+
+  void _startListening() {}
+}
+
+@freezed
+class SelectOptionFilterListEvent with _$SelectOptionFilterListEvent {
+  const factory SelectOptionFilterListEvent.initial() = _Initial;
+  const factory SelectOptionFilterListEvent.selectOption(
+      SelectOptionPB option) = _SelectOption;
+  const factory SelectOptionFilterListEvent.unselectOption(
+      SelectOptionPB option) = _UnSelectOption;
+  const factory SelectOptionFilterListEvent.didReceiveOptions(
+      List<SelectOptionPB> options) = _DidReceiveOptions;
+  const factory SelectOptionFilterListEvent.filterOption(String optionName) =
+      _SelectOptionFilter;
+}
+
+@freezed
+class SelectOptionFilterListState with _$SelectOptionFilterListState {
+  const factory SelectOptionFilterListState({
+    required List<SelectOptionPB> options,
+    required List<VisibleSelectOption> visibleOptions,
+    required Set<String> selectedOptionIds,
+    required String predicate,
+  }) = _SelectOptionFilterListState;
+
+  factory SelectOptionFilterListState.initial(List<String> selectedOptionIds) {
+    return SelectOptionFilterListState(
+      options: [],
+      predicate: '',
+      visibleOptions: [],
+      selectedOptionIds: selectedOptionIds.toSet(),
+    );
+  }
+}
+
+class VisibleSelectOption {
+  final SelectOptionPB optionPB;
+  final bool isSelected;
+
+  VisibleSelectOption(this.optionPB, this.isSelected);
+}

+ 0 - 15
frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option.dart

@@ -1,15 +0,0 @@
-import 'package:app_flowy/plugins/grid/presentation/widgets/filter/filter_info.dart';
-import 'package:flutter/material.dart';
-
-import 'choicechip.dart';
-
-class SelectOptionFilterChoicechip extends StatelessWidget {
-  final FilterInfo filterInfo;
-  const SelectOptionFilterChoicechip({required this.filterInfo, Key? key})
-      : super(key: key);
-
-  @override
-  Widget build(BuildContext context) {
-    return ChoiceChipButton(filterInfo: filterInfo);
-  }
-}

+ 117 - 0
frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart

@@ -0,0 +1,117 @@
+import 'package:app_flowy/generated/locale_keys.g.dart';
+import 'package:app_flowy/plugins/grid/presentation/widgets/filter/condition_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_sdk/protobuf/flowy-grid/field_entities.pb.dart';
+import 'package:flowy_sdk/protobuf/flowy-grid/select_option_filter.pb.dart';
+import 'package:flutter/material.dart';
+
+class SelectOptionFilterConditionList extends StatelessWidget {
+  final FilterInfo filterInfo;
+  final PopoverMutex popoverMutex;
+  final Function(SelectOptionCondition) onCondition;
+  const SelectOptionFilterConditionList({
+    required this.filterInfo,
+    required this.popoverMutex,
+    required this.onCondition,
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    final selectOptionFilter = filterInfo.selectOptionFilter()!;
+    return PopoverActionList<ConditionWrapper>(
+      asBarrier: true,
+      mutex: popoverMutex,
+      direction: PopoverDirection.bottomWithCenterAligned,
+      actions: SelectOptionCondition.values
+          .map(
+            (action) => ConditionWrapper(
+              action,
+              selectOptionFilter.condition == action,
+              filterInfo.field.fieldType,
+            ),
+          )
+          .toList(),
+      buildChild: (controller) {
+        return ConditionButton(
+          conditionName: filterName(selectOptionFilter),
+          onTap: () => controller.show(),
+        );
+      },
+      onSelected: (action, controller) async {
+        onCondition(action.inner);
+        controller.close();
+      },
+    );
+  }
+
+  String filterName(SelectOptionFilterPB filter) {
+    if (filterInfo.field.fieldType == FieldType.SingleSelect) {
+      return filter.condition.singleSelectFilterName;
+    } else {
+      return filter.condition.multiSelectFilterName;
+    }
+  }
+}
+
+class ConditionWrapper extends ActionCell {
+  final SelectOptionCondition inner;
+  final bool isSelected;
+  final FieldType fieldType;
+
+  ConditionWrapper(this.inner, this.isSelected, this.fieldType);
+
+  @override
+  Widget? rightIcon(Color iconColor) {
+    if (isSelected) {
+      return svgWidget("grid/checkmark");
+    } else {
+      return null;
+    }
+  }
+
+  @override
+  String get name {
+    if (fieldType == FieldType.SingleSelect) {
+      return inner.singleSelectFilterName;
+    } else {
+      return inner.multiSelectFilterName;
+    }
+  }
+}
+
+extension SelectOptionConditionExtension on SelectOptionCondition {
+  String get singleSelectFilterName {
+    switch (this) {
+      case SelectOptionCondition.OptionIs:
+        return LocaleKeys.grid_singleSelectOptionFilter_is.tr();
+      case SelectOptionCondition.OptionIsEmpty:
+        return LocaleKeys.grid_singleSelectOptionFilter_isEmpty.tr();
+      case SelectOptionCondition.OptionIsNot:
+        return LocaleKeys.grid_singleSelectOptionFilter_isNot.tr();
+      case SelectOptionCondition.OptionIsNotEmpty:
+        return LocaleKeys.grid_singleSelectOptionFilter_isNotEmpty.tr();
+      default:
+        return "";
+    }
+  }
+
+  String get multiSelectFilterName {
+    switch (this) {
+      case SelectOptionCondition.OptionIs:
+        return LocaleKeys.grid_multiSelectOptionFilter_contains.tr();
+      case SelectOptionCondition.OptionIsEmpty:
+        return LocaleKeys.grid_multiSelectOptionFilter_isEmpty.tr();
+      case SelectOptionCondition.OptionIsNot:
+        return LocaleKeys.grid_multiSelectOptionFilter_doesNotContain.tr();
+      case SelectOptionCondition.OptionIsNotEmpty:
+        return LocaleKeys.grid_multiSelectOptionFilter_isNotEmpty.tr();
+      default:
+        return "";
+    }
+  }
+}

+ 108 - 0
frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart

@@ -0,0 +1,108 @@
+import 'package:app_flowy/plugins/grid/application/field/field_controller.dart';
+import 'package:app_flowy/plugins/grid/application/filter/select_option_filter_list_bloc.dart';
+import 'package:app_flowy/plugins/grid/presentation/layout/sizes.dart';
+import 'package:app_flowy/plugins/grid/presentation/widgets/cell/select_option_cell/extension.dart';
+import 'package:flowy_infra/image.dart';
+import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart';
+import 'package:flowy_infra_ui/widget/spacing.dart';
+import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class SelectOptionFilterList extends StatelessWidget {
+  final String viewId;
+  final FieldInfo fieldInfo;
+  final List<String> selectedOptionIds;
+  final Function(List<String>) onSelectedOptions;
+  const SelectOptionFilterList({
+    required this.viewId,
+    required this.fieldInfo,
+    required this.selectedOptionIds,
+    required this.onSelectedOptions,
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocProvider(
+      create: (context) => SelectOptionFilterListBloc(
+        viewId: viewId,
+        fieldPB: fieldInfo.field,
+        selectedOptionIds: selectedOptionIds,
+      )..add(const SelectOptionFilterListEvent.initial()),
+      child:
+          BlocListener<SelectOptionFilterListBloc, SelectOptionFilterListState>(
+        listenWhen: (previous, current) =>
+            previous.selectedOptionIds != current.selectedOptionIds,
+        listener: (context, state) {
+          onSelectedOptions(state.selectedOptionIds.toList());
+        },
+        child: BlocBuilder<SelectOptionFilterListBloc,
+            SelectOptionFilterListState>(
+          builder: (context, state) {
+            return ListView.separated(
+              shrinkWrap: true,
+              controller: ScrollController(),
+              itemCount: state.visibleOptions.length,
+              separatorBuilder: (context, index) {
+                return VSpace(GridSize.typeOptionSeparatorHeight);
+              },
+              physics: StyledScrollPhysics(),
+              itemBuilder: (BuildContext context, int index) {
+                final option = state.visibleOptions[index];
+                return _SelectOptionFilterCell(
+                  option: option.optionPB,
+                  isSelected: option.isSelected,
+                );
+              },
+            );
+          },
+        ),
+      ),
+    );
+  }
+}
+
+class _SelectOptionFilterCell extends StatefulWidget {
+  final SelectOptionPB option;
+  final bool isSelected;
+  const _SelectOptionFilterCell({
+    required this.option,
+    required this.isSelected,
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  State<_SelectOptionFilterCell> createState() =>
+      _SelectOptionFilterCellState();
+}
+
+class _SelectOptionFilterCellState extends State<_SelectOptionFilterCell> {
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      height: GridSize.typeOptionItemHeight,
+      child: SelectOptionTagCell(
+        option: widget.option,
+        onSelected: (option) {
+          if (widget.isSelected) {
+            context
+                .read<SelectOptionFilterListBloc>()
+                .add(SelectOptionFilterListEvent.unselectOption(option));
+          } else {
+            context
+                .read<SelectOptionFilterListBloc>()
+                .add(SelectOptionFilterListEvent.selectOption(option));
+          }
+        },
+        children: [
+          if (widget.isSelected)
+            Padding(
+              padding: const EdgeInsets.only(right: 6),
+              child: svgWidget("grid/checkmark"),
+            ),
+        ],
+      ),
+    );
+  }
+}

+ 159 - 0
frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart

@@ -0,0 +1,159 @@
+import 'package:app_flowy/plugins/grid/application/filter/select_option_filter_bloc.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:appflowy_popover/appflowy_popover.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.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/select_option_filter.pb.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+import '../choicechip.dart';
+import 'condition_list.dart';
+import 'option_list.dart';
+
+class SelectOptionFilterChoicechip extends StatefulWidget {
+  final FilterInfo filterInfo;
+  const SelectOptionFilterChoicechip({required this.filterInfo, Key? key})
+      : super(key: key);
+
+  @override
+  State<SelectOptionFilterChoicechip> createState() =>
+      _SelectOptionFilterChoicechipState();
+}
+
+class _SelectOptionFilterChoicechipState
+    extends State<SelectOptionFilterChoicechip> {
+  late SelectOptionFilterEditorBloc bloc;
+
+  @override
+  void initState() {
+    bloc = SelectOptionFilterEditorBloc(filterInfo: widget.filterInfo)
+      ..add(const SelectOptionFilterEditorEvent.initial());
+    super.initState();
+  }
+
+  @override
+  void dispose() {
+    bloc.close();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocProvider.value(
+      value: bloc,
+      child: BlocBuilder<SelectOptionFilterEditorBloc,
+          SelectOptionFilterEditorState>(
+        builder: (blocContext, state) {
+          return AppFlowyPopover(
+            controller: PopoverController(),
+            constraints: BoxConstraints.loose(const Size(200, 160)),
+            direction: PopoverDirection.bottomWithCenterAligned,
+            popupBuilder: (BuildContext context) {
+              return SelectOptionFilterEditor(bloc: bloc);
+            },
+            child: ChoiceChipButton(
+              filterInfo: widget.filterInfo,
+              filterDesc: state.filterDesc,
+            ),
+          );
+        },
+      ),
+    );
+  }
+}
+
+class SelectOptionFilterEditor extends StatefulWidget {
+  final SelectOptionFilterEditorBloc bloc;
+  const SelectOptionFilterEditor({required this.bloc, Key? key})
+      : super(key: key);
+
+  @override
+  State<SelectOptionFilterEditor> createState() =>
+      _SelectOptionFilterEditorState();
+}
+
+class _SelectOptionFilterEditorState extends State<SelectOptionFilterEditor> {
+  final popoverMutex = PopoverMutex();
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocProvider.value(
+      value: widget.bloc,
+      child: BlocBuilder<SelectOptionFilterEditorBloc,
+          SelectOptionFilterEditorState>(
+        builder: (context, state) {
+          List<Widget> slivers = [
+            SliverToBoxAdapter(child: _buildFilterPannel(context, state)),
+          ];
+
+          if (state.filter.condition != SelectOptionCondition.OptionIsEmpty &&
+              state.filter.condition !=
+                  SelectOptionCondition.OptionIsNotEmpty) {
+            slivers.add(const SliverToBoxAdapter(child: VSpace(4)));
+            slivers.add(
+              SliverToBoxAdapter(
+                child: SelectOptionFilterList(
+                  viewId: state.filterInfo.viewId,
+                  fieldInfo: state.filterInfo.field,
+                  selectedOptionIds: state.filter.optionIds,
+                  onSelectedOptions: (optionIds) {
+                    context.read<SelectOptionFilterEditorBloc>().add(
+                        SelectOptionFilterEditorEvent.updateContent(optionIds));
+                  },
+                ),
+              ),
+            );
+          }
+
+          return Padding(
+            padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
+            child: CustomScrollView(
+              shrinkWrap: true,
+              slivers: slivers,
+              controller: ScrollController(),
+              physics: StyledScrollPhysics(),
+            ),
+          );
+        },
+      ),
+    );
+  }
+
+  Widget _buildFilterPannel(
+      BuildContext context, SelectOptionFilterEditorState state) {
+    return SizedBox(
+      height: 20,
+      child: Row(
+        children: [
+          FlowyText(state.filterInfo.field.name),
+          const HSpace(4),
+          SelectOptionFilterConditionList(
+            filterInfo: state.filterInfo,
+            popoverMutex: popoverMutex,
+            onCondition: (condition) {
+              context.read<SelectOptionFilterEditorBloc>().add(
+                  SelectOptionFilterEditorEvent.updateCondition(condition));
+            },
+          ),
+          const Spacer(),
+          DisclosureButton(
+            popoverMutex: popoverMutex,
+            onAction: (action) {
+              switch (action) {
+                case FilterDisclosureAction.delete:
+                  context
+                      .read<SelectOptionFilterEditorBloc>()
+                      .add(const SelectOptionFilterEditorEvent.delete());
+                  break;
+              }
+            },
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 10 - 0
frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/filter_info.dart

@@ -2,6 +2,7 @@ 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/checkbox_filter.pb.dart';
 import 'package:flowy_sdk/protobuf/flowy-grid/date_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/field_entities.pb.dart';
+import 'package:flowy_sdk/protobuf/flowy-grid/select_option_filter.pbserver.dart';
 import 'package:flowy_sdk/protobuf/flowy-grid/text_filter.pb.dart';
 import 'package:flowy_sdk/protobuf/flowy-grid/text_filter.pb.dart';
 import 'package:flowy_sdk/protobuf/flowy-grid/util.pb.dart';
 import 'package:flowy_sdk/protobuf/flowy-grid/util.pb.dart';
 
 
@@ -40,4 +41,13 @@ class FilterInfo {
     }
     }
     return CheckboxFilterPB.fromBuffer(filter.data);
     return CheckboxFilterPB.fromBuffer(filter.data);
   }
   }
+
+  SelectOptionFilterPB? selectOptionFilter() {
+    if (filter.fieldType == FieldType.SingleSelect ||
+        filter.fieldType == FieldType.MultiSelect) {
+      return SelectOptionFilterPB.fromBuffer(filter.data);
+    } else {
+      return null;
+    }
+  }
 }
 }

+ 1 - 1
frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/menu_item.dart

@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
 import 'choicechip/checkbox.dart';
 import 'choicechip/checkbox.dart';
 import 'choicechip/date.dart';
 import 'choicechip/date.dart';
 import 'choicechip/number.dart';
 import 'choicechip/number.dart';
-import 'choicechip/select_option.dart';
+import 'choicechip/select_option/select_option.dart';
 import 'choicechip/text.dart';
 import 'choicechip/text.dart';
 import 'choicechip/url.dart';
 import 'choicechip/url.dart';
 import 'filter_info.dart';
 import 'filter_info.dart';

+ 34 - 0
frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/builder.dart

@@ -145,6 +145,40 @@ TypeOptionContext<T> makeTypeOptionContext<T extends GeneratedMessage>({
   );
   );
 }
 }
 
 
+TypeOptionContext<SingleSelectTypeOptionPB> makeSingleSelectTypeOptionContext({
+  required String gridId,
+  required FieldPB fieldPB,
+}) {
+  return makeSelectTypeOptionContext(gridId: gridId, fieldPB: fieldPB);
+}
+
+TypeOptionContext<MultiSelectTypeOptionPB> makeMultiSelectTypeOptionContext({
+  required String gridId,
+  required FieldPB fieldPB,
+}) {
+  return makeSelectTypeOptionContext(gridId: gridId, fieldPB: fieldPB);
+}
+
+TypeOptionContext<T> makeSelectTypeOptionContext<T extends GeneratedMessage>({
+  required String gridId,
+  required FieldPB fieldPB,
+}) {
+  final loader = FieldTypeOptionLoader(
+    gridId: gridId,
+    field: fieldPB,
+  );
+  final dataController = TypeOptionDataController(
+    gridId: gridId,
+    loader: loader,
+  );
+  final typeOptionContext = makeTypeOptionContextWithDataController<T>(
+    gridId: gridId,
+    fieldType: fieldPB.fieldType,
+    dataController: dataController,
+  );
+  return typeOptionContext;
+}
+
 TypeOptionContext<T>
 TypeOptionContext<T>
     makeTypeOptionContextWithDataController<T extends GeneratedMessage>({
     makeTypeOptionContextWithDataController<T extends GeneratedMessage>({
   required String gridId,
   required String gridId,

+ 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)
         viewId: context.gridView.id, fieldController: context.fieldController)
       ..add(const GridFilterMenuEvent.initial());
       ..add(const GridFilterMenuEvent.initial());
     await gridResponseFuture();
     await gridResponseFuture();
-    assert(menuBloc.state.creatableFields.length == 2);
+    assert(menuBloc.state.creatableFields.length == 3);
 
 
     final service = FilterFFIService(viewId: context.gridView.id);
     final service = FilterFFIService(viewId: context.gridView.id);
     final textField = context.textFieldContext();
     final textField = context.textFieldContext();
@@ -26,7 +26,7 @@ void main() {
         condition: TextFilterCondition.TextIsEmpty,
         condition: TextFilterCondition.TextIsEmpty,
         content: "");
         content: "");
     await gridResponseFuture();
     await gridResponseFuture();
-    assert(menuBloc.state.creatableFields.length == 1);
+    assert(menuBloc.state.creatableFields.length == 2);
   });
   });
 
 
   test('test filter menu after update existing text filter)', () async {
   test('test filter menu after update existing text filter)', () async {

+ 51 - 0
frontend/app_flowy/test/bloc_test/grid_test/filter/filter_rows_by_checkbox_test.dart

@@ -0,0 +1,51 @@
+import 'package:app_flowy/plugins/grid/application/filter/filter_service.dart';
+import 'package:flowy_sdk/protobuf/flowy-grid/checkbox_filter.pbenum.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import '../util.dart';
+import 'filter_util.dart';
+
+void main() {
+  late AppFlowyGridTest gridTest;
+  setUpAll(() async {
+    gridTest = await AppFlowyGridTest.ensureInitialized();
+  });
+
+  test('filter rows by checkbox is check condition)', () async {
+    final context = await createTestFilterGrid(gridTest);
+    final service = FilterFFIService(viewId: context.gridView.id);
+
+    final controller = await context.makeCheckboxCellController(0);
+    controller.saveCellData("Yes");
+    await gridResponseFuture();
+
+    // create a new filter
+    final checkboxField = context.checkboxFieldContext();
+    await service.insertCheckboxFilter(
+      fieldId: checkboxField.id,
+      condition: CheckboxFilterCondition.IsChecked,
+    );
+    await gridResponseFuture();
+    assert(context.rowInfos.length == 1,
+        "expect 1 but receive ${context.rowInfos.length}");
+  });
+
+  test('filter rows by checkbox is uncheck condition)', () async {
+    final context = await createTestFilterGrid(gridTest);
+    final service = FilterFFIService(viewId: context.gridView.id);
+
+    final controller = await context.makeCheckboxCellController(0);
+    controller.saveCellData("Yes");
+    await gridResponseFuture();
+
+    // create a new filter
+    final checkboxField = context.checkboxFieldContext();
+    await service.insertCheckboxFilter(
+      fieldId: checkboxField.id,
+      condition: CheckboxFilterCondition.IsUnChecked,
+    );
+    await gridResponseFuture();
+    assert(context.rowInfos.length == 2,
+        "expect 2 but receive ${context.rowInfos.length}");
+  });
+}

+ 0 - 0
frontend/app_flowy/test/bloc_test/grid_test/filter/filter_rows_test.dart → frontend/app_flowy/test/bloc_test/grid_test/filter/filter_rows_by_text_test.dart


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

@@ -145,6 +145,14 @@ class GridTestContext {
         await makeCellController(field.id, rowIndex) as GridCellController;
         await makeCellController(field.id, rowIndex) as GridCellController;
     return cellController;
     return cellController;
   }
   }
+
+  Future<GridCellController> makeCheckboxCellController(int rowIndex) async {
+    final field = fieldContexts
+        .firstWhere((element) => element.fieldType == FieldType.Checkbox);
+    final cellController =
+        await makeCellController(field.id, rowIndex) as GridCellController;
+    return cellController;
+  }
 }
 }
 
 
 /// Create a empty Grid for test
 /// Create a empty Grid for test