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() { void _dispatch() {
on<SelectOptionTypeOptionEvent>( on<SelectOptionTypeOptionEvent>(
(event, emit) async { (event, emit) async {
await event.when( event.when(
createOption: (optionName) async { createOption: (optionName) {
final List<SelectOptionPB> options = final List<SelectOptionPB> options =
await typeOptionAction.insertOption(state.options, optionName); typeOptionAction.insertOption(state.options, optionName);
emit(state.copyWith(options: options)); emit(state.copyWith(options: options));
}, },
addingOption: () { addingOption: () {
@ -33,15 +33,23 @@ class SelectOptionTypeOptionBloc
emit(state.copyWith(isEditingOption: false, newOptionName: null)); emit(state.copyWith(isEditingOption: false, newOptionName: null));
}, },
updateOption: (option) { updateOption: (option) {
final List<SelectOptionPB> options = final options =
typeOptionAction.updateOption(state.options, option); typeOptionAction.updateOption(state.options, option);
emit(state.copyWith(options: options)); emit(state.copyWith(options: options));
}, },
deleteOption: (option) { deleteOption: (option) {
final List<SelectOptionPB> options = final options =
typeOptionAction.deleteOption(state.options, option); typeOptionAction.deleteOption(state.options, option);
emit(state.copyWith(options: options)); 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( const factory SelectOptionTypeOptionEvent.deleteOption(
SelectOptionPB option, SelectOptionPB option,
) = _DeleteOption; ) = _DeleteOption;
const factory SelectOptionTypeOptionEvent.reorderOption(
String fromOptionId,
String toOptionId,
) = _ReorderOption;
} }
@freezed @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/domain/type_option_service.dart';
import 'package:appflowy/plugins/database/widgets/field/type_option_editor/builder.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:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart';
import 'package:nanoid/nanoid.dart';
abstract class ISelectOptionAction { abstract class ISelectOptionAction {
ISelectOptionAction({ ISelectOptionAction({
@ -20,29 +18,25 @@ abstract class ISelectOptionAction {
onTypeOptionUpdated(newTypeOption.writeToBuffer()); onTypeOptionUpdated(newTypeOption.writeToBuffer());
} }
Future<List<SelectOptionPB>> insertOption( List<SelectOptionPB> insertOption(
List<SelectOptionPB> options, List<SelectOptionPB> options,
String optionName, String optionName,
) { ) {
final newOptions = List<SelectOptionPB>.from(options); if (options.any((element) => element.name == optionName)) {
return service.newOption(name: optionName).then((result) { return options;
return result.fold( }
(option) {
final exists =
newOptions.any((element) => element.name == option.name);
if (!exists) {
newOptions.insert(0, option);
}
updateTypeOption(newOptions); final newOptions = List<SelectOptionPB>.from(options);
return newOptions;
}, final newSelectOption = SelectOptionPB()
(err) { ..id = nanoid(4)
Log.error(err); ..color = newSelectOptionColor(options)
return newOptions; ..name = optionName;
},
); newOptions.insert(0, newSelectOption);
});
updateTypeOption(newOptions);
return newOptions;
} }
List<SelectOptionPB> deleteOption( List<SelectOptionPB> deleteOption(
@ -73,6 +67,25 @@ abstract class ISelectOptionAction {
updateTypeOption(newOptions); updateTypeOption(newOptions);
return 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 { class MultiSelectAction extends ISelectOptionAction {
@ -102,3 +115,19 @@ class SingleSelectAction extends ISelectOptionAction {
onTypeOptionUpdated(newTypeOption.writeToBuffer()); 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-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart'; import 'package:appflowy_result/appflowy_result.dart';
import 'package:nanoid/nanoid.dart';
import 'type_option_service.dart';
class SelectOptionCellBackendService { class SelectOptionCellBackendService {
SelectOptionCellBackendService({ SelectOptionCellBackendService({
@ -18,26 +17,23 @@ class SelectOptionCellBackendService {
Future<FlowyResult<void, FlowyError>> create({ Future<FlowyResult<void, FlowyError>> create({
required String name, required String name,
SelectOptionColorPB? color,
bool isSelected = true, bool isSelected = true,
}) { }) {
return TypeOptionBackendService(viewId: viewId, fieldId: fieldId) final option = SelectOptionPB()
.newOption(name: name) ..id = nanoid(4)
.then( ..name = name;
(result) { if (color != null) {
return result.fold( option.color = color;
(option) { }
final payload = RepeatedSelectOptionPayload()
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..items.add(option);
return DatabaseEventInsertOrUpdateSelectOption(payload).send(); final payload = RepeatedSelectOptionPayload()
}, ..viewId = viewId
(r) => FlowyResult.failure(r), ..fieldId = fieldId
); ..rowId = rowId
}, ..items.add(option);
);
return DatabaseEventInsertOrUpdateSelectOption(payload).send();
} }
Future<FlowyResult<void, FlowyError>> update({ 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/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/layout/sizes.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.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/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart';
import 'package:flowy_infra_ui/widget/spacing.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:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/select_option.dart'; import '../editable_cell_skeleton/select_option.dart';
@ -16,29 +17,29 @@ class DesktopGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin {
BuildContext context, BuildContext context,
CellContainerNotifier cellContainerNotifier, CellContainerNotifier cellContainerNotifier,
SelectOptionCellBloc bloc, SelectOptionCellBloc bloc,
SelectOptionCellState state,
PopoverController popoverController, PopoverController popoverController,
) { ) {
return AppFlowyPopover( return AppFlowyPopover(
controller: popoverController, controller: popoverController,
constraints: BoxConstraints.loose(const Size.square(300)), constraints: const BoxConstraints.tightFor(width: 300),
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
direction: PopoverDirection.bottomWithLeftAligned, direction: PopoverDirection.bottomWithLeftAligned,
popupBuilder: (BuildContext popoverContext) { popupBuilder: (BuildContext popoverContext) {
WidgetsBinding.instance.addPostFrameCallback((_) {
cellContainerNotifier.isFocus = true;
});
return SelectOptionCellEditor( return SelectOptionCellEditor(
cellController: bloc.cellController, cellController: bloc.cellController,
); );
}, },
onClose: () => cellContainerNotifier.isFocus = false, onClose: () => cellContainerNotifier.isFocus = false,
child: Container( child: BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
alignment: AlignmentDirectional.centerStart, builder: (context, state) {
padding: GridSize.cellContentInsets, return Container(
child: state.selectedOptions.isEmpty alignment: AlignmentDirectional.centerStart,
? const SizedBox.shrink() padding: GridSize.cellContentInsets,
: _buildOptions(context, state.selectedOptions), 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:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/select_option.dart'; import '../editable_cell_skeleton/select_option.dart';
@ -18,12 +19,11 @@ class DesktopRowDetailSelectOptionCellSkin
BuildContext context, BuildContext context,
CellContainerNotifier cellContainerNotifier, CellContainerNotifier cellContainerNotifier,
SelectOptionCellBloc bloc, SelectOptionCellBloc bloc,
SelectOptionCellState state,
PopoverController popoverController, PopoverController popoverController,
) { ) {
return AppFlowyPopover( return AppFlowyPopover(
controller: popoverController, controller: popoverController,
constraints: BoxConstraints.loose(const Size.square(300)), constraints: const BoxConstraints.tightFor(width: 300),
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
direction: PopoverDirection.bottomWithLeftAligned, direction: PopoverDirection.bottomWithLeftAligned,
popupBuilder: (BuildContext popoverContext) { popupBuilder: (BuildContext popoverContext) {
@ -35,14 +35,18 @@ class DesktopRowDetailSelectOptionCellSkin
); );
}, },
onClose: () => cellContainerNotifier.isFocus = false, onClose: () => cellContainerNotifier.isFocus = false,
child: Container( child: BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
alignment: AlignmentDirectional.centerStart, builder: (context, state) {
padding: state.selectedOptions.isEmpty return Container(
? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0) alignment: AlignmentDirectional.centerStart,
: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 5.0), padding: state.selectedOptions.isEmpty
child: state.selectedOptions.isEmpty ? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0)
? _buildPlaceholder(context) : const EdgeInsets.symmetric(horizontal: 8.0, vertical: 5.0),
: _buildOptions(context, state.selectedOptions), child: state.selectedOptions.isEmpty
? _buildPlaceholder(context)
: _buildOptions(context, state.selectedOptions),
);
},
), ),
); );
} }

View File

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

View File

@ -8,6 +8,7 @@ import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/select_option.dart'; import '../editable_cell_skeleton/select_option.dart';
@ -17,25 +18,28 @@ class MobileGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin {
BuildContext context, BuildContext context,
CellContainerNotifier cellContainerNotifier, CellContainerNotifier cellContainerNotifier,
SelectOptionCellBloc bloc, SelectOptionCellBloc bloc,
SelectOptionCellState state,
PopoverController popoverController, PopoverController popoverController,
) { ) {
return FlowyButton( return BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
hoverColor: Colors.transparent, builder: (context, state) {
radius: BorderRadius.zero, return FlowyButton(
margin: EdgeInsets.zero, hoverColor: Colors.transparent,
text: Align( radius: BorderRadius.zero,
alignment: AlignmentDirectional.centerStart, margin: EdgeInsets.zero,
child: state.selectedOptions.isEmpty text: Align(
? const SizedBox.shrink() alignment: AlignmentDirectional.centerStart,
: _buildOptions(context, state.selectedOptions), child: state.selectedOptions.isEmpty
), ? const SizedBox.shrink()
onTap: () { : _buildOptions(context, state.selectedOptions),
showMobileBottomSheet( ),
context, onTap: () {
builder: (context) { showMobileBottomSheet(
return MobileSelectOptionEditor( context,
cellController: bloc.cellController, 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:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/select_option.dart'; import '../editable_cell_skeleton/select_option.dart';
@ -20,53 +21,56 @@ class MobileRowDetailSelectOptionCellSkin
BuildContext context, BuildContext context,
CellContainerNotifier cellContainerNotifier, CellContainerNotifier cellContainerNotifier,
SelectOptionCellBloc bloc, SelectOptionCellBloc bloc,
SelectOptionCellState state,
PopoverController popoverController, PopoverController popoverController,
) { ) {
return InkWell( return BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
borderRadius: const BorderRadius.all(Radius.circular(14)), builder: (context, state) {
onTap: () => showMobileBottomSheet( return InkWell(
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),
),
borderRadius: const BorderRadius.all(Radius.circular(14)), borderRadius: const BorderRadius.all(Radius.circular(14)),
), onTap: () => showMobileBottomSheet(
child: Row( context,
children: [ builder: (context) {
Expanded( return MobileSelectOptionEditor(
child: state.selectedOptions.isEmpty cellController: bloc.cellController,
? _buildPlaceholder(context) );
: _buildOptions(context, state.selectedOptions), },
),
child: Container(
constraints: const BoxConstraints(
minHeight: 48,
minWidth: double.infinity,
), ),
const HSpace(6), padding: EdgeInsets.symmetric(
RotatedBox( horizontal: 12,
quarterTurns: 3, vertical: state.selectedOptions.isEmpty ? 13 : 10,
child: Icon( ),
Icons.chevron_left, decoration: BoxDecoration(
color: Theme.of(context).hintColor, 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) { switch (this) {
case SelectOptionColorPB.Purple: case SelectOptionColorPB.Purple:
return LocaleKeys.grid_selectOption_purpleColor.tr(); 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/flowy_mobile_search_text_field.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/base/drag_handler.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/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
@ -55,8 +55,9 @@ class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
child: BlocProvider( child: BlocProvider(
create: (context) => SelectOptionCellEditorBloc( create: (context) => SelectOptionCellEditorBloc(
cellController: widget.cellController, cellController: widget.cellController,
)..add(const SelectOptionEditorEvent.initial()), ),
child: BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>( child: BlocBuilder<SelectOptionCellEditorBloc,
SelectOptionCellEditorState>(
builder: (context, state) { builder: (context, state) {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -110,7 +111,7 @@ class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
onDelete: () { onDelete: () {
context context
.read<SelectOptionCellEditorBloc>() .read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.deleteOption(option!)); .add(SelectOptionCellEditorEvent.deleteOption(option!));
_popOrBack(); _popOrBack();
}, },
onUpdate: (name, color) { onUpdate: (name, color) {
@ -120,7 +121,7 @@ class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
} }
option.freeze(); option.freeze();
context.read<SelectOptionCellEditorBloc>().add( context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.updateOption( SelectOptionCellEditorEvent.updateOption(
option.rebuild((p0) { option.rebuild((p0) {
if (name != null) { if (name != null) {
p0.name = name; p0.name = name;
@ -142,16 +143,16 @@ class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
_SearchField( _SearchField(
controller: searchController, controller: searchController,
hintText: LocaleKeys.grid_selectOption_searchOrCreateOption.tr(), hintText: LocaleKeys.grid_selectOption_searchOrCreateOption.tr(),
onSubmitted: (option) { onSubmitted: (_) {
context context
.read<SelectOptionCellEditorBloc>() .read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.trySelectOption(option)); .add(const SelectOptionCellEditorEvent.submitTextField());
searchController.clear(); searchController.clear();
}, },
onChanged: (value) { onChanged: (value) {
typingOption = value; typingOption = value;
context.read<SelectOptionCellEditorBloc>().add( context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.selectMultipleOptions( SelectOptionCellEditorEvent.selectMultipleOptions(
[], [],
value, value,
), ),
@ -164,18 +165,18 @@ class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
onCreateOption: (optionName) { onCreateOption: (optionName) {
context context
.read<SelectOptionCellEditorBloc>() .read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.newOption(optionName)); .add(const SelectOptionCellEditorEvent.createOption());
searchController.clear(); searchController.clear();
}, },
onCheck: (option, value) { onCheck: (option, value) {
if (value) { if (value) {
context context
.read<SelectOptionCellEditorBloc>() .read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.selectOption(option.id)); .add(SelectOptionCellEditorEvent.selectOption(option.id));
} else { } else {
context context
.read<SelectOptionCellEditorBloc>() .read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.unSelectOption(option.id)); .add(SelectOptionCellEditorEvent.unSelectOption(option.id));
} }
}, },
onMoreOptions: (option) { onMoreOptions: (option) {
@ -253,18 +254,20 @@ class _OptionList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>( return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionCellEditorState>(
builder: (context, state) { builder: (context, state) {
// existing options // existing options
final List<Widget> cells = []; final List<Widget> cells = [];
// create an option cell // create an option cell
final createOption = state.createOption; if (state.createSelectOptionSuggestion != null) {
if (createOption != null) {
cells.add( cells.add(
_CreateOptionCell( _CreateOptionCell(
optionName: createOption, name: state.createSelectOptionSuggestion!.name,
onTap: () => onCreateOption(createOption), color: state.createSelectOptionSuggestion!.color,
onTap: () => onCreateOption(
state.createSelectOptionSuggestion!.name,
),
), ),
); );
} }
@ -332,14 +335,17 @@ class _SelectOption extends StatelessWidget {
const HSpace(12), const HSpace(12),
// option tag // option tag
Expanded( Expanded(
child: SelectOptionTag( child: Align(
option: option, alignment: AlignmentDirectional.centerStart,
padding: const EdgeInsets.symmetric( child: SelectOptionTag(
vertical: 10, 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), const HSpace(24),
@ -359,11 +365,13 @@ class _SelectOption extends StatelessWidget {
class _CreateOptionCell extends StatelessWidget { class _CreateOptionCell extends StatelessWidget {
const _CreateOptionCell({ const _CreateOptionCell({
required this.optionName, required this.name,
required this.color,
required this.onTap, required this.onTap,
}); });
final String optionName; final String name;
final SelectOptionColorPB color;
final VoidCallback onTap; final VoidCallback onTap;
@override @override
@ -381,13 +389,16 @@ class _CreateOptionCell extends StatelessWidget {
), ),
const HSpace(8), const HSpace(8),
Expanded( Expanded(
child: SelectOptionTag( child: Align(
isExpanded: true, alignment: AlignmentDirectional.centerStart,
name: optionName, child: SelectOptionTag(
color: Theme.of(context).colorScheme.surfaceVariant, name: name,
textAlign: TextAlign.center, color: color.toColor(context),
padding: const EdgeInsets.symmetric( textAlign: TextAlign.center,
vertical: 10, padding: const EdgeInsets.symmetric(
vertical: 10,
horizontal: 14,
),
), ),
), ),
), ),

View File

@ -1,18 +1,20 @@
import 'dart:collection'; import 'dart:collection';
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.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/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.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/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.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/layout/sizes.dart';
import '../../grid/presentation/widgets/common/type_option_separator.dart'; import '../../grid/presentation/widgets/common/type_option_separator.dart';
import '../field/type_option_editor/select/select_option_editor.dart'; import '../field/type_option_editor/select/select_option_editor.dart';
@ -33,39 +35,81 @@ class SelectOptionCellEditor extends StatefulWidget {
class _SelectOptionCellEditorState extends State<SelectOptionCellEditor> { class _SelectOptionCellEditorState extends State<SelectOptionCellEditor> {
final TextEditingController textEditingController = TextEditingController(); final TextEditingController textEditingController = TextEditingController();
final popoverMutex = PopoverMutex(); 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 @override
void dispose() { void dispose() {
popoverMutex.dispose(); popoverMutex.dispose();
textEditingController.dispose(); textEditingController.dispose();
bloc.close();
focusNode.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return BlocProvider.value(
create: (context) => SelectOptionCellEditorBloc( value: bloc,
cellController: widget.cellController, child: TextFieldTapRegion(
)..add(const SelectOptionEditorEvent.initial()), child: Column(
child: BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>( mainAxisSize: MainAxisSize.min,
builder: (context, state) { children: [
return Column( _TextField(
mainAxisSize: MainAxisSize.min, textEditingController: textEditingController,
children: [ focusNode: focusNode,
_TextField( popoverMutex: popoverMutex,
textEditingController: textEditingController, ),
popoverMutex: popoverMutex, const TypeOptionSeparator(spacing: 0.0),
), Flexible(
const TypeOptionSeparator(spacing: 0.0), child: Focus(
Flexible( descendantsAreFocusable: false,
child: _OptionList( child: _OptionList(
textEditingController: textEditingController, textEditingController: textEditingController,
popoverMutex: popoverMutex, popoverMutex: popoverMutex,
), ),
), ),
], ),
); ],
}, ),
), ),
); );
} }
@ -82,60 +126,83 @@ class _OptionList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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) { builder: (context, state) {
final cells = [ return ReorderableListView.builder(
_Title(onPressedAddButton: () => onPressedAddButton(context)), shrinkWrap: true,
...state.options.map( proxyDecorator: (child, index, _) => Material(
(option) => _SelectOptionCell( color: Colors.transparent,
option: option, child: Stack(
isSelected: state.selectedOptions.contains(option), children: [
popoverMutex: popoverMutex, BlocProvider.value(
value: context.read<SelectOptionCellEditorBloc>(),
child: child,
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
), ),
), ),
]; buildDefaultDragHandles: false,
itemCount: state.options.length,
final createOption = state.createOption; onReorderStart: (_) => popoverMutex.close(),
if (createOption != null) { itemBuilder: (_, int index) {
cells.add(_CreateOptionCell(name: createOption)); final option = state.options[index];
} return _SelectOptionCell(
key: ValueKey("select_cell_option_list_${option.id}"),
return ListView.separated( index: index,
shrinkWrap: true, option: option,
itemCount: cells.length, popoverMutex: popoverMutex,
separatorBuilder: (_, __) => );
VSpace(GridSize.typeOptionSeparatorHeight), },
physics: StyledScrollPhysics(), onReorder: (oldIndex, newIndex) {
itemBuilder: (_, int index) => cells[index], 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), 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 { class _TextField extends StatelessWidget {
const _TextField({ const _TextField({
required this.textEditingController, required this.textEditingController,
required this.focusNode,
required this.popoverMutex, required this.popoverMutex,
}); });
final TextEditingController textEditingController; final TextEditingController textEditingController;
final FocusNode focusNode;
final PopoverMutex popoverMutex; final PopoverMutex popoverMutex;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>( return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionCellEditorState>(
builder: (context, state) { builder: (context, state) {
final optionMap = LinkedHashMap<String, SelectOptionPB>.fromIterable( final optionMap = LinkedHashMap<String, SelectOptionPB>.fromIterable(
state.selectedOptions, state.selectedOptions,
@ -143,40 +210,46 @@ class _TextField extends StatelessWidget {
value: (option) => option, value: (option) => option,
); );
return Padding( return Material(
padding: const EdgeInsets.all(12.0), color: Colors.transparent,
child: SelectOptionTextField( child: Padding(
options: state.options, padding: const EdgeInsets.all(12.0),
selectedOptionMap: optionMap, child: SelectOptionTextField(
distanceToText: _editorPanelWidth * 0.7, options: state.options,
textController: textEditingController, focusNode: focusNode,
textSeparators: const [','], selectedOptionMap: optionMap,
onClick: () => popoverMutex.close(), distanceToText: _editorPanelWidth * 0.7,
newText: (text) { textController: textEditingController,
context textSeparators: const [','],
.read<SelectOptionCellEditorBloc>() onClick: () => popoverMutex.close(),
.add(SelectOptionEditorEvent.filterOption(text)); newText: (text) {
}, context
onSubmitted: (tagName) { .read<SelectOptionCellEditorBloc>()
context .add(SelectOptionCellEditorEvent.filterOption(text));
.read<SelectOptionCellEditorBloc>() },
.add(SelectOptionEditorEvent.trySelectOption(tagName)); onSubmitted: () {
}, context
onPaste: (tagNames, remainder) { .read<SelectOptionCellEditorBloc>()
context.read<SelectOptionCellEditorBloc>().add( .add(const SelectOptionCellEditorEvent.submitTextField());
SelectOptionEditorEvent.selectMultipleOptions( textEditingController.clear();
tagNames, focusNode.requestFocus();
remainder, },
), onPaste: (tagNames, remainder) {
); context.read<SelectOptionCellEditorBloc>().add(
}, SelectOptionCellEditorEvent.selectMultipleOptions(
onRemove: (optionName) { tagNames,
context.read<SelectOptionCellEditorBloc>().add( remainder,
SelectOptionEditorEvent.unSelectOption( ),
optionMap[optionName]!.id, );
), },
); onRemove: (optionName) {
}, context.read<SelectOptionCellEditorBloc>().add(
SelectOptionCellEditorEvent.unSelectOption(
optionMap[optionName]!.id,
),
);
},
),
), ),
); );
}, },
@ -185,11 +258,7 @@ class _TextField extends StatelessWidget {
} }
class _Title extends StatelessWidget { class _Title extends StatelessWidget {
const _Title({ const _Title();
required this.onPressedAddButton,
});
final VoidCallback onPressedAddButton;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -197,62 +266,9 @@ class _Title extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox( child: SizedBox(
height: GridSize.popoverItemHeight, height: GridSize.popoverItemHeight,
child: Row( child: FlowyText.regular(
children: [ LocaleKeys.grid_selectOption_panelTitle.tr(),
Flexible( color: Theme.of(context).hintColor,
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,
),
),
),
],
),
), ),
), ),
); );
@ -261,13 +277,14 @@ class _CreateOptionCell extends StatelessWidget {
class _SelectOptionCell extends StatefulWidget { class _SelectOptionCell extends StatefulWidget {
const _SelectOptionCell({ const _SelectOptionCell({
super.key,
required this.option, required this.option,
required this.isSelected, required this.index,
required this.popoverMutex, required this.popoverMutex,
}); });
final SelectOptionPB option; final SelectOptionPB option;
final bool isSelected; final int index;
final PopoverMutex popoverMutex; final PopoverMutex popoverMutex;
@override @override
@ -285,34 +302,6 @@ class _SelectOptionCellState extends State<_SelectOptionCell> {
@override @override
Widget build(BuildContext context) { 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( return AppFlowyPopover(
controller: _popoverController, controller: _popoverController,
offset: const Offset(8, 0), offset: const Offset(8, 0),
@ -322,13 +311,59 @@ class _SelectOptionCellState extends State<_SelectOptionCell> {
mutex: widget.popoverMutex, mutex: widget.popoverMutex,
clickHandler: PopoverClickHandler.gestureDetector, clickHandler: PopoverClickHandler.gestureDetector,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
child: FlowyHover( child: MouseRegion(
resetHoverOnRebuild: false, onEnter: (_) {
style: HoverStyle( context.read<SelectOptionCellEditorBloc>().add(
hoverColor: AFThemeExtension.of(context).lightGreyHover, 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) { popupBuilder: (BuildContext popoverContext) {
@ -337,13 +372,13 @@ class _SelectOptionCellState extends State<_SelectOptionCell> {
onDeleted: () { onDeleted: () {
context context
.read<SelectOptionCellEditorBloc>() .read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.deleteOption(widget.option)); .add(SelectOptionCellEditorEvent.deleteOption(widget.option));
PopoverContainer.of(popoverContext).close(); PopoverContainer.of(popoverContext).close();
}, },
onUpdated: (updatedOption) { onUpdated: (updatedOption) {
context context
.read<SelectOptionCellEditorBloc>() .read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.updateOption(updatedOption)); .add(SelectOptionCellEditorEvent.updateOption(updatedOption));
}, },
key: ValueKey( key: ValueKey(
widget.option.id, widget.option.id,
@ -355,14 +390,149 @@ class _SelectOptionCellState extends State<_SelectOptionCell> {
void _onTap() { void _onTap() {
widget.popoverMutex.close(); widget.popoverMutex.close();
if (widget.isSelected) { if (context
.read<SelectOptionCellEditorBloc>()
.state
.selectedOptions
.contains(widget.option)) {
context context
.read<SelectOptionCellEditorBloc>() .read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.unSelectOption(widget.option.id)); .add(SelectOptionCellEditorEvent.unSelectOption(widget.option.id));
} else { } else {
context context
.read<SelectOptionCellEditorBloc>() .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:flowy_infra/size.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.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'; import 'extension.dart';
@ -18,6 +15,7 @@ class SelectOptionTextField extends StatefulWidget {
required this.distanceToText, required this.distanceToText,
required this.textSeparators, required this.textSeparators,
required this.textController, required this.textController,
required this.focusNode,
required this.onSubmitted, required this.onSubmitted,
required this.newText, required this.newText,
required this.onPaste, required this.onPaste,
@ -30,8 +28,9 @@ class SelectOptionTextField extends StatefulWidget {
final double distanceToText; final double distanceToText;
final List<String> textSeparators; final List<String> textSeparators;
final TextEditingController textController; final TextEditingController textController;
final FocusNode focusNode;
final Function(String) onSubmitted; final Function() onSubmitted;
final Function(String) newText; final Function(String) newText;
final Function(List<String>, String) onPaste; final Function(List<String>, String) onPaste;
final Function(String) onRemove; final Function(String) onRemove;
@ -42,32 +41,11 @@ class SelectOptionTextField extends StatefulWidget {
} }
class _SelectOptionTextFieldState extends State<SelectOptionTextField> { class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
late final FocusNode focusNode;
@override @override
void initState() { void initState() {
super.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((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus(); widget.focusNode.requestFocus();
}); });
widget.textController.addListener(_onChanged); widget.textController.addListener(_onChanged);
} }
@ -75,7 +53,6 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
@override @override
void dispose() { void dispose() {
widget.textController.removeListener(_onChanged); widget.textController.removeListener(_onChanged);
focusNode.dispose();
super.dispose(); super.dispose();
} }
@ -83,15 +60,9 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextField( return TextField(
controller: widget.textController, controller: widget.textController,
focusNode: focusNode, focusNode: widget.focusNode,
onTap: widget.onClick, onTap: widget.onClick,
onSubmitted: (text) { onSubmitted: (_) => widget.onSubmitted(),
if (text.isNotEmpty) {
widget.onSubmitted(text.trim());
focusNode.requestFocus();
widget.textController.clear();
}
},
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
decoration: InputDecoration( decoration: InputDecoration(
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
@ -100,11 +71,6 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
), ),
isDense: true, isDense: true,
prefixIcon: _renderTags(context), 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), prefixIconConstraints: BoxConstraints(maxWidth: widget.distanceToText),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary), borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
@ -148,23 +114,26 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
) )
.toList(); .toList();
return MouseRegion( return Focus(
cursor: SystemMouseCursors.basic, descendantsAreFocusable: false,
child: Padding( child: MouseRegion(
padding: const EdgeInsets.all(8.0), cursor: SystemMouseCursors.basic,
child: ScrollConfiguration( child: Padding(
behavior: ScrollConfiguration.of(context).copyWith( padding: const EdgeInsets.all(8.0),
dragDevices: { child: ScrollConfiguration(
PointerDeviceKind.mouse, behavior: ScrollConfiguration.of(context).copyWith(
PointerDeviceKind.touch, dragDevices: {
PointerDeviceKind.trackpad, PointerDeviceKind.mouse,
PointerDeviceKind.stylus, PointerDeviceKind.touch,
PointerDeviceKind.invertedStylus, PointerDeviceKind.trackpad,
}, PointerDeviceKind.stylus,
), PointerDeviceKind.invertedStylus,
child: SingleChildScrollView( },
scrollDirection: Axis.horizontal, ),
child: Wrap(spacing: 4, children: children), 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/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.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_option_type_option_bloc.dart';
import 'package:appflowy/plugins/database/application/field/type_option/select_type_option_actions.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/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_backend/protobuf/flowy-database2/select_option_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -48,16 +50,15 @@ class SelectOptionTypeOptionWidget extends StatelessWidget {
] else ] else
const _AddOptionButton(), const _AddOptionButton(),
const VSpace(4), const VSpace(4),
...state.options.map((option) { Flexible(
return _OptionCell( child: _OptionList(
option: option,
popoverMutex: popoverMutex, popoverMutex: popoverMutex,
); ),
}), ),
]; ];
return ListView( return Column(
shrinkWrap: true, mainAxisSize: MainAxisSize.min,
children: children, children: children,
); );
}, },
@ -90,9 +91,15 @@ class _OptionTitle extends StatelessWidget {
} }
class _OptionCell extends StatefulWidget { 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 SelectOptionPB option;
final int index;
final PopoverMutex? popoverMutex; final PopoverMutex? popoverMutex;
@override @override
@ -108,6 +115,7 @@ class _OptionCellState extends State<_OptionCell> {
height: 28, height: 28,
child: SelectOptionTagCell( child: SelectOptionTagCell(
option: widget.option, option: widget.option,
index: widget.index,
onSelected: () => _popoverController.show(), onSelected: () => _popoverController.show(),
children: [ children: [
FlowyIconButton( FlowyIconButton(
@ -115,8 +123,9 @@ class _OptionCellState extends State<_OptionCell> {
iconPadding: const EdgeInsets.symmetric(horizontal: 6.0), iconPadding: const EdgeInsets.symmetric(horizontal: 6.0),
hoverColor: Colors.transparent, hoverColor: Colors.transparent,
icon: FlowySvg( icon: FlowySvg(
FlowySvgs.details_s, FlowySvgs.three_dots_s,
color: Theme.of(context).iconTheme.color, color: Theme.of(context).iconTheme.color,
size: const Size.square(16),
), ),
), ),
], ],
@ -253,3 +262,61 @@ class _CreateOptionTextFieldState extends State<CreateOptionTextField> {
super.dispose(); 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( child: FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover, hoverColor: AFThemeExtension.of(context).lightGreyHover,
text: FlowyText.medium( text: FlowyText.medium(
color.optionName(), color.colorName(),
color: AFThemeExtension.of(context).textColor, color: AFThemeExtension.of(context).textColor,
), ),
leftIcon: colorIcon, 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/database_controller.dart';
import 'package:appflowy/plugins/database/application/setting/group_bloc.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/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:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -65,13 +65,13 @@ void main() {
context.makeCellControllerFromFieldId(multiSelectField.id) context.makeCellControllerFromFieldId(multiSelectField.id)
as SelectOptionCellController; as SelectOptionCellController;
final multiSelectOptionBloc = final bloc = SelectOptionCellEditorBloc(cellController: cellController);
SelectOptionCellEditorBloc(cellController: cellController);
multiSelectOptionBloc.add(const SelectOptionEditorEvent.initial());
await boardResponseFuture(); await boardResponseFuture();
multiSelectOptionBloc.add(const SelectOptionEditorEvent.newOption("A")); bloc.add(const SelectOptionCellEditorEvent.filterOption("A"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await boardResponseFuture(); await boardResponseFuture();
multiSelectOptionBloc.add(const SelectOptionEditorEvent.newOption("B")); bloc.add(const SelectOptionCellEditorEvent.filterOption("B"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await boardResponseFuture(); await boardResponseFuture();
// set grouped by the new multi-select field" // 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/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -21,10 +21,10 @@ void main() {
); );
final bloc = SelectOptionCellEditorBloc(cellController: cellController); final bloc = SelectOptionCellEditorBloc(cellController: cellController);
bloc.add(const SelectOptionEditorEvent.initial());
await gridResponseFuture(); await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.newOption("A")); bloc.add(const SelectOptionCellEditorEvent.filterOption("A"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture(); await gridResponseFuture();
expect(bloc.state.options.length, 1); expect(bloc.state.options.length, 1);
@ -40,16 +40,16 @@ void main() {
); );
final bloc = SelectOptionCellEditorBloc(cellController: cellController); final bloc = SelectOptionCellEditorBloc(cellController: cellController);
bloc.add(const SelectOptionEditorEvent.initial());
await gridResponseFuture(); await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.newOption("A")); bloc.add(const SelectOptionCellEditorEvent.filterOption("A"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture(); await gridResponseFuture();
final SelectOptionPB optionUpdate = bloc.state.options[0] final SelectOptionPB optionUpdate = bloc.state.options[0]
..color = SelectOptionColorPB.Aqua ..color = SelectOptionColorPB.Aqua
..name = "B"; ..name = "B";
bloc.add(SelectOptionEditorEvent.updateOption(optionUpdate)); bloc.add(SelectOptionCellEditorEvent.updateOption(optionUpdate));
expect(bloc.state.options.length, 1); expect(bloc.state.options.length, 1);
expect(bloc.state.options[0].name, "B"); expect(bloc.state.options[0].name, "B");
@ -65,31 +65,33 @@ void main() {
); );
final bloc = SelectOptionCellEditorBloc(cellController: cellController); final bloc = SelectOptionCellEditorBloc(cellController: cellController);
bloc.add(const SelectOptionEditorEvent.initial());
await gridResponseFuture(); await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.newOption("A")); bloc.add(const SelectOptionCellEditorEvent.filterOption("A"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture(); await gridResponseFuture();
assert( assert(
bloc.state.options.length == 1, bloc.state.options.length == 1,
"Expect 1 but receive ${bloc.state.options.length}, Options: ${bloc.state.options}", "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(); await gridResponseFuture();
assert( assert(
bloc.state.options.length == 2, bloc.state.options.length == 2,
"Expect 2 but receive ${bloc.state.options.length}, Options: ${bloc.state.options}", "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(); await gridResponseFuture();
assert( assert(
bloc.state.options.length == 3, bloc.state.options.length == 3,
"Expect 3 but receive ${bloc.state.options.length}. Options: ${bloc.state.options}", "Expect 3 but receive ${bloc.state.options.length}. Options: ${bloc.state.options}",
); );
bloc.add(const SelectOptionEditorEvent.deleteAllOptions()); bloc.add(const SelectOptionCellEditorEvent.deleteAllOptions());
await gridResponseFuture(); await gridResponseFuture();
assert( assert(
@ -107,18 +109,18 @@ void main() {
); );
final bloc = SelectOptionCellEditorBloc(cellController: cellController); final bloc = SelectOptionCellEditorBloc(cellController: cellController);
bloc.add(const SelectOptionEditorEvent.initial());
await gridResponseFuture(); await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.newOption("A")); bloc.add(const SelectOptionCellEditorEvent.filterOption("A"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture(); await gridResponseFuture();
final optionId = bloc.state.options[0].id; final optionId = bloc.state.options[0].id;
bloc.add(SelectOptionEditorEvent.unSelectOption(optionId)); bloc.add(SelectOptionCellEditorEvent.unSelectOption(optionId));
await gridResponseFuture(); await gridResponseFuture();
assert(bloc.state.selectedOptions.isEmpty); assert(bloc.state.selectedOptions.isEmpty);
bloc.add(SelectOptionEditorEvent.selectOption(optionId)); bloc.add(SelectOptionCellEditorEvent.selectOption(optionId));
await gridResponseFuture(); await gridResponseFuture();
assert(bloc.state.selectedOptions.length == 1); assert(bloc.state.selectedOptions.length == 1);
@ -134,20 +136,22 @@ void main() {
); );
final bloc = SelectOptionCellEditorBloc(cellController: cellController); final bloc = SelectOptionCellEditorBloc(cellController: cellController);
bloc.add(const SelectOptionEditorEvent.initial());
await gridResponseFuture(); await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.newOption("A")); bloc.add(const SelectOptionCellEditorEvent.filterOption("A"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture(); await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.trySelectOption("B")); bloc.add(const SelectOptionCellEditorEvent.filterOption("B"));
bloc.add(const SelectOptionCellEditorEvent.submitTextField());
await gridResponseFuture(); await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.trySelectOption("A")); bloc.add(const SelectOptionCellEditorEvent.filterOption("A"));
bloc.add(const SelectOptionCellEditorEvent.submitTextField());
await gridResponseFuture(); await gridResponseFuture();
assert(bloc.state.selectedOptions.length == 1); expect(bloc.state.selectedOptions.length, 1);
assert(bloc.state.options.length == 2); expect(bloc.state.options.length, 1);
expect(bloc.state.selectedOptions[0].name, "A"); expect(bloc.state.selectedOptions[0].name, "A");
}); });
@ -160,17 +164,18 @@ void main() {
); );
final bloc = SelectOptionCellEditorBloc(cellController: cellController); final bloc = SelectOptionCellEditorBloc(cellController: cellController);
bloc.add(const SelectOptionEditorEvent.initial());
await gridResponseFuture(); await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.newOption("A")); bloc.add(const SelectOptionCellEditorEvent.filterOption("A"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture(); await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.newOption("B")); bloc.add(const SelectOptionCellEditorEvent.filterOption("B"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture(); await gridResponseFuture();
bloc.add( bloc.add(
const SelectOptionEditorEvent.selectMultipleOptions( const SelectOptionCellEditorEvent.selectMultipleOptions(
["A", "B", "C"], ["A", "B", "C"],
"x", "x",
), ),
@ -191,10 +196,10 @@ void main() {
); );
final bloc = SelectOptionCellEditorBloc(cellController: cellController); final bloc = SelectOptionCellEditorBloc(cellController: cellController);
bloc.add(const SelectOptionEditorEvent.initial());
await gridResponseFuture(); await gridResponseFuture();
bloc.add(const SelectOptionEditorEvent.newOption("abcd")); bloc.add(const SelectOptionCellEditorEvent.filterOption("abcd"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture(); await gridResponseFuture();
expect( expect(
bloc.state.options.length, bloc.state.options.length,
@ -202,7 +207,8 @@ void main() {
reason: "Options: ${bloc.state.options}", reason: "Options: ${bloc.state.options}",
); );
bloc.add(const SelectOptionEditorEvent.newOption("aaaa")); bloc.add(const SelectOptionCellEditorEvent.filterOption("aaaa"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture(); await gridResponseFuture();
expect( expect(
bloc.state.options.length, bloc.state.options.length,
@ -210,7 +216,8 @@ void main() {
reason: "Options: ${bloc.state.options}", reason: "Options: ${bloc.state.options}",
); );
bloc.add(const SelectOptionEditorEvent.newOption("defg")); bloc.add(const SelectOptionCellEditorEvent.filterOption("defg"));
bloc.add(const SelectOptionCellEditorEvent.createOption());
await gridResponseFuture(); await gridResponseFuture();
expect( expect(
bloc.state.options.length, bloc.state.options.length,
@ -218,7 +225,7 @@ void main() {
reason: "Options: ${bloc.state.options}", reason: "Options: ${bloc.state.options}",
); );
bloc.add(const SelectOptionEditorEvent.filterOption("a")); bloc.add(const SelectOptionCellEditorEvent.filterOption("a"));
await gridResponseFuture(); await gridResponseFuture();
expect( expect(
@ -231,7 +238,7 @@ void main() {
3, 3,
reason: "Options: ${bloc.state.options}", reason: "Options: ${bloc.state.options}",
); );
expect(bloc.state.createOption, "a"); expect(bloc.state.createSelectOptionSuggestion!.name, "a");
expect(bloc.state.filter, "a"); expect(bloc.state.filter, "a");
}); });
}); });

View File

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

View File

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