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/package.json b/frontend/appflowy_tauri/package.json index 87249b6f2c..6f8ed203a0 100644 --- a/frontend/appflowy_tauri/package.json +++ b/frontend/appflowy_tauri/package.json @@ -73,6 +73,7 @@ "slate-history": "^0.100.0", "slate-react": "^0.101.3", "ts-results": "^3.3.0", + "unsplash-js": "^7.0.19", "utf8": "^3.0.0", "valtio": "^1.12.1", "yjs": "^13.5.51" diff --git a/frontend/appflowy_tauri/pnpm-lock.yaml b/frontend/appflowy_tauri/pnpm-lock.yaml index b2b708cb99..a6d544c10a 100644 --- a/frontend/appflowy_tauri/pnpm-lock.yaml +++ b/frontend/appflowy_tauri/pnpm-lock.yaml @@ -166,6 +166,9 @@ dependencies: ts-results: specifier: ^3.3.0 version: 3.3.0 + unsplash-js: + specifier: ^7.0.19 + version: 7.0.19 utf8: specifier: ^3.0.0 version: 3.0.0 @@ -6853,6 +6856,11 @@ packages: engines: {node: '>= 10.0.0'} dev: true + /unsplash-js@7.0.19: + resolution: {integrity: sha512-j6qT2floy5Q2g2d939FJpwey1yw/GpQecFiSouyJtsHQPj3oqmqq3K4rI+GF8vU1zwGCT7ZwIGQd2dtCQLjYJw==} + engines: {node: '>=10'} + dev: false + /update-browserslist-db@1.0.11(browserslist@4.21.5): resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==} hasBin: true diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts index 3d187336d2..7e4ac9e636 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts @@ -109,6 +109,22 @@ export interface MathEquationNode extends Element { } & BlockData; } +export enum ImageType { + Internal = 1, + External = 2, +} + +export interface ImageNode extends Element { + type: EditorNodeType.ImageBlock; + blockId: string; + data: { + url?: string; + width?: number; + image_type?: ImageType; + height?: number; + } & BlockData; +} + export interface FormulaNode extends Element { type: EditorInlineNodeType.Formula; data: string; 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/assets/image.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/image.svg new file mode 100644 index 0000000000..3e86e21b8d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/image.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Colors.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Colors.tsx new file mode 100644 index 0000000000..af3a91b3b9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Colors.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export function Colors() { + return
; +} + +export default Colors; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/EmbedLink.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/EmbedLink.tsx new file mode 100644 index 0000000000..40a46fed81 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/EmbedLink.tsx @@ -0,0 +1,61 @@ +import React, { useCallback, useState } from 'react'; +import TextField from '@mui/material/TextField'; +import { useTranslation } from 'react-i18next'; +import { pattern } from '$app/utils/open_url'; +import Button from '@mui/material/Button'; + +export function EmbedLink({ onDone, onEscape }: { onDone?: (value: string) => void; onEscape?: () => void }) { + const { t } = useTranslation(); + + const [value, setValue] = useState(''); + const [error, setError] = useState(false); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + + setValue(value); + setError(!pattern.test(value)); + }, + [setValue, setError] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !error && value) { + e.preventDefault(); + e.stopPropagation(); + onDone?.(value); + } + + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onEscape?.(); + } + }, + [error, onDone, onEscape, value] + ); + + return ( +
+ + +
+ ); +} + +export default EmbedLink; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Unsplash.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Unsplash.tsx new file mode 100644 index 0000000000..01da8323b9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Unsplash.tsx @@ -0,0 +1,154 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { createApi } from 'unsplash-js'; +import TextField from '@mui/material/TextField'; +import { useTranslation } from 'react-i18next'; +import Typography from '@mui/material/Typography'; +import debounce from 'lodash-es/debounce'; +import { CircularProgress } from '@mui/material'; +import { open } from '@tauri-apps/api/shell'; + +const unsplash = createApi({ + accessKey: '1WxD1JpMOUX86lZKKob4Ca0LMZPyO2rUmAgjpWm9Ids', +}); + +const SEARCH_DEBOUNCE_TIME = 500; + +export function Unsplash({ onDone, onEscape }: { onDone?: (value: string) => void; onEscape?: () => void }) { + const { t } = useTranslation(); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [photos, setPhotos] = useState< + { + thumb: string; + regular: string; + alt: string | null; + id: string; + user: { + name: string; + link: string; + }; + }[] + >([]); + const [searchValue, setSearchValue] = useState(''); + + const handleChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + + setSearchValue(value); + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onEscape?.(); + } + }, + [onEscape] + ); + + const debounceSearchPhotos = useMemo(() => { + return debounce(async (searchValue: string) => { + const request = searchValue + ? unsplash.search.getPhotos({ query: searchValue ?? undefined, perPage: 32 }) + : unsplash.photos.list({ perPage: 32 }); + + setError(''); + setLoading(true); + await request.then((result) => { + if (result.errors) { + setError(result.errors[0]); + } else { + setPhotos( + result.response.results.map((photo) => ({ + id: photo.id, + thumb: photo.urls.thumb, + regular: photo.urls.regular, + alt: photo.alt_description, + user: { + name: photo.user.name, + link: photo.user.links.html, + }, + })) + ); + } + + setLoading(false); + }); + }, SEARCH_DEBOUNCE_TIME); + }, []); + + useEffect(() => { + void debounceSearchPhotos(searchValue); + return () => { + debounceSearchPhotos.cancel(); + }; + }, [debounceSearchPhotos, searchValue]); + + return ( +
+ + + {loading ? ( +
+ +
{t('editor.loading')}
+
+ ) : error ? ( + + {error} + + ) : ( +
+ {photos.length > 0 ? ( + <> +
+ {photos.map((photo) => ( +
+ { + onDone?.(photo.regular); + }} + src={photo.thumb} + alt={photo.alt ?? ''} + className={'h-20 w-32 rounded object-cover hover:opacity-80'} + /> +
+ by{' '} + { + void open(photo.user.link); + }} + className={'underline hover:text-function-info'} + > + {photo.user.name} + +
+
+ ))} +
+ + {t('findAndReplace.searchMore')} + + + ) : ( + + {t('findAndReplace.noResult')} + + )} +
+ )} +
+ ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadImage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadImage.tsx new file mode 100644 index 0000000000..7f3d828149 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadImage.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export function UploadImage() { + return
; +} + +export default UploadImage; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/index.ts new file mode 100644 index 0000000000..f2eab1116b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/index.ts @@ -0,0 +1,4 @@ +export * from './Unsplash'; +export * from './UploadImage'; +export * from './EmbedLink'; +export * from './Colors'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts index bb531ba551..7554c21bb0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts @@ -30,7 +30,9 @@ function getOffsetLeft( height: number; width: number; }, - horizontal: number | 'center' | 'left' | 'right' + paperWidth: number, + horizontal: number | 'center' | 'left' | 'right', + transformHorizontal: number | 'center' | 'left' | 'right' ) { let offset = 0; @@ -42,6 +44,12 @@ function getOffsetLeft( offset = rect.width; } + if (transformHorizontal === 'center') { + offset -= paperWidth / 2; + } else if (transformHorizontal === 'right') { + offset -= paperWidth; + } + return offset; } @@ -50,7 +58,9 @@ function getOffsetTop( height: number; width: number; }, - vertical: number | 'center' | 'bottom' | 'top' + papertHeight: number, + vertical: number | 'center' | 'bottom' | 'top', + transformVertical: number | 'center' | 'bottom' | 'top' ) { let offset = 0; @@ -62,6 +72,12 @@ function getOffsetTop( offset = rect.height; } + if (transformVertical === 'center') { + offset -= papertHeight / 2; + } else if (transformVertical === 'bottom') { + offset -= papertHeight; + } + return offset; } @@ -122,8 +138,12 @@ const usePopoverAutoPosition = ({ }; // calculate new paper width - const newLeft = anchorRect.left + getOffsetLeft(anchorRect, initialAnchorOrigin.horizontal); - const newTop = anchorRect.top + getOffsetTop(anchorRect, initialAnchorOrigin.vertical); + const newLeft = + anchorRect.left + + getOffsetLeft(anchorRect, newPaperWidth, initialAnchorOrigin.horizontal, initialTransformOrigin.horizontal); + const newTop = + anchorRect.top + + getOffsetTop(anchorRect, newPaperHeight, initialAnchorOrigin.vertical, initialTransformOrigin.vertical); let isExceedViewportRight = false; let isExceedViewportBottom = false; 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/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts index 40b4a6c458..a8135da1cc 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts @@ -30,6 +30,7 @@ import { ToggleListNode, inlineNodeTypes, FormulaNode, + ImageNode, } from '$app/application/document/document.types'; import cloneDeep from 'lodash-es/cloneDeep'; import { generateId } from '$app/components/editor/provider/utils/convert'; @@ -39,6 +40,7 @@ export const EmbedTypes: string[] = [ EditorNodeType.DividerBlock, EditorNodeType.EquationBlock, EditorNodeType.GridBlock, + EditorNodeType.ImageBlock, ]; export const CustomEditor = { @@ -120,7 +122,7 @@ export const CustomEditor = { at: path, }); Transforms.insertNodes(editor, cloneNode, { at: path }); - return; + return cloneNode; } const isListType = LIST_TYPES.includes(cloneNode.type as EditorNodeType); @@ -148,6 +150,8 @@ export const CustomEditor = { if (selection) { editor.select(selection); } + + return cloneNode; }, tabForward, tabBackward, @@ -346,6 +350,19 @@ export const CustomEditor = { Transforms.setNodes(editor, newProperties, { at: path }); }, + setImageBlockData(editor: ReactEditor, node: Element, newData: ImageNode['data']) { + const path = ReactEditor.findPath(editor, node); + const data = node.data || {}; + const newProperties = { + data: { + ...data, + ...newData, + }, + } as Partial; + + Transforms.setNodes(editor, newProperties, { at: path }); + }, + cloneBlock(editor: ReactEditor, block: Element): Element { const cloneNode: Element = { ...cloneDeep(block), diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageActions.tsx new file mode 100644 index 0000000000..b3d3575af2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageActions.tsx @@ -0,0 +1,163 @@ +import React, { useMemo, useState } from 'react'; +import { ImageNode } from '$app/application/document/document.types'; +import { ReactComponent as CopyIcon } from '$app/assets/copy.svg'; +import { ReactComponent as AlignLeftIcon } from '$app/assets/align-left.svg'; +import { ReactComponent as AlignCenterIcon } from '$app/assets/align-center.svg'; +import { ReactComponent as AlignRightIcon } from '$app/assets/align-right.svg'; +import { ReactComponent as DeleteIcon } from '$app/assets/delete.svg'; +import { useTranslation } from 'react-i18next'; +import { IconButton } from '@mui/material'; +import { notify } from '$app/components/_shared/notify'; +import { CustomEditor } from '$app/components/editor/command'; +import { useSlateStatic } from 'slate-react'; +import Popover from '@mui/material/Popover'; +import Tooltip from '@mui/material/Tooltip'; + +enum ImageAction { + Copy = 'copy', + AlignLeft = 'left', + AlignCenter = 'center', + AlignRight = 'right', + Delete = 'delete', +} + +function ImageActions({ node }: { node: ImageNode }) { + const { t } = useTranslation(); + const align = node.data.align; + const editor = useSlateStatic(); + const [alignAnchorEl, setAlignAnchorEl] = useState(null); + const alignOptions = useMemo(() => { + return [ + { + key: ImageAction.AlignLeft, + Icon: AlignLeftIcon, + onClick: () => { + CustomEditor.setImageBlockData(editor, node, { align: 'left' }); + setAlignAnchorEl(null); + }, + }, + { + key: ImageAction.AlignCenter, + Icon: AlignCenterIcon, + onClick: () => { + CustomEditor.setImageBlockData(editor, node, { align: 'center' }); + setAlignAnchorEl(null); + }, + }, + { + key: ImageAction.AlignRight, + Icon: AlignRightIcon, + onClick: () => { + CustomEditor.setImageBlockData(editor, node, { align: 'right' }); + setAlignAnchorEl(null); + }, + }, + ]; + }, [editor, node]); + const options = useMemo(() => { + return [ + { + key: ImageAction.Copy, + Icon: CopyIcon, + tooltip: t('button.copyLink'), + onClick: () => { + if (!node.data.url) return; + void navigator.clipboard.writeText(node.data.url); + notify.success(t('message.copy.success')); + }, + }, + (!align || align === 'left') && { + key: ImageAction.AlignLeft, + Icon: AlignLeftIcon, + tooltip: t('button.align'), + onClick: (e: React.MouseEvent) => { + setAlignAnchorEl(e.currentTarget); + }, + }, + align === 'center' && { + key: ImageAction.AlignCenter, + Icon: AlignCenterIcon, + tooltip: t('button.align'), + onClick: (e: React.MouseEvent) => { + setAlignAnchorEl(e.currentTarget); + }, + }, + align === 'right' && { + key: ImageAction.AlignRight, + Icon: AlignRightIcon, + tooltip: t('button.align'), + onClick: (e: React.MouseEvent) => { + setAlignAnchorEl(e.currentTarget); + }, + }, + { + key: ImageAction.Delete, + Icon: DeleteIcon, + tooltip: t('button.delete'), + onClick: () => { + CustomEditor.deleteNode(editor, node); + }, + }, + ].filter(Boolean) as { + key: ImageAction; + Icon: React.FC>; + tooltip: string; + onClick: (e: React.MouseEvent) => void; + }[]; + }, [align, node, t, editor]); + + return ( +
+ {options.map((option) => { + const { key, Icon, tooltip, onClick } = option; + + return ( + + + + + + ); + })} + {!!alignAnchorEl && ( + setAlignAnchorEl(null)} + > + {alignOptions.map((option) => { + const { key, Icon, onClick } = option; + + return ( + + + + ); + })} + + )} +
+ ); +} + +export default ImageActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageBlock.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageBlock.tsx new file mode 100644 index 0000000000..6d3a7cfd22 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageBlock.tsx @@ -0,0 +1,49 @@ +import React, { forwardRef, memo, useCallback, useRef } from 'react'; +import { EditorElementProps, ImageNode } from '$app/application/document/document.types'; +import { ReactEditor, useSelected, useSlateStatic } from 'slate-react'; +import ImageRender from '$app/components/editor/components/blocks/image/ImageRender'; +import ImageEmpty from '$app/components/editor/components/blocks/image/ImageEmpty'; + +export const ImageBlock = memo( + forwardRef>(({ node, children, className, ...attributes }, ref) => { + const selected = useSelected(); + const { url, align } = node.data; + const containerRef = useRef(null); + const editor = useSlateStatic(); + const onFocusNode = useCallback(() => { + ReactEditor.focus(editor); + const path = ReactEditor.findPath(editor, node); + + editor.select(path); + }, [editor, node]); + + return ( +
{ + if (!selected) onFocusNode(); + }} + className={`${className} image-block relative w-full cursor-pointer py-1`} + > +
+ {children} +
+
+ {url ? ( + + ) : ( + + )} +
+
+ ); + }) +); + +export default ImageBlock; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageEmpty.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageEmpty.tsx new file mode 100644 index 0000000000..56da74ff05 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageEmpty.tsx @@ -0,0 +1,63 @@ +import React, { useEffect } from 'react'; +import { ReactComponent as ImageIcon } from '$app/assets/image.svg'; +import { useTranslation } from 'react-i18next'; +import UploadPopover from '$app/components/editor/components/blocks/image/UploadPopover'; +import { EditorNodeType, ImageNode } from '$app/application/document/document.types'; +import { useEditorBlockDispatch, useEditorBlockState } from '$app/components/editor/stores/block'; + +function ImageEmpty({ + containerRef, + onEscape, + node, +}: { + containerRef: React.RefObject; + onEscape: () => void; + node: ImageNode; +}) { + const { t } = useTranslation(); + const state = useEditorBlockState(EditorNodeType.ImageBlock); + const open = Boolean(state?.popoverOpen && state?.blockId === node.blockId && containerRef.current); + const { openPopover, closePopover } = useEditorBlockDispatch(); + + useEffect(() => { + const container = containerRef.current; + + if (!container) { + return; + } + + const handleClick = () => { + openPopover(EditorNodeType.ImageBlock, node.blockId); + }; + + container.addEventListener('click', handleClick); + return () => { + container.removeEventListener('click', handleClick); + }; + }, [containerRef, node.blockId, openPopover]); + return ( + <> +
+ + {t('document.plugins.image.addAnImage')} +
+ {open && ( + { + closePopover(EditorNodeType.ImageBlock); + onEscape(); + }} + /> + )} + + ); +} + +export default ImageEmpty; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageRender.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageRender.tsx new file mode 100644 index 0000000000..01e4df6c5c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageRender.tsx @@ -0,0 +1,91 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { ImageNode } from '$app/application/document/document.types'; +import { useTranslation } from 'react-i18next'; +import { CircularProgress } from '@mui/material'; +import { ErrorOutline } from '@mui/icons-material'; +import ImageResizer from '$app/components/editor/components/blocks/image/ImageResizer'; +import { CustomEditor } from '$app/components/editor/command'; +import { useSlateStatic } from 'slate-react'; +import ImageActions from '$app/components/editor/components/blocks/image/ImageActions'; + +function ImageRender({ selected, node }: { selected: boolean; node: ImageNode }) { + const [loading, setLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + const imgRef = useRef(null); + const editor = useSlateStatic(); + const { url, width: imageWidth } = node.data; + const { t } = useTranslation(); + const blockId = node.blockId; + + const [showActions, setShowActions] = useState(false); + const [initialWidth, setInitialWidth] = useState(null); + + const handleWidthChange = useCallback( + (newWidth: number) => { + CustomEditor.setImageBlockData(editor, node, { + width: newWidth, + }); + }, + [editor, node] + ); + + useEffect(() => { + if (!loading && !hasError && initialWidth === null && imgRef.current) { + setInitialWidth(imgRef.current.offsetWidth); + } + }, [hasError, initialWidth, loading]); + + return ( + <> +
{ + setShowActions(true); + }} + onMouseLeave={() => { + setShowActions(false); + }} + className={'relative'} + > + { + setHasError(false); + setLoading(false); + }} + onError={() => { + setHasError(true); + setLoading(false); + }} + src={url} + alt={`image-${blockId}`} + className={'object-cover'} + style={{ width: loading || hasError ? '0' : imageWidth ?? '100%', opacity: selected ? 0.8 : 1 }} + /> + {initialWidth && } + {showActions && } +
+ + {loading && ( +
+ +
{t('editor.loading')}
+
+ )} + {hasError && ( +
+ +
{t('editor.imageLoadFailed')}
+
+ )} + + ); +} + +export default ImageRender; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageResizer.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageResizer.tsx new file mode 100644 index 0000000000..a2164202a6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageResizer.tsx @@ -0,0 +1,54 @@ +import React, { useCallback, useRef } from 'react'; + +const MIN_WIDTH = 80; + +function ImageResizer({ width, onWidthChange }: { width: number; onWidthChange: (newWidth: number) => void }) { + const originalWidth = useRef(width); + const startX = useRef(0); + + const onResize = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + const diff = e.clientX - startX.current; + const newWidth = originalWidth.current + diff; + + if (newWidth < MIN_WIDTH) { + return; + } + + onWidthChange(newWidth); + }, + [onWidthChange] + ); + + const onResizeEnd = useCallback(() => { + document.removeEventListener('mousemove', onResize); + document.removeEventListener('mouseup', onResizeEnd); + }, [onResize]); + + const onResizeStart = useCallback( + (e: React.MouseEvent) => { + startX.current = e.clientX; + document.addEventListener('mousemove', onResize); + document.addEventListener('mouseup', onResizeEnd); + }, + [onResize, onResizeEnd] + ); + + return ( +
{ + originalWidth.current = width; + }} + style={{ + right: '2px', + }} + className={'image-resizer'} + > +
+
+ ); +} + +export default ImageResizer; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/UploadPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/UploadPopover.tsx new file mode 100644 index 0000000000..1c46776063 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/UploadPopover.tsx @@ -0,0 +1,189 @@ +import React, { useCallback, useMemo, SyntheticEvent, useState } from 'react'; +import Popover, { PopoverOrigin } from '@mui/material/Popover/Popover'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; +import { TabPanel, ViewTab, ViewTabs } from '$app/components/database/components/tab_bar/ViewTabs'; +import { useTranslation } from 'react-i18next'; +import { EmbedLink, Unsplash } from '$app/components/_shared/image_upload'; +import SwipeableViews from 'react-swipeable-views'; +import { CustomEditor } from '$app/components/editor/command'; +import { useSlateStatic } from 'slate-react'; +import { ImageNode, ImageType } from '$app/application/document/document.types'; + +enum TAB_KEY { + UPLOAD = 'upload', + EMBED_LINK = 'embed_link', + UNSPLASH = 'unsplash', +} +const initialOrigin: { + transformOrigin: PopoverOrigin; + anchorOrigin: PopoverOrigin; +} = { + transformOrigin: { + vertical: 'top', + horizontal: 'center', + }, + anchorOrigin: { + vertical: 'bottom', + horizontal: 'center', + }, +}; + +function UploadPopover({ + open, + anchorEl, + onClose, + node, +}: { + open: boolean; + anchorEl: HTMLDivElement | null; + onClose: () => void; + node: ImageNode; +}) { + const editor = useSlateStatic(); + + const { t } = useTranslation(); + + const { transformOrigin, anchorOrigin, isEntered, paperHeight, paperWidth } = usePopoverAutoPosition({ + initialPaperWidth: 433, + initialPaperHeight: 300, + anchorEl, + initialAnchorOrigin: initialOrigin.anchorOrigin, + initialTransformOrigin: initialOrigin.transformOrigin, + open, + }); + + const tabOptions = useMemo(() => { + return [ + // { + // label: t('button.upload'), + // key: TAB_KEY.UPLOAD, + // Component: UploadImage, + // }, + { + label: t('document.imageBlock.embedLink.label'), + key: TAB_KEY.EMBED_LINK, + Component: EmbedLink, + onDone: (link: string) => { + CustomEditor.setImageBlockData(editor, node, { + url: link, + image_type: ImageType.External, + }); + onClose(); + }, + }, + { + key: TAB_KEY.UNSPLASH, + label: t('document.imageBlock.unsplash.label'), + Component: Unsplash, + onDone: (link: string) => { + CustomEditor.setImageBlockData(editor, node, { + url: link, + image_type: ImageType.External, + }); + onClose(); + }, + }, + ]; + }, [editor, node, onClose, t]); + + const [tabValue, setTabValue] = useState(tabOptions[0].key); + + const handleTabChange = useCallback((_: SyntheticEvent, newValue: string) => { + setTabValue(newValue as TAB_KEY); + }, []); + + const selectedIndex = tabOptions.findIndex((tab) => tab.key === tabValue); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + e.stopPropagation(); + + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + + if (e.key === 'Tab') { + e.preventDefault(); + e.stopPropagation(); + setTabValue((prev) => { + const currentIndex = tabOptions.findIndex((tab) => tab.key === prev); + const nextIndex = (currentIndex + 1) % tabOptions.length; + + return tabOptions[nextIndex]?.key ?? tabOptions[0].key; + }); + } + }, + [onClose, tabOptions] + ); + + return ( + { + e.stopPropagation(); + }} + onKeyDown={onKeyDown} + PaperProps={{ + style: { + padding: 0, + }, + }} + > +
+ + {tabOptions.map((tab) => { + const { key, label } = tab; + + return ; + })} + + +
+ + {tabOptions.map((tab, index) => { + const { key, Component, onDone } = tab; + + return ( + + + + ); + })} + +
+
+
+ ); +} + +export default UploadPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/index.ts new file mode 100644 index 0000000000..73c3003a92 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/index.ts @@ -0,0 +1 @@ +export * from './ImageBlock'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx index 71b5d0f706..542eb977d9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx @@ -1,10 +1,11 @@ -import { forwardRef, memo, useEffect, useRef, useState } from 'react'; -import { EditorElementProps, MathEquationNode } from '$app/application/document/document.types'; +import { forwardRef, memo, useEffect, useRef } from 'react'; +import { EditorElementProps, EditorNodeType, MathEquationNode } from '$app/application/document/document.types'; import KatexMath from '$app/components/_shared/katex_math/KatexMath'; import { useTranslation } from 'react-i18next'; import { FunctionsOutlined } from '@mui/icons-material'; import EditPopover from '$app/components/editor/components/blocks/math_equation/EditPopover'; import { ReactEditor, useSelected, useSlateStatic } from 'slate-react'; +import { useEditorBlockDispatch, useEditorBlockState } from '$app/components/editor/stores/block'; export const MathEquation = memo( forwardRef>( @@ -12,7 +13,9 @@ export const MathEquation = memo( const formula = node.data.formula; const { t } = useTranslation(); const containerRef = useRef(null); - const [open, setOpen] = useState(false); + const { openPopover, closePopover } = useEditorBlockDispatch(); + const state = useEditorBlockState(EditorNodeType.EquationBlock); + const open = Boolean(state?.popoverOpen && state?.blockId === node.blockId && containerRef.current); const selected = useSelected(); @@ -26,7 +29,7 @@ export const MathEquation = memo( if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); - setOpen(true); + openPopover(EditorNodeType.EquationBlock, node.blockId); } }; @@ -37,7 +40,7 @@ export const MathEquation = memo( return () => { slateDom.removeEventListener('keydown', handleKeyDown); }; - }, [editor, selected]); + }, [editor, node.blockId, openPopover, selected]); return ( <> @@ -45,9 +48,9 @@ export const MathEquation = memo( {...attributes} ref={containerRef} onClick={() => { - setOpen(true); + openPopover(EditorNodeType.EquationBlock, node.blockId); }} - className={`${className} relative w-full cursor-pointer py-2`} + className={`${className} math-equation-block relative w-full cursor-pointer py-2`} >
{ - setOpen(false); + closePopover(EditorNodeType.EquationBlock); }} node={node} open={open} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx index 18f7bd8c03..3ac4f1e5c6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx @@ -21,6 +21,7 @@ import { EditorInlineBlockStateProvider, } from '$app/components/editor/stores'; import CommandPanel from '../tools/command_panel/CommandPanel'; +import { EditorBlockStateProvider } from '$app/components/editor/stores/block'; function Editor({ sharedType, disableFocus }: { sharedType: Y.XmlText; id: string; disableFocus?: boolean }) { const { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType); @@ -33,6 +34,7 @@ function Editor({ sharedType, disableFocus }: { sharedType: Y.XmlText; id: strin decorateState, slashState, inlineBlockState, + blockState, } = useInitialEditorState(editor); const decorate = useCallback( @@ -60,24 +62,26 @@ function Editor({ sharedType, disableFocus }: { sharedType: Y.XmlText; id: strin return ( - - - - - + + + + + + - - -
- - - + + +
+ + + + ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx index f11551c5d8..903ae5741f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx @@ -21,6 +21,8 @@ import { Callout } from '$app/components/editor/components/blocks/callout'; import { Mention } from '$app/components/editor/components/inline_nodes/mention'; import { GridBlock } from '$app/components/editor/components/blocks/database'; import { MathEquation } from '$app/components/editor/components/blocks/math_equation'; +import { ImageBlock } from '$app/components/editor/components/blocks/image'; + import { Text as TextComponent } from '../blocks/text'; import { Page } from '../blocks/page'; import { useElementState } from '$app/components/editor/components/editor/Element.hooks'; @@ -68,6 +70,8 @@ function Element({ element, attributes, children }: RenderElementProps) { return GridBlock; case EditorNodeType.EquationBlock: return MathEquation; + case EditorNodeType.ImageBlock: + return ImageBlock; default: return UnSupportBlock; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx index 0bc9a59a3f..3cb9dae044 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx @@ -13,8 +13,8 @@ import KeyboardNavigation, { KeyboardNavigationOption, } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; import isHotkey from 'is-hotkey'; -import LinkEditInput, { pattern } from '$app/components/editor/components/inline_nodes/link/LinkEditInput'; -import { openUrl } from '$app/utils/open_url'; +import LinkEditInput from '$app/components/editor/components/inline_nodes/link/LinkEditInput'; +import { openUrl, pattern } from '$app/utils/open_url'; function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaultHref: string }) { const editor = useSlateStatic(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditInput.tsx index 4a0cc3e33c..b9ca0345af 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditInput.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditInput.tsx @@ -1,8 +1,7 @@ import React, { useEffect, useState } from 'react'; import { TextField } from '@mui/material'; import { useTranslation } from 'react-i18next'; - -export const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/; +import { pattern } from '$app/utils/open_url'; function LinkEditInput({ link, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx index 624b9ff0f1..20c326d640 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx @@ -12,7 +12,7 @@ import KeyboardNavigation, { KeyboardNavigationOption, } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; import { Color } from '$app/components/editor/components/tools/block_actions/color'; -import { getModifier } from '$app/utils/get_modifier'; +import { getModifier } from '$app/utils/hotkeys'; import isHotkey from 'is-hotkey'; import { EditorNodeType } from '$app/application/document/document.types'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx index c55462f87b..1ebb783871 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx @@ -14,14 +14,18 @@ import { ReactComponent as NumberedListIcon } from '$app/assets/numbers.svg'; import { ReactComponent as QuoteIcon } from '$app/assets/quote.svg'; import { ReactComponent as ToggleListIcon } from '$app/assets/show-menu.svg'; import { ReactComponent as GridIcon } from '$app/assets/grid.svg'; +import { ReactComponent as ImageIcon } from '$app/assets/image.svg'; import { DataObjectOutlined, FunctionsOutlined, HorizontalRuleOutlined, MenuBookOutlined } from '@mui/icons-material'; import { CustomEditor } from '$app/components/editor/command'; import { randomEmoji } from '$app/utils/emoji'; import { KeyboardNavigationOption } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; import { YjsEditor } from '@slate-yjs/core'; +import { useEditorBlockDispatch } from '$app/components/editor/stores/block'; enum SlashCommandPanelTab { BASIC = 'basic', + MEDIA = 'media', + DATABASE = 'database', ADVANCED = 'advanced', } @@ -40,6 +44,7 @@ export enum SlashOptionType { Code, Grid, MathEquation, + Image, } const slashOptionGroup = [ { @@ -55,11 +60,20 @@ const slashOptionGroup = [ SlashOptionType.Quote, SlashOptionType.ToggleList, SlashOptionType.Divider, + SlashOptionType.Callout, ], }, + { + key: SlashCommandPanelTab.MEDIA, + options: [SlashOptionType.Code, SlashOptionType.Image], + }, + { + key: SlashCommandPanelTab.DATABASE, + options: [SlashOptionType.Grid], + }, { key: SlashCommandPanelTab.ADVANCED, - options: [SlashOptionType.Callout, SlashOptionType.Code, SlashOptionType.Grid, SlashOptionType.MathEquation], + options: [SlashOptionType.MathEquation], }, ]; @@ -78,6 +92,7 @@ const slashOptionMapToEditorNodeType = { [SlashOptionType.Code]: EditorNodeType.CodeBlock, [SlashOptionType.Grid]: EditorNodeType.GridBlock, [SlashOptionType.MathEquation]: EditorNodeType.EquationBlock, + [SlashOptionType.Image]: EditorNodeType.ImageBlock, }; const headingTypeToLevelMap: Record = { @@ -95,6 +110,7 @@ export function useSlashCommandPanel({ searchText: string; closePanel: (deleteText?: boolean) => void; }) { + const { openPopover } = useEditorBlockDispatch(); const { t } = useTranslation(); const editor = useSlate(); const onConfirm = useCallback( @@ -127,6 +143,12 @@ export function useSlashCommandPanel({ }); } + if (nodeType === EditorNodeType.ImageBlock) { + Object.assign(data, { + url: '', + }); + } + closePanel(true); const newNode = getBlock(editor); @@ -145,12 +167,20 @@ export function useSlashCommandPanel({ editor.select(nextPath); } - CustomEditor.turnToBlock(editor, { + const turnIntoBlock = CustomEditor.turnToBlock(editor, { type: nodeType, data, }); + + setTimeout(() => { + if (turnIntoBlock && turnIntoBlock.blockId) { + if (turnIntoBlock.type === EditorNodeType.ImageBlock || turnIntoBlock.type === EditorNodeType.EquationBlock) { + openPopover(turnIntoBlock.type, turnIntoBlock.blockId); + } + } + }, 0); }, - [editor, closePanel] + [editor, closePanel, openPopover] ); const typeToLabelIconMap = useMemo(() => { @@ -212,6 +242,10 @@ export function useSlashCommandPanel({ label: t('document.plugins.mathEquation.name'), Icon: FunctionsOutlined, }, + [SlashOptionType.Image]: { + label: t('editor.image'), + Icon: ImageIcon, + }, }; }, [t]); @@ -219,6 +253,8 @@ export function useSlashCommandPanel({ return { [SlashCommandPanelTab.BASIC]: 'Basic', [SlashCommandPanelTab.ADVANCED]: 'Advanced', + [SlashCommandPanelTab.MEDIA]: 'Media', + [SlashCommandPanelTab.DATABASE]: 'Database', }; }, []); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx index 5cb6e96596..3dab0fa182 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx @@ -22,12 +22,15 @@ function SelectionActions({ isAcrossBlocks, storeSelection, restoreSelection, + isIncludeRoot, }: { storeSelection: () => void; restoreSelection: () => void; isAcrossBlocks: boolean; visible: boolean; + isIncludeRoot: boolean; }) { + if (isIncludeRoot) return null; return (
{!isAcrossBlocks && ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts index 29ce475e45..2208058b61 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts @@ -14,6 +14,7 @@ export function useSelectionToolbar(ref: MutableRefObject const [isAcrossBlocks, setIsAcrossBlocks] = useState(false); const [visible, setVisible] = useState(false); const isFocusedEditor = useFocused(); + const isIncludeRoot = CustomEditor.selectionIncludeRoot(editor); // paint the selection when the editor is blurred const { add: addDecorate, clear: clearDecorate, getStaticState } = useDecorateDispatch(); @@ -61,12 +62,6 @@ export function useSelectionToolbar(ref: MutableRefObject return; } - // Close toolbar when selection include root - if (CustomEditor.selectionIncludeRoot(editor)) { - closeToolbar(); - return; - } - const position = getSelectionPosition(editor); if (!position) { @@ -123,7 +118,7 @@ export function useSelectionToolbar(ref: MutableRefObject closeToolbar(); }; - if (!isFocusedEditor || !selection || SlateRange.isCollapsed(selection)) { + if (isIncludeRoot || !isFocusedEditor || !selection || SlateRange.isCollapsed(selection)) { close(); return; } @@ -205,5 +200,6 @@ export function useSelectionToolbar(ref: MutableRefObject restoreSelection, storeSelection, isAcrossBlocks, + isIncludeRoot, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx index 214fa1730a..d4ca9c9de0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx @@ -6,7 +6,7 @@ import withErrorBoundary from '$app/components/_shared/error_boundary/withError' const Toolbar = memo(() => { const ref = useRef(null); - const { visible, restoreSelection, storeSelection, isAcrossBlocks } = useSelectionToolbar(ref); + const { visible, restoreSelection, storeSelection, isAcrossBlocks, isIncludeRoot } = useSelectionToolbar(ref); return (
{ }} > { setOpen(false); @@ -60,6 +61,36 @@ export function Align() { } }, []); + useEffect(() => { + const editorDom = ReactEditor.toDOMNode(editor, editor); + const handleShortcut = (e: KeyboardEvent) => { + if (createHotkey(HOT_KEY_NAME.ALIGN_LEFT)(e)) { + e.preventDefault(); + e.stopPropagation(); + CustomEditor.toggleAlign(editor, 'left'); + return; + } + + if (createHotkey(HOT_KEY_NAME.ALIGN_CENTER)(e)) { + e.preventDefault(); + e.stopPropagation(); + CustomEditor.toggleAlign(editor, 'center'); + return; + } + + if (createHotkey(HOT_KEY_NAME.ALIGN_RIGHT)(e)) { + e.preventDefault(); + e.stopPropagation(); + CustomEditor.toggleAlign(editor, 'right'); + return; + } + }; + + editorDom.addEventListener('keydown', handleShortcut); + return () => { + editorDom.removeEventListener('keydown', handleShortcut); + }; + }, [editor]); return ( getHotKey(EditorMarkFormat.Bold).modifier, []); + const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.BOLD), []); const onClick = useCallback(() => { CustomEditor.toggleMark(editor, { key: EditorMarkFormat.Bold, @@ -20,6 +20,26 @@ export function Bold() { }); }, [editor]); + useEffect(() => { + const editorDom = ReactEditor.toDOMNode(editor, editor); + const handleShortcut = (e: KeyboardEvent) => { + if (createHotkey(HOT_KEY_NAME.BOLD)(e)) { + e.preventDefault(); + e.stopPropagation(); + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.Bold, + value: true, + }); + return; + } + }; + + editorDom.addEventListener('keydown', handleShortcut); + return () => { + editorDom.removeEventListener('keydown', handleShortcut); + }; + }, [editor]); + return ( { if (isHotkey('mod+k', e)) { + if (editor.selection && Range.isCollapsed(editor.selection)) return; e.preventDefault(); e.stopPropagation(); onClick(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/InlineCode.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/InlineCode.tsx index ef761e5c8c..39b48ad525 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/InlineCode.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/InlineCode.tsx @@ -1,17 +1,17 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; import { useTranslation } from 'react-i18next'; -import { useSlateStatic } from 'slate-react'; +import { ReactEditor, useSlateStatic } from 'slate-react'; import { CustomEditor } from '$app/components/editor/command'; import { ReactComponent as CodeSvg } from '$app/assets/inline-code.svg'; import { EditorMarkFormat } from '$app/application/document/document.types'; -import { getHotKey } from '$app/components/editor/plugins/shortcuts'; +import { createHotkey, createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys'; export function InlineCode() { const { t } = useTranslation(); const editor = useSlateStatic(); const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Code); - const modifier = useMemo(() => getHotKey(EditorMarkFormat.Code).modifier, []); + const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.CODE), []); const onClick = useCallback(() => { CustomEditor.toggleMark(editor, { @@ -20,6 +20,26 @@ export function InlineCode() { }); }, [editor]); + useEffect(() => { + const editorDom = ReactEditor.toDOMNode(editor, editor); + const handleShortcut = (e: KeyboardEvent) => { + if (createHotkey(HOT_KEY_NAME.CODE)(e)) { + e.preventDefault(); + e.stopPropagation(); + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.Code, + value: true, + }); + return; + } + }; + + editorDom.addEventListener('keydown', handleShortcut); + return () => { + editorDom.removeEventListener('keydown', handleShortcut); + }; + }, [editor]); + return ( getHotKey(EditorMarkFormat.Italic).modifier, []); + const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.ITALIC), []); const onClick = useCallback(() => { CustomEditor.toggleMark(editor, { @@ -20,6 +20,25 @@ export function Italic() { }); }, [editor]); + useEffect(() => { + const editorDom = ReactEditor.toDOMNode(editor, editor); + const handleShortcut = (e: KeyboardEvent) => { + if (createHotkey(HOT_KEY_NAME.ITALIC)(e)) { + e.preventDefault(); + e.stopPropagation(); + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.Italic, + value: true, + }); + return; + } + }; + + editorDom.addEventListener('keydown', handleShortcut); + return () => { + editorDom.removeEventListener('keydown', handleShortcut); + }; + }, [editor]); return ( getHotKey(EditorMarkFormat.StrikeThrough).modifier, []); + const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.STRIKETHROUGH), []); const onClick = useCallback(() => { CustomEditor.toggleMark(editor, { @@ -20,6 +20,26 @@ export function StrikeThrough() { }); }, [editor]); + useEffect(() => { + const editorDom = ReactEditor.toDOMNode(editor, editor); + const handleShortcut = (e: KeyboardEvent) => { + if (createHotkey(HOT_KEY_NAME.STRIKETHROUGH)(e)) { + e.preventDefault(); + e.stopPropagation(); + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.StrikeThrough, + value: true, + }); + return; + } + }; + + editorDom.addEventListener('keydown', handleShortcut); + return () => { + editorDom.removeEventListener('keydown', handleShortcut); + }; + }, [editor]); + return ( getHotKey(EditorMarkFormat.Underline).modifier, []); + const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.UNDERLINE), []); const onClick = useCallback(() => { CustomEditor.toggleMark(editor, { @@ -20,6 +20,26 @@ export function Underline() { }); }, [editor]); + useEffect(() => { + const editorDom = ReactEditor.toDOMNode(editor, editor); + const handleShortcut = (e: KeyboardEvent) => { + if (createHotkey(HOT_KEY_NAME.UNDERLINE)(e)) { + e.preventDefault(); + e.stopPropagation(); + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.Underline, + value: true, + }); + return; + } + }; + + editorDom.addEventListener('keydown', handleShortcut); + return () => { + editorDom.removeEventListener('keydown', handleShortcut); + }; + }, [editor]); + return ( { - [key: string]: { modifier: string; hotkey: string; markKey: EditorMarkFormat; markValue: string | boolean }; -} = () => { - const modifier = getModifier(); - - return { - [EditorMarkFormat.Bold]: { - hotkey: 'mod+b', - modifier: `${modifier} + B`, - markKey: EditorMarkFormat.Bold, - markValue: true, - }, - [EditorMarkFormat.Italic]: { - hotkey: 'mod+i', - modifier: `${modifier} + I`, - markKey: EditorMarkFormat.Italic, - markValue: true, - }, - [EditorMarkFormat.Underline]: { - hotkey: 'mod+u', - modifier: `${modifier} + U`, - markKey: EditorMarkFormat.Underline, - markValue: true, - }, - [EditorMarkFormat.StrikeThrough]: { - hotkey: 'mod+shift+s', - modifier: `${modifier} + Shift + S`, - markKey: EditorMarkFormat.StrikeThrough, - markValue: true, - }, - [EditorMarkFormat.Code]: { - hotkey: 'mod+shift+c', - modifier: `${modifier} + Shift + C`, - markKey: EditorMarkFormat.Code, - markValue: true, - }, - 'align-left': { - hotkey: 'control+shift+l', - modifier: `Ctrl + Shift + L`, - markKey: EditorMarkFormat.Align, - markValue: 'left', - }, - 'align-center': { - hotkey: 'control+shift+e', - modifier: `Ctrl + Shift + E`, - markKey: EditorMarkFormat.Align, - markValue: 'center', - }, - 'align-right': { - hotkey: 'control+shift+r', - modifier: `Ctrl + Shift + R`, - markKey: EditorMarkFormat.Align, - markValue: 'right', - }, - }; -}; - -export const getHotKey = (key: EditorMarkFormat) => { - return getHotKeys()[key]; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/index.ts index fc262b9036..7cfd550743 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/index.ts @@ -1,3 +1,2 @@ export * from './shortcuts.hooks'; export * from './withShortcuts'; -export * from './hotkey'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts index 03a6833bd2..a22c5b7544 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts @@ -1,28 +1,14 @@ import { ReactEditor } from 'slate-react'; import { useCallback, KeyboardEvent } from 'react'; -import { - EditorMarkFormat, - EditorNodeType, - TodoListNode, - ToggleListNode, -} from '$app/application/document/document.types'; +import { EditorNodeType, TodoListNode, ToggleListNode } from '$app/application/document/document.types'; import isHotkey from 'is-hotkey'; import { getBlock } from '$app/components/editor/plugins/utils'; import { SOFT_BREAK_TYPES } from '$app/components/editor/plugins/constants'; import { CustomEditor } from '$app/components/editor/command'; -import { getHotKeys } from '$app/components/editor/plugins/shortcuts/hotkey'; /** * Hotkeys shortcuts * @description [getHotKeys] is defined in [hotkey.ts] - * - bold: Mod+b - * - italic: Mod+i - * - underline: Mod+u - * - strikethrough: Mod+Shift+s - * - code: Mod+Shift+c - * - align left: Mod+Shift+l - * - align center: Mod+Shift+e - * - align right: Mod+Shift+r * - indent: Tab * - outdent: Shift+Tab * - split block: Enter @@ -33,24 +19,6 @@ import { getHotKeys } from '$app/components/editor/plugins/shortcuts/hotkey'; export function useShortcuts(editor: ReactEditor) { const onKeyDown = useCallback( (e: KeyboardEvent) => { - Object.entries(getHotKeys()).forEach(([_, item]) => { - if (isHotkey(item.hotkey, e)) { - e.stopPropagation(); - e.preventDefault(); - if (CustomEditor.selectionIncludeRoot(editor)) return; - if (item.markKey === EditorMarkFormat.Align) { - CustomEditor.toggleAlign(editor, item.markValue as string); - return; - } - - CustomEditor.toggleMark(editor, { - key: item.markKey, - value: item.markValue, - }); - return; - } - }); - const node = getBlock(editor); if (isHotkey('Escape', e)) { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/block.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/block.ts new file mode 100644 index 0000000000..00992964fb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/block.ts @@ -0,0 +1,70 @@ +import { createContext, useCallback, useContext, useMemo } from 'react'; +import { proxy, useSnapshot } from 'valtio'; +import { EditorNodeType } from '$app/application/document/document.types'; + +export interface EditorBlockState { + [EditorNodeType.ImageBlock]: { + popoverOpen: boolean; + blockId?: string; + }; + [EditorNodeType.EquationBlock]: { + popoverOpen: boolean; + blockId?: string; + }; +} + +const initialState = { + [EditorNodeType.ImageBlock]: { + popoverOpen: false, + blockId: undefined, + }, + [EditorNodeType.EquationBlock]: { + popoverOpen: false, + blockId: undefined, + }, +}; + +export const EditorBlockStateContext = createContext(initialState); + +export const EditorBlockStateProvider = EditorBlockStateContext.Provider; + +export function useEditorInitialBlockState() { + const state = useMemo(() => { + return proxy({ + ...initialState, + }); + }, []); + + return state; +} + +export function useEditorBlockState(key: EditorNodeType.ImageBlock | EditorNodeType.EquationBlock) { + const context = useContext(EditorBlockStateContext); + + return useSnapshot(context[key]); +} + +export function useEditorBlockDispatch() { + const context = useContext(EditorBlockStateContext); + + const openPopover = useCallback( + (key: EditorNodeType.ImageBlock | EditorNodeType.EquationBlock, blockId: string) => { + context[key].popoverOpen = true; + context[key].blockId = blockId; + }, + [context] + ); + + const closePopover = useCallback( + (key: EditorNodeType.ImageBlock | EditorNodeType.EquationBlock) => { + context[key].popoverOpen = false; + context[key].blockId = undefined; + }, + [context] + ); + + return { + openPopover, + closePopover, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/index.ts index e93794da88..22f0bb81be 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/index.ts @@ -3,6 +3,7 @@ import { useInitialDecorateState } from '$app/components/editor/stores/decorate' import { useInitialSelectedBlocks } from '$app/components/editor/stores/selected'; import { useInitialSlashState } from '$app/components/editor/stores/slash'; import { useInitialEditorInlineBlockState } from '$app/components/editor/stores/inline_node'; +import { useEditorInitialBlockState } from '$app/components/editor/stores/block'; export * from './decorate'; export * from './selected'; @@ -14,6 +15,7 @@ export function useInitialEditorState(editor: ReactEditor) { const selectedBlocks = useInitialSelectedBlocks(editor); const slashState = useInitialSlashState(); const inlineBlockState = useInitialEditorInlineBlockState(); + const blockState = useEditorInitialBlockState(); return { selectedBlocks, @@ -21,5 +23,6 @@ export function useInitialEditorState(editor: ReactEditor) { decorateState, slashState, inlineBlockState, + blockState, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/selected.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/selected.ts index e3a28ff5fd..803f474723 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/selected.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/selected.ts @@ -1,20 +1,8 @@ -import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { createContext, useEffect, useMemo, useState } from 'react'; import { proxySet, subscribeKey } from 'valtio/utils'; import { ReactEditor } from 'slate-react'; import { Element } from 'slate'; -export function useSelectedBlocksSize() { - const selectedBlocks = useContext(EditorSelectedBlockContext); - - const [selectedLength, setSelectedLength] = useState(0); - - useEffect(() => { - subscribeKey(selectedBlocks, 'size', (v) => setSelectedLength(v)); - }, [selectedBlocks]); - - return selectedLength; -} - export function useInitialSelectedBlocks(editor: ReactEditor) { const selectedBlocks = useMemo(() => proxySet([]), []); const [selectedLength, setSelectedLength] = useState(0); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx index f977bbe852..14bc179189 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx @@ -36,7 +36,7 @@ function Layout({ children }: { children: ReactNode }) {
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx index ca37d8aedd..0dfe7e51f3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx @@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { sidebarActions } from '$app_reducers/sidebar/slice'; import { ReactComponent as ShowMenuIcon } from '$app/assets/show-menu.svg'; import { useTranslation } from 'react-i18next'; -import { getModifier } from '$app/utils/get_modifier'; +import { getModifier } from '$app/utils/hotkeys'; import isHotkey from 'is-hotkey'; function CollapseMenuButton() { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/MoreButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/MoreButton.tsx index e0a36a5903..94a86655ac 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/MoreButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/MoreButton.tsx @@ -10,7 +10,7 @@ import RenameDialog from '../../_shared/confirm_dialog/RenameDialog'; import { Page } from '$app_reducers/pages/slice'; import DeleteDialog from '$app/components/layout/nested_page/DeleteDialog'; import OperationMenu from '$app/components/layout/nested_page/OperationMenu'; -import { getModifier } from '$app/utils/get_modifier'; +import { getModifier } from '$app/utils/hotkeys'; import isHotkey from 'is-hotkey'; function MoreButton({ diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/get_modifier.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/get_modifier.ts deleted file mode 100644 index a81e5e9093..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/get_modifier.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const isMac = () => { - return navigator.userAgent.includes('Mac OS X'); -}; - -const MODIFIERS = { - control: 'Ctrl', - meta: '⌘', -}; - -export const getModifier = () => { - return isMac() ? MODIFIERS.meta : MODIFIERS.control; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts new file mode 100644 index 0000000000..fab7f0612f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts @@ -0,0 +1,61 @@ +import isHotkey from 'is-hotkey'; + +export const isMac = () => { + return navigator.userAgent.includes('Mac OS X'); +}; + +const MODIFIERS = { + control: 'Ctrl', + meta: '⌘', +}; + +export const getModifier = () => { + return isMac() ? MODIFIERS.meta : MODIFIERS.control; +}; + +export enum HOT_KEY_NAME { + ALIGN_LEFT = 'align-left', + ALIGN_CENTER = 'align-center', + ALIGN_RIGHT = 'align-right', + BOLD = 'bold', + ITALIC = 'italic', + UNDERLINE = 'underline', + STRIKETHROUGH = 'strikethrough', + CODE = 'code', +} + +const defaultHotKeys = { + [HOT_KEY_NAME.ALIGN_LEFT]: 'control+shift+l', + [HOT_KEY_NAME.ALIGN_CENTER]: 'control+shift+e', + [HOT_KEY_NAME.ALIGN_RIGHT]: 'control+shift+r', + [HOT_KEY_NAME.BOLD]: 'mod+b', + [HOT_KEY_NAME.ITALIC]: 'mod+i', + [HOT_KEY_NAME.UNDERLINE]: 'mod+u', + [HOT_KEY_NAME.STRIKETHROUGH]: 'mod+shift+s', + [HOT_KEY_NAME.CODE]: 'mod+shift+c', +}; + +const replaceModifier = (hotkey: string) => { + return hotkey.replace('mod', getModifier()).replace('control', 'ctrl'); +}; + +export const createHotkey = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record) => { + const keys = customHotKeys || defaultHotKeys; + const hotkey = keys[hotkeyName]; + + return (event: KeyboardEvent) => { + return isHotkey(hotkey, event); + }; +}; + +export const createHotKeyLabel = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record) => { + const keys = customHotKeys || defaultHotKeys; + const hotkey = replaceModifier(keys[hotkeyName]); + + return hotkey + .split('+') + .map((key) => { + return key === ' ' ? 'Space' : key.charAt(0).toUpperCase() + key.slice(1); + }) + .join(' + '); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts index f14b256517..3fd9933a45 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts @@ -1,6 +1,6 @@ import { open as openWindow } from '@tauri-apps/api/shell'; -export const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/; +export const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})(\S*)*\/?(\?[=&\w.%-]*)?(#[\w.\-!~*'()]*)?$/; export function openUrl(str: string) { if (pattern.test(str)) { diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 5239a36635..5ceecd7cc9 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -245,7 +245,9 @@ "Cancel": "Cancel", "clear": "Clear", "remove": "Remove", - "dontRemove": "Don't remove" + "dontRemove": "Don't remove", + "copyLink": "Copy Link", + "align": "Align" }, "label": { "welcome": "Welcome!", @@ -588,6 +590,7 @@ "multiSelectFieldName": "Multiselect", "urlFieldName": "URL", "checklistFieldName": "Checklist", + "relationFieldName": "Relation", "numberFormat": "Number format", "dateFormat": "Date format", "includeTime": "Include time", @@ -688,6 +691,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", @@ -1154,7 +1163,8 @@ "replace": "Replace", "replaceAll": "Replace all", "noResult": "No results", - "caseSensitive": "Case sensitive" + "caseSensitive": "Case sensitive", + "searchMore": "Search to find more results" }, "error": { "weAreSorry": "We're sorry", 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/select_option_filter.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/select_option_filter.rs index 86698b3904..a7e8cbb60a 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/select_option_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/select_option_filter.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; @@ -48,7 +50,7 @@ impl FromFilterString for SelectOptionFilterPB { where Self: Sized, { - let ids = SelectOptionIds::from(filter.content.clone()); + let ids = SelectOptionIds::from_str(&filter.content).unwrap_or_default(); SelectOptionFilterPB { condition: SelectOptionConditionPB::try_from(filter.condition as u8) .unwrap_or(SelectOptionConditionPB::OptionIs), @@ -59,7 +61,7 @@ impl FromFilterString for SelectOptionFilterPB { impl std::convert::From<&Filter> for SelectOptionFilterPB { fn from(filter: &Filter) -> Self { - let ids = SelectOptionIds::from(filter.content.clone()); + let ids = SelectOptionIds::from_str(&filter.content).unwrap_or_default(); SelectOptionFilterPB { condition: SelectOptionConditionPB::try_from(filter.condition as u8) .unwrap_or(SelectOptionConditionPB::OptionIs), 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..4caafafef0 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 @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::str::FromStr; use collab_database::fields::Field; use collab_database::rows::{get_field_type_from_cell, Cell, Cells}; @@ -94,13 +95,8 @@ pub fn get_cell_protobuf( let from_field_type = from_field_type.unwrap(); let to_field_type = FieldType::from(field.field_type); - match try_decode_cell_str_to_cell_protobuf( - cell, - &from_field_type, - &to_field_type, - field, - cell_cache, - ) { + match try_decode_cell_to_cell_protobuf(cell, &from_field_type, &to_field_type, field, cell_cache) + { Ok(cell_bytes) => cell_bytes, Err(e) => { tracing::error!("Decode cell data failed, {:?}", e); @@ -125,7 +121,7 @@ pub fn get_cell_protobuf( /// /// returns: CellBytes /// -pub fn try_decode_cell_str_to_cell_protobuf( +pub fn try_decode_cell_to_cell_protobuf( cell: &Cell, from_field_type: &FieldType, to_field_type: &FieldType, @@ -136,7 +132,7 @@ pub fn try_decode_cell_str_to_cell_protobuf( .get_type_option_cell_data_handler(to_field_type) { None => Ok(CellProtobufBlob::default()), - Some(handler) => handler.handle_cell_str(cell, from_field_type, field), + Some(handler) => handler.handle_cell_protobuf(cell, from_field_type, field), } } @@ -245,13 +241,6 @@ pub fn delete_select_option_cell(option_ids: Vec, field: &Field) -> Cell apply_cell_changeset(BoxAny::new(changeset), None, field, None).unwrap() } -/// Deserialize the String into cell specific data type. -pub trait FromCellString { - fn from_cell_str(s: &str) -> FlowyResult - where - Self: Sized; -} - pub struct CellBuilder<'a> { cells: Cells, field_maps: HashMap, @@ -290,12 +279,12 @@ impl<'a> CellBuilder<'a> { tracing::warn!("Shouldn't insert cell data to cell whose field type is LastEditedTime or CreatedTime"); }, FieldType::SingleSelect | FieldType::MultiSelect => { - if let Ok(ids) = SelectOptionIds::from_cell_str(&cell_str) { + if let Ok(ids) = SelectOptionIds::from_str(&cell_str) { cells.insert(field_id, insert_select_option_cell(ids.into_inner(), field)); } }, FieldType::Checkbox => { - if let Ok(value) = CheckboxCellDataPB::from_cell_str(&cell_str) { + if let Ok(value) = CheckboxCellDataPB::from_str(&cell_str) { cells.insert(field_id, insert_checkbox_cell(value.is_checked, field)); } }, @@ -303,10 +292,13 @@ impl<'a> CellBuilder<'a> { cells.insert(field_id, insert_url_cell(cell_str, field)); }, FieldType::Checklist => { - if let Ok(ids) = SelectOptionIds::from_cell_str(&cell_str) { + if let Ok(ids) = SelectOptionIds::from_str(&cell_str) { 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/cell/type_cell_data.rs b/frontend/rust-lib/flowy-database2/src/services/cell/type_cell_data.rs index 4d6e9cd080..ccc877059a 100644 --- a/frontend/rust-lib/flowy-database2/src/services/cell/type_cell_data.rs +++ b/frontend/rust-lib/flowy-database2/src/services/cell/type_cell_data.rs @@ -1,109 +1,6 @@ use bytes::Bytes; -use serde::{Deserialize, Serialize}; -use flowy_error::{internal_error, FlowyError, FlowyResult}; - -use crate::entities::FieldType; - -/// TypeCellData is a generic CellData, you can parse the type_cell_data according to the field_type. -/// The `data` is encoded by JSON format. You can use `IntoCellData` to decode the opaque data to -/// concrete cell type. -/// TypeCellData -> IntoCellData -> T -/// -/// The `TypeCellData` is the same as the cell data that was saved to disk except it carries the -/// field_type. The field_type indicates the cell data original `FieldType`. The field_type will -/// be changed if the current Field's type switch from one to another. -/// -#[derive(Debug, Serialize, Deserialize)] -pub struct TypeCellData { - #[serde(rename = "data")] - pub cell_str: String, - pub field_type: FieldType, -} - -impl TypeCellData { - pub fn from_field_type(field_type: &FieldType) -> TypeCellData { - Self { - cell_str: "".to_string(), - field_type: *field_type, - } - } - - pub fn from_json_str(s: &str) -> FlowyResult { - let type_cell_data: TypeCellData = serde_json::from_str(s).map_err(|err| { - let msg = format!("Deserialize {} to type cell data failed.{}", s, err); - FlowyError::internal().with_context(msg) - })?; - Ok(type_cell_data) - } - - pub fn into_inner(self) -> String { - self.cell_str - } -} - -impl std::convert::TryFrom for TypeCellData { - type Error = FlowyError; - - fn try_from(value: String) -> Result { - TypeCellData::from_json_str(&value) - } -} - -impl ToString for TypeCellData { - fn to_string(&self) -> String { - self.cell_str.clone() - } -} - -impl TypeCellData { - pub fn new(cell_str: String, field_type: FieldType) -> Self { - TypeCellData { - cell_str, - field_type, - } - } - - pub fn to_json(&self) -> String { - serde_json::to_string(self).unwrap_or_else(|_| "".to_owned()) - } - - pub fn is_number(&self) -> bool { - self.field_type == FieldType::Number - } - - pub fn is_text(&self) -> bool { - self.field_type == FieldType::RichText - } - - pub fn is_checkbox(&self) -> bool { - self.field_type == FieldType::Checkbox - } - - pub fn is_date(&self) -> bool { - self.field_type == FieldType::DateTime - } - - pub fn is_single_select(&self) -> bool { - self.field_type == FieldType::SingleSelect - } - - pub fn is_multi_select(&self) -> bool { - self.field_type == FieldType::MultiSelect - } - - pub fn is_checklist(&self) -> bool { - self.field_type == FieldType::Checklist - } - - pub fn is_url(&self) -> bool { - self.field_type == FieldType::URL - } - - pub fn is_select_option(&self) -> bool { - self.field_type == FieldType::MultiSelect || self.field_type == FieldType::SingleSelect - } -} +use flowy_error::{internal_error, FlowyResult}; /// The data is encoded by protobuf or utf8. You should choose the corresponding decode struct to parse it. /// @@ -116,13 +13,8 @@ impl TypeCellData { #[derive(Default, Debug)] pub struct CellProtobufBlob(pub Bytes); -pub trait DecodedCellData { - type Object; - fn is_empty(&self) -> bool; -} - pub trait CellProtobufBlobParser { - type Object: DecodedCellData; + type Object; fn parser(bytes: &Bytes) -> FlowyResult; } 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/checkbox_type_option/checkbox_tests.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_tests.rs index 295dff0037..53c86d400f 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_tests.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_tests.rs @@ -1,11 +1,12 @@ #[cfg(test)] mod tests { + use std::str::FromStr; + use collab_database::fields::Field; use crate::entities::CheckboxCellDataPB; use crate::entities::FieldType; use crate::services::cell::CellDataDecoder; - use crate::services::cell::FromCellString; use crate::services::field::type_options::checkbox_type_option::*; use crate::services::field::FieldBuilder; @@ -43,7 +44,7 @@ mod tests { assert_eq!( type_option .decode_cell( - &CheckboxCellDataPB::from_cell_str(input_str).unwrap().into(), + &CheckboxCellDataPB::from_str(input_str).unwrap().into(), field_type, field ) diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs index 327d426f39..ec693d414d 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs @@ -124,12 +124,8 @@ impl TypeOptionCellDataFilter for CheckboxTypeOption { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool { - if !field_type.is_checkbox() { - return true; - } filter.is_visible(cell_data) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs index afab124d04..35de68136b 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs @@ -7,7 +7,7 @@ use collab_database::rows::{new_cell_builder, Cell}; use flowy_error::{FlowyError, FlowyResult}; use crate::entities::{CheckboxCellDataPB, FieldType}; -use crate::services::cell::{CellProtobufBlobParser, DecodedCellData, FromCellString}; +use crate::services::cell::CellProtobufBlobParser; use crate::services::field::{TypeOptionCellData, CELL_DATA}; pub const CHECK: &str = "Yes"; @@ -22,7 +22,7 @@ impl TypeOptionCellData for CheckboxCellDataPB { impl From<&Cell> for CheckboxCellDataPB { fn from(cell: &Cell) -> Self { let value = cell.get_str_value(CELL_DATA).unwrap_or_default(); - CheckboxCellDataPB::from_cell_str(&value).unwrap_or_default() + CheckboxCellDataPB::from_str(&value).unwrap_or_default() } } @@ -49,15 +49,6 @@ impl FromStr for CheckboxCellDataPB { } } -impl FromCellString for CheckboxCellDataPB { - fn from_cell_str(s: &str) -> FlowyResult - where - Self: Sized, - { - Self::from_str(s) - } -} - impl ToString for CheckboxCellDataPB { fn to_string(&self) -> String { if self.is_checked { @@ -68,14 +59,6 @@ impl ToString for CheckboxCellDataPB { } } -impl DecodedCellData for CheckboxCellDataPB { - type Object = CheckboxCellDataPB; - - fn is_empty(&self) -> bool { - false - } -} - pub struct CheckboxCellDataParser(); impl CellProtobufBlobParser for CheckboxCellDataParser { type Object = CheckboxCellDataPB; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist.rs index 7a6f818f2c..8d080b2d07 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist.rs @@ -178,12 +178,8 @@ impl TypeOptionCellDataFilter for ChecklistTypeOption { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool { - if !field_type.is_checklist() { - return true; - } let selected_options = cell_data.selected_options(); filter.is_visible(&cell_data.options, &selected_options) } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs index 34badaa58f..15acf6ad50 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs @@ -342,12 +342,8 @@ impl TypeOptionCellDataFilter for DateTypeOption { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool { - if !field_type.is_date() { - return true; - } filter.is_visible(cell_data).unwrap_or(true) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs index 0bb1190768..c2b0259aff 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs @@ -11,7 +11,7 @@ use strum_macros::EnumIter; use flowy_error::{internal_error, FlowyResult}; use crate::entities::{DateCellDataPB, FieldType}; -use crate::services::cell::{CellProtobufBlobParser, DecodedCellData, FromCellString}; +use crate::services::cell::CellProtobufBlobParser; use crate::services::field::{TypeOptionCellData, CELL_DATA}; #[derive(Clone, Debug, Default)] @@ -196,16 +196,6 @@ impl<'de> serde::Deserialize<'de> for DateCellData { } } -impl FromCellString for DateCellData { - fn from_cell_str(s: &str) -> FlowyResult - where - Self: Sized, - { - let result: DateCellData = serde_json::from_str(s).unwrap(); - Ok(result) - } -} - impl ToString for DateCellData { fn to_string(&self) -> String { serde_json::to_string(self).unwrap() @@ -288,14 +278,6 @@ impl TimeFormat { } } -impl DecodedCellData for DateCellDataPB { - type Object = DateCellDataPB; - - fn is_empty(&self) -> bool { - self.date.is_empty() - } -} - pub struct DateCellDataParser(); impl CellProtobufBlobParser for DateCellDataParser { type Object = DateCellDataPB; 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/number_type_option/number_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option.rs index 94da6b0c0b..b79ca661fc 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option.rs @@ -242,12 +242,8 @@ impl TypeOptionCellDataFilter for NumberTypeOption { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool { - if !field_type.is_number() { - return true; - } match self.format_cell_data(cell_data) { Ok(cell_data) => filter.is_visible(&cell_data).unwrap_or(true), Err(_) => true, diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option_entities.rs index 59b069908f..5085bc3db3 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option_entities.rs @@ -1,4 +1,4 @@ -use crate::services::cell::{CellBytesCustomParser, CellProtobufBlobParser, DecodedCellData}; +use crate::services::cell::{CellBytesCustomParser, CellProtobufBlobParser}; use crate::services::field::number_currency::Currency; use crate::services::field::{NumberFormat, EXTRACT_NUM_REGEX, START_WITH_DOT_NUM_REGEX}; use bytes::Bytes; @@ -108,14 +108,6 @@ impl ToString for NumberCellFormat { } } -impl DecodedCellData for NumberCellFormat { - type Object = NumberCellFormat; - - fn is_empty(&self) -> bool { - self.decimal.is_none() - } -} - pub struct NumberCellDataParser(); impl CellProtobufBlobParser for NumberCellDataParser { type Object = NumberCellFormat; 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..357729a0c8 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation.rs @@ -0,0 +1,154 @@ +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, _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/selection_type_option/multi_select_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/multi_select_type_option.rs index 61e5a5f31b..572bfc5021 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/multi_select_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/multi_select_type_option.rs @@ -125,12 +125,8 @@ impl TypeOptionCellDataFilter for MultiSelectTypeOption { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool { - if !field_type.is_multi_select() { - return true; - } let selected_options = self.get_selected_options(cell_data.clone()).select_options; filter.is_visible(&selected_options, FieldType::MultiSelect) } @@ -216,8 +212,6 @@ mod tests { debug_assert_eq!(multi_select.options.len(), 2); } - // #[test] - #[test] fn multi_select_insert_multi_option_test() { let google = SelectOption::new("Google"); diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_ids.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_ids.rs index 55fe2635bc..c47738b788 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_ids.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_ids.rs @@ -1,10 +1,11 @@ +use std::str::FromStr; + use collab::core::any_map::AnyMapExtension; use collab_database::rows::{new_cell_builder, Cell}; -use flowy_error::FlowyResult; +use flowy_error::FlowyError; use crate::entities::FieldType; -use crate::services::cell::{DecodedCellData, FromCellString}; use crate::services::field::{TypeOptionCellData, CELL_DATA}; pub const SELECTION_IDS_SEPARATOR: &str = ","; @@ -37,33 +38,25 @@ impl TypeOptionCellData for SelectOptionIds { } } -impl FromCellString for SelectOptionIds { - fn from_cell_str(s: &str) -> FlowyResult - where - Self: Sized, - { - Ok(Self::from(s.to_owned())) - } -} - impl From<&Cell> for SelectOptionIds { fn from(cell: &Cell) -> Self { let value = cell.get_str_value(CELL_DATA).unwrap_or_default(); - Self::from(value) + Self::from_str(&value).unwrap_or_default() } } -impl std::convert::From for SelectOptionIds { - fn from(s: String) -> Self { - if s.is_empty() { - return Self(vec![]); - } +impl FromStr for SelectOptionIds { + type Err = FlowyError; + fn from_str(s: &str) -> Result { + if s.is_empty() { + return Ok(Self(vec![])); + } let ids = s .split(SELECTION_IDS_SEPARATOR) .map(|id| id.to_string()) .collect::>(); - Self(ids) + Ok(Self(ids)) } } @@ -89,7 +82,7 @@ impl std::convert::From> for SelectOptionIds { fn from(s: Option) -> Self { match s { None => Self(vec![]), - Some(s) => Self::from(s), + Some(s) => Self::from_str(&s).unwrap_or_default(), } } } @@ -107,11 +100,3 @@ impl std::ops::DerefMut for SelectOptionIds { &mut self.0 } } - -impl DecodedCellData for SelectOptionIds { - type Object = SelectOptionIds; - - fn is_empty(&self) -> bool { - self.0.is_empty() - } -} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_type_option.rs index 367284161b..9ea8990ebb 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_type_option.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use bytes::Bytes; use collab_database::fields::{Field, TypeOptionData}; use collab_database::rows::Cell; @@ -5,7 +7,7 @@ use collab_database::rows::Cell; use flowy_error::{internal_error, ErrorCode, FlowyResult}; use crate::entities::{CheckboxCellDataPB, FieldType, SelectOptionCellDataPB}; -use crate::services::cell::{CellDataDecoder, CellProtobufBlobParser, DecodedCellData}; +use crate::services::cell::{CellDataDecoder, CellProtobufBlobParser}; use crate::services::field::selection_type_option::type_option_transform::SelectOptionTypeOptionTransformHelper; use crate::services::field::{ make_selected_options, MultiSelectTypeOption, SelectOption, SelectOptionCellData, @@ -205,20 +207,12 @@ impl CellProtobufBlobParser for SelectOptionIdsParser { type Object = SelectOptionIds; fn parser(bytes: &Bytes) -> FlowyResult { match String::from_utf8(bytes.to_vec()) { - Ok(s) => Ok(SelectOptionIds::from(s)), - Err(_) => Ok(SelectOptionIds::from("".to_owned())), + Ok(s) => SelectOptionIds::from_str(&s), + Err(_) => Ok(SelectOptionIds::default()), } } } -impl DecodedCellData for SelectOptionCellDataPB { - type Object = SelectOptionCellDataPB; - - fn is_empty(&self) -> bool { - self.select_options.is_empty() - } -} - pub struct SelectOptionCellDataParser(); impl CellProtobufBlobParser for SelectOptionCellDataParser { type Object = SelectOptionCellDataPB; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/single_select_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/single_select_type_option.rs index c047922b3a..2925dc04ef 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/single_select_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/single_select_type_option.rs @@ -116,12 +116,8 @@ impl TypeOptionCellDataFilter for SingleSelectTypeOption { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool { - if !field_type.is_single_select() { - return true; - } let selected_options = self.get_selected_options(cell_data.clone()).select_options; filter.is_visible(&selected_options, FieldType::SingleSelect) } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs index 5f39f6ad24..b18664e32e 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs @@ -10,8 +10,7 @@ use flowy_error::{FlowyError, FlowyResult}; use crate::entities::{FieldType, TextFilterPB}; use crate::services::cell::{ - stringify_cell_data, CellDataChangeset, CellDataDecoder, CellProtobufBlobParser, DecodedCellData, - FromCellString, + stringify_cell_data, CellDataChangeset, CellDataDecoder, CellProtobufBlobParser, }; use crate::services::field::type_options::util::ProtobufStr; use crate::services::field::{ @@ -145,13 +144,8 @@ impl TypeOptionCellDataFilter for RichTextTypeOption { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool { - if !field_type.is_text() { - return false; - } - filter.is_visible(cell_data) } } @@ -191,29 +185,12 @@ impl std::ops::Deref for TextCellData { } } -impl FromCellString for TextCellData { - fn from_cell_str(s: &str) -> FlowyResult - where - Self: Sized, - { - Ok(TextCellData(s.to_owned())) - } -} - impl ToString for TextCellData { fn to_string(&self) -> String { self.0.clone() } } -impl DecodedCellData for TextCellData { - type Object = TextCellData; - - fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - pub struct TextCellDataParser(); impl CellProtobufBlobParser for TextCellDataParser { type Object = TextCellData; @@ -261,12 +238,6 @@ impl std::ops::DerefMut for StrCellData { } } -impl FromCellString for StrCellData { - fn from_cell_str(s: &str) -> FlowyResult { - Ok(Self(s.to_owned())) - } -} - impl std::convert::From for StrCellData { fn from(s: String) -> Self { Self(s) diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/timestamp_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/timestamp_type_option.rs index a5b78a6d89..01eb06835f 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/timestamp_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/timestamp_type_option.rs @@ -175,12 +175,8 @@ impl TypeOptionCellDataFilter for TimestampTypeOption { fn apply_filter( &self, _filter: &::CellFilter, - field_type: &FieldType, _cell_data: &::CellData, ) -> bool { - if !field_type.is_last_edited_time() && !field_type.is_created_time() { - return true; - } false } } 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..172b192728 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,30 +10,29 @@ 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; pub trait TypeOption { - /// `CellData` represents as the decoded model for current type option. Each of them impl the - /// `FromCellString` and `Default` trait. If the cell string can not be decoded into the specified - /// cell data type then the default value will be returned. - /// For example: + /// `CellData` represents the decoded model for the current type option. Each of them must + /// implement the From<&Cell> trait. If the `Cell` cannot be decoded into this type, the default + /// value will be returned. + /// + /// Note: Use `StrCellData` for any `TypeOption` whose cell data is simply `String`. /// /// - FieldType::Checkbox => CheckboxCellData /// - FieldType::Date => DateCellData /// - FieldType::URL => URLCellData /// - /// Uses `StrCellData` for any `TypeOption` if their cell data is pure `String`. - /// type CellData: for<'a> From<&'a Cell> + TypeOptionCellData + ToString @@ -118,7 +117,7 @@ pub trait TypeOptionTransform: TypeOption { /// /// # Arguments /// - /// * `cell_str`: the cell string of the current field type + /// * `cell`: the cell in the current field type /// * `transformed_field_type`: the cell will be transformed to the is field type's cell data. /// current `TypeOption` field type. /// @@ -136,7 +135,6 @@ pub trait TypeOptionCellDataFilter: TypeOption + CellDataDecoder { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool; } @@ -202,6 +200,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 +258,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 +283,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..5492d92194 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; @@ -32,7 +33,7 @@ pub const CELL_DATA: &str = "data"; /// 2. there are no generic types parameters. /// pub trait TypeOptionCellDataHandler: Send + Sync + 'static { - fn handle_cell_str( + fn handle_cell_protobuf( &self, cell: &Cell, decoded_field_type: &FieldType, @@ -223,7 +224,7 @@ where + Sync + 'static, { - fn handle_cell_str( + fn handle_cell_protobuf( &self, cell: &Cell, decoded_field_type: &FieldType, @@ -312,7 +313,7 @@ where let filter_cache = self.cell_filter_cache.as_ref()?.read(); let cell_filter = filter_cache.get::<::CellFilter>(&field.id)?; let cell_data = self.get_decoded_cell_data(cell, field_type, field).ok()?; - Some(self.apply_filter(cell_filter, field_type, &cell_data)) + Some(self.apply_filter(cell_filter, &cell_data)) }; perform_filter().unwrap_or(true) @@ -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/field/type_options/url_type_option/url_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option.rs index b236b31018..447b5cbf56 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option.rs @@ -112,13 +112,8 @@ impl TypeOptionCellDataFilter for URLTypeOption { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool { - if !field_type.is_url() { - return true; - } - filter.is_visible(cell_data) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option_entities.rs index 442a6f062b..facc4bfd2e 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option_entities.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use flowy_error::{internal_error, FlowyResult}; use crate::entities::{FieldType, URLCellDataPB}; -use crate::services::cell::{CellProtobufBlobParser, DecodedCellData, FromCellString}; +use crate::services::cell::CellProtobufBlobParser; use crate::services::field::{TypeOptionCellData, CELL_DATA}; #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -60,14 +60,6 @@ impl From for URLCellDataPB { } } -impl DecodedCellData for URLCellDataPB { - type Object = URLCellDataPB; - - fn is_empty(&self) -> bool { - self.content.is_empty() - } -} - impl From for URLCellData { fn from(data: URLCellDataPB) -> Self { Self { @@ -83,14 +75,6 @@ impl AsRef for URLCellData { } } -impl DecodedCellData for URLCellData { - type Object = URLCellData; - - fn is_empty(&self) -> bool { - self.data.is_empty() - } -} - pub struct URLCellDataParser(); impl CellProtobufBlobParser for URLCellDataParser { type Object = URLCellDataPB; @@ -100,12 +84,6 @@ impl CellProtobufBlobParser for URLCellDataParser { } } -impl FromCellString for URLCellData { - fn from_cell_str(s: &str) -> FlowyResult { - serde_json::from_str::(s).map_err(internal_error) - } -} - impl ToString for URLCellData { fn to_string(&self) -> String { self.to_json().unwrap() 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!(