feat: select option cell editor revamp (#5011)

* chore: gen new select option color on frontend

* chore: reorder select options

* chore: fix performance regression

* chore: add text field tap region

* chore: implement hover focus

* chore: implement keyboard focus

* chore: fix tests

* chore: reorder options in field editor

* chore: fix tests
This commit is contained in:
Richard Shiue 2024-03-31 10:54:17 +08:00 committed by GitHub
parent adc2ee755e
commit 419464c175
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1186 additions and 833 deletions

View File

@ -0,0 +1,446 @@
import 'dart:async';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/field/type_option/select_type_option_actions.dart';
import 'package:appflowy/plugins/database/domain/field_service.dart';
import 'package:appflowy/plugins/database/domain/select_option_cell_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'select_option_cell_editor_bloc.freezed.dart';
const String createSelectOptionSuggestionId =
"create_select_option_suggestion_id";
class SelectOptionCellEditorBloc
extends Bloc<SelectOptionCellEditorEvent, SelectOptionCellEditorState> {
SelectOptionCellEditorBloc({required this.cellController})
: _selectOptionService = SelectOptionCellBackendService(
viewId: cellController.viewId,
fieldId: cellController.fieldId,
rowId: cellController.rowId,
),
_typeOptionAction = cellController.fieldType == FieldType.SingleSelect
? SingleSelectAction(
viewId: cellController.viewId,
fieldId: cellController.fieldId,
onTypeOptionUpdated: (typeOptionData) =>
FieldBackendService.updateFieldTypeOption(
viewId: cellController.viewId,
fieldId: cellController.fieldId,
typeOptionData: typeOptionData,
),
)
: MultiSelectAction(
viewId: cellController.viewId,
fieldId: cellController.fieldId,
onTypeOptionUpdated: (typeOptionData) =>
FieldBackendService.updateFieldTypeOption(
viewId: cellController.viewId,
fieldId: cellController.fieldId,
typeOptionData: typeOptionData,
),
),
super(SelectOptionCellEditorState.initial(cellController)) {
_dispatch();
_startListening();
_loadOptions();
}
final SelectOptionCellBackendService _selectOptionService;
final ISelectOptionAction _typeOptionAction;
final SelectOptionCellController cellController;
VoidCallback? _onCellChangedFn;
void _dispatch() {
on<SelectOptionCellEditorEvent>(
(event, emit) async {
await event.when(
didReceiveOptions: (options, selectedOptions) {
final result = _makeOptions(state.filter, options);
emit(
state.copyWith(
allOptions: options,
options: result.options,
createSelectOptionSuggestion:
result.createSelectOptionSuggestion,
selectedOptions: selectedOptions,
),
);
},
createOption: () async {
if (state.createSelectOptionSuggestion == null) {
return;
}
await _createOption(
name: state.createSelectOptionSuggestion!.name,
color: state.createSelectOptionSuggestion!.color,
);
emit(
state.copyWith(
filter: null,
),
);
},
deleteOption: (option) async {
await _deleteOption([option]);
},
deleteAllOptions: () async {
if (state.allOptions.isNotEmpty) {
await _deleteOption(state.allOptions);
}
},
updateOption: (option) async {
await _updateOption(option);
},
selectOption: (optionId) async {
await _selectOptionService.select(optionIds: [optionId]);
final selectedOption = [
...state.selectedOptions,
state.options.firstWhere(
(element) => element.id == optionId,
),
];
emit(
state.copyWith(
selectedOptions: selectedOption,
),
);
},
unSelectOption: (optionId) async {
await _selectOptionService.unSelect(optionIds: [optionId]);
final selectedOptions = [...state.selectedOptions]
..removeWhere((e) => e.id == optionId);
emit(
state.copyWith(
selectedOptions: selectedOptions,
),
);
},
submitTextField: () {
_submitTextFieldValue(emit);
},
selectMultipleOptions: (optionNames, remainder) {
if (optionNames.isNotEmpty) {
_selectMultipleOptions(optionNames);
}
_filterOption(remainder, emit);
},
reorderOption: (fromOptionId, toOptionId) {
final options = _typeOptionAction.reorderOption(
state.allOptions,
fromOptionId,
toOptionId,
);
final result = _makeOptions(state.filter, options);
emit(
state.copyWith(
allOptions: options,
options: result.options,
),
);
},
filterOption: (optionName) {
_filterOption(optionName, emit);
},
focusPreviousOption: () {
if (state.options.isEmpty) {
return;
}
if (state.focusedOptionId == null) {
emit(state.copyWith(focusedOptionId: state.options.last.id));
} else {
final currentIndex = state.options
.indexWhere((option) => option.id == state.focusedOptionId);
if (currentIndex != -1) {
final newIndex = (currentIndex - 1) % state.options.length;
emit(
state.copyWith(
focusedOptionId: state.options[newIndex].id,
),
);
}
}
},
focusNextOption: () {
if (state.options.isEmpty) {
return;
}
if (state.focusedOptionId == null) {
emit(state.copyWith(focusedOptionId: state.options.first.id));
} else {
final currentIndex = state.options
.indexWhere((option) => option.id == state.focusedOptionId);
if (currentIndex != -1) {
final newIndex = (currentIndex + 1) % state.options.length;
emit(
state.copyWith(
focusedOptionId: state.options[newIndex].id,
),
);
}
}
},
updateFocusedOption: (optionId) {
emit(state.copyWith(focusedOptionId: optionId));
},
);
},
);
}
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(_onCellChangedFn!);
_onCellChangedFn = null;
}
return super.close();
}
Future<void> _createOption({
required String name,
required SelectOptionColorPB color,
}) async {
final result = await _selectOptionService.create(
name: name,
color: color,
);
result.fold((l) => {}, (err) => Log.error(err));
}
Future<void> _deleteOption(List<SelectOptionPB> options) async {
final result = await _selectOptionService.delete(options: options);
result.fold((l) => null, (err) => Log.error(err));
}
Future<void> _updateOption(SelectOptionPB option) async {
final result = await _selectOptionService.update(
option: option,
);
result.fold((l) => null, (err) => Log.error(err));
}
void _submitTextFieldValue(Emitter<SelectOptionCellEditorState> emit) {
if (state.focusedOptionId == null) {
return;
}
final optionId = state.focusedOptionId!;
if (optionId == createSelectOptionSuggestionId) {
_createOption(
name: state.createSelectOptionSuggestion!.name,
color: state.createSelectOptionSuggestion!.color,
);
emit(
state.copyWith(
filter: null,
createSelectOptionSuggestion: null,
),
);
} else if (!state.selectedOptions.any((option) => option.id == optionId)) {
_selectOptionService.select(optionIds: [optionId]);
}
}
void _selectMultipleOptions(List<String> optionNames) {
// The options are unordered. So in order to keep the inserted [optionNames]
// order, it needs to get the option id in the [optionNames] order.
final lowerCaseNames = optionNames.map((e) => e.toLowerCase());
final Map<String, String> optionIdsMap = {};
for (final option in state.options) {
optionIdsMap[option.name.toLowerCase()] = option.id;
}
final optionIds = lowerCaseNames
.where((name) => optionIdsMap[name] != null)
.map((name) => optionIdsMap[name]!)
.toList();
_selectOptionService.select(optionIds: optionIds);
}
void _filterOption(
String optionName,
Emitter<SelectOptionCellEditorState> emit,
) {
final _MakeOptionResult result = _makeOptions(
optionName,
state.allOptions,
);
final focusedOptionId = result.options.isEmpty
? result.createSelectOptionSuggestion == null
? null
: createSelectOptionSuggestionId
: result.options.length != state.options.length
? result.options.first.id
: state.focusedOptionId;
emit(
state.copyWith(
filter: optionName,
options: result.options,
createSelectOptionSuggestion: result.createSelectOptionSuggestion,
focusedOptionId: focusedOptionId,
),
);
}
Future<void> _loadOptions() async {
final result = await _selectOptionService.getCellData();
if (isClosed) {
Log.warn("Unexpecteded closing the bloc");
return;
}
return result.fold(
(data) => add(
SelectOptionCellEditorEvent.didReceiveOptions(
data.options,
data.selectOptions,
),
),
(err) {
Log.error(err);
return null;
},
);
}
_MakeOptionResult _makeOptions(
String? filter,
List<SelectOptionPB> allOptions,
) {
final List<SelectOptionPB> options = List.from(allOptions);
String? newOptionName = filter;
if (filter != null && filter.isNotEmpty) {
options.retainWhere((option) {
final name = option.name.toLowerCase();
final lFilter = filter.toLowerCase();
if (name == lFilter) {
newOptionName = null;
}
return name.contains(lFilter);
});
} else {
newOptionName = null;
}
return _MakeOptionResult(
options: options,
createSelectOptionSuggestion: newOptionName != null
? CreateSelectOptionSuggestion(
name: newOptionName!,
color: newSelectOptionColor(allOptions),
)
: null,
);
}
void _startListening() {
_onCellChangedFn = cellController.addListener(
onCellChanged: (selectOptionContext) {
_loadOptions();
},
onCellFieldChanged: (field) {
_loadOptions();
},
);
}
}
@freezed
class SelectOptionCellEditorEvent with _$SelectOptionCellEditorEvent {
const factory SelectOptionCellEditorEvent.didReceiveOptions(
List<SelectOptionPB> options,
List<SelectOptionPB> selectedOptions,
) = _DidReceiveOptions;
const factory SelectOptionCellEditorEvent.createOption() = _CreateOption;
const factory SelectOptionCellEditorEvent.selectOption(String optionId) =
_SelectOption;
const factory SelectOptionCellEditorEvent.unSelectOption(String optionId) =
_UnSelectOption;
const factory SelectOptionCellEditorEvent.updateOption(
SelectOptionPB option,
) = _UpdateOption;
const factory SelectOptionCellEditorEvent.deleteOption(
SelectOptionPB option,
) = _DeleteOption;
const factory SelectOptionCellEditorEvent.deleteAllOptions() =
_DeleteAllOptions;
const factory SelectOptionCellEditorEvent.reorderOption(
String fromOptionId,
String toOptionId,
) = _ReorderOption;
const factory SelectOptionCellEditorEvent.filterOption(String optionName) =
_SelectOptionFilter;
const factory SelectOptionCellEditorEvent.submitTextField() =
_SubmitTextField;
const factory SelectOptionCellEditorEvent.selectMultipleOptions(
List<String> optionNames,
String remainder,
) = _SelectMultipleOptions;
const factory SelectOptionCellEditorEvent.focusPreviousOption() =
_FocusPreviousOption;
const factory SelectOptionCellEditorEvent.focusNextOption() =
_FocusNextOption;
const factory SelectOptionCellEditorEvent.updateFocusedOption(
String? optionId,
) = _UpdateFocusedOption;
}
@freezed
class SelectOptionCellEditorState with _$SelectOptionCellEditorState {
const factory SelectOptionCellEditorState({
required List<SelectOptionPB> options,
required List<SelectOptionPB> allOptions,
required List<SelectOptionPB> selectedOptions,
required CreateSelectOptionSuggestion? createSelectOptionSuggestion,
required String? filter,
required String? focusedOptionId,
}) = _SelectOptionEditorState;
factory SelectOptionCellEditorState.initial(
SelectOptionCellController context,
) {
final data = context.getCellData(loadIfNotExist: false);
return SelectOptionCellEditorState(
options: data?.options ?? [],
allOptions: data?.options ?? [],
selectedOptions: data?.selectOptions ?? [],
createSelectOptionSuggestion: null,
filter: null,
focusedOptionId: null,
);
}
}
class _MakeOptionResult {
_MakeOptionResult({
required this.options,
required this.createSelectOptionSuggestion,
});
List<SelectOptionPB> options;
CreateSelectOptionSuggestion? createSelectOptionSuggestion;
}
class CreateSelectOptionSuggestion {
CreateSelectOptionSuggestion({
required this.name,
required this.color,
});
final String name;
final SelectOptionColorPB color;
}

