feat: add basic relation field (#4397)

* feat: add basic relation field

* fix: clippy

* fix: tauri build 🤞

* chore: merge changes

* fix: merge main

* chore: initial code review pass

* fix: rust-lib test

* chore: code cleanup

* fix: unwrap or default
This commit is contained in:
Richard Shiue 2024-02-29 14:38:18 +08:00 committed by GitHub
parent f826d05f03
commit f4ca3ef782
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 1804 additions and 34 deletions

View File

@ -0,0 +1,139 @@
import 'dart:async';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'relation_cell_bloc.freezed.dart';
class RelationCellBloc extends Bloc<RelationCellEvent, RelationCellState> {
RelationCellBloc({required this.cellController})
: super(RelationCellState.initial()) {
_dispatch();
_startListening();
_init();
}
final RelationCellController cellController;
void Function()? _onCellChangedFn;
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(_onCellChangedFn!);
_onCellChangedFn = null;
}
return super.close();
}
void _dispatch() {
on<RelationCellEvent>(
(event, emit) async {
await event.when(
didUpdateCell: (RelationCellDataPB? cellData) async {
if (cellData == null || cellData.rowIds.isEmpty) {
emit(state.copyWith(rows: const []));
return;
}
final payload = RepeatedRowIdPB(
databaseId: state.relatedDatabaseId,
rowIds: cellData.rowIds,
);
final result =
await DatabaseEventGetRelatedRowDatas(payload).send();
final rows = result.fold(
(data) => data.rows,
(err) {
Log.error(err);
return const <RelatedRowDataPB>[];
},
);
emit(state.copyWith(rows: rows));
},
didUpdateRelationDatabaseId: (databaseId) {
emit(state.copyWith(relatedDatabaseId: databaseId));
},
selectRow: (rowId) async {
await _handleSelectRow(rowId);
},
);
},
);
}
void _startListening() {
_onCellChangedFn = cellController.addListener(
onCellChanged: (data) {
if (!isClosed) {
add(RelationCellEvent.didUpdateCell(data));
}
},
onCellFieldChanged: (field) {
if (!isClosed) {
// hack: SingleFieldListener receives notification before
// FieldController's copy is updated.
Future.delayed(const Duration(milliseconds: 50), () {
final RelationTypeOptionPB typeOption =
cellController.getTypeOption(RelationTypeOptionDataParser());
add(
RelationCellEvent.didUpdateRelationDatabaseId(
typeOption.databaseId,
),
);
});
}
},
);
}
void _init() {
final RelationTypeOptionPB typeOption =
cellController.getTypeOption(RelationTypeOptionDataParser());
add(RelationCellEvent.didUpdateRelationDatabaseId(typeOption.databaseId));
final cellData = cellController.getCellData();
add(RelationCellEvent.didUpdateCell(cellData));
}
Future<void> _handleSelectRow(String rowId) async {
final payload = RelationCellChangesetPB(
viewId: cellController.viewId,
cellId: CellIdPB(
viewId: cellController.viewId,
fieldId: cellController.fieldId,
rowId: cellController.rowId,
),
);
if (state.rows.any((row) => row.rowId == rowId)) {
payload.removedRowIds.add(rowId);
} else {
payload.insertedRowIds.add(rowId);
}
final result = await DatabaseEventUpdateRelationCell(payload).send();
result.fold((l) => null, (err) => Log.error(err));
}
}
@freezed
class RelationCellEvent with _$RelationCellEvent {
const factory RelationCellEvent.didUpdateRelationDatabaseId(
String databaseId,
) = _DidUpdateRelationDatabaseId;
const factory RelationCellEvent.didUpdateCell(RelationCellDataPB? data) =
_DidUpdateCell;
const factory RelationCellEvent.selectRow(String rowId) = _SelectRowId;
}
@freezed
class RelationCellState with _$RelationCellState {
const factory RelationCellState({
required String relatedDatabaseId,
required List<RelatedRowDataPB> rows,
}) = _RelationCellState;
factory RelationCellState.initial() =>
const RelationCellState(relatedDatabaseId: "", rows: []);
}

View File

@ -0,0 +1,77 @@
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'relation_row_search_bloc.freezed.dart';
class RelationRowSearchBloc
extends Bloc<RelationRowSearchEvent, RelationRowSearchState> {
RelationRowSearchBloc({
required this.databaseId,
}) : super(RelationRowSearchState.initial()) {
_dispatch();
_init();
}
final String databaseId;
final List<RelatedRowDataPB> allRows = [];
void _dispatch() {
on<RelationRowSearchEvent>(
(event, emit) {
event.when(
didUpdateRowList: (List<RelatedRowDataPB> rowList) {
allRows.clear();
allRows.addAll(rowList);
emit(state.copyWith(filteredRows: allRows));
},
updateFilter: (String filter) => _updateFilter(filter, emit),
);
},
);
}
Future<void> _init() async {
final payload = DatabaseIdPB(value: databaseId);
final result = await DatabaseEventGetRelatedDatabaseRows(payload).send();
result.fold(
(data) => add(RelationRowSearchEvent.didUpdateRowList(data.rows)),
(err) => Log.error(err),
);
}
void _updateFilter(String filter, Emitter<RelationRowSearchState> emit) {
final rows = [...allRows];
if (filter.isNotEmpty) {
rows.retainWhere(
(row) => row.name.toLowerCase().contains(filter.toLowerCase()),
);
}
emit(state.copyWith(filter: filter, filteredRows: rows));
}
}
@freezed
class RelationRowSearchEvent with _$RelationRowSearchEvent {
const factory RelationRowSearchEvent.didUpdateRowList(
List<RelatedRowDataPB> rowList,
) = _DidUpdateRowList;
const factory RelationRowSearchEvent.updateFilter(String filter) =
_UpdateFilter;
}
@freezed
class RelationRowSearchState with _$RelationRowSearchState {
const factory RelationRowSearchState({
required String filter,
required List<RelatedRowDataPB> filteredRows,
}) = _RelationRowSearchState;
factory RelationRowSearchState.initial() => const RelationRowSearchState(
filter: "",
filteredRows: [],
);
}

View File

@ -14,6 +14,7 @@ typedef ChecklistCellController = CellController<ChecklistCellDataPB, String>;
typedef DateCellController = CellController<DateCellDataPB, String>;
typedef TimestampCellController = CellController<TimestampCellDataPB, String>;
typedef URLCellController = CellController<URLCellDataPB, String>;
typedef RelationCellController = CellController<RelationCellDataPB, String>;
CellController makeCellController(
DatabaseController databaseController,
@ -118,6 +119,19 @@ CellController makeCellController(
),
cellDataPersistence: TextCellDataPersistence(),
);
case FieldType.Relation:
return RelationCellController(
viewId: viewId,
fieldController: fieldController,
cellContext: cellContext,
rowCache: rowCache,
cellDataLoader: CellDataLoader(
parser: RelationCellDataParser(),
reloadOnFieldChange: true,
),
cellDataPersistence: TextCellDataPersistence(),
);
}
throw UnimplementedError;
}

View File

@ -133,3 +133,10 @@ class URLCellDataParser implements CellDataParser<URLCellDataPB> {
return URLCellDataPB.fromBuffer(data);
}
}
class RelationCellDataParser implements CellDataParser<RelationCellDataPB> {
@override
RelationCellDataPB? parserData(List<int> data) {
return data.isEmpty ? null : RelationCellDataPB.fromBuffer(data);
}
}

View File

@ -72,3 +72,11 @@ class ChecklistTypeOptionDataParser
return ChecklistTypeOptionPB.fromBuffer(buffer);
}
}
class RelationTypeOptionDataParser
extends TypeOptionParser<RelationTypeOptionPB> {
@override
RelationTypeOptionPB fromBuffer(List<int> buffer) {
return RelationTypeOptionPB.fromBuffer(buffer);
}
}

View File

@ -144,9 +144,9 @@ class GridCreateFilterBloc
fieldId: fieldId,
condition: TextFilterConditionPB.Contains,
);
default:
throw UnimplementedError();
}
return FlowyResult.success(null);
}
@override

View File

