mirror of
				https://github.com/AppFlowy-IO/AppFlowy.git
				synced 2025-10-31 10:03:18 +00:00 
			
		
		
		
	 068500df84
			
		
	
	
		068500df84
		
			
		
	
	
	
	
		
			
			* feat: inline sub page mention * fix: disable editing documents in trash * fix: duplicate block behavior * refactor: clean up code * feat: use formatText function instead of modify delta manually * fix: paste behavior format mention * fix: default icon for mentioned pages * fix: view new parent turn into page reference * test: add base test * chore: add feature flag * chore: default flag to on * fix: minor fixes to behavior * fix: review and code cleanup * fix: dart linter * fix: content is required * test: use doc title to rename page * test: add test coverage * test: fix wrong expect --------- Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
		
			
				
	
	
		
			438 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			438 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:async';
 | |
| import 'dart:ui';
 | |
| 
 | |
| import 'package:appflowy/generated/locale_keys.g.dart';
 | |
| import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
 | |
| import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
 | |
| import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
 | |
| import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart';
 | |
| import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart';
 | |
| import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart';
 | |
| import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart';
 | |
| import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
 | |
| import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
 | |
| import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_title.dart';
 | |
| import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart';
 | |
| import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
 | |
| import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart';
 | |
| import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
 | |
| import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
 | |
| import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
 | |
| import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
 | |
| import 'package:appflowy_editor/appflowy_editor.dart';
 | |
| import 'package:easy_localization/easy_localization.dart';
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:flutter/services.dart';
 | |
| import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
 | |
| import 'package:flutter_test/flutter_test.dart';
 | |
| import 'package:universal_platform/universal_platform.dart';
 | |
| 
 | |
| import 'util.dart';
 | |
| 
 | |
| extension EditorWidgetTester on WidgetTester {
 | |
|   EditorOperations get editor => EditorOperations(this);
 | |
| }
 | |
| 
 | |
