From c159a5e42b5ca76c9b7984942d86696907f0c8ee Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Sat, 17 Feb 2024 15:28:49 +0800 Subject: [PATCH] chore: enable number filter (#4653) * chore: enable filtering by number field type * chore: code cleanup * fix: integration test * chore: remove unnecessary async from event handler --- .../database/database_filter_test.dart | 2 +- .../application/field/field_info.dart | 9 +- .../filter/number_filter_editor_bloc.dart | 117 ++++++++++ .../widgets/filter/choicechip/number.dart | 218 +++++++++++++++++- .../choicechip/select_option/option_list.dart | 62 ++--- .../widgets/filter/filter_info.dart | 57 ++--- .../widgets/filter/filter_menu_item.dart | 39 ++-- frontend/resources/translations/en.json | 12 +- 8 files changed, 411 insertions(+), 105 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/number_filter_editor_bloc.dart diff --git a/frontend/appflowy_flutter/integration_test/database/database_filter_test.dart b/frontend/appflowy_flutter/integration_test/database/database_filter_test.dart index c1c52bb23d..a7f5726842 100644 --- a/frontend/appflowy_flutter/integration_test/database/database_filter_test.dart +++ b/frontend/appflowy_flutter/integration_test/database/database_filter_test.dart @@ -9,7 +9,7 @@ import '../util/database_test_op.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - group('database filter', () { + group('grid filter:', () { testWidgets('add text filter', (tester) async { await tester.openV020database(); diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart index 717776f075..0bcb645b4a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart @@ -47,9 +47,12 @@ class FieldInfo with _$FieldInfo { } bool get canCreateFilter { - if (hasFilter) return false; + if (hasFilter) { + return false; + } switch (field.fieldType) { + case FieldType.Number: case FieldType.Checkbox: case FieldType.MultiSelect: case FieldType.RichText: @@ -62,7 +65,9 @@ class FieldInfo with _$FieldInfo { } bool get canCreateSort { - if (hasSort) return false; + if (hasSort) { + return false; + } switch (field.fieldType) { case FieldType.RichText: diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/number_filter_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/number_filter_editor_bloc.dart new file mode 100644 index 0000000000..832bfa905a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/number_filter_editor_bloc.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/filter/filter_listener.dart'; +import 'package:appflowy/plugins/database/application/filter/filter_service.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'number_filter_editor_bloc.freezed.dart'; + +class NumberFilterEditorBloc + extends Bloc { + NumberFilterEditorBloc({required this.filterInfo}) + : _filterBackendSvc = FilterBackendService(viewId: filterInfo.viewId), + _listener = FilterListener( + viewId: filterInfo.viewId, + filterId: filterInfo.filter.id, + ), + super(NumberFilterEditorState.initial(filterInfo)) { + _dispatch(); + _startListening(); + } + + final FilterInfo filterInfo; + final FilterBackendService _filterBackendSvc; + final FilterListener _listener; + + void _dispatch() { + on( + (event, emit) async { + event.when( + didReceiveFilter: (filter) { + final filterInfo = state.filterInfo.copyWith(filter: filter); + emit( + state.copyWith( + filterInfo: filterInfo, + filter: filterInfo.numberFilter()!, + ), + ); + }, + updateCondition: (NumberFilterConditionPB condition) { + _filterBackendSvc.insertNumberFilter( + filterId: filterInfo.filter.id, + fieldId: filterInfo.fieldInfo.id, + condition: condition, + content: state.filter.content, + ); + }, + updateContent: (content) { + _filterBackendSvc.insertNumberFilter( + filterId: filterInfo.filter.id, + fieldId: filterInfo.fieldInfo.id, + condition: state.filter.condition, + content: content, + ); + }, + delete: () { + _filterBackendSvc.deleteFilter( + fieldId: filterInfo.fieldInfo.id, + filterId: filterInfo.filter.id, + fieldType: filterInfo.fieldInfo.fieldType, + ); + }, + ); + }, + ); + } + + void _startListening() { + _listener.start( + onDeleted: () { + if (!isClosed) { + add(const NumberFilterEditorEvent.delete()); + } + }, + onUpdated: (filter) { + if (!isClosed) { + add(NumberFilterEditorEvent.didReceiveFilter(filter)); + } + }, + ); + } + + @override + Future close() async { + await _listener.stop(); + return super.close(); + } +} + +@freezed +class NumberFilterEditorEvent with _$NumberFilterEditorEvent { + const factory NumberFilterEditorEvent.didReceiveFilter(FilterPB filter) = + _DidReceiveFilter; + const factory NumberFilterEditorEvent.updateCondition( + NumberFilterConditionPB condition, + ) = _UpdateCondition; + const factory NumberFilterEditorEvent.updateContent(String content) = + _UpdateContent; + const factory NumberFilterEditorEvent.delete() = _Delete; +} + +@freezed +class NumberFilterEditorState with _$NumberFilterEditorState { + const factory NumberFilterEditorState({ + required FilterInfo filterInfo, + required NumberFilterPB filter, + }) = _NumberFilterEditorState; + + factory NumberFilterEditorState.initial(FilterInfo filterInfo) { + return NumberFilterEditorState( + filterInfo: filterInfo, + filter: filterInfo.numberFilter()!, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart index f94f72b59c..0947239273 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart @@ -1,15 +1,227 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/number_filter_editor_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../condition_button.dart'; +import '../disclosure_button.dart'; import '../filter_info.dart'; import 'choicechip.dart'; -class NumberFilterChoicechip extends StatelessWidget { - const NumberFilterChoicechip({required this.filterInfo, super.key}); +class NumberFilterChoiceChip extends StatefulWidget { + const NumberFilterChoiceChip({ + super.key, + required this.filterInfo, + }); final FilterInfo filterInfo; + @override + State createState() => _NumberFilterChoiceChipState(); +} + +class _NumberFilterChoiceChipState extends State { @override Widget build(BuildContext context) { - return ChoiceChipButton(filterInfo: filterInfo); + return BlocProvider( + create: (_) => NumberFilterEditorBloc( + filterInfo: widget.filterInfo, + ), + child: BlocBuilder( + builder: (context, state) { + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(200, 100)), + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: const NumberFilterEditor(), + ); + }, + child: ChoiceChipButton( + filterInfo: state.filterInfo, + ), + ); + }, + ), + ); + } +} + +class NumberFilterEditor extends StatefulWidget { + const NumberFilterEditor({super.key}); + + @override + State createState() => _NumberFilterEditorState(); +} + +class _NumberFilterEditorState extends State { + final popoverMutex = PopoverMutex(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final List children = [ + _buildFilterPanel(context, state), + if (state.filter.condition != NumberFilterConditionPB.NumberIsEmpty && + state.filter.condition != + NumberFilterConditionPB.NumberIsNotEmpty) ...[ + const VSpace(4), + _buildFilterNumberField(context, state), + ], + ]; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: IntrinsicHeight(child: Column(children: children)), + ); + }, + ); + } + + Widget _buildFilterPanel( + BuildContext context, + NumberFilterEditorState state, + ) { + return SizedBox( + height: 20, + child: Row( + children: [ + Expanded( + child: FlowyText( + state.filterInfo.fieldInfo.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(4), + Expanded( + child: NumberFilterConditionPBList( + filterInfo: state.filterInfo, + popoverMutex: popoverMutex, + onCondition: (condition) { + context + .read() + .add(NumberFilterEditorEvent.updateCondition(condition)); + }, + ), + ), + const HSpace(4), + DisclosureButton( + popoverMutex: popoverMutex, + onAction: (action) { + switch (action) { + case FilterDisclosureAction.delete: + context + .read() + .add(const NumberFilterEditorEvent.delete()); + break; + } + }, + ), + ], + ), + ); + } + + Widget _buildFilterNumberField( + BuildContext context, + NumberFilterEditorState state, + ) { + return FlowyTextField( + text: state.filter.content, + hintText: LocaleKeys.grid_settings_typeAValue.tr(), + debounceDuration: const Duration(milliseconds: 300), + autoFocus: false, + onChanged: (text) { + context + .read() + .add(NumberFilterEditorEvent.updateContent(text)); + }, + ); + } +} + +class NumberFilterConditionPBList extends StatelessWidget { + const NumberFilterConditionPBList({ + super.key, + required this.filterInfo, + required this.popoverMutex, + required this.onCondition, + }); + + final FilterInfo filterInfo; + final PopoverMutex popoverMutex; + final Function(NumberFilterConditionPB) onCondition; + + @override + Widget build(BuildContext context) { + final numberFilter = filterInfo.numberFilter()!; + return PopoverActionList( + asBarrier: true, + mutex: popoverMutex, + direction: PopoverDirection.bottomWithCenterAligned, + actions: NumberFilterConditionPB.values + .map( + (action) => ConditionWrapper( + action, + numberFilter.condition == action, + ), + ) + .toList(), + buildChild: (controller) { + return ConditionButton( + conditionName: numberFilter.condition.filterName, + onTap: () => controller.show(), + ); + }, + onSelected: (action, controller) { + onCondition(action.inner); + controller.close(); + }, + ); + } +} + +class ConditionWrapper extends ActionCell { + ConditionWrapper(this.inner, this.isSelected); + + final NumberFilterConditionPB inner; + final bool isSelected; + + @override + Widget? rightIcon(Color iconColor) => + isSelected ? const FlowySvg(FlowySvgs.check_s) : null; + + @override + String get name => inner.filterName; +} + +extension NumberFilterConditionPBExtension on NumberFilterConditionPB { + String get filterName { + return switch (this) { + NumberFilterConditionPB.Equal => LocaleKeys.grid_numberFilter_equal.tr(), + NumberFilterConditionPB.NotEqual => + LocaleKeys.grid_numberFilter_notEqual.tr(), + NumberFilterConditionPB.LessThan => + LocaleKeys.grid_numberFilter_lessThan.tr(), + NumberFilterConditionPB.LessThanOrEqualTo => + LocaleKeys.grid_numberFilter_lessThanOrEqualTo.tr(), + NumberFilterConditionPB.GreaterThan => + LocaleKeys.grid_numberFilter_greaterThan.tr(), + NumberFilterConditionPB.GreaterThanOrEqualTo => + LocaleKeys.grid_numberFilter_greaterThanOrEqualTo.tr(), + NumberFilterConditionPB.NumberIsEmpty => + LocaleKeys.grid_numberFilter_isEmpty.tr(), + NumberFilterConditionPB.NumberIsNotEmpty => + LocaleKeys.grid_numberFilter_isNotEmpty.tr(), + _ => "", + }; } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart index 54fa63c2c0..5d2406094a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart @@ -27,51 +27,37 @@ class SelectOptionFilterList extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) { - late SelectOptionFilterListBloc bloc; - if (filterInfo.fieldInfo.fieldType == FieldType.SingleSelect) { - bloc = SelectOptionFilterListBloc( - selectedOptionIds: selectedOptionIds, - delegate: - SingleSelectOptionFilterDelegateImpl(filterInfo: filterInfo), - ); - } else { - bloc = SelectOptionFilterListBloc( - selectedOptionIds: selectedOptionIds, - delegate: - MultiSelectOptionFilterDelegateImpl(filterInfo: filterInfo), - ); - } - - bloc.add(const SelectOptionFilterListEvent.initial()); - return bloc; + return SelectOptionFilterListBloc( + selectedOptionIds: selectedOptionIds, + delegate: filterInfo.fieldInfo.fieldType == FieldType.SingleSelect + ? SingleSelectOptionFilterDelegateImpl(filterInfo: filterInfo) + : MultiSelectOptionFilterDelegateImpl(filterInfo: filterInfo), + )..add(const SelectOptionFilterListEvent.initial()); }, child: - BlocListener( + BlocConsumer( listenWhen: (previous, current) => previous.selectedOptionIds != current.selectedOptionIds, listener: (context, state) { onSelectedOptions(state.selectedOptionIds.toList()); }, - child: BlocBuilder( - builder: (context, state) { - return ListView.separated( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: state.visibleOptions.length, - separatorBuilder: (context, index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - itemBuilder: (BuildContext context, int index) { - final option = state.visibleOptions[index]; - return SelectOptionFilterCell( - option: option.optionPB, - isSelected: option.isSelected, - ); - }, - ); - }, - ), + builder: (context, state) { + return ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: state.visibleOptions.length, + separatorBuilder: (context, index) { + return VSpace(GridSize.typeOptionSeparatorHeight); + }, + itemBuilder: (BuildContext context, int index) { + final option = state.visibleOptions[index]; + return SelectOptionFilterCell( + option: option.optionPB, + isSelected: option.isSelected, + ); + }, + ); + }, ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart index 36c6ebf030..97fc590748 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart @@ -1,11 +1,5 @@ import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checklist_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/date_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/select_option_filter.pbserver.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/text_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/util.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; class FilterInfo { FilterInfo(this.viewId, this.filter, this.fieldInfo); @@ -27,44 +21,39 @@ class FilterInfo { String get fieldId => filter.fieldId; DateFilterPB? dateFilter() { - if (![ - FieldType.DateTime, - FieldType.LastEditedTime, - FieldType.CreatedTime, - ].contains(filter.fieldType)) { - return null; - } - return DateFilterPB.fromBuffer(filter.data); + return filter.fieldType == FieldType.DateTime + ? DateFilterPB.fromBuffer(filter.data) + : null; } TextFilterPB? textFilter() { - if (filter.fieldType != FieldType.RichText) { - return null; - } - return TextFilterPB.fromBuffer(filter.data); + return filter.fieldType == FieldType.RichText + ? TextFilterPB.fromBuffer(filter.data) + : null; } CheckboxFilterPB? checkboxFilter() { - if (filter.fieldType != FieldType.Checkbox) { - return null; - } - return CheckboxFilterPB.fromBuffer(filter.data); + return filter.fieldType == FieldType.Checkbox + ? CheckboxFilterPB.fromBuffer(filter.data) + : null; } SelectOptionFilterPB? selectOptionFilter() { - if (filter.fieldType == FieldType.SingleSelect || - filter.fieldType == FieldType.MultiSelect) { - return SelectOptionFilterPB.fromBuffer(filter.data); - } else { - return null; - } + return filter.fieldType == FieldType.SingleSelect || + filter.fieldType == FieldType.MultiSelect + ? SelectOptionFilterPB.fromBuffer(filter.data) + : null; } ChecklistFilterPB? checklistFilter() { - if (filter.fieldType == FieldType.Checklist) { - return ChecklistFilterPB.fromBuffer(filter.data); - } else { - return null; - } + return filter.fieldType == FieldType.Checklist + ? ChecklistFilterPB.fromBuffer(filter.data) + : null; + } + + NumberFilterPB? numberFilter() { + return filter.fieldType == FieldType.Number + ? NumberFilterPB.fromBuffer(filter.data) + : null; } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart index 524a17cee4..f661ea57de 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart @@ -17,31 +17,18 @@ class FilterMenuItem extends StatelessWidget { @override Widget build(BuildContext context) { - return buildFilterChoicechip(filterInfo); - } -} - -Widget buildFilterChoicechip(FilterInfo filterInfo) { - switch (filterInfo.fieldInfo.fieldType) { - case FieldType.Checkbox: - return CheckboxFilterChoicechip(filterInfo: filterInfo); - case FieldType.DateTime: - case FieldType.LastEditedTime: - case FieldType.CreatedTime: - return DateFilterChoicechip(filterInfo: filterInfo); - case FieldType.MultiSelect: - return SelectOptionFilterChoicechip(filterInfo: filterInfo); - case FieldType.Number: - return NumberFilterChoicechip(filterInfo: filterInfo); - case FieldType.RichText: - return TextFilterChoicechip(filterInfo: filterInfo); - case FieldType.SingleSelect: - return SelectOptionFilterChoicechip(filterInfo: filterInfo); - case FieldType.URL: - return URLFilterChoicechip(filterInfo: filterInfo); - case FieldType.Checklist: - return ChecklistFilterChoicechip(filterInfo: filterInfo); - default: - return const SizedBox(); + return switch (filterInfo.fieldInfo.fieldType) { + FieldType.Checkbox => CheckboxFilterChoicechip(filterInfo: filterInfo), + FieldType.DateTime => DateFilterChoicechip(filterInfo: filterInfo), + FieldType.MultiSelect => + SelectOptionFilterChoicechip(filterInfo: filterInfo), + FieldType.Number => NumberFilterChoiceChip(filterInfo: filterInfo), + FieldType.RichText => TextFilterChoicechip(filterInfo: filterInfo), + FieldType.SingleSelect => + SelectOptionFilterChoicechip(filterInfo: filterInfo), + FieldType.URL => URLFilterChoicechip(filterInfo: filterInfo), + FieldType.Checklist => ChecklistFilterChoicechip(filterInfo: filterInfo), + _ => const SizedBox(), + }; } } diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 83afac352b..967e511bce 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -540,6 +540,16 @@ "empty": "Is empty", "notEmpty": "Is not empty" }, + "numberFilter": { + "equal": "Equals", + "notEqual": "Does not equal", + "lessThan": "Is less than", + "greaterThan": "Is greater than", + "lessThanOrEqualTo": "Is less than or equal to", + "greaterThanOrEqualTo": "Is greater than or equal to", + "isEmpty": "Is empty", + "isNotEmpty": "Is not empty" + }, "field": { "hide": "Hide", "show": "Show", @@ -1262,4 +1272,4 @@ "userIcon": "User icon" }, "noLogFiles": "There're no log files" -} +} \ No newline at end of file