@ -9,6 +9,19 @@ import '../../layout/sizes.dart';
typedef SelectFieldCallback = void Function(FieldType);
const List<FieldType> _supportedFieldTypes = [
FieldType.RichText,
FieldType.Number,
FieldType.SingleSelect,
FieldType.MultiSelect,
FieldType.DateTime,
FieldType.Checkbox,
FieldType.Checklist,
FieldType.URL,
FieldType.LastEditedTime,
FieldType.CreatedTime,
];
class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate {
const FieldTypeList({required this.onSelectField, super.key});
@ -16,7 +29,7 @@ class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate {
@override
Widget build(BuildContext context) {
final cells = FieldType.values.map((fieldType) {
final cells = _supportedFieldTypes.map((fieldType) {
return FieldTypeCell(
fieldType: fieldType,
onSelectField: (fieldType) {

View File

@ -9,6 +9,7 @@ import 'checklist.dart';
import 'date.dart';
import 'multi_select.dart';
import 'number.dart';
import 'relation.dart';
import 'rich_text.dart';
import 'single_select.dart';
import 'timestamp.dart';
@ -29,6 +30,7 @@ abstract class TypeOptionEditorFactory {
FieldType.MultiSelect => const MultiSelectTypeOptionEditorFactory(),
FieldType.Checkbox => const CheckboxTypeOptionEditorFactory(),
FieldType.Checklist => const ChecklistTypeOptionEditorFactory(),
FieldType.Relation => const RelationTypeOptionEditorFactory(),
_ => throw UnimplementedError(),
};
}

View File

@ -0,0 +1,160 @@
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/type_option_data_parser.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:protobuf/protobuf.dart';
import 'builder.dart';
class RelationTypeOptionEditorFactory implements TypeOptionEditorFactory {
const RelationTypeOptionEditorFactory();
@override
Widget? build({
required BuildContext context,
required String viewId,
required FieldPB field,
required PopoverMutex popoverMutex,
required TypeOptionDataCallback onTypeOptionUpdated,
}) {
final typeOption = _parseTypeOptionData(field.typeOptionData);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.only(left: 14, right: 8),
height: GridSize.popoverItemHeight,
alignment: Alignment.centerLeft,
child: FlowyText.regular(
LocaleKeys.grid_relation_relatedDatabasePlaceLabel.tr(),
color: Theme.of(context).hintColor,
fontSize: 11,
),
),
AppFlowyPopover(
mutex: popoverMutex,
triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click,
offset: const Offset(6, 0),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8),
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText(
typeOption.databaseId.isEmpty
? LocaleKeys.grid_relation_relatedDatabasePlaceholder.tr()
: typeOption.databaseId,
color: typeOption.databaseId.isEmpty
? Theme.of(context).hintColor
: null,
overflow: TextOverflow.ellipsis,
),
rightIcon: const FlowySvg(FlowySvgs.more_s),
),
),
popupBuilder: (context) {
return _DatabaseList(
onSelectDatabase: (newDatabaseId) {
final newTypeOption = _updateTypeOption(
typeOption: typeOption,
databaseId: newDatabaseId,
);
onTypeOptionUpdated(newTypeOption.writeToBuffer());
PopoverContainer.of(context).close();
},
currentDatabaseId:
typeOption.databaseId.isEmpty ? null : typeOption.databaseId,
);
},
),
],
);
}
RelationTypeOptionPB _parseTypeOptionData(List<int> data) {
return RelationTypeOptionDataParser().fromBuffer(data);
}
RelationTypeOptionPB _updateTypeOption({
required RelationTypeOptionPB typeOption,
required String databaseId,
}) {
typeOption.freeze();
return typeOption.rebuild((typeOption) {
typeOption.databaseId = databaseId;
});
}
}
class _DatabaseList extends StatefulWidget {
const _DatabaseList({
required this.onSelectDatabase,
required this.currentDatabaseId,
});
final String? currentDatabaseId;
final void Function(String databaseId) onSelectDatabase;
@override
State<_DatabaseList> createState() => _DatabaseListState();
}
class _DatabaseListState extends State<_DatabaseList> {
late Future<FlowyResult<RepeatedDatabaseDescriptionPB, FlowyError>> future;
@override
void initState() {
super.initState();
future = DatabaseEventGetDatabases().send();
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: future,
builder: (context, snapshot) {
final data = snapshot.data;
if (!snapshot.hasData ||
snapshot.connectionState != ConnectionState.done ||
data!.isFailure()) {
return const SizedBox.shrink();
}
final databaseIds = data
.fold<List<DatabaseDescriptionPB>>((l) => l.items, (r) => [])
.map((databaseDescription) {
final databaseId = databaseDescription.databaseId;
return FlowyButton(
onTap: () => widget.onSelectDatabase(databaseId),
text: FlowyText.medium(
databaseId,
overflow: TextOverflow.ellipsis,
),
rightIcon: databaseId == widget.currentDatabaseId
? FlowySvg(
FlowySvgs.check_s,
color: Theme.of(context).colorScheme.primary,
)
: null,
);
}).toList();
return ListView.separated(
shrinkWrap: true,
separatorBuilder: (_, __) =>
VSpace(GridSize.typeOptionSeparatorHeight),
itemCount: databaseIds.length,
itemBuilder: (context, index) => databaseIds[index],
);
},
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/timestamp_card_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:flutter/widgets.dart';
@ -84,6 +85,12 @@ class CardCellBuilder {
databaseController: databaseController,
cellContext: cellContext,
),
FieldType.Relation => RelationCardCell(
key: key,
style: isStyleOrNull(style),
databaseController: databaseController,
cellContext: cellContext,
),
_ => throw UnimplementedError,
};
}

View File

@ -0,0 +1,82 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'card_cell.dart';
class RelationCardCellStyle extends CardCellStyle {
RelationCardCellStyle({
required super.padding,
required this.textStyle,
required this.wrap,
});
final TextStyle textStyle;
final bool wrap;
}
class RelationCardCell extends CardCell<RelationCardCellStyle> {
const RelationCardCell({
super.key,
required super.style,
required this.databaseController,
required this.cellContext,
});
final DatabaseController databaseController;
final CellContext cellContext;
@override
State<RelationCardCell> createState() => _RelationCellState();
}
class _RelationCellState extends State<RelationCardCell> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) {
return RelationCellBloc(
cellController: makeCellController(
widget.databaseController,
widget.cellContext,
).as(),
);
},
child: BlocBuilder<RelationCellBloc, RelationCellState>(
builder: (context, state) {
if (state.rows.isEmpty) {
return const SizedBox.shrink();
}
final children = state.rows
.map(
(row) => FlowyText.medium(
row.name,
decoration: TextDecoration.underline,
overflow: TextOverflow.ellipsis,
),
)
.toList();
return Container(
alignment: AlignmentDirectional.topStart,
padding: widget.style.padding,
child: widget.style.wrap
? Wrap(spacing: 4, runSpacing: 4, children: children)
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: children,
),
),
);
},
),
);
}
}

View File

@ -6,6 +6,7 @@ import '../card_cell_skeleton/checkbox_card_cell.dart';
import '../card_cell_skeleton/checklist_card_cell.dart';
import '../card_cell_skeleton/date_card_cell.dart';
import '../card_cell_skeleton/number_card_cell.dart';
import '../card_cell_skeleton/relation_card_cell.dart';
import '../card_cell_skeleton/select_option_card_cell.dart';
import '../card_cell_skeleton/text_card_cell.dart';
import '../card_cell_skeleton/timestamp_card_cell.dart';
@ -73,5 +74,10 @@ CardCellStyleMap desktopCalendarCardCellStyleMap(BuildContext context) {
decoration: TextDecoration.underline,
),
),
FieldType.Relation: RelationCardCellStyle(
padding: padding,
wrap: true,
textStyle: textStyle,
),
};
}

View File

@ -6,6 +6,7 @@ import '../card_cell_skeleton/checkbox_card_cell.dart';
import '../card_cell_skeleton/checklist_card_cell.dart';
import '../card_cell_skeleton/date_card_cell.dart';
import '../card_cell_skeleton/number_card_cell.dart';
import '../card_cell_skeleton/relation_card_cell.dart';
import '../card_cell_skeleton/select_option_card_cell.dart';
import '../card_cell_skeleton/text_card_cell.dart';
import '../card_cell_skeleton/timestamp_card_cell.dart';
@ -73,5 +74,10 @@ CardCellStyleMap desktopBoardCardCellStyleMap(BuildContext context) {
decoration: TextDecoration.underline,
),
),
FieldType.Relation: RelationCardCellStyle(
padding: padding,
wrap: true,
textStyle: textStyle,
),
};
}

View File

@ -6,6 +6,7 @@ import '../card_cell_skeleton/checkbox_card_cell.dart';
import '../card_cell_skeleton/checklist_card_cell.dart';
import '../card_cell_skeleton/date_card_cell.dart';
import '../card_cell_skeleton/number_card_cell.dart';
import '../card_cell_skeleton/relation_card_cell.dart';
import '../card_cell_skeleton/select_option_card_cell.dart';
import '../card_cell_skeleton/text_card_cell.dart';
import '../card_cell_skeleton/timestamp_card_cell.dart';
@ -72,5 +73,10 @@ CardCellStyleMap mobileBoardCardCellStyleMap(BuildContext context) {
decoration: TextDecoration.underline,
),
),
FieldType.Relation: RelationCardCellStyle(
padding: padding,
textStyle: textStyle,
wrap: true,
),
};
}

