From 419464c1756d6d2a00c23826e1a637d4c5c420fd Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Sun, 31 Mar 2024 10:54:17 +0800 Subject: [PATCH] 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 --- .../bloc/select_option_cell_editor_bloc.dart | 446 ++++++++++++++ .../cell/bloc/select_option_editor_bloc.dart | 318 ---------- .../select_option_type_option_bloc.dart | 22 +- .../select_type_option_actions.dart | 73 ++- .../domain/select_option_cell_service.dart | 34 +- .../choicechip/select_option/option_list.dart | 2 +- .../desktop_grid_select_option_cell.dart | 23 +- ...desktop_row_detail_select_option_cell.dart | 24 +- .../editable_cell_skeleton/select_option.dart | 16 +- .../mobile_grid_select_option_cell.dart | 38 +- .../mobile_row_detail_select_cell_option.dart | 86 +-- .../widgets/cell_editor/extension.dart | 43 +- .../mobile_select_option_editor.dart | 75 ++- .../select_option_cell_editor.dart | 548 ++++++++++++------ .../cell_editor/select_option_text_field.dart | 83 +-- .../select/select_option.dart | 87 ++- .../select/select_option_editor.dart | 2 +- .../group_by_multi_select_field_test.dart | 12 +- .../cell/select_option_cell_test.dart | 69 ++- .../select_option_text_field_test.dart | 12 +- frontend/resources/translations/en.json | 6 +- 21 files changed, 1186 insertions(+), 833 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart delete mode 100644 frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_editor_bloc.dart diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart new file mode 100644 index 0000000000..0f57a32cb7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart @@ -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 { + 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( + (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 close() async { + if (_onCellChangedFn != null) { + cellController.removeListener(_onCellChangedFn!); + _onCellChangedFn = null; + } + return super.close(); + } + + Future _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 _deleteOption(List options) async { + final result = await _selectOptionService.delete(options: options); + result.fold((l) => null, (err) => Log.error(err)); + } + + Future _updateOption(SelectOptionPB option) async { + final result = await _selectOptionService.update( + option: option, + ); + + result.fold((l) => null, (err) => Log.error(err)); + } + + void _submitTextFieldValue(Emitter 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 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 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 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 _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 allOptions, + ) { + final List 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 options, + List 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 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 options, + required List allOptions, + required List 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 options; + CreateSelectOptionSuggestion? createSelectOptionSuggestion; +} + +class CreateSelectOptionSuggestion { + CreateSelectOptionSuggestion({ + required this.name, + required this.color, + }); + + final String name; + final SelectOptionColorPB color; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_editor_bloc.dart deleted file mode 100644 index 224d916719..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_editor_bloc.dart +++ /dev/null @@ -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 { - 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( - (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 close() async { - if (_onCellChangedFn != null) { - cellController.removeListener(_onCellChangedFn!); - _onCellChangedFn = null; - } - return super.close(); - } - - Future _createOption(String name) async { - final result = await _selectOptionService.create(name: name); - result.fold((l) => {}, (err) => Log.error(err)); - } - - Future _deleteOption(List options) async { - final result = await _selectOptionService.delete(options: options); - result.fold((l) => null, (err) => Log.error(err)); - } - - Future _updateOption(SelectOptionPB option) async { - final result = await _selectOptionService.update( - option: option, - ); - - result.fold((l) => null, (err) => Log.error(err)); - } - - void _trySelectOption( - String optionName, - Emitter 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 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 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 emit) { - final _MakeOptionResult result = _makeOptions( - optionName, - state.allOptions, - ); - emit( - state.copyWith( - filter: optionName, - options: result.options, - createOption: result.createOption, - ), - ); - } - - Future _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 allOptions, - ) { - final List 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 options, - List 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 optionNames, - String remainder, - ) = _SelectMultipleOptions; -} - -@freezed -class SelectOptionEditorState with _$SelectOptionEditorState { - const factory SelectOptionEditorState({ - required List options, - required List allOptions, - required List 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 options; - String? createOption; -} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_option_type_option_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_option_type_option_bloc.dart index cd1db30fc6..72f49dd084 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_option_type_option_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_option_type_option_bloc.dart @@ -20,10 +20,10 @@ class SelectOptionTypeOptionBloc void _dispatch() { on( (event, emit) async { - await event.when( - createOption: (optionName) async { + event.when( + createOption: (optionName) { final List 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 options = + final options = typeOptionAction.updateOption(state.options, option); emit(state.copyWith(options: options)); }, deleteOption: (option) { - final List 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 diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_type_option_actions.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_type_option_actions.dart index 2b9b1539e1..235bdb60eb 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_type_option_actions.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_type_option_actions.dart @@ -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> insertOption( + List insertOption( List options, String optionName, ) { - final newOptions = List.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.from(options); + + final newSelectOption = SelectOptionPB() + ..id = nanoid(4) + ..color = newSelectOptionColor(options) + ..name = optionName; + + newOptions.insert(0, newSelectOption); + + updateTypeOption(newOptions); + return newOptions; } List deleteOption( @@ -73,6 +67,25 @@ abstract class ISelectOptionAction { updateTypeOption(newOptions); return newOptions; } + + List reorderOption( + List options, + String fromOptionId, + String toOptionId, + ) { + final newOptions = List.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 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; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/select_option_cell_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/select_option_cell_service.dart index 165671b211..45f3155c4e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/domain/select_option_cell_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/select_option_cell_service.dart @@ -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> 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> update({ diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart index 67f10d4e3f..a8ec42f2f6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart @@ -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'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_select_option_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_select_option_cell.dart index 05bd7af87c..5a76dcdc4d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_select_option_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_select_option_cell.dart @@ -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( + builder: (context, state) { + return Container( + alignment: AlignmentDirectional.centerStart, + padding: GridSize.cellContentInsets, + child: state.selectedOptions.isEmpty + ? const SizedBox.shrink() + : _buildOptions(context, state.selectedOptions), + ); + }, ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart index 9ddc6278c3..0e8c6fdffa 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart @@ -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( + 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), + ); + }, ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart index de3908e585..32df29a60a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart @@ -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 { Widget build(BuildContext context) { return BlocProvider.value( value: cellBloc, - child: BlocBuilder( - builder: (context, state) { - return widget.skin.build( - context, - widget.cellContainerNotifier, - cellBloc, - state, - _popover, - ); - }, + child: widget.skin.build( + context, + widget.cellContainerNotifier, + cellBloc, + _popover, ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_select_option_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_select_option_cell.dart index 95d7a41737..9c01536e71 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_select_option_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_select_option_cell.dart @@ -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( + 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, + ); + }, ); }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_select_cell_option.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_select_cell_option.dart index 6266e28022..59941394fa 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_select_cell_option.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_select_cell_option.dart @@ -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( + 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), + ], + ), + ), + ); + }, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/extension.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/extension.dart index c68d39492a..fe9921b4d5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/extension.dart @@ -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 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, - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart index f1c8b424f5..81ecfe28e6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart @@ -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 { child: BlocProvider( create: (context) => SelectOptionCellEditorBloc( cellController: widget.cellController, - )..add(const SelectOptionEditorEvent.initial()), - child: BlocBuilder( + ), + child: BlocBuilder( builder: (context, state) { return Column( mainAxisSize: MainAxisSize.min, @@ -110,7 +111,7 @@ class _MobileSelectOptionEditorState extends State { onDelete: () { context .read() - .add(SelectOptionEditorEvent.deleteOption(option!)); + .add(SelectOptionCellEditorEvent.deleteOption(option!)); _popOrBack(); }, onUpdate: (name, color) { @@ -120,7 +121,7 @@ class _MobileSelectOptionEditorState extends State { } option.freeze(); context.read().add( - SelectOptionEditorEvent.updateOption( + SelectOptionCellEditorEvent.updateOption( option.rebuild((p0) { if (name != null) { p0.name = name; @@ -142,16 +143,16 @@ class _MobileSelectOptionEditorState extends State { _SearchField( controller: searchController, hintText: LocaleKeys.grid_selectOption_searchOrCreateOption.tr(), - onSubmitted: (option) { + onSubmitted: (_) { context .read() - .add(SelectOptionEditorEvent.trySelectOption(option)); + .add(const SelectOptionCellEditorEvent.submitTextField()); searchController.clear(); }, onChanged: (value) { typingOption = value; context.read().add( - SelectOptionEditorEvent.selectMultipleOptions( + SelectOptionCellEditorEvent.selectMultipleOptions( [], value, ), @@ -164,18 +165,18 @@ class _MobileSelectOptionEditorState extends State { onCreateOption: (optionName) { context .read() - .add(SelectOptionEditorEvent.newOption(optionName)); + .add(const SelectOptionCellEditorEvent.createOption()); searchController.clear(); }, onCheck: (option, value) { if (value) { context .read() - .add(SelectOptionEditorEvent.selectOption(option.id)); + .add(SelectOptionCellEditorEvent.selectOption(option.id)); } else { context .read() - .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( + return BlocBuilder( builder: (context, state) { // existing options final List 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, + ), ), ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_cell_editor.dart index 8976805f64..bbb6ee50ed 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_cell_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_cell_editor.dart @@ -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 { 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( - 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( + return BlocBuilder( + 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(), + 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().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() - .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( + return BlocBuilder( builder: (context, state) { final optionMap = LinkedHashMap.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() - .add(SelectOptionEditorEvent.filterOption(text)); - }, - onSubmitted: (tagName) { - context - .read() - .add(SelectOptionEditorEvent.trySelectOption(tagName)); - }, - onPaste: (tagNames, remainder) { - context.read().add( - SelectOptionEditorEvent.selectMultipleOptions( - tagNames, - remainder, - ), - ); - }, - onRemove: (optionName) { - context.read().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() + .add(SelectOptionCellEditorEvent.filterOption(text)); + }, + onSubmitted: () { + context + .read() + .add(const SelectOptionCellEditorEvent.submitTextField()); + textEditingController.clear(); + focusNode.requestFocus(); + }, + onPaste: (tagNames, remainder) { + context.read().add( + SelectOptionCellEditorEvent.selectMultipleOptions( + tagNames, + remainder, + ), + ); + }, + onRemove: (optionName) { + context.read().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() - .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().add( + SelectOptionCellEditorEvent.updateFocusedOption( + widget.option.id, + ), + ); + }, + child: Container( + height: 28, + decoration: BoxDecoration( + color: context + .watch() + .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() + .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() - .add(SelectOptionEditorEvent.deleteOption(widget.option)); + .add(SelectOptionCellEditorEvent.deleteOption(widget.option)); PopoverContainer.of(popoverContext).close(); }, onUpdated: (updatedOption) { context .read() - .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() + .state + .selectedOptions + .contains(widget.option)) { context .read() - .add(SelectOptionEditorEvent.unSelectOption(widget.option.id)); + .add(SelectOptionCellEditorEvent.unSelectOption(widget.option.id)); } else { context .read() - .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 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().state.focusedOptionId == + createSelectOptionSuggestionId + ? AFThemeExtension.of(context).lightGreyHover + : null, + borderRadius: const BorderRadius.all(Radius.circular(6)), + ), + child: GestureDetector( + onTap: () => context + .read() + .add(const SelectOptionCellEditorEvent.createOption()), + child: MouseRegion( + onEnter: (_) { + context.read().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, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_text_field.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_text_field.dart index 3f1d2a6ac1..d6ad3ffebe 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_text_field.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_text_field.dart @@ -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 textSeparators; final TextEditingController textController; + final FocusNode focusNode; - final Function(String) onSubmitted; + final Function() onSubmitted; final Function(String) newText; final Function(List, String) onPaste; final Function(String) onRemove; @@ -42,32 +41,11 @@ class SelectOptionTextField extends StatefulWidget { } class _SelectOptionTextFieldState extends State { - 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 { @override void dispose() { widget.textController.removeListener(_onChanged); - focusNode.dispose(); super.dispose(); } @@ -83,15 +60,9 @@ class _SelectOptionTextFieldState extends State { 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 { ), 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 { ) .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), + ), ), ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/select/select_option.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/select/select_option.dart index 8328b9f408..4c56121890 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/select/select_option.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/select/select_option.dart @@ -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 { super.dispose(); } } + +class _OptionList extends StatelessWidget { + const _OptionList({ + this.popoverMutex, + }); + + final PopoverMutex? popoverMutex; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + 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(), + 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().add( + SelectOptionTypeOptionEvent.reorderOption( + fromOptionId, + toOptionId, + ), + ); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/select/select_option_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/select/select_option_editor.dart index b2bae08b1a..5df44f4b49 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/select/select_option_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/select/select_option_editor.dart @@ -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, diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_multi_select_field_test.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_multi_select_field_test.dart index efe89e5bd5..2385373d14 100644 --- a/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_multi_select_field_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_multi_select_field_test.dart @@ -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" diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/select_option_cell_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/select_option_cell_test.dart index e12fe5e23e..62f11e5b80 100644 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/select_option_cell_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/select_option_cell_test.dart @@ -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"); }); }); diff --git a/frontend/appflowy_flutter/test/widget_test/select_option_text_field_test.dart b/frontend/appflowy_flutter/test/widget_test/select_option_text_field_test.dart index e4c0cff7e2..b1e2e4ccea 100644 --- a/frontend/appflowy_flutter/test/widget_test/select_option_text_field_test.dart +++ b/frontend/appflowy_flutter/test/widget_test/select_option_text_field_test.dart @@ -17,11 +17,13 @@ void main() { String remainder = ''; List select = []; + final textController = TextEditingController(); + final textField = SelectOptionTextField( options: const [], selectedOptionMap: LinkedHashMap(), 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'); diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 0eba6e97b4..d9043b8f99 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -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", @@ -1440,4 +1440,4 @@ "noNetworkConnected": "No network connected" } } -} +} \ No newline at end of file