View File

@ -1,318 +0,0 @@
import 'dart:async';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/domain/select_option_cell_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'select_option_editor_bloc.freezed.dart';
class SelectOptionCellEditorBloc
extends Bloc<SelectOptionEditorEvent, SelectOptionEditorState> {
SelectOptionCellEditorBloc({required this.cellController})
: _selectOptionService = SelectOptionCellBackendService(
viewId: cellController.viewId,
fieldId: cellController.fieldId,
rowId: cellController.rowId,
),
super(SelectOptionEditorState.initial(cellController)) {
_dispatch();
}
final SelectOptionCellBackendService _selectOptionService;
final SelectOptionCellController cellController;
VoidCallback? _onCellChangedFn;
void _dispatch() {
on<SelectOptionEditorEvent>(
(event, emit) async {
await event.when(
initial: () async {
_startListening();
await _loadOptions();
},
didReceiveOptions: (options, selectedOptions) {
final result = _makeOptions(state.filter, options);
emit(
state.copyWith(
allOptions: options,
options: result.options,
createOption: result.createOption,
selectedOptions: selectedOptions,
),
);
},
newOption: (optionName) async {
await _createOption(optionName);
emit(
state.copyWith(
filter: null,
),
);
},
deleteOption: (option) async {
await _deleteOption([option]);
},
deleteAllOptions: () async {
if (state.allOptions.isNotEmpty) {
await _deleteOption(state.allOptions);
}
},
updateOption: (option) async {
await _updateOption(option);
},
selectOption: (optionId) async {
await _selectOptionService.select(optionIds: [optionId]);
final selectedOption = [
...state.selectedOptions,
state.options.firstWhere(
(element) => element.id == optionId,
),
];
emit(
state.copyWith(
selectedOptions: selectedOption,
),
);
},
unSelectOption: (optionId) async {
await _selectOptionService.unSelect(optionIds: [optionId]);
final selectedOptions = [...state.selectedOptions]
..removeWhere((e) => e.id == optionId);
emit(
state.copyWith(
selectedOptions: selectedOptions,
),
);
},
trySelectOption: (optionName) {
_trySelectOption(optionName, emit);
},
selectMultipleOptions: (optionNames, remainder) {
if (optionNames.isNotEmpty) {
_selectMultipleOptions(optionNames);
}
_filterOption(remainder, emit);
},
filterOption: (optionName) {
_filterOption(optionName, emit);
},
);
},
);
}
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(_onCellChangedFn!);
_onCellChangedFn = null;
}
return super.close();
}
Future<void> _createOption(String name) async {
final result = await _selectOptionService.create(name: name);
result.fold((l) => {}, (err) => Log.error(err));
}
Future<void> _deleteOption(List<SelectOptionPB> options) async {
final result = await _selectOptionService.delete(options: options);
result.fold((l) => null, (err) => Log.error(err));
}
Future<void> _updateOption(SelectOptionPB option) async {
final result = await _selectOptionService.update(
option: option,
);
result.fold((l) => null, (err) => Log.error(err));
}
void _trySelectOption(
String optionName,
Emitter<SelectOptionEditorState> emit,
) {
SelectOptionPB? matchingOption;
bool optionExistsButSelected = false;
for (final option in state.options) {
if (option.name.toLowerCase() == optionName.toLowerCase()) {
if (!state.selectedOptions.contains(option)) {
matchingOption = option;
break;
} else {
optionExistsButSelected = true;
}
}
}
// if there isn't a matching option at all, then create it
if (matchingOption == null && !optionExistsButSelected) {
_createOption(optionName);
}
// if there is an unselected matching option, select it
if (matchingOption != null) {
_selectOptionService.select(optionIds: [matchingOption.id]);
}
// clear the filter
emit(state.copyWith(filter: null));
}
void _selectMultipleOptions(List<String> optionNames) {
// The options are unordered. So in order to keep the inserted [optionNames]
// order, it needs to get the option id in the [optionNames] order.
final lowerCaseNames = optionNames.map((e) => e.toLowerCase());
final Map<String, String> optionIdsMap = {};
for (final option in state.options) {
optionIdsMap[option.name.toLowerCase()] = option.id;
}
final optionIds = lowerCaseNames
.where((name) => optionIdsMap[name] != null)
.map((name) => optionIdsMap[name]!)
.toList();
_selectOptionService.select(optionIds: optionIds);
}
void _filterOption(String optionName, Emitter<SelectOptionEditorState> emit) {
final _MakeOptionResult result = _makeOptions(
optionName,
state.allOptions,
);
emit(
state.copyWith(
filter: optionName,
options: result.options,
createOption: result.createOption,
),
);
}
Future<void> _loadOptions() async {
final result = await _selectOptionService.getCellData();
if (isClosed) {
Log.warn("Unexpecteded closing the bloc");
return;
}
return result.fold(
(data) => add(
SelectOptionEditorEvent.didReceiveOptions(
data.options,
data.selectOptions,
),
),
(err) {
Log.error(err);
return null;
},
);
}
_MakeOptionResult _makeOptions(
String? filter,
List<SelectOptionPB> allOptions,
) {
final List<SelectOptionPB> options = List.from(allOptions);
String? createOption = filter;
if (filter != null && filter.isNotEmpty) {
options.retainWhere((option) {
final name = option.name.toLowerCase();
final lFilter = filter.toLowerCase();
if (name == lFilter) {
createOption = null;
}
return name.contains(lFilter);
});
} else {
createOption = null;
}
return _MakeOptionResult(
options: options,
createOption: createOption,
);
}
void _startListening() {
_onCellChangedFn = cellController.addListener(
onCellChanged: (selectOptionContext) {
_loadOptions();
},
onCellFieldChanged: (field) {
_loadOptions();
},
);
}
}
@freezed
class SelectOptionEditorEvent with _$SelectOptionEditorEvent {
const factory SelectOptionEditorEvent.initial() = _Initial;
const factory SelectOptionEditorEvent.didReceiveOptions(
List<SelectOptionPB> options,
List<SelectOptionPB> selectedOptions,
) = _DidReceiveOptions;
const factory SelectOptionEditorEvent.newOption(String optionName) =
_NewOption;
const factory SelectOptionEditorEvent.selectOption(String optionId) =
_SelectOption;
const factory SelectOptionEditorEvent.unSelectOption(String optionId) =
_UnSelectOption;
const factory SelectOptionEditorEvent.updateOption(SelectOptionPB option) =
_UpdateOption;
const factory SelectOptionEditorEvent.deleteOption(SelectOptionPB option) =
_DeleteOption;
const factory SelectOptionEditorEvent.deleteAllOptions() = _DeleteAllOptions;
const factory SelectOptionEditorEvent.filterOption(String optionName) =
_SelectOptionFilter;
const factory SelectOptionEditorEvent.trySelectOption(String optionName) =
_TrySelectOption;
const factory SelectOptionEditorEvent.selectMultipleOptions(
List<String> optionNames,
String remainder,
) = _SelectMultipleOptions;
}
@freezed
class SelectOptionEditorState with _$SelectOptionEditorState {
const factory SelectOptionEditorState({
required List<SelectOptionPB> options,
required List<SelectOptionPB> allOptions,
required List<SelectOptionPB> selectedOptions,
required String? createOption,
required String? filter,
}) = _SelectOptionEditorState;
factory SelectOptionEditorState.initial(SelectOptionCellController context) {
final data = context.getCellData(loadIfNotExist: false);
return SelectOptionEditorState(
options: data?.options ?? [],
allOptions: data?.options ?? [],
selectedOptions: data?.selectOptions ?? [],
createOption: null,
filter: null,
);
}
}
class _MakeOptionResult {
_MakeOptionResult({
required this.options,
required this.createOption,
});
List<SelectOptionPB> options;
String? createOption;
}