View File

@ -0,0 +1,58 @@
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
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/relation.dart';
class DesktopGridRelationCellSkin extends IEditableRelationCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
RelationCellBloc bloc,
RelationCellState state,
PopoverController popoverController,
) {
return AppFlowyPopover(
controller: popoverController,
direction: PopoverDirection.bottomWithLeftAligned,
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400),
margin: EdgeInsets.zero,
onClose: () => cellContainerNotifier.isFocus = false,
popupBuilder: (context) {
return BlocProvider.value(
value: bloc,
child: RelationCellEditor(
selectedRowIds: state.rows.map((row) => row.rowId).toList(),
databaseId: state.relatedDatabaseId,
onSelectRow: (rowId) {
bloc.add(RelationCellEvent.selectRow(rowId));
},
),
);
},
child: Container(
alignment: AlignmentDirectional.centerStart,
padding: GridSize.cellContentInsets,
child: Wrap(
runSpacing: 4.0,
spacing: 4.0,
children: state.rows
.map(
(row) => FlowyText.medium(
row.name,
decoration: TextDecoration.underline,
overflow: TextOverflow.ellipsis,
),
)
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,61 @@
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
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/relation.dart';
class DesktopRowDetailRelationCellSkin extends IEditableRelationCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
RelationCellBloc bloc,
RelationCellState state,
PopoverController popoverController,
) {
return AppFlowyPopover(
controller: popoverController,
direction: PopoverDirection.bottomWithLeftAligned,
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400),
margin: EdgeInsets.zero,
onClose: () => cellContainerNotifier.isFocus = false,
popupBuilder: (context) {
return BlocProvider.value(
value: bloc,
child: BlocBuilder<RelationCellBloc, RelationCellState>(
builder: (context, state) => RelationCellEditor(
selectedRowIds: state.rows.map((row) => row.rowId).toList(),
databaseId: state.relatedDatabaseId,
onSelectRow: (rowId) {
context
.read<RelationCellBloc>()
.add(RelationCellEvent.selectRow(rowId));
},
),
),
);
},
child: Container(
alignment: AlignmentDirectional.centerStart,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Wrap(
runSpacing: 4.0,
spacing: 4.0,
children: state.rows
.map(
(row) => FlowyText.medium(
row.name,
decoration: TextDecoration.underline,
overflow: TextOverflow.ellipsis,
),
)
.toList(),
),
),
);
}
}

View File

@ -13,6 +13,7 @@ import 'editable_cell_skeleton/checkbox.dart';
import 'editable_cell_skeleton/checklist.dart';
import 'editable_cell_skeleton/date.dart';
import 'editable_cell_skeleton/number.dart';
import 'editable_cell_skeleton/relation.dart';
import 'editable_cell_skeleton/select_option.dart';
import 'editable_cell_skeleton/text.dart';
import 'editable_cell_skeleton/timestamp.dart';
@ -106,6 +107,12 @@ class EditableCellBuilder {
skin: IEditableURLCellSkin.fromStyle(style),
key: key,
),
FieldType.Relation => EditableRelationCell(
databaseController: databaseController,
cellContext: cellContext,
skin: IEditableRelationCellSkin.fromStyle(style),
key: key,
),
_ => throw UnimplementedError(),
};
}
@ -186,6 +193,12 @@ class EditableCellBuilder {
skin: skinMap.urlSkin!,
key: key,
),
FieldType.Relation => EditableRelationCell(
databaseController: databaseController,
cellContext: cellContext,
skin: skinMap.relationSkin!,
key: key,
),
_ => throw UnimplementedError(),
};
}
@ -340,6 +353,7 @@ class EditableCellSkinMap {
this.numberSkin,
this.textSkin,
this.urlSkin,
this.relationSkin,
});
final IEditableCheckboxCellSkin? checkboxSkin;
@ -350,6 +364,7 @@ class EditableCellSkinMap {
final IEditableNumberCellSkin? numberSkin;
final IEditableTextCellSkin? textSkin;
final IEditableURLCellSkin? urlSkin;
final IEditableRelationCellSkin? relationSkin;
bool has(FieldType fieldType) {
return switch (fieldType) {

View File

@ -0,0 +1,94 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../desktop_grid/desktop_grid_relation_cell.dart';
import '../desktop_row_detail/desktop_row_detail_relation_cell.dart';
import '../mobile_grid/mobile_grid_relation_cell.dart';
import '../mobile_row_detail/mobile_row_detail_relation_cell.dart';
abstract class IEditableRelationCellSkin {
factory IEditableRelationCellSkin.fromStyle(EditableCellStyle style) {
return switch (style) {
EditableCellStyle.desktopGrid => DesktopGridRelationCellSkin(),
EditableCellStyle.desktopRowDetail => DesktopRowDetailRelationCellSkin(),
EditableCellStyle.mobileGrid => MobileGridRelationCellSkin(),
EditableCellStyle.mobileRowDetail => MobileRowDetailRelationCellSkin(),
};
}
const IEditableRelationCellSkin();
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
RelationCellBloc bloc,
RelationCellState state,
PopoverController popoverController,
);
}
class EditableRelationCell extends EditableCellWidget {
EditableRelationCell({
super.key,
required this.databaseController,
required this.cellContext,
required this.skin,
});
final DatabaseController databaseController;
final CellContext cellContext;
final IEditableRelationCellSkin skin;
@override
GridCellState<EditableRelationCell> createState() => _RelationCellState();
}
class _RelationCellState extends GridCellState<EditableRelationCell> {
final PopoverController _popover = PopoverController();
late final cellBloc = RelationCellBloc(
cellController: makeCellController(
widget.databaseController,
widget.cellContext,
).as(),
);
@override
void dispose() {
cellBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: cellBloc,
child: BlocBuilder<RelationCellBloc, RelationCellState>(
builder: (context, state) {
return widget.skin.build(
context,
widget.cellContainerNotifier,
cellBloc,
state,
_popover,
);
},
),
);
}
@override
void onRequestFocus() {
_popover.show();
widget.cellContainerNotifier.isFocus = true;
}
@override
String? onCopy() => "";
}

View File

@ -0,0 +1,54 @@
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import '../editable_cell_skeleton/relation.dart';
class MobileGridRelationCellSkin extends IEditableRelationCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
RelationCellBloc bloc,
RelationCellState state,
PopoverController popoverController,
) {
return FlowyButton(
radius: BorderRadius.zero,
hoverColor: Colors.transparent,
margin: EdgeInsets.zero,
text: Align(
alignment: AlignmentDirectional.centerStart,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: state.rows
.map(
(row) => FlowyText(
row.name,
fontSize: 15,
decoration: TextDecoration.underline,
),
)
.toList(),
),
),
),
onTap: () {
showMobileBottomSheet(
context,
padding: EdgeInsets.zero,
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
builder: (context) {
return const FlowyText("Coming soon");
},
);
},
);
}
}

View File

@ -0,0 +1,56 @@
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class MobileRowDetailRelationCellSkin extends IEditableRelationCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
RelationCellBloc bloc,
RelationCellState state,
PopoverController popoverController,
) {
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(14)),
onTap: () => showMobileBottomSheet(
context,
padding: EdgeInsets.zero,
builder: (context) {
return const FlowyText("Coming soon");
},
),
child: Container(
constraints: const BoxConstraints(
minHeight: 48,
minWidth: double.infinity,
),
decoration: BoxDecoration(
border: Border.fromBorderSide(
BorderSide(color: Theme.of(context).colorScheme.outline),
),
borderRadius: const BorderRadius.all(Radius.circular(14)),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13),
child: Wrap(
runSpacing: 4.0,
spacing: 4.0,
children: state.rows
.map(
(row) => FlowyText(
row.name,
fontSize: 16,
decoration: TextDecoration.underline,
overflow: TextOverflow.ellipsis,
),
)
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,148 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.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 '../../application/cell/bloc/relation_cell_bloc.dart';
import '../../application/cell/bloc/relation_row_search_bloc.dart';
class RelationCellEditor extends StatelessWidget {
const RelationCellEditor({
super.key,
required this.databaseId,
required this.selectedRowIds,
required this.onSelectRow,
});
final String databaseId;
final List<String> selectedRowIds;
final void Function(String rowId) onSelectRow;
@override
Widget build(BuildContext context) {
if (databaseId.isEmpty) {
// no i18n here because UX needs thorough checking.
return const Center(
child: FlowyText(
'''
No database has been selected,
please select one first in the field editor.
''',
maxLines: null,
textAlign: TextAlign.center,
),
);
}
return BlocProvider<RelationRowSearchBloc>(
create: (context) => RelationRowSearchBloc(
databaseId: databaseId,
),
child: BlocBuilder<RelationCellBloc, RelationCellState>(
builder: (context, cellState) {
return BlocBuilder<RelationRowSearchBloc, RelationRowSearchState>(
builder: (context, state) {
final children = state.filteredRows
.map(
(row) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: FlowyButton(
text: FlowyText.medium(
row.name.trim().isEmpty
? LocaleKeys.grid_title_placeholder.tr()
: row.name,
color: row.name.trim().isEmpty
? Theme.of(context).hintColor
: null,
overflow: TextOverflow.ellipsis,
),
rightIcon: cellState.rows
.map((e) => e.rowId)
.contains(row.rowId)
? FlowySvg(
FlowySvgs.check_s,
color: Theme.of(context).primaryColor,
)
: null,
onTap: () => onSelectRow(row.rowId),
),
),
)
.toList();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(6.0),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0) +
GridSize.typeOptionContentInsets,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText.regular(
LocaleKeys.grid_relation_inRelatedDatabase.tr(),
fontSize: 11,
color: Theme.of(context).hintColor,
),
const HSpace(2.0),
FlowyButton(
useIntrinsicWidth: true,
margin: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
text: FlowyText.regular(
cellState.relatedDatabaseId,
fontSize: 11,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
VSpace(GridSize.typeOptionSeparatorHeight),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: FlowyTextField(
onChanged: (text) => context
.read<RelationRowSearchBloc>()
.add(RelationRowSearchEvent.updateFilter(text)),
),
),
const VSpace(6.0),
const TypeOptionSeparator(spacing: 0.0),
if (state.filteredRows.isEmpty)
Padding(
padding: const EdgeInsets.all(6.0) +
GridSize.typeOptionContentInsets,
child: FlowyText.regular(
LocaleKeys.grid_relation_emptySearchResult.tr(),
color: Theme.of(context).hintColor,
),
)
else
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 6.0),
separatorBuilder: (context, index) =>
VSpace(GridSize.typeOptionSeparatorHeight),
itemCount: children.length,
itemBuilder: (context, index) => children[index],
),
),
],
);
},
);
},
),
);
}
}

