diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart new file mode 100644 index 0000000000..2e56e1691a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart @@ -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 { + RelationCellBloc({required this.cellController}) + : super(RelationCellState.initial()) { + _dispatch(); + _startListening(); + _init(); + } + + final RelationCellController cellController; + void Function()? _onCellChangedFn; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener(_onCellChangedFn!); + _onCellChangedFn = null; + } + return super.close(); + } + + void _dispatch() { + on( + (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 []; + }, + ); + 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 _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 rows, + }) = _RelationCellState; + + factory RelationCellState.initial() => + const RelationCellState(relatedDatabaseId: "", rows: []); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_row_search_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_row_search_bloc.dart new file mode 100644 index 0000000000..995e5c85d4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_row_search_bloc.dart @@ -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 { + RelationRowSearchBloc({ + required this.databaseId, + }) : super(RelationRowSearchState.initial()) { + _dispatch(); + _init(); + } + + final String databaseId; + final List allRows = []; + + void _dispatch() { + on( + (event, emit) { + event.when( + didUpdateRowList: (List rowList) { + allRows.clear(); + allRows.addAll(rowList); + emit(state.copyWith(filteredRows: allRows)); + }, + updateFilter: (String filter) => _updateFilter(filter, emit), + ); + }, + ); + } + + Future _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 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 rowList, + ) = _DidUpdateRowList; + const factory RelationRowSearchEvent.updateFilter(String filter) = + _UpdateFilter; +} + +@freezed +class RelationRowSearchState with _$RelationRowSearchState { + const factory RelationRowSearchState({ + required String filter, + required List filteredRows, + }) = _RelationRowSearchState; + + factory RelationRowSearchState.initial() => const RelationRowSearchState( + filter: "", + filteredRows: [], + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart index 63e9324f4a..881e6e164b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart @@ -14,6 +14,7 @@ typedef ChecklistCellController = CellController; typedef DateCellController = CellController; typedef TimestampCellController = CellController; typedef URLCellController = CellController; +typedef RelationCellController = CellController; 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; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart index b05a4abdb3..c5502bd8b1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart @@ -133,3 +133,10 @@ class URLCellDataParser implements CellDataParser { return URLCellDataPB.fromBuffer(data); } } + +class RelationCellDataParser implements CellDataParser { + @override + RelationCellDataPB? parserData(List data) { + return data.isEmpty ? null : RelationCellDataPB.fromBuffer(data); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/relation_cell_service.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/relation_cell_service.dart new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/type_option_data_parser.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/type_option_data_parser.dart index 8e91d9e15c..b49b3a80df 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/type_option_data_parser.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/type_option_data_parser.dart @@ -72,3 +72,11 @@ class ChecklistTypeOptionDataParser return ChecklistTypeOptionPB.fromBuffer(buffer); } } + +class RelationTypeOptionDataParser + extends TypeOptionParser { + @override + RelationTypeOptionPB fromBuffer(List buffer) { + return RelationTypeOptionPB.fromBuffer(buffer); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart index 6f6f078536..12dd22f266 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart @@ -144,9 +144,9 @@ class GridCreateFilterBloc fieldId: fieldId, condition: TextFilterConditionPB.Contains, ); + default: + throw UnimplementedError(); } - - return FlowyResult.success(null); } @override diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_list.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_list.dart index c57494615c..3cc9bd1c72 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_list.dart @@ -9,6 +9,19 @@ import '../../layout/sizes.dart'; typedef SelectFieldCallback = void Function(FieldType); +const List _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) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/builder.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/builder.dart index 7b3389c834..88d81ab5db 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/builder.dart @@ -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(), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/relation.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/relation.dart new file mode 100644 index 0000000000..e848200e4b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/relation.dart @@ -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 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> 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>((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], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart index 9e4c4b3fd5..ae52567208 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart @@ -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, }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart new file mode 100644 index 0000000000..b4619a7f47 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart @@ -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 { + const RelationCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _RelationCellState(); +} + +class _RelationCellState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) { + return RelationCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + }, + child: BlocBuilder( + 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, + ), + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart index 85101e9197..2ea0f5ac08 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart @@ -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, + ), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart index a7cef2fe6b..1b229f76f0 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart @@ -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, + ), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart index d4c9bc57fc..7c5d3be1be 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart @@ -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, + ), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart new file mode 100644 index 0000000000..f18717b58d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart @@ -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(), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart new file mode 100644 index 0000000000..e545718080 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart @@ -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( + builder: (context, state) => RelationCellEditor( + selectedRowIds: state.rows.map((row) => row.rowId).toList(), + databaseId: state.relatedDatabaseId, + onSelectRow: (rowId) { + context + .read() + .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(), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart index 8348232f4a..c70dedd68e 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart @@ -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) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart new file mode 100644 index 0000000000..4e39900abf --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart @@ -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 createState() => _RelationCellState(); +} + +class _RelationCellState extends GridCellState { + 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( + 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() => ""; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_relation_cell.dart new file mode 100644 index 0000000000..0e411440ef --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_relation_cell.dart @@ -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"); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_relation_cell.dart new file mode 100644 index 0000000000..eebb3e1c75 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_relation_cell.dart @@ -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(), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart new file mode 100644 index 0000000000..18182c6fb3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart @@ -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 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( + create: (context) => RelationRowSearchBloc( + databaseId: databaseId, + ), + child: BlocBuilder( + builder: (context, cellState) { + return BlocBuilder( + 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() + .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], + ), + ), + ], + ); + }, + ); + }, + ), + ); + } +} 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 2879bc8887..3f1d2a6ac1 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 @@ -75,6 +75,7 @@ class _SelectOptionTextFieldState extends State { @override void dispose() { widget.textController.removeListener(_onChanged); + focusNode.dispose(); super.dispose(); } diff --git a/frontend/appflowy_flutter/lib/util/field_type_extension.dart b/frontend/appflowy_flutter/lib/util/field_type_extension.dart index 4b88244c79..67093f401e 100644 --- a/frontend/appflowy_flutter/lib/util/field_type_extension.dart +++ b/frontend/appflowy_flutter/lib/util/field_type_extension.dart @@ -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(), }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-relation.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-relation.svg new file mode 100644 index 0000000000..f82a41d226 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-relation.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeText.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeText.tsx index 41dc5286e3..daae232fde 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeText.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeText.tsx @@ -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'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/ProppertyTypeSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/ProppertyTypeSvg.tsx index e2710b06cc..7ee4e6f83d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/ProppertyTypeSvg.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/ProppertyTypeSvg.tsx @@ -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.RichText]: TextSvg, @@ -21,6 +22,7 @@ export const FieldTypeSvgMap: Record [FieldType.Checklist]: ChecklistSvg, [FieldType.LastEditedTime]: LastEditedTimeSvg, [FieldType.CreatedTime]: LastEditedTimeSvg, + [FieldType.Relation]: RelationSvg, }; export const ProppertyTypeSvg: FC<{ type: FieldType; className?: string }> = memo(({ type, ...props }) => { diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 5239a36635..3c6bf76dac 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -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", diff --git a/frontend/rust-lib/event-integration/src/database_event.rs b/frontend/rust-lib/event-integration/src/database_event.rs index ec25fafef2..3f2a84a34c 100644 --- a/frontend/rust-lib/event-integration/src/database_event.rs +++ b/frontend/rust-lib/event-integration/src/database_event.rs @@ -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::() .items } + + pub async fn update_relation_cell( + &self, + changeset: RelationCellChangesetPB, + ) -> Option { + 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, + ) -> Vec { + EventBuilder::new(self.clone()) + .event(DatabaseEvent::GetRelatedRowDatas) + .payload(RepeatedRowIdPB { + database_id, + row_ids, + }) + .async_send() + .await + .parse::() + .rows + } } diff --git a/frontend/rust-lib/event-integration/tests/database/local_test/test.rs b/frontend/rust-lib/event-integration/tests/database/local_test/test.rs index c05b00a8b8..6849e0a8a3 100644 --- a/frontend/rust-lib/event-integration/tests/database/local_test/test.rs +++ b/frontend/rust-lib/event-integration/tests/database/local_test/test.rs @@ -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(¤t_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(¤t_workspace.id, "origin".to_owned(), vec![]) + .await; + let relation_grid_view = test + .create_grid(¤t_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"); +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs index 3a718cef29..9002ca295e 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs @@ -69,6 +69,12 @@ impl AsRef for DatabaseIdPB { } } +#[derive(Clone, ProtoBuf, Default, Debug)] +pub struct RepeatedDatabaseIdPB { + #[pb(index = 1)] + pub value: Vec, +} + #[derive(Clone, ProtoBuf, Default, Debug, Validate)] pub struct DatabaseViewIdPB { #[pb(index = 1)] diff --git a/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs index 5b5cba5582..2db4ffb1b8 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs @@ -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() } diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs index d628a13801..7840bd4ff6 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs @@ -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::*; diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/relation_filter.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/relation_filter.rs new file mode 100644 index 0000000000..7c6e6ce11b --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/relation_filter.rs @@ -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 } + } +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs index 4d2d9283c7..cdeef0401c 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs @@ -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 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 { diff --git a/frontend/rust-lib/flowy-database2/src/entities/macros.rs b/frontend/rust-lib/flowy-database2/src/entities/macros.rs index 03b9b2021d..520e8305b5 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/macros.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/macros.rs @@ -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 }, } diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs index 45e7bfb8a3..f97afeb75b 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs @@ -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::*; diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/relation_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/relation_entities.rs new file mode 100644 index 0000000000..bebcb6189e --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/relation_entities.rs @@ -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, +} + +impl From for RelationCellDataPB { + fn from(data: RelationCellData) -> Self { + Self { + row_ids: data.row_ids.into_iter().map(Into::into).collect(), + } + } +} + +impl From 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, + + #[pb(index = 4)] + pub removed_row_ids: Vec, +} + +#[derive(Clone, Debug, Default, ProtoBuf)] +pub struct RelationTypeOptionPB { + #[pb(index = 1)] + pub database_id: String, +} + +impl From for RelationTypeOptionPB { + fn from(value: RelationTypeOption) -> Self { + RelationTypeOptionPB { + database_id: value.database_id, + } + } +} + +impl From 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, +} + +#[derive(Debug, Default, Clone, ProtoBuf)] +pub struct RepeatedRowIdPB { + #[pb(index = 1)] + pub database_id: String, + + #[pb(index = 2)] + pub row_ids: Vec, +} diff --git a/frontend/rust-lib/flowy-database2/src/event_handler.rs b/frontend/rust-lib/flowy-database2/src/event_handler.rs index c61b89b583..e14908b090 100644 --- a/frontend/rust-lib/flowy-database2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-database2/src/event_handler.rs @@ -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, + _manager: AFPluginState>, +) -> FlowyResult<()> { + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn update_relation_cell_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> 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(¶ms) + // .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, + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let params: RepeatedRowIdPB = data.into_inner(); + let database_editor = manager.get_database(¶ms.database_id).await?; + let row_datas = database_editor + .get_related_rows(Some(¶ms.row_ids)) + .await?; + + data_result_ok(RepeatedRelatedRowDataPB { rows: row_datas }) +} + +pub(crate) async fn get_related_database_rows_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + 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 }) +} diff --git a/frontend/rust-lib/flowy-database2/src/event_map.rs b/frontend/rust-lib/flowy-database2/src/event_map.rs index 94ab6d49ed..17e9c68ff5 100644 --- a/frontend/rust-lib/flowy-database2/src/event_map.rs +++ b/frontend/rust-lib/flowy-database2/src/event_map.rs @@ -83,6 +83,11 @@ pub fn init(database_manager: Weak) -> 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, } diff --git a/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs b/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs index d669ec6ff0..123ce57cb5 100644 --- a/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs +++ b/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs @@ -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()); + }, } } } diff --git a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs index ab62f80286..acd99968b0 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs @@ -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 { + 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::(FieldType::Relation) + .ok_or(FlowyError::record_not_found())?; + + Ok(type_option.database_id) + } + + pub async fn get_related_rows( + &self, + row_ids: Option<&Vec>, + ) -> FlowyResult> { + 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::>() + }; + + Ok(row_data) + } + fn get_auto_updated_fields(&self, view_id: &str) -> Vec { self .database diff --git a/frontend/rust-lib/flowy-database2/src/services/field/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/mod.rs index 5df564a7da..72cc377c60 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/mod.rs @@ -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::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs index 075c5c41f2..f4cd13d020 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs @@ -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::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/mod.rs new file mode 100644 index 0000000000..4ae30a6589 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/mod.rs @@ -0,0 +1,5 @@ +mod relation; +mod relation_entities; + +pub use relation::*; +pub use relation_entities::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation.rs new file mode 100644 index 0000000000..cda3d23b61 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation.rs @@ -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 for RelationTypeOption { + fn from(value: TypeOptionData) -> Self { + let database_id = value.get_str_value("database_id").unwrap_or_default(); + Self { database_id } + } +} + +impl From 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, + ) -> 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 { + 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 { + 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 { + None + } +} + +impl TypeOptionCellDataSerde for RelationTypeOption { + fn protobuf_encode(&self, cell_data: RelationCellData) -> RelationCellDataPB { + cell_data.into() + } + + fn parse_cell(&self, cell: &Cell) -> FlowyResult { + Ok(cell.into()) + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation_entities.rs new file mode 100644 index 0000000000..97b18590af --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation_entities.rs @@ -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, +} + +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::>(), + )); + new_cell_builder(FieldType::Relation) + .insert_any(CELL_DATA, data) + .build() + } +} + +impl From 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::>(); + + 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::>() + .join(", ") + } +} + +#[derive(Debug, Clone, Default)] +pub struct RelationCellChangeset { + pub inserted_row_ids: Vec, + pub removed_row_ids: Vec, +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs index 4c47748c47..d8ace8c2a6 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs @@ -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>( 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(), } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs index f98bcd0cc0..4d144cf0d2 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs @@ -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::(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 }, + FieldType::Relation => { + Box::new(RelationTypeOption::from(type_option_data)) as Box + }, } } diff --git a/frontend/rust-lib/flowy-database2/src/services/filter/controller.rs b/frontend/rust-lib/flowy-database2/src/services/filter/controller.rs index 983dfef1a8..122b714ee6 100644 --- a/frontend/rust-lib/flowy-database2/src/services/filter/controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/filter/controller.rs @@ -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())); + }, } } } diff --git a/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs b/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs index 3a31446374..2049e58153 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs @@ -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()), }; diff --git a/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs b/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs index f08335c8a3..318e6579d8 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs @@ -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(), }; diff --git a/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs b/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs index 4fd1f5fb8f..e87cec40e6 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs @@ -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); + }, } } diff --git a/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs b/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs index 8101e85748..34ef732f39 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs @@ -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!(