| class EditorOperations {
 | |
|   const EditorOperations(this.tester);
 | |
| 
 | |
|   final WidgetTester tester;
 | |
| 
 | |
|   EditorState getCurrentEditorState() =>
 | |
|       tester.widget<AppFlowyEditor>(find.byType(AppFlowyEditor)).editorState;
 | |
| 
 | |
|   Node getNodeAtPath(Path path) {
 | |
|     final editorState = getCurrentEditorState();
 | |
|     return editorState.getNodeAtPath(path)!;
 | |
|   }
 | |
| 
 | |
|   /// Tap the line of editor at [index]
 | |
|   Future<void> tapLineOfEditorAt(int index) async {
 | |
|     final textBlocks = find.byType(AppFlowyRichText);
 | |
|     index = index.clamp(0, textBlocks.evaluate().length - 1);
 | |
|     final center = tester.getCenter(textBlocks.at(index));
 | |
|     final right = tester.getTopRight(textBlocks.at(index));
 | |
|     final centerRight = Offset(right.dx, center.dy);
 | |
|     await tester.tapAt(centerRight);
 | |
|     await tester.pumpAndSettle();
 | |
|   }
 | |
| 
 | |
|   /// Hover on cover plugin button above the document
 | |
|   Future<void> hoverOnCoverToolbar() async {
 | |
|     final coverToolbar = find.byType(DocumentHeaderToolbar);
 | |
|     await tester.startGesture(
 | |
|       tester.getBottomLeft(coverToolbar).translate(5, -5),
 | |
|       kind: PointerDeviceKind.mouse,
 | |
|     );
 | |
|     await tester.pumpAndSettle();
 | |
|   }
 | |
| 
 | |
|   /// Taps on the 'Add Icon' button in the cover toolbar
 | |
|   Future<void> tapAddIconButton() async {
 | |
|     await tester.tapButtonWithName(
 | |
|       LocaleKeys.document_plugins_cover_addIcon.tr(),
 | |
|     );
 | |
|     expect(find.byType(FlowyEmojiPicker), findsOneWidget);
 | |
|   }
 | |
| 
 | |
|   Future<void> paste() async {
 | |
|     if (UniversalPlatform.isMacOS) {
 | |
|       await tester.simulateKeyEvent(
 | |
|         LogicalKeyboardKey.keyV,
 | |
|         isMetaPressed: true,
 | |
|       );
 | |
|     } else {
 | |
|       await tester.simulateKeyEvent(
 | |
|         LogicalKeyboardKey.keyV,
 | |
|         isControlPressed: true,
 | |
|       );
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Future<void> tapGettingStartedIcon() async {
 | |
|     await tester.tapButton(
 | |
|       find.descendant(
 | |
|         of: find.byType(DocumentCoverWidget),
 | |
|         matching: find.findTextInFlowyText('⭐️'),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   /// Taps on the 'Skin tone' button
 | |
|   ///
 | |
|   /// Must call [tapAddIconButton] first.
 | |
|   Future<void> changeEmojiSkinTone(EmojiSkinTone skinTone) async {
 | |
|     await tester.tapButton(
 | |
|       find.byTooltip(LocaleKeys.emoji_selectSkinTone.tr()),
 | |
|     );
 | |
|     final skinToneButton = find.byKey(emojiSkinToneKey(skinTone.icon));
 | |
|     await tester.tapButton(skinToneButton);
 | |
|   }
 | |
| 
 | |
|   /// Taps the 'Remove Icon' button in the cover toolbar and the icon popover
 | |
|   Future<void> tapRemoveIconButton({bool isInPicker = false}) async {
 | |
|     final Finder button = !isInPicker
 | |
|         ? find.text(LocaleKeys.document_plugins_cover_removeIcon.tr())
 | |
|         : find.descendant(
 | |
|             of: find.byType(FlowyIconEmojiPicker),
 | |
|             matching: find.text(LocaleKeys.button_remove.tr()),
 | |
|           );
 | |
|     await tester.tapButton(button);
 | |
|   }
 | |
| 
 | |
|   /// Requires that the document must already have an icon. This opens the icon
 | |
|   /// picker
 | |
|   Future<void> tapOnIconWidget() async {
 | |
|     final iconWidget = find.byType(EmojiIconWidget);
 | |
|     await tester.tapButton(iconWidget);
 | |
|   }
 | |
| 
 | |
|   Future<void> tapOnAddCover() async {
 | |
|     await tester.tapButtonWithName(
 | |
|       LocaleKeys.document_plugins_cover_addCover.tr(),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Future<void> tapOnChangeCover() async {
 | |
|     await tester.tapButtonWithName(
 | |
|       LocaleKeys.document_plugins_cover_changeCover.tr(),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Future<void> switchSolidColorBackground() async {
 | |
|     final findPurpleButton = find.byWidgetPredicate(
 | |
|       (widget) => widget is ColorItem && widget.option.name == 'Purple',
 | |
|     );
 | |
|     await tester.tapButton(findPurpleButton);
 | |
|   }
 | |
| 
 | |
|   Future<void> addNetworkImageCover(String imageUrl) async {
 | |
|     final embedLinkButton = find.findTextInFlowyText(
 | |
|       LocaleKeys.document_imageBlock_embedLink_label.tr(),
 | |
|     );
 | |
|     await tester.tapButton(embedLinkButton);
 | |
| 
 | |
|     final imageUrlTextField = find.descendant(
 | |
|       of: find.byType(EmbedImageUrlWidget),
 | |
|       matching: find.byType(TextField),
 | |
|     );
 | |
|     await tester.enterText(imageUrlTextField, imageUrl);
 | |
|     await tester.pumpAndSettle();
 | |
|     await tester.tapButton(
 | |
|       find.descendant(
 | |
|         of: find.byType(EmbedImageUrlWidget),
 | |
|         matching: find.findTextInFlowyText(
 | |
|           LocaleKeys.document_imageBlock_embedLink_label.tr(),
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Future<void> tapOnRemoveCover() async =>
 | |
|       tester.tapButton(find.byType(DeleteCoverButton));
 | |
| 
 | |
|   /// A cover must be present in the document to function properly since this
 | |
|   /// catches all cover types collectively
 | |
|   Future<void> hoverOnCover() async {
 | |
|     final cover = find.byType(DocumentCover);
 | |
|     await tester.startGesture(
 | |
|       tester.getCenter(cover),
 | |
|       kind: PointerDeviceKind.mouse,
 | |
|     );
 | |
|     await tester.pumpAndSettle();
 | |
|   }
 | |
| 
 | |
|   Future<void> dismissCoverPicker() async {
 | |
|     await tester.sendKeyEvent(LogicalKeyboardKey.escape);
 | |
|     await tester.pumpAndSettle();
 | |
|   }
 | |
| 
 | |
|   /// trigger the slash command (selection menu)
 | |
|   Future<void> showSlashMenu() async {
 | |
|     await tester.ime.insertCharacter('/');
 | |
|   }
 | |
| 
 | |
|   /// trigger the mention (@) command
 | |
|   Future<void> showAtMenu() async {
 | |
|     await tester.ime.insertCharacter('@');
 | |
|   }
 | |
| 
 | |
|   /// trigger the plus action menu (+) command
 | |
|   Future<void> showPlusMenu() async {
 | |
|     await tester.ime.insertCharacter('+');
 | |
|   }
 | |
| 
 | |
|   /// Tap the slash menu item with [name]
 | |
|   ///
 | |
|   /// Must call [showSlashMenu] first.
 | |
|   Future<void> tapSlashMenuItemWithName(
 | |
|     String name, {
 | |
|     double offset = 200,
 | |
|   }) async {
 | |
|     final slashMenu = find
 | |
|         .ancestor(
 | |
|           of: find.byType(SelectionMenuItemWidget),
 | |
|           matching: find.byWidgetPredicate(
 | |
|             (widget) => widget is Scrollable,
 | |
|           ),
 | |
|         )
 | |
|         .first;
 | |
|     final slashMenuItem = find.text(name, findRichText: true);
 | |
|     await tester.scrollUntilVisible(
 | |
|       slashMenuItem,
 | |
|       offset,
 | |
|       scrollable: slashMenu,
 | |
|       duration: const Duration(milliseconds: 250),
 | |
|     );
 | |
|     assert(slashMenuItem.hasFound);
 | |
|     await tester.tapButton(slashMenuItem);
 | |
|   }
 | |
| 
 | |
|   /// Tap the at menu item with [name]
 | |
|   ///
 | |
|   /// Must call [showAtMenu] first.
 | |
|   Future<void> tapAtMenuItemWithName(String name) async {
 | |
|     final atMenuItem = find.descendant(
 | |
|       of: find.byType(InlineActionsHandler),
 | |
|       matching: find.text(name, findRichText: true),
 | |
|     );
 | |
|     await tester.tapButton(atMenuItem);
 | |
|   }
 | |
| 
 | |
|   /// Update the editor's selection
 | |
|   Future<void> updateSelection(Selection? selection) async {
 | |
|     final editorState = getCurrentEditorState();
 | |
|     unawaited(
 | |
|       editorState.updateSelectionWithReason(
 | |
|         selection,
 | |
|         reason: SelectionUpdateReason.uiEvent,
 | |
|       ),
 | |
|     );
 | |
|     await tester.pumpAndSettle(const Duration(milliseconds: 200));
 | |
|   }
 | |
| 
 | |
|   /// hover and click on the + button beside the block component.
 | |
|   Future<void> hoverAndClickOptionAddButton(
 | |
|     Path path,
 | |
|     bool withModifiedKey, // alt on windows or linux, option on macos
 | |
|   ) async {
 | |
|     final optionAddButton = find.byWidgetPredicate(
 | |
|       (widget) =>
 | |
|           widget is BlockComponentActionWrapper &&
 | |
|           widget.node.path.equals(path),
 | |
|     );
 | |
|     await tester.hoverOnWidget(
 | |
|       optionAddButton,
 | |
|       onHover: () async {
 | |
|         if (withModifiedKey) {
 | |
|           await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
 | |
|         }
 | |
|         await tester.tapButton(
 | |
|           find.byWidgetPredicate(
 | |
|             (widget) =>
 | |
|                 widget is BlockAddButton &&
 | |
|                 widget.blockComponentContext.node.path.equals(path),
 | |
|           ),
 | |
|         );
 | |
|         if (withModifiedKey) {
 | |
|           await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
 | |
|         }
 | |
|       },
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   /// hover and click on the option menu button beside the block component.
 | |
|   Future<void> hoverAndClickOptionMenuButton(Path path) async {
 | |
|     final optionMenuButton = find.byWidgetPredicate(
 | |
|       (widget) =>
 | |
|           widget is BlockComponentActionWrapper &&
 | |
|           widget.node.path.equals(path),
 | |
|     );
 | |
|     await tester.hoverOnWidget(
 | |
|       optionMenuButton,
 | |
|       onHover: () async {
 | |
|         await tester.tapButton(
 | |
|           find.byWidgetPredicate(
 | |
|             (widget) =>
 | |
|                 widget is BlockOptionButton &&
 | |
|                 widget.blockComponentContext.node.path.equals(path),
 | |
|           ),
 | |
|         );
 | |
|         await tester.pumpUntilFound(find.byType(PopoverActionList));
 | |
|       },
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   /// open the turn into menu
 | |
|   Future<void> openTurnIntoMenu(Path path) async {
 | |
|     await hoverAndClickOptionMenuButton(path);
 | |
|     await tester.tapButton(
 | |
|       find.findTextInFlowyText(
 | |
|         LocaleKeys.document_plugins_optionAction_turnInto.tr(),
 | |
|       ),
 | |
|     );
 | |
|     await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu));
 | |
|   }
 | |
| 
 | |
|   /// copy link to block
 | |
|   Future<void> copyLinkToBlock(Path path) async {
 | |
|     await hoverAndClickOptionMenuButton(path);
 | |
|     await tester.tapButton(
 | |
|       find.findTextInFlowyText(
 | |
|         LocaleKeys.document_plugins_optionAction_copyLinkToBlock.tr(),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Future<void> openDepthMenu(Path path) async {
 | |
|     await hoverAndClickOptionMenuButton(path);
 | |
|     await tester.tapButton(
 | |
|       find.findTextInFlowyText(
 | |
|         LocaleKeys.document_plugins_optionAction_depth.tr(),
 | |
|       ),
 | |
|     );
 | |
|     await tester.pumpUntilFound(find.byType(DepthOptionMenu));
 | |
|   }
 | |
| 
 | |
|   /// Drag block
 | |
|   ///
 | |
|   /// [offset] is the offset to move the block.
 | |
|   ///
 | |
|   /// [path] is the path of the block to move.
 | |
|   Future<void> dragBlock(
 | |
|     Path path,
 | |
|     Offset offset,
 | |
|   ) async {
 | |
|     final dragToMoveAction = find.byWidgetPredicate(
 | |
|       (widget) =>
 | |
|           widget is DraggableOptionButton &&
 | |
|           widget.blockComponentContext.node.path.equals(path),
 | |
|     );
 | |
| 
 | |
|     await tester.hoverOnWidget(
 | |
|       dragToMoveAction,
 | |
|       onHover: () async {
 | |
|         final dragToMoveTooltip = find.findFlowyTooltip(
 | |
|           LocaleKeys.blockActions_dragTooltip.tr(),
 | |
|         );
 | |
|         await tester.pumpUntilFound(dragToMoveTooltip);
 | |
|         final location = tester.getCenter(dragToMoveAction);
 | |
|         final gesture = await tester.startGesture(
 | |
|           location,
 | |
|           pointer: 7,
 | |
|         );
 | |
|         await tester.pump();
 | |
| 
 | |
|         // divide the steps to small move to avoid the drag area not found error
 | |
|         const steps = 5;
 | |
|         final stepOffset = Offset(offset.dx / steps, offset.dy / steps);
 | |
| 
 | |
|         for (var i = 0; i < steps; i++) {
 | |
|           await gesture.moveBy(stepOffset);
 | |
|           await tester.pump(Durations.short1);
 | |
|         }
 | |
| 
 | |
|         // check if the drag to move action is dragging
 | |
|         expect(
 | |
|           isDraggingAppFlowyEditorBlock.value,
 | |
|           isTrue,
 | |
|         );
 | |
| 
 | |
|         await gesture.up();
 | |
|         await tester.pump();
 | |
|       },
 | |
|     );
 | |
|     await tester.pumpAndSettle(Durations.short1);
 | |
|   }
 | |
| 
 | |
|   Finder findDocumentTitle(String? title) {
 | |
|     final parent = UniversalPlatform.isDesktop
 | |
|         ? find.byType(CoverTitle)
 | |
|         : find.byType(DocumentImmersiveCover);
 | |
| 
 | |
|     return find.descendant(
 | |
|       of: parent,
 | |
|       matching: find.byWidgetPredicate(
 | |
|         (widget) {
 | |
|           if (widget is! TextField) {
 | |
|             return false;
 | |
|           }
 | |
| 
 | |
|           if (widget.controller?.text == title) {
 | |
|             return true;
 | |
|           }
 | |
| 
 | |
|           if (title == null) {
 | |
|             return true;
 | |
|           }
 | |
| 
 | |
|           if (title.isEmpty) {
 | |
|             return widget.controller?.text.isEmpty ?? false;
 | |
|           }
 | |
| 
 | |
|           return false;
 | |
|         },
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   /// open the more action menu on mobile
 | |
|   Future<void> openMoreActionMenuOnMobile() async {
 | |
|     final moreActionButton = find.byType(MobileViewPageMoreButton);
 | |
|     await tester.tapButton(moreActionButton);
 | |
|     await tester.pumpAndSettle();
 | |
|   }
 | |
| 
 | |
|   /// click the more action item on mobile
 | |
|   ///
 | |
|   /// rename, add collaborator, publish, delete, etc.
 | |
|   Future<void> clickMoreActionItemOnMobile(String name) async {
 | |
|     final moreActionItem = find.descendant(
 | |
|       of: find.byType(MobileQuickActionButton),
 | |
|       matching: find.findTextInFlowyText(name),
 | |
|     );
 | |
|     await tester.tapButton(moreActionItem);
 | |
|     await tester.pumpAndSettle();
 | |
|   }
 | |
| }
 |