View File

@ -75,6 +75,7 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
@override
void dispose() {
widget.textController.removeListener(_onChanged);
focusNode.dispose();
super.dispose();
}

View File

@ -20,6 +20,7 @@ extension FieldTypeExtension on FieldType {
FieldType.LastEditedTime =>
LocaleKeys.grid_field_updatedAtFieldName.tr(),
FieldType.CreatedTime => LocaleKeys.grid_field_createdAtFieldName.tr(),
FieldType.Relation => LocaleKeys.grid_field_relationFieldName.tr(),
_ => throw UnimplementedError(),
};
@ -34,6 +35,7 @@ extension FieldTypeExtension on FieldType {
FieldType.Checklist => FlowySvgs.checklist_s,
FieldType.LastEditedTime => FlowySvgs.last_modified_s,
FieldType.CreatedTime => FlowySvgs.created_at_s,
FieldType.Relation => FlowySvgs.relation_s,
_ => throw UnimplementedError(),
};
@ -48,6 +50,7 @@ extension FieldTypeExtension on FieldType {
FieldType.Checklist => const Color(0xFF98F4CD),
FieldType.LastEditedTime => const Color(0xFFFDEDA7),
FieldType.CreatedTime => const Color(0xFFFDEDA7),
FieldType.Relation => const Color(0xFFFDEDA7),
_ => throw UnimplementedError(),
};
}

View File

@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="11.8274" cy="5.82739" r="1.5" stroke="#333333"/>
<path d="M10.5008 5.38471L6.24097 4.78992" stroke="#333333"/>
<path d="M4.86475 6.24121L6.02777 10.1009" stroke="#333333"/>
<circle cx="7" cy="11" r="1.5" stroke="#333333"/>
<circle cx="5" cy="5" r="1.5" stroke="#333333"/>
<path d="M10.9011 7.14258L8.1484 10.0447" stroke="#333333"/>
</svg>

After

Width:  |  Height:  |  Size: 448 B

View File

@ -17,6 +17,7 @@ export const PropertyTypeText = ({ type }: { type: FieldType }) => {
[FieldType.Checklist]: t('grid.field.checklistFieldName'),
[FieldType.LastEditedTime]: t('grid.field.updatedAtFieldName'),
[FieldType.CreatedTime]: t('grid.field.createdAtFieldName'),
[FieldType.Relation]: t('grid.field.relationFieldName'),
};
return map[type] || 'unknown';

View File

@ -9,6 +9,7 @@ import { ReactComponent as ChecklistSvg } from '$app/assets/database/field-type-
import { ReactComponent as CheckboxSvg } from '$app/assets/database/field-type-checkbox.svg';
import { ReactComponent as URLSvg } from '$app/assets/database/field-type-url.svg';
import { ReactComponent as LastEditedTimeSvg } from '$app/assets/database/field-type-last-edited-time.svg';
import { ReactComponent as RelationSvg } from '$app/assets/database/field-type-relation.svg';
export const FieldTypeSvgMap: Record<FieldType, FC<React.SVGProps<SVGSVGElement>>> = {
[FieldType.RichText]: TextSvg,
@ -21,6 +22,7 @@ export const FieldTypeSvgMap: Record<FieldType, FC<React.SVGProps<SVGSVGElement>
[FieldType.Checklist]: ChecklistSvg,
[FieldType.LastEditedTime]: LastEditedTimeSvg,
[FieldType.CreatedTime]: LastEditedTimeSvg,
[FieldType.Relation]: RelationSvg,
};
export const ProppertyTypeSvg: FC<{ type: FieldType; className?: string }> = memo(({ type, ...props }) => {

View File

@ -588,6 +588,7 @@
"multiSelectFieldName": "Multiselect",
"urlFieldName": "URL",
"checklistFieldName": "Checklist",
"relationFieldName": "Relation",
"numberFormat": "Number format",
"dateFormat": "Date format",
"includeTime": "Include time",
@ -688,6 +689,12 @@
"hideComplete": "Hide completed tasks",
"showComplete": "Show all tasks"
},
"relation": {
"relatedDatabasePlaceLabel": "Related Database",
"relatedDatabasePlaceholder": "None",
"inRelatedDatabase": "In",
"emptySearchResult": "No records found"
},
"menuName": "Grid",
"referencedGridPrefix": "View of",
"calculate": "Calculate",

View File

@ -335,6 +335,16 @@ impl EventIntegrationTest {
ChecklistCellDataPB::try_from(Bytes::from(cell.data)).unwrap()
}
pub async fn get_relation_cell(
&self,
view_id: &str,
field_id: &str,
row_id: &str,
) -> RelationCellDataPB {
let cell = self.get_cell(view_id, row_id, field_id).await;
RelationCellDataPB::try_from(Bytes::from(cell.data)).unwrap_or_default()
}
pub async fn update_checklist_cell(
&self,
changeset: ChecklistCellDataChangesetPB,
@ -469,4 +479,33 @@ impl EventIntegrationTest {
.parse::<RepeatedCalendarEventPB>()
.items
}
pub async fn update_relation_cell(
&self,
changeset: RelationCellChangesetPB,
) -> Option<FlowyError> {
EventBuilder::new(self.clone())
.event(DatabaseEvent::UpdateRelationCell)
.payload(changeset)
.async_send()
.await
.error()
}
pub async fn get_related_row_data(
&self,
database_id: String,
row_ids: Vec<String>,
) -> Vec<RelatedRowDataPB> {
EventBuilder::new(self.clone())
.event(DatabaseEvent::GetRelatedRowDatas)
.payload(RepeatedRowIdPB {
database_id,
row_ids,
})
.async_send()
.await
.parse::<RepeatedRelatedRowDataPB>()
.rows
}
}

View File

@ -7,7 +7,7 @@ use event_integration::EventIntegrationTest;
use flowy_database2::entities::{
CellChangesetPB, CellIdPB, CheckboxCellDataPB, ChecklistCellDataChangesetPB, DatabaseLayoutPB,
DatabaseSettingChangesetPB, DatabaseViewIdPB, DateCellChangesetPB, FieldType,
OrderObjectPositionPB, SelectOptionCellDataPB, UpdateRowMetaChangesetPB,
OrderObjectPositionPB, RelationCellChangesetPB, SelectOptionCellDataPB, UpdateRowMetaChangesetPB,
};
use lib_infra::util::timestamp;
@ -778,3 +778,99 @@ async fn create_calendar_event_test() {
let events = test.get_all_calendar_events(&calendar_view.id).await;
assert_eq!(events.len(), 1);
}
#[tokio::test]
async fn update_relation_cell_test() {
let test = EventIntegrationTest::new_with_guest_user().await;
let current_workspace = test.get_current_workspace().await;
let grid_view = test
.create_grid(&current_workspace.id, "my grid view".to_owned(), vec![])
.await;
let relation_field = test.create_field(&grid_view.id, FieldType::Relation).await;
let database = test.get_database(&grid_view.id).await;
// update the relation cell
let changeset = RelationCellChangesetPB {
view_id: grid_view.id.clone(),
cell_id: CellIdPB {
view_id: grid_view.id.clone(),
field_id: relation_field.id.clone(),
row_id: database.rows[0].id.clone(),
},
inserted_row_ids: vec![
"row1rowid".to_string(),
"row2rowid".to_string(),
"row3rowid".to_string(),
],
..Default::default()
};
test.update_relation_cell(changeset).await;
// get the cell
let cell = test
.get_relation_cell(&grid_view.id, &relation_field.id, &database.rows[0].id)
.await;
assert_eq!(cell.row_ids.len(), 3);
}
#[tokio::test]
async fn get_detailed_relation_cell_data() {
let test = EventIntegrationTest::new_with_guest_user().await;
let current_workspace = test.get_current_workspace().await;
let origin_grid_view = test
.create_grid(&current_workspace.id, "origin".to_owned(), vec![])
.await;
let relation_grid_view = test
.create_grid(&current_workspace.id, "relation grid".to_owned(), vec![])
.await;
let relation_field = test
.create_field(&relation_grid_view.id, FieldType::Relation)
.await;
let origin_database = test.get_database(&origin_grid_view.id).await;
let origin_fields = test.get_all_database_fields(&origin_grid_view.id).await;
let linked_row = origin_database.rows[0].clone();
test
.update_cell(CellChangesetPB {
view_id: origin_grid_view.id.clone(),
row_id: linked_row.id.clone(),
field_id: origin_fields.items[0].id.clone(),
cell_changeset: "hello world".to_string(),
})
.await;
let new_database = test.get_database(&relation_grid_view.id).await;
// update the relation cell
let changeset = RelationCellChangesetPB {
view_id: relation_grid_view.id.clone(),
cell_id: CellIdPB {
view_id: relation_grid_view.id.clone(),
field_id: relation_field.id.clone(),
row_id: new_database.rows[0].id.clone(),
},
inserted_row_ids: vec![linked_row.id.clone()],
..Default::default()
};
test.update_relation_cell(changeset).await;
// get the cell
let cell = test
.get_relation_cell(
&relation_grid_view.id,
&relation_field.id,
&new_database.rows[0].id,
)
.await;
// using the row ids, get the row data
let rows = test
.get_related_row_data(origin_database.id.clone(), cell.row_ids)
.await;
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].name, "hello world");
}

