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
This commit is contained in:
Richard Shiue 2024-02-17 15:28:49 +08:00 committed by GitHub
parent 1311e2d379
commit c159a5e42b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 411 additions and 105 deletions

View File

@ -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();

View File

@ -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:

View File

@ -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<NumberFilterEditorEvent, NumberFilterEditorState> {
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<NumberFilterEditorEvent>(
(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<void> 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()!,
);
}
}

View File

@ -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<NumberFilterChoiceChip> createState() => _NumberFilterChoiceChipState();
}
class _NumberFilterChoiceChipState extends State<NumberFilterChoiceChip> {
@override
Widget build(BuildContext context) {
return ChoiceChipButton(filterInfo: filterInfo);
return BlocProvider(
create: (_) => NumberFilterEditorBloc(
filterInfo: widget.filterInfo,
),
child: BlocBuilder<NumberFilterEditorBloc, NumberFilterEditorState>(
builder: (context, state) {
return AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(200, 100)),
direction: PopoverDirection.bottomWithCenterAligned,
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<NumberFilterEditorBloc>(),
child: const NumberFilterEditor(),
);
},
child: ChoiceChipButton(
filterInfo: state.filterInfo,
),
);
},
),
);
}
}
class NumberFilterEditor extends StatefulWidget {
const NumberFilterEditor({super.key});
@override
State<NumberFilterEditor> createState() => _NumberFilterEditorState();
}
class _NumberFilterEditorState extends State<NumberFilterEditor> {
final popoverMutex = PopoverMutex();
@override
Widget build(BuildContext context) {
return BlocBuilder<NumberFilterEditorBloc, NumberFilterEditorState>(
builder: (context, state) {
final List<Widget> 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<NumberFilterEditorBloc>()
.add(NumberFilterEditorEvent.updateCondition(condition));
},
),
),
const HSpace(4),
DisclosureButton(
popoverMutex: popoverMutex,
onAction: (action) {
switch (action) {
case FilterDisclosureAction.delete:
context
.read<NumberFilterEditorBloc>()
.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<NumberFilterEditorBloc>()
.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<ConditionWrapper>(
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(),
_ => "",
};
}
}

View File

@ -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<SelectOptionFilterListBloc, SelectOptionFilterListState>(
BlocConsumer<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(
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,
);
},
);
},
),
);
}

View File

@ -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;
}
}

View File

@ -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(),
};
}
}

View File

@ -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"
}
}