feat: insert a new field to the left or right of an existing one (#4022)

* feat: allow inserting fields before or after a certain field

* fix: tauri build

* chore: implement frontend

* test: rust-lib tests

* test: integration test

* chore: point to temp collab rev

* chore: bump collab rev

* chore: fix tauri build

* chore: fix the tauri build, for real this time

* fix: new field editor show detail not general

---------

Co-authored-by: nathan <nathan@appflowy.io>
This commit is contained in:
Richard Shiue
2023-11-29 04:42:53 +08:00
committed by GitHub
parent 38e3947b2d
commit 20b485bcfe
26 changed files with 478 additions and 256 deletions

View File

@ -1,3 +1,4 @@
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_service.dart';
import 'package:appflowy/plugins/database_view/application/field_settings/field_settings_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
@ -25,11 +26,13 @@ class FieldEditorBloc extends Bloc<FieldEditorEvent, FieldEditorState> {
final FieldBackendService fieldService;
final FieldSettingsBackendService fieldSettingsService;
final TypeOptionController typeOptionController;
final void Function(String newFieldId)? onFieldInserted;
FieldEditorBloc({
required this.viewId,
required this.field,
required this.fieldController,
this.onFieldInserted,
required FieldTypeOptionLoader loader,
}) : typeOptionController = TypeOptionController(
field: field,
@ -73,6 +76,28 @@ class FieldEditorBloc extends Bloc<FieldEditorEvent, FieldEditorState> {
final result = await fieldService.updateField(name: newName);
_logIfError(result);
},
insertLeft: () async {
final result = await TypeOptionBackendService.createFieldTypeOption(
viewId: viewId,
position: CreateFieldPosition.Before,
targetFieldId: field.id,
);
result.fold(
(typeOptionPB) => onFieldInserted?.call(typeOptionPB.field_2.id),
(err) => Log.error("Failed creating field $err"),
);
},
insertRight: () async {
final result = await TypeOptionBackendService.createFieldTypeOption(
viewId: viewId,
position: CreateFieldPosition.After,
targetFieldId: field.id,
);
result.fold(
(typeOptionPB) => onFieldInserted?.call(typeOptionPB.field_2.id),
(err) => Log.error("Failed creating field $err"),
);
},
toggleFieldVisibility: () async {
final currentVisibility =
state.field.visibility ?? FieldVisibility.AlwaysShown;
@ -122,6 +147,8 @@ class FieldEditorEvent with _$FieldEditorEvent {
const factory FieldEditorEvent.switchFieldType(final FieldType fieldType) =
_SwitchFieldType;
const factory FieldEditorEvent.renameField(final String name) = _RenameField;
const factory FieldEditorEvent.insertLeft() = _InsertLeft;
const factory FieldEditorEvent.insertRight() = _InsertRight;
const factory FieldEditorEvent.toggleFieldVisibility() =
_ToggleFieldVisiblity;
const factory FieldEditorEvent.deleteField() = _DeleteField;

View File

@ -26,11 +26,20 @@ class TypeOptionBackendService {
static Future<Either<TypeOptionPB, FlowyError>> createFieldTypeOption({
required String viewId,
FieldType fieldType = FieldType.RichText,
CreateFieldPosition position = CreateFieldPosition.End,
String? targetFieldId,
}) {
final payload = CreateFieldPayloadPB.create()
..viewId = viewId
..fieldType = FieldType.RichText;
return DatabaseEventCreateTypeOption(payload).send();
if (position == CreateFieldPosition.Before ||
position == CreateFieldPosition.After && targetFieldId != null) {
payload.targetFieldId = targetFieldId!;
}
payload.fieldPosition = position;
return DatabaseEventCreateField(payload).send();
}
}

View File

@ -20,17 +20,17 @@ class GridHeaderBloc extends Bloc<GridHeaderEvent, GridHeaderState> {
}) : super(GridHeaderState.initial()) {
on<GridHeaderEvent>(
(event, emit) async {
await event.map(
initial: (_InitialHeader value) {
await event.when(
initial: () {
_startListening();
add(
GridHeaderEvent.didReceiveFieldUpdate(fieldController.fieldInfos),
);
},
didReceiveFieldUpdate: (_DidReceiveFieldUpdate value) {
didReceiveFieldUpdate: (List<FieldInfo> fields) {
emit(
state.copyWith(
fields: value.fields
fields: fields
.where(
(element) =>
element.visibility != null &&
@ -40,8 +40,17 @@ class GridHeaderBloc extends Bloc<GridHeaderEvent, GridHeaderState> {
),
);
},
moveField: (_MoveField value) async {
await _moveField(value, emit);
startEditingField: (fieldId) {
emit(state.copyWith(editingFieldId: fieldId));
},
startEditingNewField: (fieldId) {
emit(state.copyWith(editingFieldId: fieldId, newFieldId: fieldId));
},
endEditingField: () {
emit(state.copyWith(editingFieldId: null, newFieldId: null));
},
moveField: (field, fromIndex, toIndex) async {
await _moveField(field, fromIndex, toIndex, emit);
},
);
},
@ -49,19 +58,17 @@ class GridHeaderBloc extends Bloc<GridHeaderEvent, GridHeaderState> {
}
Future<void> _moveField(
_MoveField value,
FieldPB field,
int fromIndex,
int toIndex,
Emitter<GridHeaderState> emit,
) async {
final fields = List<FieldInfo>.from(state.fields);
fields.insert(value.toIndex, fields.removeAt(value.fromIndex));
fields.insert(toIndex, fields.removeAt(fromIndex));
emit(state.copyWith(fields: fields));
final fieldService =
FieldBackendService(viewId: viewId, fieldId: value.field.id);
final result = await fieldService.moveField(
value.fromIndex,
value.toIndex,
);
final fieldService = FieldBackendService(viewId: viewId, fieldId: field.id);
final result = await fieldService.moveField(fromIndex, toIndex);
result.fold((l) {}, (err) => Log.error(err));
}
@ -79,6 +86,11 @@ class GridHeaderEvent with _$GridHeaderEvent {
const factory GridHeaderEvent.initial() = _InitialHeader;
const factory GridHeaderEvent.didReceiveFieldUpdate(List<FieldInfo> fields) =
_DidReceiveFieldUpdate;
const factory GridHeaderEvent.startEditingField(String fieldId) =
_StartEditingField;
const factory GridHeaderEvent.startEditingNewField(String fieldId) =
_StartEditingNewField;
const factory GridHeaderEvent.endEditingField() = _EndEditingField;
const factory GridHeaderEvent.moveField(
FieldPB field,
int fromIndex,
@ -88,8 +100,12 @@ class GridHeaderEvent with _$GridHeaderEvent {
@freezed
class GridHeaderState with _$GridHeaderState {
const factory GridHeaderState({required List<FieldInfo> fields}) =
_GridHeaderState;
const factory GridHeaderState({
required List<FieldInfo> fields,
required String? editingFieldId,
required String? newFieldId,
}) = _GridHeaderState;
factory GridHeaderState.initial() => const GridHeaderState(fields: []);
factory GridHeaderState.initial() =>
const GridHeaderState(fields: [], editingFieldId: null, newFieldId: null);
}

View File

@ -16,16 +16,27 @@ import 'field_editor.dart';
import 'field_type_extension.dart';
class GridFieldCell extends StatefulWidget {
final String viewId;
final FieldController fieldController;
final FieldInfo fieldInfo;
const GridFieldCell({
super.key,
required this.viewId,
required this.fieldController,
required this.fieldInfo,
required this.onTap,
required this.onEditorOpened,
required this.onFieldInsertedOnEitherSide,
required this.isEditing,
required this.isNew,
});
final String viewId;
final FieldController fieldController;
final FieldInfo fieldInfo;
final VoidCallback onTap;
final VoidCallback onEditorOpened;
final void Function(String fieldId) onFieldInsertedOnEitherSide;
final bool isEditing;
final bool isNew;
@override
State<GridFieldCell> createState() => _GridFieldCellState();
}
@ -39,6 +50,11 @@ class _GridFieldCellState extends State<GridFieldCell> {
super.initState();
popoverController = PopoverController();
_bloc = FieldCellBloc(viewId: widget.viewId, fieldInfo: widget.fieldInfo);
if (widget.isEditing) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
popoverController.show();
});
}
}
@override
@ -46,6 +62,11 @@ class _GridFieldCellState extends State<GridFieldCell> {
if (widget.fieldInfo != oldWidget.fieldInfo && !_bloc.isClosed) {
_bloc.add(FieldCellEvent.onFieldChanged(widget.fieldInfo));
}
if (widget.isEditing) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
popoverController.show();
});
}
super.didUpdateWidget(oldWidget);
}
@ -62,16 +83,20 @@ class _GridFieldCellState extends State<GridFieldCell> {
direction: PopoverDirection.bottomWithLeftAligned,
controller: popoverController,
popupBuilder: (BuildContext context) {
widget.onEditorOpened();
return FieldEditor(
viewId: widget.viewId,
fieldController: widget.fieldController,
field: widget.fieldInfo.field,
initialPage: FieldEditorPage.general,
initialPage: widget.isNew
? FieldEditorPage.details
: FieldEditorPage.general,
onFieldInserted: widget.onFieldInsertedOnEitherSide,
);
},
child: FieldCellButton(
field: widget.fieldInfo.field,
onTap: () => popoverController.show(),
onTap: widget.onTap,
),
);

View File

@ -30,6 +30,7 @@ class FieldEditor extends StatefulWidget {
final FieldController fieldController;
final FieldPB field;
final FieldEditorPage initialPage;
final void Function(String fieldId)? onFieldInserted;
const FieldEditor({
super.key,
@ -37,6 +38,7 @@ class FieldEditor extends StatefulWidget {
required this.field,
required this.fieldController,
this.initialPage = FieldEditorPage.details,
this.onFieldInserted,
});
@override
@ -61,6 +63,7 @@ class _FieldEditorState extends State<FieldEditor> {
viewId: widget.viewId,
field: widget.field,
fieldController: widget.fieldController,
onFieldInserted: widget.onFieldInserted,
loader: FieldTypeOptionLoader(
viewId: widget.viewId,
field: widget.field,
@ -89,6 +92,10 @@ class _FieldEditorState extends State<FieldEditor> {
},
),
VSpace(GridSize.typeOptionSeparatorHeight),
_actionCell(FieldAction.insertLeft),
VSpace(GridSize.typeOptionSeparatorHeight),
_actionCell(FieldAction.insertRight),
VSpace(GridSize.typeOptionSeparatorHeight),
_actionCell(FieldAction.toggleVisibility),
VSpace(GridSize.typeOptionSeparatorHeight),
_actionCell(FieldAction.duplicate),
@ -170,40 +177,56 @@ class FieldActionCell extends StatelessWidget {
),
onHover: (_) => popoverMutex?.close(),
onTap: () => action.run(context, viewId, fieldInfo),
leftIcon: FlowySvg(
action.icon(fieldInfo),
size: const Size.square(16),
color: enable ? null : Theme.of(context).disabledColor,
leftIcon: action.icon(
fieldInfo,
enable ? null : Theme.of(context).disabledColor,
),
);
}
}
enum FieldAction {
insertLeft,
insertRight,
toggleVisibility,
duplicate,
delete,
}
delete;
extension _FieldActionExtension on FieldAction {
FlowySvgData icon(FieldInfo fieldInfo) {
Widget icon(FieldInfo fieldInfo, Color? color) {
late final FlowySvgData svgData;
switch (this) {
case FieldAction.insertLeft:
svgData = FlowySvgs.arrow_s;
case FieldAction.insertRight:
svgData = FlowySvgs.arrow_s;
case FieldAction.toggleVisibility:
if (fieldInfo.visibility != null &&
fieldInfo.visibility == FieldVisibility.AlwaysHidden) {
return FlowySvgs.show_m;
svgData = FlowySvgs.show_m;
} else {
return FlowySvgs.hide_s;
svgData = FlowySvgs.hide_s;
}
case FieldAction.duplicate:
return FlowySvgs.copy_s;
svgData = FlowySvgs.copy_s;
case FieldAction.delete:
return FlowySvgs.delete_s;
svgData = FlowySvgs.delete_s;
}
final icon = FlowySvg(
svgData,
size: const Size.square(16),
color: color,
);
return this == FieldAction.insertRight
? Transform.flip(flipX: true, child: icon)
: icon;
}
String title(FieldInfo fieldInfo) {
switch (this) {
case FieldAction.insertLeft:
return LocaleKeys.grid_field_insertLeft.tr();
case FieldAction.insertRight:
return LocaleKeys.grid_field_insertRight.tr();
case FieldAction.toggleVisibility:
if (fieldInfo.visibility != null &&
fieldInfo.visibility == FieldVisibility.AlwaysHidden) {
@ -220,6 +243,18 @@ extension _FieldActionExtension on FieldAction {
void run(BuildContext context, String viewId, FieldInfo fieldInfo) {
switch (this) {
case FieldAction.insertLeft:
PopoverContainer.of(context).close();
context
.read<FieldEditorBloc>()
.add(const FieldEditorEvent.insertLeft());
break;
case FieldAction.insertRight:
PopoverContainer.of(context).close();
context
.read<FieldEditorBloc>()
.add(const FieldEditorEvent.insertRight());
break;
case FieldAction.toggleVisibility:
PopoverContainer.of(context).close();
context

View File

@ -7,17 +7,14 @@ import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:reorderables/reorderables.dart';
import '../../../../application/field/type_option/type_option_service.dart';
import '../../layout/sizes.dart';
import 'field_editor.dart';
import 'field_cell.dart';
class GridHeaderSliverAdaptor extends StatefulWidget {
@ -93,7 +90,6 @@ class _GridHeaderState extends State<_GridHeader> {
@override
Widget build(BuildContext context) {
return BlocBuilder<GridHeaderBloc, GridHeaderState>(
buildWhen: (previous, current) => previous.fields != current.fields,
builder: (context, state) {
final cells = state.fields
.map(
@ -103,6 +99,17 @@ class _GridHeaderState extends State<_GridHeader> {
viewId: widget.viewId,
fieldInfo: fieldInfo,
fieldController: widget.fieldController,
onTap: () => context
.read<GridHeaderBloc>()
.add(GridHeaderEvent.startEditingField(fieldInfo.id)),
onFieldInsertedOnEitherSide: (fieldId) => context
.read<GridHeaderBloc>()
.add(GridHeaderEvent.startEditingNewField(fieldId)),
onEditorOpened: () => context
.read<GridHeaderBloc>()
.add(const GridHeaderEvent.endEditingField()),
isEditing: state.editingFieldId == fieldInfo.id,
isNew: state.newFieldId == fieldInfo.id,
)
: MobileFieldButton(
key: _getKeyById(fieldInfo.id),
@ -184,74 +191,57 @@ class _CellTrailing extends StatelessWidget {
)
: null,
padding: GridSize.headerContentInsets,
child: CreateFieldButton(viewId: viewId),
child: CreateFieldButton(
viewId: viewId,
onFieldCreated: (fieldId) => context
.read<GridHeaderBloc>()
.add(GridHeaderEvent.startEditingNewField(fieldId)),
),
);
}
}
class CreateFieldButton extends StatefulWidget {
final String viewId;
const CreateFieldButton({
super.key,
required this.viewId,
required this.onFieldCreated,
});
final String viewId;
final void Function(String fieldId) onFieldCreated;
@override
State<CreateFieldButton> createState() => _CreateFieldButtonState();
}
class _CreateFieldButtonState extends State<CreateFieldButton> {
final popoverController = PopoverController();
late TypeOptionPB typeOption;
@override
Widget build(BuildContext context) {
final fieldController =
context.read<GridBloc>().databaseController.fieldController;
return AppFlowyPopover(
controller: popoverController,
direction: PopoverDirection.bottomWithRightAligned,
asBarrier: true,
margin: EdgeInsets.zero,
constraints: BoxConstraints.loose(const Size(240, 600)),
triggerActions: PopoverTriggerFlags.none,
child: FlowyButton(
margin: PlatformExtension.isDesktop
? GridSize.cellContentInsets
: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
radius: BorderRadius.zero,
text: FlowyText.medium(
LocaleKeys.grid_field_newProperty.tr(),
overflow: TextOverflow.ellipsis,
color:
PlatformExtension.isDesktop ? null : Theme.of(context).hintColor,
),
hoverColor: AFThemeExtension.of(context).greyHover,
onTap: () async {
final result = await TypeOptionBackendService.createFieldTypeOption(
viewId: widget.viewId,
);
result.fold(
(l) {
typeOption = l;
popoverController.show();
},
(r) => Log.error("Failed to create field type option: $r"),
);
},
leftIcon: FlowySvg(
FlowySvgs.add_s,
color:
PlatformExtension.isDesktop ? null : Theme.of(context).hintColor,
),
return FlowyButton(
margin: PlatformExtension.isDesktop
? GridSize.cellContentInsets
: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
radius: BorderRadius.zero,
text: FlowyText.medium(
LocaleKeys.grid_field_newProperty.tr(),
overflow: TextOverflow.ellipsis,
color: PlatformExtension.isDesktop ? null : Theme.of(context).hintColor,
),
popupBuilder: (BuildContext popoverContext) {
return FieldEditor(
hoverColor: AFThemeExtension.of(context).greyHover,
onTap: () async {
final result = await TypeOptionBackendService.createFieldTypeOption(
viewId: widget.viewId,
fieldController: fieldController,
field: typeOption.field_2,
);
result.fold(
(typeOptionPB) => widget.onFieldCreated(typeOptionPB.field_2.id),
(err) => Log.error("Failed to create field type option: $err"),
);
},
leftIcon: FlowySvg(
FlowySvgs.add_s,
color: PlatformExtension.isDesktop ? null : Theme.of(context).hintColor,
),
);
}
}