View File

@ -69,6 +69,12 @@ impl AsRef<str> for DatabaseIdPB {
}
}
#[derive(Clone, ProtoBuf, Default, Debug)]
pub struct RepeatedDatabaseIdPB {
#[pb(index = 1)]
pub value: Vec<DatabaseIdPB>,
}
#[derive(Clone, ProtoBuf, Default, Debug, Validate)]
pub struct DatabaseViewIdPB {
#[pb(index = 1)]

View File

@ -473,6 +473,7 @@ pub enum FieldType {
Checklist = 7,
LastEditedTime = 8,
CreatedTime = 9,
Relation = 10,
}
impl Display for FieldType {
@ -509,8 +510,9 @@ impl FieldType {
FieldType::Checkbox => "Checkbox",
FieldType::URL => "URL",
FieldType::Checklist => "Checklist",
FieldType::LastEditedTime => "Last edited time",
FieldType::LastEditedTime => "Last modified",
FieldType::CreatedTime => "Created time",
FieldType::Relation => "Relation",
};
s.to_string()
}
@ -559,6 +561,10 @@ impl FieldType {
matches!(self, FieldType::Checklist)
}
pub fn is_relation(&self) -> bool {
matches!(self, FieldType::Relation)
}
pub fn can_be_group(&self) -> bool {
self.is_select_option() || self.is_checkbox() || self.is_url()
}

View File

@ -3,6 +3,7 @@ mod checklist_filter;
mod date_filter;
mod filter_changeset;
mod number_filter;
mod relation_filter;
mod select_option_filter;
mod text_filter;
mod util;
@ -12,6 +13,7 @@ pub use checklist_filter::*;
pub use date_filter::*;
pub use filter_changeset::*;
pub use number_filter::*;
pub use relation_filter::*;
pub use select_option_filter::*;
pub use text_filter::*;
pub use util::*;

View File

@ -0,0 +1,24 @@
use flowy_derive::ProtoBuf;
use crate::services::filter::{Filter, FromFilterString};
#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)]
pub struct RelationFilterPB {
#[pb(index = 1)]
pub condition: i64,
}
impl FromFilterString for RelationFilterPB {
fn from_filter(_filter: &Filter) -> Self
where
Self: Sized,
{
RelationFilterPB { condition: 0 }
}
}
impl From<&Filter> for RelationFilterPB {
fn from(_filter: &Filter) -> Self {
RelationFilterPB { condition: 0 }
}
}

View File

@ -11,7 +11,7 @@ use validator::Validate;
use crate::entities::parser::NotEmptyStr;
use crate::entities::{
CheckboxFilterPB, ChecklistFilterPB, DateFilterContentPB, DateFilterPB, FieldType,
NumberFilterPB, SelectOptionFilterPB, TextFilterPB,
NumberFilterPB, RelationFilterPB, SelectOptionFilterPB, TextFilterPB,
};
use crate::services::field::SelectOptionIds;
use crate::services::filter::Filter;
@ -44,6 +44,7 @@ impl std::convert::From<&Filter> for FilterPB {
FieldType::Checklist => ChecklistFilterPB::from(filter).try_into().unwrap(),
FieldType::Checkbox => CheckboxFilterPB::from(filter).try_into().unwrap(),
FieldType::URL => TextFilterPB::from(filter).try_into().unwrap(),
FieldType::Relation => RelationFilterPB::from(filter).try_into().unwrap(),
};
Self {
id: filter.id.clone(),
@ -186,6 +187,10 @@ impl TryInto<UpdateFilterParams> for UpdateFilterPayloadPB {
condition = filter.condition as u8;
content = SelectOptionIds::from(filter.option_ids).to_string();
},
FieldType::Relation => {
let filter = RelationFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?;
condition = filter.condition as u8;
},
}
Ok(UpdateFilterParams {

View File

@ -14,8 +14,9 @@ macro_rules! impl_into_field_type {
7 => FieldType::Checklist,
8 => FieldType::LastEditedTime,
9 => FieldType::CreatedTime,
10 => FieldType::Relation,
_ => {
tracing::error!("🔴Can't parser FieldType from value: {}", ty);
tracing::error!("🔴Can't parse FieldType from value: {}", ty);
FieldType::RichText
},
}
@ -34,7 +35,7 @@ macro_rules! impl_into_field_visibility {
1 => FieldVisibility::HideWhenEmpty,
2 => FieldVisibility::AlwaysHidden,
_ => {
tracing::error!("🔴Can't parser FieldVisibility from value: {}", ty);
tracing::error!("🔴Can't parse FieldVisibility from value: {}", ty);
FieldVisibility::AlwaysShown
},
}

View File

@ -2,6 +2,7 @@ mod checkbox_entities;
mod checklist_entities;
mod date_entities;
mod number_entities;
mod relation_entities;
mod select_option_entities;
mod text_entities;
mod timestamp_entities;
@ -11,6 +12,7 @@ pub use checkbox_entities::*;
pub use checklist_entities::*;
pub use date_entities::*;
pub use number_entities::*;
pub use relation_entities::*;
pub use select_option_entities::*;
pub use text_entities::*;
pub use timestamp_entities::*;

View File

@ -0,0 +1,87 @@
use flowy_derive::ProtoBuf;
use crate::entities::CellIdPB;
use crate::services::field::{RelationCellData, RelationTypeOption};
#[derive(Debug, Clone, Default, ProtoBuf)]
pub struct RelationCellDataPB {
#[pb(index = 1)]
pub row_ids: Vec<String>,
}
impl From<RelationCellData> for RelationCellDataPB {
fn from(data: RelationCellData) -> Self {
Self {
row_ids: data.row_ids.into_iter().map(Into::into).collect(),
}
}
}
impl From<RelationCellDataPB> for RelationCellData {
fn from(data: RelationCellDataPB) -> Self {
Self {
row_ids: data.row_ids.into_iter().map(Into::into).collect(),
}
}
}
#[derive(Debug, Clone, Default, ProtoBuf)]
pub struct RelationCellChangesetPB {
#[pb(index = 1)]
pub view_id: String,
#[pb(index = 2)]
pub cell_id: CellIdPB,
#[pb(index = 3)]
pub inserted_row_ids: Vec<String>,
#[pb(index = 4)]
pub removed_row_ids: Vec<String>,
}
#[derive(Clone, Debug, Default, ProtoBuf)]
pub struct RelationTypeOptionPB {
#[pb(index = 1)]
pub database_id: String,
}
impl From<RelationTypeOption> for RelationTypeOptionPB {
fn from(value: RelationTypeOption) -> Self {
RelationTypeOptionPB {
database_id: value.database_id,
}
}
}
impl From<RelationTypeOptionPB> for RelationTypeOption {
fn from(value: RelationTypeOptionPB) -> Self {
RelationTypeOption {
database_id: value.database_id,
}
}
}
#[derive(Debug, Clone, Default, ProtoBuf)]
pub struct RelatedRowDataPB {
#[pb(index = 1)]
pub row_id: String,
#[pb(index = 2)]
pub name: String,
}
#[derive(Debug, Clone, Default, ProtoBuf)]
pub struct RepeatedRelatedRowDataPB {
#[pb(index = 1)]
pub rows: Vec<RelatedRowDataPB>,
}
#[derive(Debug, Default, Clone, ProtoBuf)]
pub struct RepeatedRowIdPB {
#[pb(index = 1)]
pub database_id: String,
#[pb(index = 2)]
pub row_ids: Vec<String>,
}

View File

@ -13,7 +13,8 @@ use crate::entities::*;
use crate::manager::DatabaseManager;
use crate::services::cell::CellBuilder;
use crate::services::field::{
type_option_data_from_pb, ChecklistCellChangeset, DateCellChangeset, SelectOptionCellChangeset,
type_option_data_from_pb, ChecklistCellChangeset, DateCellChangeset, RelationCellChangeset,
SelectOptionCellChangeset,
};
use crate::services::field_settings::FieldSettingsChangesetParams;
use crate::services::group::GroupChangeset;
@ -978,3 +979,81 @@ pub(crate) async fn remove_calculation_handler(
Ok(())
}
pub(crate) async fn get_related_database_ids_handler(
_data: AFPluginData<DatabaseViewIdPB>,
_manager: AFPluginState<Weak<DatabaseManager>>,
) -> FlowyResult<()> {
Ok(())
}
#[tracing::instrument(level = "debug", skip_all, err)]
pub(crate) async fn update_relation_cell_handler(
data: AFPluginData<RelationCellChangesetPB>,
manager: AFPluginState<Weak<DatabaseManager>>,
) -> FlowyResult<()> {
let manager = upgrade_manager(manager)?;
let params: RelationCellChangesetPB = data.into_inner();
let view_id = parser::NotEmptyStr::parse(params.view_id)
.map_err(|_| flowy_error::ErrorCode::DatabaseViewIdIsEmpty)?
.0;
let cell_id: CellIdParams = params.cell_id.try_into()?;
let params = RelationCellChangeset {
inserted_row_ids: params
.inserted_row_ids
.into_iter()
.map(Into::into)
.collect(),
removed_row_ids: params.removed_row_ids.into_iter().map(Into::into).collect(),
};
let database_editor = manager.get_database_with_view_id(&view_id).await?;
// // get the related database
// let related_database_id = database_editor
// .get_related_database_id(&cell_id.field_id)
// .await?;
// let related_database_editor = manager.get_database(&related_database_id).await?;
// // validate the changeset contents
// related_database_editor
// .validate_row_ids_exist(&params)
// .await?;
// update the cell in the database
database_editor
.update_cell_with_changeset(
&view_id,
cell_id.row_id,
&cell_id.field_id,
BoxAny::new(params),
)
.await?;
Ok(())
}
pub(crate) async fn get_related_row_datas_handler(
data: AFPluginData<RepeatedRowIdPB>,
manager: AFPluginState<Weak<DatabaseManager>>,
) -> DataResult<RepeatedRelatedRowDataPB, FlowyError> {
let manager = upgrade_manager(manager)?;
let params: RepeatedRowIdPB = data.into_inner();
let database_editor = manager.get_database(&params.database_id).await?;
let row_datas = database_editor
.get_related_rows(Some(&params.row_ids))
.await?;
data_result_ok(RepeatedRelatedRowDataPB { rows: row_datas })
}
pub(crate) async fn get_related_database_rows_handler(
data: AFPluginData<DatabaseIdPB>,
manager: AFPluginState<Weak<DatabaseManager>>,
) -> DataResult<RepeatedRelatedRowDataPB, FlowyError> {
let manager = upgrade_manager(manager)?;
let database_id = data.into_inner().value;
let database_editor = manager.get_database(&database_id).await?;
let row_datas = database_editor.get_related_rows(None).await?;
data_result_ok(RepeatedRelatedRowDataPB { rows: row_datas })
}

View File

@ -83,6 +83,11 @@ pub fn init(database_manager: Weak<DatabaseManager>) -> AFPlugin {
.event(DatabaseEvent::GetAllCalculations, get_all_calculations_handler)
.event(DatabaseEvent::UpdateCalculation, update_calculation_handler)
.event(DatabaseEvent::RemoveCalculation, remove_calculation_handler)
// Relation
.event(DatabaseEvent::GetRelatedDatabaseIds, get_related_database_ids_handler)
.event(DatabaseEvent::UpdateRelationCell, update_relation_cell_handler)
.event(DatabaseEvent::GetRelatedRowDatas, get_related_row_datas_handler)
.event(DatabaseEvent::GetRelatedDatabaseRows, get_related_database_rows_handler)
}
/// [DatabaseEvent] defines events that are used to interact with the Grid. You could check [this](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/backend/protobuf)
@ -342,4 +347,22 @@ pub enum DatabaseEvent {
#[event(input = "RemoveCalculationChangesetPB")]
RemoveCalculation = 165,
/// Currently unused. Get a list of database ids that this database relates
/// to.
#[event(input = "DatabaseViewIdPB", output = "RepeatedDatabaseIdPB")]
GetRelatedDatabaseIds = 170,
/// Updates a relation cell, adding or removing links to rows in another
/// database
#[event(input = "RelationCellChangesetPB")]
UpdateRelationCell = 171,
/// Get the names of the linked rows in a relation cell.
#[event(input = "RepeatedRowIdPB", output = "RepeatedRelatedRowDataPB")]
GetRelatedRowDatas = 172,
/// Get the names of all the rows in a related database.
#[event(input = "DatabaseIdPB", output = "RepeatedRelatedRowDataPB")]
GetRelatedDatabaseRows = 173,
}

View File

@ -307,6 +307,9 @@ impl<'a> CellBuilder<'a> {
cells.insert(field_id, insert_select_option_cell(ids.into_inner(), field));
}
},
FieldType::Relation => {
cells.insert(field_id, (&RelationCellData::from(cell_str)).into());
},
}
}
}