View File

@ -20,10 +20,10 @@ class SelectOptionTypeOptionBloc
void _dispatch() {
on<SelectOptionTypeOptionEvent>(
(event, emit) async {
await event.when(
createOption: (optionName) async {
event.when(
createOption: (optionName) {
final List<SelectOptionPB> options =
await typeOptionAction.insertOption(state.options, optionName);
typeOptionAction.insertOption(state.options, optionName);
emit(state.copyWith(options: options));
},
addingOption: () {
@ -33,15 +33,23 @@ class SelectOptionTypeOptionBloc
emit(state.copyWith(isEditingOption: false, newOptionName: null));
},
updateOption: (option) {
final List<SelectOptionPB> options =
final options =
typeOptionAction.updateOption(state.options, option);
emit(state.copyWith(options: options));
},
deleteOption: (option) {
final List<SelectOptionPB> options =
final options =
typeOptionAction.deleteOption(state.options, option);
emit(state.copyWith(options: options));
},
reorderOption: (fromOptionId, toOptionId) {
final options = typeOptionAction.reorderOption(
state.options,
fromOptionId,
toOptionId,
);
emit(state.copyWith(options: options));
},
);
},
);
@ -61,6 +69,10 @@ class SelectOptionTypeOptionEvent with _$SelectOptionTypeOptionEvent {
const factory SelectOptionTypeOptionEvent.deleteOption(
SelectOptionPB option,
) = _DeleteOption;
const factory SelectOptionTypeOptionEvent.reorderOption(
String fromOptionId,
String toOptionId,
) = _ReorderOption;
}
@freezed

View File

@ -1,9 +1,7 @@
import 'dart:async';
import 'package:appflowy/plugins/database/domain/type_option_service.dart';
import 'package:appflowy/plugins/database/widgets/field/type_option_editor/builder.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart';
import 'package:nanoid/nanoid.dart';
abstract class ISelectOptionAction {
ISelectOptionAction({
@ -20,29 +18,25 @@ abstract class ISelectOptionAction {
onTypeOptionUpdated(newTypeOption.writeToBuffer());
}
Future<List<SelectOptionPB>> insertOption(
List<SelectOptionPB> insertOption(
List<SelectOptionPB> options,
String optionName,
) {
final newOptions = List<SelectOptionPB>.from(options);
return service.newOption(name: optionName).then((result) {
return result.fold(
(option) {
final exists =
newOptions.any((element) => element.name == option.name);
if (!exists) {
newOptions.insert(0, option);
}
if (options.any((element) => element.name == optionName)) {
return options;
}
updateTypeOption(newOptions);
return newOptions;
},
(err) {
Log.error(err);
return newOptions;
},
);
});
final newOptions = List<SelectOptionPB>.from(options);
final newSelectOption = SelectOptionPB()
..id = nanoid(4)
..color = newSelectOptionColor(options)
..name = optionName;
newOptions.insert(0, newSelectOption);
updateTypeOption(newOptions);
return newOptions;
}
List<SelectOptionPB> deleteOption(
@ -73,6 +67,25 @@ abstract class ISelectOptionAction {
updateTypeOption(newOptions);
return newOptions;
}
List<SelectOptionPB> reorderOption(
List<SelectOptionPB> options,
String fromOptionId,
String toOptionId,
) {
final newOptions = List<SelectOptionPB>.from(options);
final fromIndex =
newOptions.indexWhere((element) => element.id == fromOptionId);
final toIndex =
newOptions.indexWhere((element) => element.id == toOptionId);
if (fromIndex != -1 && toIndex != -1) {
newOptions.insert(toIndex, newOptions.removeAt(fromIndex));
}
updateTypeOption(newOptions);
return newOptions;
}
}
class MultiSelectAction extends ISelectOptionAction {
@ -102,3 +115,19 @@ class SingleSelectAction extends ISelectOptionAction {
onTypeOptionUpdated(newTypeOption.writeToBuffer());
}
}
SelectOptionColorPB newSelectOptionColor(List<SelectOptionPB> options) {
final colorFrequency = List.filled(SelectOptionColorPB.values.length, 0);
for (final option in options) {
colorFrequency[option.color.value]++;
}
final minIndex = colorFrequency
.asMap()
.entries
.reduce((a, b) => a.value <= b.value ? a : b)
.key;
return SelectOptionColorPB.valueOf(minIndex) ?? SelectOptionColorPB.Purple;
}

View File

@ -2,8 +2,7 @@ import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'type_option_service.dart';
import 'package:nanoid/nanoid.dart';
class SelectOptionCellBackendService {
SelectOptionCellBackendService({
@ -18,26 +17,23 @@ class SelectOptionCellBackendService {
Future<FlowyResult<void, FlowyError>> create({
required String name,
SelectOptionColorPB? color,
bool isSelected = true,
}) {
return TypeOptionBackendService(viewId: viewId, fieldId: fieldId)
.newOption(name: name)
.then(
(result) {
return result.fold(
(option) {
final payload = RepeatedSelectOptionPayload()
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..items.add(option);
final option = SelectOptionPB()
..id = nanoid(4)
..name = name;
if (color != null) {
option.color = color;
}
return DatabaseEventInsertOrUpdateSelectOption(payload).send();
},
(r) => FlowyResult.failure(r),
);
},
);
final payload = RepeatedSelectOptionPayload()
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..items.add(option);
return DatabaseEventInsertOrUpdateSelectOption(payload).send();
}
Future<FlowyResult<void, FlowyError>> update({

View File

@ -3,7 +3,7 @@ import 'package:appflowy/plugins/database/grid/application/filter/select_option_
import 'package:appflowy/plugins/database/grid/application/filter/select_option_filter_list_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';

View File

@ -7,6 +7,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/select_option.dart';
@ -16,29 +17,29 @@ class DesktopGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin {
BuildContext context,
CellContainerNotifier cellContainerNotifier,
SelectOptionCellBloc bloc,
SelectOptionCellState state,
PopoverController popoverController,
) {
return AppFlowyPopover(
controller: popoverController,
constraints: BoxConstraints.loose(const Size.square(300)),
constraints: const BoxConstraints.tightFor(width: 300),
margin: EdgeInsets.zero,
direction: PopoverDirection.bottomWithLeftAligned,
popupBuilder: (BuildContext popoverContext) {
WidgetsBinding.instance.addPostFrameCallback((_) {
cellContainerNotifier.isFocus = true;
});
return SelectOptionCellEditor(
cellController: bloc.cellController,
);
},
onClose: () => cellContainerNotifier.isFocus = false,
child: Container(
alignment: AlignmentDirectional.centerStart,
padding: GridSize.cellContentInsets,
child: state.selectedOptions.isEmpty
? const SizedBox.shrink()
: _buildOptions(context, state.selectedOptions),
child: BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
builder: (context, state) {
return Container(
alignment: AlignmentDirectional.centerStart,
padding: GridSize.cellContentInsets,
child: state.selectedOptions.isEmpty
? const SizedBox.shrink()
: _buildOptions(context, state.selectedOptions),
);
},
),
);
}

View File

@ -8,6 +8,7 @@ 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 '../editable_cell_skeleton/select_option.dart';
@ -18,12 +19,11 @@ class DesktopRowDetailSelectOptionCellSkin
BuildContext context,
CellContainerNotifier cellContainerNotifier,
SelectOptionCellBloc bloc,
SelectOptionCellState state,
PopoverController popoverController,
) {
return AppFlowyPopover(
controller: popoverController,
constraints: BoxConstraints.loose(const Size.square(300)),
constraints: const BoxConstraints.tightFor(width: 300),
margin: EdgeInsets.zero,
direction: PopoverDirection.bottomWithLeftAligned,
popupBuilder: (BuildContext popoverContext) {
@ -35,14 +35,18 @@ class DesktopRowDetailSelectOptionCellSkin
);
},
onClose: () => cellContainerNotifier.isFocus = false,
child: Container(
alignment: AlignmentDirectional.centerStart,
padding: state.selectedOptions.isEmpty
? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0)
: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 5.0),
child: state.selectedOptions.isEmpty
? _buildPlaceholder(context)
: _buildOptions(context, state.selectedOptions),
child: BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
builder: (context, state) {
return Container(
alignment: AlignmentDirectional.centerStart,
padding: state.selectedOptions.isEmpty
? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0)
: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 5.0),
child: state.selectedOptions.isEmpty
? _buildPlaceholder(context)
: _buildOptions(context, state.selectedOptions),
);
},
),
);
}

View File

@ -32,7 +32,6 @@ abstract class IEditableSelectOptionCellSkin {
BuildContext context,
CellContainerNotifier cellContainerNotifier,
SelectOptionCellBloc bloc,
SelectOptionCellState state,
PopoverController popoverController,
);
}
@ -77,16 +76,11 @@ class _SelectOptionCellState extends GridCellState<EditableSelectOptionCell> {
Widget build(BuildContext context) {
return BlocProvider.value(
value: cellBloc,
child: BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
builder: (context, state) {
return widget.skin.build(
context,
widget.cellContainerNotifier,
cellBloc,
state,
_popover,
);
},
child: widget.skin.build(
context,
widget.cellContainerNotifier,
cellBloc,
_popover,
),
);
}

View File

@ -8,6 +8,7 @@ import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:collection/collection.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/select_option.dart';
@ -17,25 +18,28 @@ class MobileGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin {
BuildContext context,
CellContainerNotifier cellContainerNotifier,
SelectOptionCellBloc bloc,
SelectOptionCellState state,
PopoverController popoverController,
) {
return FlowyButton(
hoverColor: Colors.transparent,
radius: BorderRadius.zero,
margin: EdgeInsets.zero,
text: Align(
alignment: AlignmentDirectional.centerStart,
child: state.selectedOptions.isEmpty
? const SizedBox.shrink()
: _buildOptions(context, state.selectedOptions),
),
onTap: () {
showMobileBottomSheet(
context,
builder: (context) {
return MobileSelectOptionEditor(
cellController: bloc.cellController,
return BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
builder: (context, state) {
return FlowyButton(
hoverColor: Colors.transparent,
radius: BorderRadius.zero,
margin: EdgeInsets.zero,
text: Align(
alignment: AlignmentDirectional.centerStart,
child: state.selectedOptions.isEmpty
? const SizedBox.shrink()
: _buildOptions(context, state.selectedOptions),
),
onTap: () {
showMobileBottomSheet(
context,
builder: (context) {
return MobileSelectOptionEditor(
cellController: bloc.cellController,
);
},
);
},
);

View File

@ -10,6 +10,7 @@ import 'package:collection/collection.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 '../editable_cell_skeleton/select_option.dart';
@ -20,53 +21,56 @@ class MobileRowDetailSelectOptionCellSkin
BuildContext context,
CellContainerNotifier cellContainerNotifier,
SelectOptionCellBloc bloc,
SelectOptionCellState state,
PopoverController popoverController,
) {
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(14)),
onTap: () => showMobileBottomSheet(
context,
builder: (context) {
return MobileSelectOptionEditor(
cellController: bloc.cellController,
);
},
),
child: Container(
constraints: const BoxConstraints(
minHeight: 48,
minWidth: double.infinity,
),
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: state.selectedOptions.isEmpty ? 13 : 10,
),
decoration: BoxDecoration(
border: Border.fromBorderSide(
BorderSide(color: Theme.of(context).colorScheme.outline),
),
return BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
builder: (context, state) {
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(14)),
),
child: Row(
children: [
Expanded(
child: state.selectedOptions.isEmpty
? _buildPlaceholder(context)
: _buildOptions(context, state.selectedOptions),
onTap: () => showMobileBottomSheet(
context,
builder: (context) {
return MobileSelectOptionEditor(
cellController: bloc.cellController,
);
},
),
child: Container(
constraints: const BoxConstraints(
minHeight: 48,
minWidth: double.infinity,
),
const HSpace(6),
RotatedBox(
quarterTurns: 3,
child: Icon(
Icons.chevron_left,
color: Theme.of(context).hintColor,
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: state.selectedOptions.isEmpty ? 13 : 10,
),
decoration: BoxDecoration(
border: Border.fromBorderSide(
BorderSide(color: Theme.of(context).colorScheme.outline),
),
borderRadius: const BorderRadius.all(Radius.circular(14)),
),
const HSpace(2),
],
),
),
child: Row(
children: [
Expanded(
child: state.selectedOptions.isEmpty
? _buildPlaceholder(context)
: _buildOptions(context, state.selectedOptions),
),
const HSpace(6),
RotatedBox(
quarterTurns: 3,
child: Icon(
Icons.chevron_left,
color: Theme.of(context).hintColor,
),
),
const HSpace(2),
],
),
),
);
},
);
}

View File

@ -33,7 +33,7 @@ extension SelectOptionColorExtension on SelectOptionColorPB {
}
}
String optionName() {
String colorName() {
switch (this) {
case SelectOptionColorPB.Purple:
return LocaleKeys.grid_selectOption_purpleColor.tr();
@ -123,44 +123,3 @@ class SelectOptionTag extends StatelessWidget {
}
}
class SelectOptionTagCell extends StatelessWidget {
const SelectOptionTagCell({
super.key,
required this.option,
required this.onSelected,
this.children = const [],
});
final SelectOptionPB option;
final VoidCallback onSelected;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onSelected,
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 5.0,
vertical: 4.0,
),
child: SelectOptionTag(
option: option,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
),
),
),
),
...children,
],
);
}
}

View File

@ -5,7 +5,7 @@ import 'package:appflowy/mobile/presentation/base/option_color_list.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/select_option_editor_bloc.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
@ -55,8 +55,9 @@ class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
child: BlocProvider(
create: (context) => SelectOptionCellEditorBloc(
cellController: widget.cellController,
)..add(const SelectOptionEditorEvent.initial()),
child: BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
),
child: BlocBuilder<SelectOptionCellEditorBloc,
SelectOptionCellEditorState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
@ -110,7 +111,7 @@ class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
onDelete: () {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.deleteOption(option!));
.add(SelectOptionCellEditorEvent.deleteOption(option!));
_popOrBack();
},
onUpdate: (name, color) {
@ -120,7 +121,7 @@ class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
}
option.freeze();
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.updateOption(
SelectOptionCellEditorEvent.updateOption(
option.rebuild((p0) {
if (name != null) {
p0.name = name;
@ -142,16 +143,16 @@ class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
_SearchField(
controller: searchController,
hintText: LocaleKeys.grid_selectOption_searchOrCreateOption.tr(),
onSubmitted: (option) {
onSubmitted: (_) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.trySelectOption(option));
.add(const SelectOptionCellEditorEvent.submitTextField());
searchController.clear();
},
onChanged: (value) {
typingOption = value;
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.selectMultipleOptions(
SelectOptionCellEditorEvent.selectMultipleOptions(
[],
value,
),
@ -164,18 +165,18 @@ class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
onCreateOption: (optionName) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.newOption(optionName));
.add(const SelectOptionCellEditorEvent.createOption());
searchController.clear();
},
onCheck: (option, value) {
if (value) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.selectOption(option.id));
.add(SelectOptionCellEditorEvent.selectOption(option.id));
} else {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.unSelectOption(option.id));
.add(SelectOptionCellEditorEvent.unSelectOption(option.id));
}
},
onMoreOptions: (option) {
@ -253,18 +254,20 @@ class _OptionList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionCellEditorState>(
builder: (context, state) {
// existing options
final List<Widget> cells = [];
// create an option cell
final createOption = state.createOption;
if (createOption != null) {
if (state.createSelectOptionSuggestion != null) {
cells.add(
_CreateOptionCell(
optionName: createOption,
onTap: () => onCreateOption(createOption),
name: state.createSelectOptionSuggestion!.name,
color: state.createSelectOptionSuggestion!.color,
onTap: () => onCreateOption(
state.createSelectOptionSuggestion!.name,
),
),
);
}
@ -332,14 +335,17 @@ class _SelectOption extends StatelessWidget {
const HSpace(12),
// option tag
Expanded(
child: SelectOptionTag(
option: option,
padding: const EdgeInsets.symmetric(
vertical: 10,
child: Align(
alignment: AlignmentDirectional.centerStart,
child: SelectOptionTag(
option: option,
padding: const EdgeInsets.symmetric(
vertical: 10,
horizontal: 14,
),
textAlign: TextAlign.center,
fontSize: 15.0,
),
textAlign: TextAlign.center,
fontSize: 15.0,
isExpanded: true,
),
),
const HSpace(24),
@ -359,11 +365,13 @@ class _SelectOption extends StatelessWidget {
class _CreateOptionCell extends StatelessWidget {
const _CreateOptionCell({
required this.optionName,
required this.name,
required this.color,
required this.onTap,
});
final String optionName;
final String name;
final SelectOptionColorPB color;
final VoidCallback onTap;
@override
@ -381,13 +389,16 @@ class _CreateOptionCell extends StatelessWidget {
),
const HSpace(8),
Expanded(
child: SelectOptionTag(
isExpanded: true,
name: optionName,
color: Theme.of(context).colorScheme.surfaceVariant,
textAlign: TextAlign.center,
padding: const EdgeInsets.symmetric(
vertical: 10,
child: Align(
alignment: AlignmentDirectional.centerStart,
child: SelectOptionTag(
name: name,
color: color.toColor(context),
textAlign: TextAlign.center,
padding: const EdgeInsets.symmetric(
vertical: 10,
horizontal: 14,
),
),
),
),

View File

@ -1,18 +1,20 @@
import 'dart:collection';
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../application/cell/bloc/select_option_editor_bloc.dart';
import '../../grid/presentation/layout/sizes.dart';
import '../../grid/presentation/widgets/common/type_option_separator.dart';
import '../field/type_option_editor/select/select_option_editor.dart';
@ -33,39 +35,81 @@ class SelectOptionCellEditor extends StatefulWidget {
class _SelectOptionCellEditorState extends State<SelectOptionCellEditor> {
final TextEditingController textEditingController = TextEditingController();
final popoverMutex = PopoverMutex();
late final bloc = SelectOptionCellEditorBloc(
cellController: widget.cellController,
);
late final FocusNode focusNode;
@override
void initState() {
super.initState();
focusNode = FocusNode(
onKeyEvent: (node, event) {
if (event is KeyUpEvent) {
return KeyEventResult.ignored;
}
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowUp:
if (textEditingController.value.composing.isCollapsed) {
bloc.add(const SelectOptionCellEditorEvent.focusPreviousOption());
return KeyEventResult.handled;
}
case LogicalKeyboardKey.arrowDown:
if (textEditingController.value.composing.isCollapsed) {
bloc.add(const SelectOptionCellEditorEvent.focusNextOption());
return KeyEventResult.handled;
}
case LogicalKeyboardKey.escape:
if (!textEditingController.value.composing.isCollapsed) {
final end = textEditingController.value.composing.end;
final text = textEditingController.text;
textEditingController.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: end),
);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
);
}
@override
void dispose() {
popoverMutex.dispose();
textEditingController.dispose();
bloc.close();
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => SelectOptionCellEditorBloc(
cellController: widget.cellController,
)..add(const SelectOptionEditorEvent.initial()),
child: BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_TextField(
textEditingController: textEditingController,
popoverMutex: popoverMutex,
),
const TypeOptionSeparator(spacing: 0.0),
Flexible(
return BlocProvider.value(
value: bloc,
child: TextFieldTapRegion(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_TextField(
textEditingController: textEditingController,
focusNode: focusNode,
popoverMutex: popoverMutex,
),
const TypeOptionSeparator(spacing: 0.0),
Flexible(
child: Focus(
descendantsAreFocusable: false,
child: _OptionList(
textEditingController: textEditingController,
popoverMutex: popoverMutex,
),
),
],
);
},
),
],
),
),
);
}
@ -82,60 +126,83 @@ class _OptionList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionCellEditorState>(
buildWhen: (previous, current) =>
!listEquals(previous.options, current.options) ||
previous.createSelectOptionSuggestion !=
current.createSelectOptionSuggestion,
builder: (context, state) {
final cells = [
_Title(onPressedAddButton: () => onPressedAddButton(context)),
...state.options.map(
(option) => _SelectOptionCell(
option: option,
isSelected: state.selectedOptions.contains(option),
popoverMutex: popoverMutex,
return ReorderableListView.builder(
shrinkWrap: true,
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: Stack(
children: [
BlocProvider.value(
value: context.read<SelectOptionCellEditorBloc>(),
child: child,
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
];
final createOption = state.createOption;
if (createOption != null) {
cells.add(_CreateOptionCell(name: createOption));
}
return ListView.separated(
shrinkWrap: true,
itemCount: cells.length,
separatorBuilder: (_, __) =>
VSpace(GridSize.typeOptionSeparatorHeight),
physics: StyledScrollPhysics(),
itemBuilder: (_, int index) => cells[index],
buildDefaultDragHandles: false,
itemCount: state.options.length,
onReorderStart: (_) => popoverMutex.close(),
itemBuilder: (_, int index) {
final option = state.options[index];
return _SelectOptionCell(
key: ValueKey("select_cell_option_list_${option.id}"),
index: index,
option: option,
popoverMutex: popoverMutex,
);
},
onReorder: (oldIndex, newIndex) {
if (oldIndex < newIndex) {
newIndex--;
}
final fromOptionId = state.options[oldIndex].id;
final toOptionId = state.options[newIndex].id;
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionCellEditorEvent.reorderOption(
fromOptionId,
toOptionId,
),
);
},
header: const _Title(),
footer: state.createSelectOptionSuggestion == null
? null
: _CreateOptionCell(
suggestion: state.createSelectOptionSuggestion!,
),
padding: const EdgeInsets.symmetric(vertical: 8.0),
);
},
);
}
void onPressedAddButton(BuildContext context) {
final text = textEditingController.text;
if (text.isNotEmpty) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.trySelectOption(text));
}
textEditingController.clear();
}
}
class _TextField extends StatelessWidget {
const _TextField({
required this.textEditingController,
required this.focusNode,
required this.popoverMutex,
});
final TextEditingController textEditingController;
final FocusNode focusNode;
final PopoverMutex popoverMutex;
@override
Widget build(BuildContext context) {
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionCellEditorState>(
builder: (context, state) {
final optionMap = LinkedHashMap<String, SelectOptionPB>.fromIterable(
state.selectedOptions,
@ -143,40 +210,46 @@ class _TextField extends StatelessWidget {
value: (option) => option,
);
return Padding(
padding: const EdgeInsets.all(12.0),
child: SelectOptionTextField(
options: state.options,
selectedOptionMap: optionMap,
distanceToText: _editorPanelWidth * 0.7,
textController: textEditingController,
textSeparators: const [','],
onClick: () => popoverMutex.close(),
newText: (text) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.filterOption(text));
},
onSubmitted: (tagName) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.trySelectOption(tagName));
},
onPaste: (tagNames, remainder) {
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.selectMultipleOptions(
tagNames,
remainder,
),
);
},
onRemove: (optionName) {
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.unSelectOption(
optionMap[optionName]!.id,
),
);
},
return Material(
color: Colors.transparent,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: SelectOptionTextField(
options: state.options,
focusNode: focusNode,
selectedOptionMap: optionMap,
distanceToText: _editorPanelWidth * 0.7,
textController: textEditingController,
textSeparators: const [','],
onClick: () => popoverMutex.close(),
newText: (text) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionCellEditorEvent.filterOption(text));
},
onSubmitted: () {
context
.read<SelectOptionCellEditorBloc>()
.add(const SelectOptionCellEditorEvent.submitTextField());
textEditingController.clear();
focusNode.requestFocus();
},
onPaste: (tagNames, remainder) {
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionCellEditorEvent.selectMultipleOptions(
tagNames,
remainder,
),
);
},
onRemove: (optionName) {
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionCellEditorEvent.unSelectOption(
optionMap[optionName]!.id,
),
);
},
),
),
);
},
@ -185,11 +258,7 @@ class _TextField extends StatelessWidget {
}
class _Title extends StatelessWidget {
const _Title({
required this.onPressedAddButton,
});
final VoidCallback onPressedAddButton;
const _Title();
@override
Widget build(BuildContext context) {
@ -197,62 +266,9 @@ class _Title extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
height: GridSize.popoverItemHeight,
child: Row(
children: [
Flexible(
child: FlowyText.medium(
LocaleKeys.grid_selectOption_panelTitle.tr(),
color: Theme.of(context).hintColor,
),
),
],
),
),
);
}
}
class _CreateOptionCell extends StatelessWidget {
const _CreateOptionCell({
required this.name,
});
final String name;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: SizedBox(
height: 28,
child: FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
onTap: () => context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.newOption(name)),
text: Row(
children: [
FlowyText.medium(
LocaleKeys.grid_selectOption_create.tr(),
color: Theme.of(context).hintColor,
),
const HSpace(10),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: SelectOptionTag(
name: name,
fontSize: 11,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 1,
),
color: Theme.of(context).colorScheme.surfaceVariant,
),
),
),
],
),
child: FlowyText.regular(
LocaleKeys.grid_selectOption_panelTitle.tr(),
color: Theme.of(context).hintColor,
),
),
);
@ -261,13 +277,14 @@ class _CreateOptionCell extends StatelessWidget {
class _SelectOptionCell extends StatefulWidget {
const _SelectOptionCell({
super.key,
required this.option,
required this.isSelected,
required this.index,
required this.popoverMutex,
});
final SelectOptionPB option;
final bool isSelected;
final int index;
final PopoverMutex popoverMutex;
@override
@ -285,34 +302,6 @@ class _SelectOptionCellState extends State<_SelectOptionCell> {
@override
Widget build(BuildContext context) {
final child = SizedBox(
height: 28,
child: SelectOptionTagCell(
option: widget.option,
onSelected: _onTap,
children: [
if (widget.isSelected)
FlowyIconButton(
width: 20,
hoverColor: Colors.transparent,
onPressed: _onTap,
icon: FlowySvg(
FlowySvgs.check_s,
color: Theme.of(context).iconTheme.color,
),
),
FlowyIconButton(
onPressed: () => _popoverController.show(),
iconPadding: const EdgeInsets.symmetric(horizontal: 6.0),
hoverColor: Colors.transparent,
icon: FlowySvg(
FlowySvgs.details_s,
color: Theme.of(context).iconTheme.color,
),
),
],
),
);
return AppFlowyPopover(
controller: _popoverController,
offset: const Offset(8, 0),
@ -322,13 +311,59 @@ class _SelectOptionCellState extends State<_SelectOptionCell> {
mutex: widget.popoverMutex,
clickHandler: PopoverClickHandler.gestureDetector,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: FlowyHover(
resetHoverOnRebuild: false,
style: HoverStyle(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
child: MouseRegion(
onEnter: (_) {
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionCellEditorEvent.updateFocusedOption(
widget.option.id,
),
);
},
child: Container(
height: 28,
decoration: BoxDecoration(
color: context
.watch<SelectOptionCellEditorBloc>()
.state
.focusedOptionId ==
widget.option.id
? AFThemeExtension.of(context).lightGreyHover
: null,
borderRadius: const BorderRadius.all(Radius.circular(6)),
),
child: SelectOptionTagCell(
option: widget.option,
index: widget.index,
onSelected: _onTap,
children: [
if (context
.watch<SelectOptionCellEditorBloc>()
.state
.selectedOptions
.contains(widget.option))
FlowyIconButton(
width: 20,
hoverColor: Colors.transparent,
onPressed: _onTap,
icon: FlowySvg(
FlowySvgs.check_s,
color: Theme.of(context).iconTheme.color,
),
),
FlowyIconButton(
onPressed: () => _popoverController.show(),
iconPadding: const EdgeInsets.symmetric(horizontal: 6.0),
hoverColor: Colors.transparent,
icon: FlowySvg(
FlowySvgs.three_dots_s,
size: const Size.square(16),
color: Theme.of(context).colorScheme.onBackground,
),
),
],
),
),
child: child,
),
),
popupBuilder: (BuildContext popoverContext) {
@ -337,13 +372,13 @@ class _SelectOptionCellState extends State<_SelectOptionCell> {
onDeleted: () {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.deleteOption(widget.option));
.add(SelectOptionCellEditorEvent.deleteOption(widget.option));
PopoverContainer.of(popoverContext).close();
},
onUpdated: (updatedOption) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.updateOption(updatedOption));
.add(SelectOptionCellEditorEvent.updateOption(updatedOption));
},
key: ValueKey(
widget.option.id,
@ -355,14 +390,149 @@ class _SelectOptionCellState extends State<_SelectOptionCell> {
void _onTap() {
widget.popoverMutex.close();
if (widget.isSelected) {
if (context
.read<SelectOptionCellEditorBloc>()
.state
.selectedOptions
.contains(widget.option)) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.unSelectOption(widget.option.id));
.add(SelectOptionCellEditorEvent.unSelectOption(widget.option.id));
} else {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.selectOption(widget.option.id));
.add(SelectOptionCellEditorEvent.selectOption(widget.option.id));
}
}
}
class SelectOptionTagCell extends StatelessWidget {
const SelectOptionTagCell({
super.key,
required this.option,
required this.onSelected,
this.children = const [],
this.index,
});
final SelectOptionPB option;
final VoidCallback onSelected;
final List<Widget> children;
final int? index;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (index != null)
ReorderableDragStartListener(
index: index!,
child: MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grab,
child: GestureDetector(
onTap: onSelected,
child: SizedBox(
width: 26,
child: Center(
child: FlowySvg(
FlowySvgs.drag_element_s,
size: const Size.square(14),
color: Theme.of(context).colorScheme.onBackground,
),
),
),
),
),
),
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onSelected,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 5.0,
vertical: 4.0,
),
child: SelectOptionTag(
option: option,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
),
),
),
),
),
...children,
],
);
}
}
class _CreateOptionCell extends StatelessWidget {
const _CreateOptionCell({
required this.suggestion,
});
final CreateSelectOptionSuggestion suggestion;
@override
Widget build(BuildContext context) {
return Container(
height: 28,
margin: const EdgeInsets.symmetric(horizontal: 8.0),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color:
context.watch<SelectOptionCellEditorBloc>().state.focusedOptionId ==
createSelectOptionSuggestionId
? AFThemeExtension.of(context).lightGreyHover
: null,
borderRadius: const BorderRadius.all(Radius.circular(6)),
),
child: GestureDetector(
onTap: () => context
.read<SelectOptionCellEditorBloc>()
.add(const SelectOptionCellEditorEvent.createOption()),
child: MouseRegion(
onEnter: (_) {
context.read<SelectOptionCellEditorBloc>().add(
const SelectOptionCellEditorEvent.updateFocusedOption(
createSelectOptionSuggestionId,
),
);
},
child: Row(
children: [
FlowyText.medium(
LocaleKeys.grid_selectOption_create.tr(),
color: Theme.of(context).hintColor,
),
const HSpace(10),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: SelectOptionTag(
name: suggestion.name,
color: suggestion.color.toColor(context),
fontSize: 11,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 1,
),
),
),
),
],
),
),
),
);
}
}

