diff --git a/frontend/app_flowy/assets/translations/en.json b/frontend/app_flowy/assets/translations/en.json index 0300be94c9..23ee45d008 100644 --- a/frontend/app_flowy/assets/translations/en.json +++ b/frontend/app_flowy/assets/translations/en.json @@ -191,6 +191,18 @@ "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": { "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 bfc53281c1..482b839758 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 @@ -531,7 +531,7 @@ class FieldInfo { case FieldType.Checkbox: // case FieldType.MultiSelect: case FieldType.RichText: - // case FieldType.SingleSelect: + case FieldType.SingleSelect: return true; default: return false; diff --git a/frontend/app_flowy/lib/plugins/grid/application/filter/filter_create_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/filter/filter_create_bloc.dart index b497042ea5..77060cfc6f 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/filter/filter_create_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/filter/filter_create_bloc.dart @@ -99,9 +99,10 @@ class GridCreateFilterBloc timestamp: timestamp, ); case FieldType.MultiSelect: - return _ffiService.insertSingleSelectFilter( + return _ffiService.insertSelectOptionFilter( fieldId: fieldId, condition: SelectOptionCondition.OptionIs, + fieldType: FieldType.MultiSelect, ); case FieldType.Number: return _ffiService.insertNumberFilter( @@ -116,9 +117,10 @@ class GridCreateFilterBloc content: '', ); case FieldType.SingleSelect: - return _ffiService.insertSingleSelectFilter( + return _ffiService.insertSelectOptionFilter( fieldId: fieldId, condition: SelectOptionCondition.OptionIs, + fieldType: FieldType.SingleSelect, ); case FieldType.URL: return _ffiService.insertURLFilter( diff --git a/frontend/app_flowy/lib/plugins/grid/application/filter/filter_service.dart b/frontend/app_flowy/lib/plugins/grid/application/filter/filter_service.dart index 67348ae2c7..eb8d7fcaa3 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/filter/filter_service.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/filter/filter_service.dart @@ -126,10 +126,11 @@ class FilterFFIService { ); } - Future> insertSingleSelectFilter({ + Future> insertSelectOptionFilter({ required String fieldId, - String? filterId, + required FieldType fieldType, required SelectOptionCondition condition, + String? filterId, List optionIds = const [], }) { final filter = SelectOptionFilterPB() @@ -139,25 +140,7 @@ class FilterFFIService { return insertFilter( fieldId: fieldId, filterId: filterId, - fieldType: FieldType.SingleSelect, - data: filter.writeToBuffer(), - ); - } - - Future> insertMultiSelectFilter({ - required String fieldId, - String? filterId, - required SelectOptionCondition condition, - List optionIds = const [], - }) { - final filter = SelectOptionFilterPB() - ..condition = condition - ..optionIds.addAll(optionIds); - - return insertFilter( - fieldId: fieldId, - filterId: filterId, - fieldType: FieldType.MultiSelect, + fieldType: fieldType, data: filter.writeToBuffer(), ); } @@ -168,8 +151,6 @@ class FilterFFIService { required FieldType fieldType, required List data, }) { - TextFilterCondition.DoesNotContain.value; - var insertFilterPayload = AlterFilterPayloadPB.create() ..fieldId = fieldId ..fieldType = fieldType diff --git a/frontend/app_flowy/lib/plugins/grid/application/filter/select_option_filter_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/filter/select_option_filter_bloc.dart new file mode 100644 index 0000000000..c622224bd7 --- /dev/null +++ b/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 { + 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( + (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 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 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 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: '', + ); + } +} diff --git a/frontend/app_flowy/lib/plugins/grid/application/filter/select_option_filter_editor_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/filter/select_option_filter_editor_bloc.dart new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/app_flowy/lib/plugins/grid/application/filter/select_option_filter_list_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/filter/select_option_filter_list_bloc.dart new file mode 100644 index 0000000000..8e8532f068 --- /dev/null +++ b/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 + extends Bloc { + final SingleSelectTypeOptionContext typeOptionContext; + SelectOptionFilterListBloc({ + required String viewId, + required FieldPB fieldPB, + required List selectedOptionIds, + }) : typeOptionContext = makeSingleSelectTypeOptionContext( + gridId: viewId, + fieldPB: fieldPB, + ), + super(SelectOptionFilterListState.initial(selectedOptionIds)) { + on( + (event, emit) async { + await event.when( + initial: () async { + _startListening(); + _loadOptions(); + }, + selectOption: (option) { + final selectedOptionIds = Set.from(state.selectedOptionIds); + selectedOptionIds.add(option.id); + + _updateSelectOptions( + selectedOptionIds: selectedOptionIds, + emit: emit, + ); + }, + unselectOption: (option) { + final selectedOptionIds = Set.from(state.selectedOptionIds); + selectedOptionIds.remove(option.id); + + _updateSelectOptions( + selectedOptionIds: selectedOptionIds, + emit: emit, + ); + }, + didReceiveOptions: (newOptions) { + List 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 close() async { + return super.close(); + } + + void _updateSelectOptions({ + String? predicate, + Set? selectedOptionIds, + required Emitter emit, + }) { + final List visibleOptions = _makeVisibleOptions( + predicate ?? state.predicate, + selectedOptionIds ?? state.selectedOptionIds, + ); + + emit(state.copyWith( + predicate: predicate ?? state.predicate, + visibleOptions: visibleOptions, + selectedOptionIds: selectedOptionIds ?? state.selectedOptionIds, + )); + } + + List _makeVisibleOptions( + String predicate, + Set selectedOptionIds, + ) { + List 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 options) = _DidReceiveOptions; + const factory SelectOptionFilterListEvent.filterOption(String optionName) = + _SelectOptionFilter; +} + +@freezed +class SelectOptionFilterListState with _$SelectOptionFilterListState { + const factory SelectOptionFilterListState({ + required List options, + required List visibleOptions, + required Set selectedOptionIds, + required String predicate, + }) = _SelectOptionFilterListState; + + factory SelectOptionFilterListState.initial(List selectedOptionIds) { + return SelectOptionFilterListState( + options: [], + predicate: '', + visibleOptions: [], + selectedOptionIds: selectedOptionIds.toSet(), + ); + } +} + +class VisibleSelectOption { + final SelectOptionPB optionPB; + final bool isSelected; + + VisibleSelectOption(this.optionPB, this.isSelected); +} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option.dart deleted file mode 100644 index 0d3dca62c1..0000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option.dart +++ /dev/null @@ -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); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart new file mode 100644 index 0000000000..5944f3be6b --- /dev/null +++ b/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( + 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 ""; + } + } +} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart new file mode 100644 index 0000000000..f8a1bfdec1 --- /dev/null +++ b/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 selectedOptionIds; + final Function(List) 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( + listenWhen: (previous, current) => + previous.selectedOptionIds != current.selectedOptionIds, + listener: (context, state) { + onSelectedOptions(state.selectedOptionIds.toList()); + }, + child: BlocBuilder( + 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() + .add(SelectOptionFilterListEvent.unselectOption(option)); + } else { + context + .read() + .add(SelectOptionFilterListEvent.selectOption(option)); + } + }, + children: [ + if (widget.isSelected) + Padding( + padding: const EdgeInsets.only(right: 6), + child: svgWidget("grid/checkmark"), + ), + ], + ), + ); + } +} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart new file mode 100644 index 0000000000..e9ce6678af --- /dev/null +++ b/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 createState() => + _SelectOptionFilterChoicechipState(); +} + +class _SelectOptionFilterChoicechipState + extends State { + 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( + 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 createState() => + _SelectOptionFilterEditorState(); +} + +class _SelectOptionFilterEditorState extends State { + final popoverMutex = PopoverMutex(); + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: widget.bloc, + child: BlocBuilder( + builder: (context, state) { + List 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().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().add( + SelectOptionFilterEditorEvent.updateCondition(condition)); + }, + ), + const Spacer(), + DisclosureButton( + popoverMutex: popoverMutex, + onAction: (action) { + switch (action) { + case FilterDisclosureAction.delete: + context + .read() + .add(const SelectOptionFilterEditorEvent.delete()); + break; + } + }, + ), + ], + ), + ); + } +} 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 4cf9c8befd..fac6400a5c 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 @@ -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/date_filter.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/util.pb.dart'; @@ -40,4 +41,13 @@ class FilterInfo { } return CheckboxFilterPB.fromBuffer(filter.data); } + + SelectOptionFilterPB? selectOptionFilter() { + if (filter.fieldType == FieldType.SingleSelect || + filter.fieldType == FieldType.MultiSelect) { + return SelectOptionFilterPB.fromBuffer(filter.data); + } else { + return null; + } + } } diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/menu_item.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/menu_item.dart index ff311ad8b2..d4f4696d0c 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/filter/menu_item.dart +++ b/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/date.dart'; import 'choicechip/number.dart'; -import 'choicechip/select_option.dart'; +import 'choicechip/select_option/select_option.dart'; import 'choicechip/text.dart'; import 'choicechip/url.dart'; import 'filter_info.dart'; diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/builder.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/builder.dart index 75bf8149db..981260c830 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/builder.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/builder.dart @@ -145,6 +145,40 @@ TypeOptionContext makeTypeOptionContext({ ); } +TypeOptionContext makeSingleSelectTypeOptionContext({ + required String gridId, + required FieldPB fieldPB, +}) { + return makeSelectTypeOptionContext(gridId: gridId, fieldPB: fieldPB); +} + +TypeOptionContext makeMultiSelectTypeOptionContext({ + required String gridId, + required FieldPB fieldPB, +}) { + return makeSelectTypeOptionContext(gridId: gridId, fieldPB: fieldPB); +} + +TypeOptionContext makeSelectTypeOptionContext({ + required String gridId, + required FieldPB fieldPB, +}) { + final loader = FieldTypeOptionLoader( + gridId: gridId, + field: fieldPB, + ); + final dataController = TypeOptionDataController( + gridId: gridId, + loader: loader, + ); + final typeOptionContext = makeTypeOptionContextWithDataController( + gridId: gridId, + fieldType: fieldPB.fieldType, + dataController: dataController, + ); + return typeOptionContext; +} + TypeOptionContext makeTypeOptionContextWithDataController({ required String gridId, 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 2ecb6cb313..46a52fc834 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 == 2); + assert(menuBloc.state.creatableFields.length == 3); 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.length == 1); + assert(menuBloc.state.creatableFields.length == 2); }); test('test filter menu after update existing text filter)', () async { diff --git a/frontend/app_flowy/test/bloc_test/grid_test/filter/filter_rows_by_checkbox_test.dart b/frontend/app_flowy/test/bloc_test/grid_test/filter/filter_rows_by_checkbox_test.dart new file mode 100644 index 0000000000..60ba6580fe --- /dev/null +++ b/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}"); + }); +} 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_by_text_test.dart similarity index 100% rename from frontend/app_flowy/test/bloc_test/grid_test/filter/filter_rows_test.dart rename to frontend/app_flowy/test/bloc_test/grid_test/filter/filter_rows_by_text_test.dart diff --git a/frontend/app_flowy/test/bloc_test/grid_test/util.dart b/frontend/app_flowy/test/bloc_test/grid_test/util.dart index e361a8f39b..657120488e 100644 --- a/frontend/app_flowy/test/bloc_test/grid_test/util.dart +++ b/frontend/app_flowy/test/bloc_test/grid_test/util.dart @@ -145,6 +145,14 @@ class GridTestContext { await makeCellController(field.id, rowIndex) as GridCellController; return cellController; } + + Future 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