View File

@ -26,8 +26,8 @@ use crate::services::database_view::{
};
use crate::services::field::{
default_type_option_data_from_type, select_type_option_from_field, transform_type_option,
type_option_data_from_pb, ChecklistCellChangeset, SelectOptionCellChangeset, SelectOptionIds,
TimestampCellData, TypeOptionCellDataHandler, TypeOptionCellExt,
type_option_data_from_pb, ChecklistCellChangeset, RelationTypeOption, SelectOptionCellChangeset,
SelectOptionIds, StrCellData, TimestampCellData, TypeOptionCellDataHandler, TypeOptionCellExt,
};
use crate::services::field_settings::{
default_field_settings_by_layout_map, FieldSettings, FieldSettingsChangesetParams,
@ -1238,6 +1238,61 @@ impl DatabaseEditor {
Ok(())
}
pub async fn get_related_database_id(&self, field_id: &str) -> FlowyResult<String> {
let mut field = self
.database
.lock()
.get_fields(Some(vec![field_id.to_string()]));
let field = field.pop().ok_or(FlowyError::internal())?;
let type_option = field
.get_type_option::<RelationTypeOption>(FieldType::Relation)
.ok_or(FlowyError::record_not_found())?;
Ok(type_option.database_id)
}
pub async fn get_related_rows(
&self,
row_ids: Option<&Vec<String>>,
) -> FlowyResult<Vec<RelatedRowDataPB>> {
let primary_field = self.database.lock().fields.get_primary_field().unwrap();
let handler =
TypeOptionCellExt::new_with_cell_data_cache(&primary_field, Some(self.cell_cache.clone()))
.get_type_option_cell_data_handler(&FieldType::RichText)
.ok_or(FlowyError::internal())?;
let row_data = {
let database = self.database.lock();
let mut rows = database.get_database_rows();
if let Some(row_ids) = row_ids {
rows.retain(|row| row_ids.contains(&row.id));
}
rows
.iter()
.map(|row| {
let title = database
.get_cell(&primary_field.id, &row.id)
.cell
.and_then(|cell| {
handler
.get_cell_data(&cell, &FieldType::RichText, &primary_field)
.ok()
})
.and_then(|cell_data| cell_data.unbox_or_none())
.unwrap_or_else(|| StrCellData("".to_string()));
RelatedRowDataPB {
row_id: row.id.to_string(),
name: title.0,
}
})
.collect::<Vec<_>>()
};
Ok(row_data)
}
fn get_auto_updated_fields(&self, view_id: &str) -> Vec<Field> {
self
.database

View File

@ -1,6 +1,6 @@
mod field_builder;
mod field_operation;
mod type_options;
pub mod type_options;
pub use field_builder::*;
pub use field_operation::*;

View File

@ -2,6 +2,7 @@ pub mod checkbox_type_option;
pub mod checklist_type_option;
pub mod date_type_option;
pub mod number_type_option;
pub mod relation_type_option;
pub mod selection_type_option;
pub mod text_type_option;
pub mod timestamp_type_option;
@ -14,6 +15,7 @@ pub use checkbox_type_option::*;
pub use checklist_type_option::*;
pub use date_type_option::*;
pub use number_type_option::*;
pub use relation_type_option::*;
pub use selection_type_option::*;
pub use text_type_option::*;
pub use timestamp_type_option::*;

View File

@ -0,0 +1,5 @@
mod relation;
mod relation_entities;
pub use relation::*;
pub use relation_entities::*;

View File

@ -0,0 +1,159 @@
use std::cmp::Ordering;
use collab::core::any_map::AnyMapExtension;
use collab_database::fields::{Field, TypeOptionData, TypeOptionDataBuilder};
use collab_database::rows::Cell;
use flowy_error::FlowyResult;
use serde::{Deserialize, Serialize};
use crate::entities::{FieldType, RelationCellDataPB, RelationFilterPB};
use crate::services::cell::{CellDataChangeset, CellDataDecoder};
use crate::services::field::{
default_order, TypeOption, TypeOptionCellDataCompare, TypeOptionCellDataFilter,
TypeOptionCellDataSerde, TypeOptionTransform,
};
use crate::services::sort::SortCondition;
use super::{RelationCellChangeset, RelationCellData};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RelationTypeOption {
pub database_id: String,
}
impl From<TypeOptionData> for RelationTypeOption {
fn from(value: TypeOptionData) -> Self {
let database_id = value.get_str_value("database_id").unwrap_or_default();
Self { database_id }
}
}
impl From<RelationTypeOption> for TypeOptionData {
fn from(value: RelationTypeOption) -> Self {
TypeOptionDataBuilder::new()
.insert_str_value("database_id", value.database_id)
.build()
}
}
impl TypeOption for RelationTypeOption {
type CellData = RelationCellData;
type CellChangeset = RelationCellChangeset;
type CellProtobufType = RelationCellDataPB;
type CellFilter = RelationFilterPB;
}
impl CellDataChangeset for RelationTypeOption {
fn apply_changeset(
&self,
changeset: RelationCellChangeset,
cell: Option<Cell>,
) -> FlowyResult<(Cell, RelationCellData)> {
if cell.is_none() {
let cell_data = RelationCellData {
row_ids: changeset.inserted_row_ids,
};
return Ok(((&cell_data).into(), cell_data));
}
let cell_data: RelationCellData = cell.unwrap().as_ref().into();
let mut row_ids = cell_data.row_ids.clone();
for inserted in changeset.inserted_row_ids.iter() {
if row_ids.iter().any(|row_id| row_id == inserted) {
row_ids.push(inserted.clone())
}
}
for removed_id in changeset.removed_row_ids.iter() {
if let Some(index) = row_ids.iter().position(|row_id| row_id == removed_id) {
row_ids.remove(index);
}
}
let cell_data = RelationCellData { row_ids };
Ok(((&cell_data).into(), cell_data))
}
}
impl CellDataDecoder for RelationTypeOption {
fn decode_cell(
&self,
cell: &Cell,
decoded_field_type: &FieldType,
_field: &Field,
) -> FlowyResult<RelationCellData> {
if !decoded_field_type.is_relation() {
return Ok(Default::default());
}
Ok(cell.into())
}
fn stringify_cell_data(&self, cell_data: RelationCellData) -> String {
cell_data.to_string()
}
fn stringify_cell(&self, cell: &Cell) -> String {
let cell_data = RelationCellData::from(cell);
cell_data.to_string()
}
fn numeric_cell(&self, _cell: &Cell) -> Option<f64> {
None
}
}
impl TypeOptionCellDataCompare for RelationTypeOption {
fn apply_cmp(
&self,
_cell_data: &RelationCellData,
_other_cell_data: &RelationCellData,
_sort_condition: SortCondition,
) -> Ordering {
default_order()
}
}
impl TypeOptionCellDataFilter for RelationTypeOption {
fn apply_filter(
&self,
_filter: &RelationFilterPB,
_field_type: &FieldType,
_cell_data: &RelationCellData,
) -> bool {
true
}
}
impl TypeOptionTransform for RelationTypeOption {
fn transformable(&self) -> bool {
false
}
fn transform_type_option(
&mut self,
_old_type_option_field_type: FieldType,
_old_type_option_data: TypeOptionData,
) {
}
fn transform_type_option_cell(
&self,
_cell: &Cell,
_transformed_field_type: &FieldType,
_field: &Field,
) -> Option<RelationCellData> {
None
}
}
impl TypeOptionCellDataSerde for RelationTypeOption {
fn protobuf_encode(&self, cell_data: RelationCellData) -> RelationCellDataPB {
cell_data.into()
}
fn parse_cell(&self, cell: &Cell) -> FlowyResult<RelationCellData> {
Ok(cell.into())
}
}

View File

@ -0,0 +1,85 @@
use std::sync::Arc;
use collab::preclude::Any;
use collab_database::rows::{new_cell_builder, Cell, RowId};
use crate::entities::FieldType;
use crate::services::field::{TypeOptionCellData, CELL_DATA};
#[derive(Debug, Clone, Default)]
pub struct RelationCellData {
pub row_ids: Vec<RowId>,
}
impl From<&Cell> for RelationCellData {
fn from(value: &Cell) -> Self {
let row_ids = match value.get(CELL_DATA) {
Some(Any::Array(array)) => array
.iter()
.flat_map(|item| {
if let Any::String(string) = item {
Some(RowId::from(string.clone().to_string()))
} else {
None
}
})
.collect(),
_ => vec![],
};
Self { row_ids }
}
}
impl From<&RelationCellData> for Cell {
fn from(value: &RelationCellData) -> Self {
let data = Any::Array(Arc::from(
value
.row_ids
.clone()
.into_iter()
.map(|id| Any::String(Arc::from(id.to_string())))
.collect::<Vec<_>>(),
));
new_cell_builder(FieldType::Relation)
.insert_any(CELL_DATA, data)
.build()
}
}
impl From<String> for RelationCellData {
fn from(s: String) -> Self {
if s.is_empty() {
return RelationCellData { row_ids: vec![] };
}
let ids = s
.split(", ")
.map(|id| id.to_string().into())
.collect::<Vec<_>>();
RelationCellData { row_ids: ids }
}
}
impl TypeOptionCellData for RelationCellData {
fn is_cell_empty(&self) -> bool {
self.row_ids.is_empty()
}
}
impl ToString for RelationCellData {
fn to_string(&self) -> String {
self
.row_ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(", ")
}
}
#[derive(Debug, Clone, Default)]
pub struct RelationCellChangeset {
pub inserted_row_ids: Vec<RowId>,
pub removed_row_ids: Vec<RowId>,
}

View File

@ -10,14 +10,14 @@ use flowy_error::FlowyResult;
use crate::entities::{
CheckboxTypeOptionPB, ChecklistTypeOptionPB, DateTypeOptionPB, FieldType,
MultiSelectTypeOptionPB, NumberTypeOptionPB, RichTextTypeOptionPB, SingleSelectTypeOptionPB,
TimestampTypeOptionPB, URLTypeOptionPB,
MultiSelectTypeOptionPB, NumberTypeOptionPB, RelationTypeOptionPB, RichTextTypeOptionPB,
SingleSelectTypeOptionPB, TimestampTypeOptionPB, URLTypeOptionPB,
};
use crate::services::cell::CellDataDecoder;
use crate::services::field::checklist_type_option::ChecklistTypeOption;
use crate::services::field::{
CheckboxTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, RichTextTypeOption,
SingleSelectTypeOption, TimestampTypeOption, URLTypeOption,
CheckboxTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, RelationTypeOption,
RichTextTypeOption, SingleSelectTypeOption, TimestampTypeOption, URLTypeOption,
};
use crate::services::filter::FromFilterString;
use crate::services::sort::SortCondition;
@ -202,6 +202,9 @@ pub fn type_option_data_from_pb<T: Into<Bytes>>(
FieldType::Checklist => {
ChecklistTypeOptionPB::try_from(bytes).map(|pb| ChecklistTypeOption::from(pb).into())
},
FieldType::Relation => {
RelationTypeOptionPB::try_from(bytes).map(|pb| RelationTypeOption::from(pb).into())
},
}
}
@ -257,6 +260,12 @@ pub fn type_option_to_pb(type_option: TypeOptionData, field_type: &FieldType) ->
.try_into()
.unwrap()
},
FieldType::Relation => {
let relation_type_option: RelationTypeOption = type_option.into();
RelationTypeOptionPB::from(relation_type_option)
.try_into()
.unwrap()
},
}
}
@ -276,5 +285,6 @@ pub fn default_type_option_data_from_type(field_type: FieldType) -> TypeOptionDa
FieldType::Checkbox => CheckboxTypeOption::default().into(),
FieldType::URL => URLTypeOption::default().into(),
FieldType::Checklist => ChecklistTypeOption.into(),
FieldType::Relation => RelationTypeOption::default().into(),
}
}