View File

@ -4,9 +4,6 @@ import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities
import 'package:flowy_infra/size.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:flutter/services.dart';
import 'extension.dart';
@ -18,6 +15,7 @@ class SelectOptionTextField extends StatefulWidget {
required this.distanceToText,
required this.textSeparators,
required this.textController,
required this.focusNode,
required this.onSubmitted,
required this.newText,
required this.onPaste,
@ -30,8 +28,9 @@ class SelectOptionTextField extends StatefulWidget {
final double distanceToText;
final List<String> textSeparators;
final TextEditingController textController;
final FocusNode focusNode;
final Function(String) onSubmitted;
final Function() onSubmitted;
final Function(String) newText;
final Function(List<String>, String) onPaste;
final Function(String) onRemove;
@ -42,32 +41,11 @@ class SelectOptionTextField extends StatefulWidget {
}
class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
late final FocusNode focusNode;
@override
void initState() {
super.initState();
focusNode = FocusNode(
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
if (!widget.textController.value.composing.isCollapsed) {
final TextRange(:start, :end) =
widget.textController.value.composing;
final text = widget.textController.text;
widget.textController.value = TextEditingValue(
text: "${text.substring(0, start)}${text.substring(end)}",
selection: TextSelection(baseOffset: start, extentOffset: start),
);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
);
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
widget.focusNode.requestFocus();
});
widget.textController.addListener(_onChanged);
}
@ -75,7 +53,6 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
@override
void dispose() {
widget.textController.removeListener(_onChanged);
focusNode.dispose();
super.dispose();
}
@ -83,15 +60,9 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
Widget build(BuildContext context) {
return TextField(
controller: widget.textController,
focusNode: focusNode,
focusNode: widget.focusNode,
onTap: widget.onClick,
onSubmitted: (text) {
if (text.isNotEmpty) {
widget.onSubmitted(text.trim());
focusNode.requestFocus();
widget.textController.clear();
}
},
onSubmitted: (_) => widget.onSubmitted(),
style: Theme.of(context).textTheme.bodyMedium,
decoration: InputDecoration(
enabledBorder: OutlineInputBorder(
@ -100,11 +71,6 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
),
isDense: true,
prefixIcon: _renderTags(context),
hintText: LocaleKeys.grid_selectOption_searchOption.tr(),
hintStyle: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: Theme.of(context).hintColor),
prefixIconConstraints: BoxConstraints(maxWidth: widget.distanceToText),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
@ -148,23 +114,26 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
)
.toList();
return MouseRegion(
cursor: SystemMouseCursors.basic,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {
PointerDeviceKind.mouse,
PointerDeviceKind.touch,
PointerDeviceKind.trackpad,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
},
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(spacing: 4, children: children),
return Focus(
descendantsAreFocusable: false,
child: MouseRegion(
cursor: SystemMouseCursors.basic,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {
PointerDeviceKind.mouse,
PointerDeviceKind.touch,
PointerDeviceKind.trackpad,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
},
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(spacing: 4, children: children),
),
),
),
),

View File

@ -1,9 +1,11 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/field/type_option/select_option_type_option_bloc.dart';
import 'package:appflowy/plugins/database/application/field/type_option/select_type_option_actions.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
@ -48,16 +50,15 @@ class SelectOptionTypeOptionWidget extends StatelessWidget {
] else
const _AddOptionButton(),
const VSpace(4),
...state.options.map((option) {
return _OptionCell(
option: option,
Flexible(
child: _OptionList(
popoverMutex: popoverMutex,
);
}),
),
),
];
return ListView(
shrinkWrap: true,
return Column(
mainAxisSize: MainAxisSize.min,
children: children,
);
},
@ -90,9 +91,15 @@ class _OptionTitle extends StatelessWidget {
}
class _OptionCell extends StatefulWidget {
const _OptionCell({required this.option, this.popoverMutex});
const _OptionCell({
super.key,
required this.option,
required this.index,
this.popoverMutex,
});
final SelectOptionPB option;
final int index;
final PopoverMutex? popoverMutex;
@override
@ -108,6 +115,7 @@ class _OptionCellState extends State<_OptionCell> {
height: 28,
child: SelectOptionTagCell(
option: widget.option,
index: widget.index,
onSelected: () => _popoverController.show(),
children: [
FlowyIconButton(
@ -115,8 +123,9 @@ class _OptionCellState extends State<_OptionCell> {
iconPadding: const EdgeInsets.symmetric(horizontal: 6.0),
hoverColor: Colors.transparent,
icon: FlowySvg(
FlowySvgs.details_s,
FlowySvgs.three_dots_s,
color: Theme.of(context).iconTheme.color,
size: const Size.square(16),
),
),
],
@ -253,3 +262,61 @@ class _CreateOptionTextFieldState extends State<CreateOptionTextField> {
super.dispose();
}
}
class _OptionList extends StatelessWidget {
const _OptionList({
this.popoverMutex,
});
final PopoverMutex? popoverMutex;
@override
Widget build(BuildContext context) {
return BlocBuilder<SelectOptionTypeOptionBloc, SelectOptionTypeOptionState>(
builder: (context, state) {
return ReorderableListView.builder(
shrinkWrap: true,
onReorderStart: (_) => popoverMutex?.close(),
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: Stack(
children: [
BlocProvider.value(
value: context.read<SelectOptionTypeOptionBloc>(),
child: child,
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
buildDefaultDragHandles: false,
itemBuilder: (context, index) => _OptionCell(
key: ValueKey("select_type_option_list_${state.options[index].id}"),
index: index,
option: state.options[index],
popoverMutex: popoverMutex,
),
itemCount: state.options.length,
onReorder: (oldIndex, newIndex) {
if (oldIndex < newIndex) {
newIndex--;
}
final fromOptionId = state.options[oldIndex].id;
final toOptionId = state.options[newIndex].id;
context.read<SelectOptionTypeOptionBloc>().add(
SelectOptionTypeOptionEvent.reorderOption(
fromOptionId,
toOptionId,
),
);
},
);
},
);
}
}

View File

@ -230,7 +230,7 @@ class _SelectOptionColorCell extends StatelessWidget {
child: FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
text: FlowyText.medium(
color.optionName(),
color.colorName(),
color: AFThemeExtension.of(context).textColor,
),
leftIcon: colorIcon,

View File

@ -2,7 +2,7 @@ import 'package:appflowy/plugins/database/application/cell/cell_controller_build
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/application/setting/group_bloc.dart';
import 'package:appflowy/plugins/database/board/application/board_bloc.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/select_option_editor_bloc.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:flutter_test/flutter_test.dart';
@ -65,13 +65,13 @@ void main() {
context.makeCellControllerFromFieldId(multiSelectField.id)
as SelectOptionCellController;
final multiSelectOptionBloc =
SelectOptionCellEditorBloc(cellController: cellController);
multiSelectOptionBloc.add(const SelectOptionEditorEvent.initial());
final bloc = SelectOptionCellEditorBloc(cellController: cellController);
await boardResponseFuture();
multiSelectOptionBloc.add(const SelectOptionEditorEvent.newOption("A"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("A"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await boardResponseFuture();
multiSelectOptionBloc.add(const SelectOptionEditorEvent.newOption("B"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("B"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await boardResponseFuture();
// set grouped by the new multi-select field"

View File

@ -1,4 +1,4 @@
import 'package:appflowy/plugins/database/application/cell/bloc/select_option_editor_bloc.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart';
import 'package:flutter_test/flutter_test.dart';
@ -21,10 +21,10 @@ void main() {
);
final bloc = SelectOptionCellEditorBloc(cellController: cellController);
bloc.add(const SelectOptionEditorEvent.initial());
await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.newOption("A"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("A"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture();
expect(bloc.state.options.length, 1);
@ -40,16 +40,16 @@ void main() {
);
final bloc = SelectOptionCellEditorBloc(cellController: cellController);
bloc.add(const SelectOptionEditorEvent.initial());
await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.newOption("A"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("A"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture();
final SelectOptionPB optionUpdate = bloc.state.options[0]
..color = SelectOptionColorPB.Aqua
..name = "B";
bloc.add(SelectOptionEditorEvent.updateOption(optionUpdate));
bloc.add(SelectOptionCellEditorEvent.updateOption(optionUpdate));
expect(bloc.state.options.length, 1);
expect(bloc.state.options[0].name, "B");
@ -65,31 +65,33 @@ void main() {
);
final bloc = SelectOptionCellEditorBloc(cellController: cellController);
bloc.add(const SelectOptionEditorEvent.initial());
await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.newOption("A"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("A"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture();
assert(
bloc.state.options.length == 1,
"Expect 1 but receive ${bloc.state.options.length}, Options: ${bloc.state.options}",
);
bloc.add(const SelectOptionEditorEvent.newOption("B"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("B"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture();
assert(
bloc.state.options.length == 2,
"Expect 2 but receive ${bloc.state.options.length}, Options: ${bloc.state.options}",
);
bloc.add(const SelectOptionEditorEvent.newOption("C"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("C"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture();
assert(
bloc.state.options.length == 3,
"Expect 3 but receive ${bloc.state.options.length}. Options: ${bloc.state.options}",
);
bloc.add(const SelectOptionEditorEvent.deleteAllOptions());
bloc.add(const SelectOptionCellEditorEvent.deleteAllOptions());
await gridResponseFuture();
assert(
@ -107,18 +109,18 @@ void main() {
);
final bloc = SelectOptionCellEditorBloc(cellController: cellController);
bloc.add(const SelectOptionEditorEvent.initial());
await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.newOption("A"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("A"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture();
final optionId = bloc.state.options[0].id;
bloc.add(SelectOptionEditorEvent.unSelectOption(optionId));
bloc.add(SelectOptionCellEditorEvent.unSelectOption(optionId));
await gridResponseFuture();
assert(bloc.state.selectedOptions.isEmpty);
bloc.add(SelectOptionEditorEvent.selectOption(optionId));
bloc.add(SelectOptionCellEditorEvent.selectOption(optionId));
await gridResponseFuture();
assert(bloc.state.selectedOptions.length == 1);
@ -134,20 +136,22 @@ void main() {
);
final bloc = SelectOptionCellEditorBloc(cellController: cellController);
bloc.add(const SelectOptionEditorEvent.initial());
await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.newOption("A"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("A"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.trySelectOption("B"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("B"));
bloc.add(const SelectOptionCellEditorEvent.submitTextField());
await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.trySelectOption("A"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("A"));
bloc.add(const SelectOptionCellEditorEvent.submitTextField());
await gridResponseFuture();
assert(bloc.state.selectedOptions.length == 1);
assert(bloc.state.options.length == 2);
expect(bloc.state.selectedOptions.length, 1);
expect(bloc.state.options.length, 1);
expect(bloc.state.selectedOptions[0].name, "A");
});
@ -160,17 +164,18 @@ void main() {
);
final bloc = SelectOptionCellEditorBloc(cellController: cellController);
bloc.add(const SelectOptionEditorEvent.initial());
await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.newOption("A"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("A"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.newOption("B"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("B"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture();
bloc.add(
const SelectOptionEditorEvent.selectMultipleOptions(
const SelectOptionCellEditorEvent.selectMultipleOptions(
["A", "B", "C"],
"x",
),
@ -191,10 +196,10 @@ void main() {
);
final bloc = SelectOptionCellEditorBloc(cellController: cellController);
bloc.add(const SelectOptionEditorEvent.initial());
await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.newOption("abcd"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("abcd"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture();
expect(
bloc.state.options.length,
@ -202,7 +207,8 @@ void main() {
reason: "Options: ${bloc.state.options}",
);
bloc.add(const SelectOptionEditorEvent.newOption("aaaa"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("aaaa"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture();
expect(
bloc.state.options.length,
@ -210,7 +216,8 @@ void main() {
reason: "Options: ${bloc.state.options}",
);
bloc.add(const SelectOptionEditorEvent.newOption("defg"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("defg"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture();
expect(
bloc.state.options.length,
@ -218,7 +225,7 @@ void main() {
reason: "Options: ${bloc.state.options}",
);
bloc.add(const SelectOptionEditorEvent.filterOption("a"));
bloc.add(const SelectOptionCellEditorEvent.filterOption("a"));
await gridResponseFuture();
expect(
@ -231,7 +238,7 @@ void main() {
3,
reason: "Options: ${bloc.state.options}",
);
expect(bloc.state.createOption, "a");
expect(bloc.state.createSelectOptionSuggestion!.name, "a");
expect(bloc.state.filter, "a");
});
});

View File

@ -17,11 +17,13 @@ void main() {
String remainder = '';
List<String> select = [];
final textController = TextEditingController();
final textField = SelectOptionTextField(
options: const [],
selectedOptionMap: LinkedHashMap<String, SelectOptionPB>(),
distanceToText: 0.0,
onSubmitted: (text) => submit = text,
onSubmitted: () => submit = textController.text,
onPaste: (options, remaining) {
remainder = remaining;
select = options;
@ -29,7 +31,8 @@ void main() {
onRemove: (_) {},
newText: (text) => remainder = text,
textSeparators: const [','],
textController: TextEditingController(),
textController: textController,
focusNode: FocusNode(),
);
testWidgets('SelectOptionTextField callback outputs',
@ -57,11 +60,6 @@ void main() {
await tester.testTextInput.receiveAction(TextInputAction.done);
expect(submit, 'an option');
submit = '';
await tester.enterText(find.byType(TextField), ' ');
await tester.testTextInput.receiveAction(TextInputAction.done);
expect(submit, '');
// test inputs containing commas
await tester.enterText(find.byType(TextField), 'a a, bbbb , c');
expect(remainder, 'c');

View File

@ -736,9 +736,9 @@
"blueColor": "Blue",
"deleteTag": "Delete tag",
"colorPanelTitle": "Color",
"panelTitle": "Select an option",
"panelTitle": "Select an option or create one",
"searchOption": "Search for an option",
"searchOrCreateOption": "Search...",
"searchOrCreateOption": "Search for an option or create one",
"createNew": "Create a new",
"orSelectOne": "Or select an option",
"typeANewOption": "Type a new option",