View File

@ -14,9 +14,10 @@ use crate::services::cell::{
};
use crate::services::field::checklist_type_option::ChecklistTypeOption;
use crate::services::field::{
CheckboxTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, RichTextTypeOption,
SingleSelectTypeOption, TimestampTypeOption, TypeOption, TypeOptionCellDataCompare,
TypeOptionCellDataFilter, TypeOptionCellDataSerde, TypeOptionTransform, URLTypeOption,
CheckboxTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, RelationTypeOption,
RichTextTypeOption, SingleSelectTypeOption, TimestampTypeOption, TypeOption,
TypeOptionCellDataCompare, TypeOptionCellDataFilter, TypeOptionCellDataSerde,
TypeOptionTransform, URLTypeOption,
};
use crate::services::sort::SortCondition;
@ -490,6 +491,16 @@ impl<'a> TypeOptionCellExt<'a> {
self.cell_data_cache.clone(),
)
}),
FieldType::Relation => self
.field
.get_type_option::<RelationTypeOption>(field_type)
.map(|type_option| {
TypeOptionCellDataHandlerImpl::new_with_boxed(
type_option,
self.cell_filter_cache.clone(),
self.cell_data_cache.clone(),
)
}),
}
}
}
@ -568,6 +579,9 @@ fn get_type_option_transform_handler(
FieldType::Checklist => {
Box::new(ChecklistTypeOption::from(type_option_data)) as Box<dyn TypeOptionTransformHandler>
},
FieldType::Relation => {
Box::new(RelationTypeOption::from(type_option_data)) as Box<dyn TypeOptionTransformHandler>
},
}
}

View File

@ -358,6 +358,12 @@ impl FilterController {
.write()
.insert(field_id, ChecklistFilterPB::from_filter(filter.as_ref()));
},
FieldType::Relation => {
self
.cell_filter_cache
.write()
.insert(field_id, RelationFilterPB::from_filter(filter.as_ref()));
},
}
}
}

View File

@ -3,7 +3,8 @@ use std::time::Duration;
use flowy_database2::entities::FieldType;
use flowy_database2::services::field::{
ChecklistCellChangeset, DateCellChangeset, DateCellData, MultiSelectTypeOption,
SelectOptionCellChangeset, SingleSelectTypeOption, StrCellData, URLCellData,
RelationCellChangeset, SelectOptionCellChangeset, SingleSelectTypeOption, StrCellData,
URLCellData,
};
use lib_infra::box_any::BoxAny;
@ -52,6 +53,10 @@ async fn grid_cell_update() {
}),
FieldType::Checkbox => BoxAny::new("1".to_string()),
FieldType::URL => BoxAny::new("1".to_string()),
FieldType::Relation => BoxAny::new(RelationCellChangeset {
inserted_row_ids: vec!["abcdefabcdef".to_string().into()],
..Default::default()
}),
_ => BoxAny::new("".to_string()),
};

View File

@ -5,8 +5,8 @@ use strum::IntoEnumIterator;
use flowy_database2::entities::FieldType;
use flowy_database2::services::field::checklist_type_option::ChecklistTypeOption;
use flowy_database2::services::field::{
DateFormat, DateTypeOption, FieldBuilder, MultiSelectTypeOption, SelectOption, SelectOptionColor,
SingleSelectTypeOption, TimeFormat, TimestampTypeOption,
DateFormat, DateTypeOption, FieldBuilder, MultiSelectTypeOption, RelationTypeOption,
SelectOption, SelectOptionColor, SingleSelectTypeOption, TimeFormat, TimestampTypeOption,
};
use flowy_database2::services::field_settings::default_field_settings_for_fields;
use flowy_database2::services::setting::BoardLayoutSetting;
@ -126,6 +126,16 @@ pub fn make_test_board() -> DatabaseData {
.build();
fields.push(checklist_field);
},
FieldType::Relation => {
let type_option = RelationTypeOption {
database_id: "".to_string(),
};
let relation_field = FieldBuilder::new(field_type, type_option)
.name("Related")
.visibility(true)
.build();
fields.push(relation_field);
},
}
}
@ -227,7 +237,6 @@ pub fn make_test_board() -> DatabaseData {
FieldType::SingleSelect => {
row_builder.insert_single_select_cell(|mut options| options.remove(2))
},
FieldType::Checkbox => row_builder.insert_checkbox_cell("false"),
_ => "".to_owned(),
};

View File

@ -3,10 +3,10 @@ use collab_database::views::{DatabaseLayout, DatabaseView};
use strum::IntoEnumIterator;
use flowy_database2::entities::FieldType;
use flowy_database2::services::field::checklist_type_option::ChecklistTypeOption;
use flowy_database2::services::field::{
DateFormat, DateTypeOption, FieldBuilder, MultiSelectTypeOption, NumberFormat, NumberTypeOption,
SelectOption, SelectOptionColor, SingleSelectTypeOption, TimeFormat, TimestampTypeOption,
ChecklistTypeOption, DateFormat, DateTypeOption, FieldBuilder, MultiSelectTypeOption,
NumberFormat, NumberTypeOption, RelationTypeOption, SelectOption, SelectOptionColor,
SingleSelectTypeOption, TimeFormat, TimestampTypeOption,
};
use flowy_database2::services::field_settings::default_field_settings_for_fields;
@ -128,6 +128,16 @@ pub fn make_test_grid() -> DatabaseData {
.build();
fields.push(checklist_field);
},
FieldType::Relation => {
let type_option = RelationTypeOption {
database_id: "".to_string(),
};
let relation_field = FieldBuilder::new(field_type, type_option)
.name("Related")
.visibility(true)
.build();
fields.push(relation_field);
},
}
}

View File

@ -27,14 +27,14 @@ async fn export_csv_test() {
let test = DatabaseEditorTest::new_grid().await;
let database = test.editor.clone();
let s = database.export_csv(CSVFormat::Original).await.unwrap();
let expected = r#"Name,Price,Time,Status,Platform,is urgent,link,TODO,Last Modified,Created At
A,$1,2022/03/14,,"Google,Facebook",Yes,AppFlowy website - https://www.appflowy.io,First thing,,
,$2,2022/03/14,,"Google,Twitter",Yes,,"Have breakfast,Have lunch,Take a nap,Have dinner,Shower and head to bed",,
C,$3,2022/03/14,Completed,"Facebook,Google,Twitter",No,,,,
DA,$14,2022/11/17,Completed,,No,,Task 1,,
AE,,2022/11/13,Planned,"Facebook,Twitter",No,,,,
AE,$5,2022/12/25,Planned,Facebook,Yes,,"Sprint,Sprint some more,Rest",,
CB,,,,,,,,,
let expected = r#"Name,Price,Time,Status,Platform,is urgent,link,TODO,Last Modified,Created At,Related
A,$1,2022/03/14,,"Google,Facebook",Yes,AppFlowy website - https://www.appflowy.io,First thing,,,
,$2,2022/03/14,,"Google,Twitter",Yes,,"Have breakfast,Have lunch,Take a nap,Have dinner,Shower and head to bed",,,
C,$3,2022/03/14,Completed,"Facebook,Google,Twitter",No,,,,,
DA,$14,2022/11/17,Completed,,No,,Task 1,,,
AE,,2022/11/13,Planned,"Facebook,Twitter",No,,,,,
AE,$5,2022/12/25,Planned,Facebook,Yes,,"Sprint,Sprint some more,Rest",,,
CB,,,,,,,,,,
"#;
println!("{}", s);
assert_eq!(s, expected);
@ -99,6 +99,7 @@ async fn export_and_then_import_meta_csv_test() {
FieldType::Checklist => {},
FieldType::LastEditedTime => {},
FieldType::CreatedTime => {},
FieldType::Relation => {},
}
} else {
panic!(
@ -180,6 +181,7 @@ async fn history_database_import_test() {
FieldType::Checklist => {},
FieldType::LastEditedTime => {},
FieldType::CreatedTime => {},
FieldType::Relation => {},
}
} else {
panic!(