浏览代码

Made showPanel and hidePanel public and refactored tests.

Oskar Wróbel 8 年之前
父节点
当前提交
529b24d83b
共有 2 个文件被更改,包括 318 次插入243 次删除
  1. 21 17
      packages/ckeditor5-link/src/link.js
  2. 297 226
      packages/ckeditor5-link/tests/link.js

+ 21 - 17
packages/ckeditor5-link/src/link.js

@@ -90,21 +90,21 @@ export default class Link extends Plugin {
 		// Execute link command after clicking on formView `Save` button.
 		this.listenTo( formView, 'submit', () => {
 			editor.execute( 'link', formView.urlInputView.inputView.element.value );
-			this._hidePanel( true );
+			this.hidePanel( true );
 		} );
 
 		// Execute unlink command after clicking on formView `Unlink` button.
 		this.listenTo( formView, 'unlink', () => {
 			editor.execute( 'unlink' );
-			this._hidePanel( true );
+			this.hidePanel( true );
 		} );
 
 		// Hide the panel after clicking on formView `Cancel` button.
-		this.listenTo( formView, 'cancel', () => this._hidePanel( true ) );
+		this.listenTo( formView, 'cancel', () => this.hidePanel( true ) );
 
 		// Close the panel on esc key press when the form has focus.
 		formView.keystrokes.set( 'Esc', ( data, cancel ) => {
-			this._hidePanel( true );
+			this.hidePanel( true );
 			cancel();
 		} );
 
@@ -123,7 +123,7 @@ export default class Link extends Plugin {
 		const t = editor.t;
 
 		// Handle `Ctrl+K` keystroke and show the panel.
-		editor.keystrokes.set( 'CTRL+K', () => this._showPanel( true ) );
+		editor.keystrokes.set( 'CTRL+K', () => this.showPanel( true ) );
 
 		editor.ui.componentFactory.add( 'link', ( locale ) => {
 			const button = new ButtonView( locale );
@@ -138,7 +138,7 @@ export default class Link extends Plugin {
 			button.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' );
 
 			// Show the panel on button click.
-			this.listenTo( button, 'execute', () => this._showPanel( true ) );
+			this.listenTo( button, 'execute', () => this.showPanel( true ) );
 
 			return button;
 		} );
@@ -191,7 +191,7 @@ export default class Link extends Plugin {
 			// When collapsed selection is inside link element (link element is clicked).
 			if ( viewSelection.isCollapsed && parentLink ) {
 				// Then show panel but keep focus inside editor editable.
-				this._showPanel();
+				this.showPanel();
 
 				// Avoid duplication of the same listener.
 				this.stopListening( viewDocument, 'render' );
@@ -202,7 +202,7 @@ export default class Link extends Plugin {
 					const currentParentLink = getPositionParentLink( viewSelection.getFirstPosition() );
 
 					if ( !viewSelection.isCollapsed || parentLink !== currentParentLink ) {
-						this._hidePanel();
+						this.hidePanel();
 					} else {
 						this._balloon.updatePosition();
 					}
@@ -221,7 +221,7 @@ export default class Link extends Plugin {
 		// Close the panel on the Esc key press when the editable has focus and the balloon is visible.
 		this.editor.keystrokes.set( 'Esc', ( data, cancel ) => {
 			if ( this._balloon.visibleView === this.formView ) {
-				this._hidePanel();
+				this.hidePanel();
 				cancel();
 			}
 		} );
@@ -231,19 +231,24 @@ export default class Link extends Plugin {
 			emitter: this.formView,
 			activator: () => this._balloon.hasView( this.formView ),
 			contextElement: this._balloon.view.element,
-			callback: () => this._hidePanel()
+			callback: () => this.hidePanel()
 		} );
 	}
 
 	/**
 	 * Adds the {@link #formView} to the {@link #_balloon}.
+	 * When view is already added then try to focus it `focusInput` parameter is set as true.
 	 *
-	 * @private
 	 * @param {Boolean} [focusInput=false] When `true`, link form will be focused on panel show.
 	 * @return {Promise} A promise resolved when the {#formView} {@link module:ui/view~View#init} is done.
 	 */
-	_showPanel( focusInput ) {
+	showPanel( focusInput ) {
 		if ( this._balloon.hasView( this.formView ) ) {
+			// Check if formView should be focused and focus it if is visible.
+			if ( focusInput && this._balloon.visibleView === this.formView ) {
+				this.formView.urlInputView.select();
+			}
+
 			return Promise.resolve();
 		}
 
@@ -260,20 +265,19 @@ export default class Link extends Plugin {
 	/**
 	 * Removes the {@link #formView} from the {@link #_balloon}.
 	 *
-	 * @private
 	 * @param {Boolean} [focusEditable=false] When `true`, editable focus will be restored on panel hide.
 	 */
-	_hidePanel( focusEditable ) {
+	hidePanel( focusEditable ) {
 		if ( !this._balloon.hasView( this.formView ) ) {
 			return;
 		}
 
-		this._balloon.remove( this.formView );
-		this.stopListening( this.editor.editing.view, 'render' );
-
 		if ( focusEditable ) {
 			this.editor.editing.view.focus();
 		}
+
+		this.stopListening( this.editor.editing.view, 'render' );
+		this._balloon.remove( this.formView );
 	}
 
 	/**

+ 297 - 226
packages/ckeditor5-link/tests/link.js

@@ -10,6 +10,7 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Link from '../src/link';
 import LinkEngine from '../src/linkengine';
 import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
@@ -28,7 +29,7 @@ describe( 'Link', () => {
 		document.body.appendChild( editorElement );
 
 		return ClassicTestEditor.create( editorElement, {
-			plugins: [ Link ]
+			plugins: [ Link, Paragraph ]
 		} )
 		.then( newEditor => {
 			newEditor.editing.view.attachDomRoot( editorElement );
@@ -69,81 +70,211 @@ describe( 'Link', () => {
 		expect( editor.editing.view.getObserver( ClickObserver ) ).to.instanceOf( ClickObserver );
 	} );
 
-	describe( 'link toolbar button', () => {
-		it( 'should register link button', () => {
-			expect( linkButton ).to.instanceOf( ButtonView );
+	describe( 'showPanel()', () => {
+		let balloonAddSpy;
+
+		beforeEach( () => {
+			balloonAddSpy = testUtils.sinon.spy( balloon, 'add' );
+			editor.editing.view.isFocused = true;
 		} );
 
-		it( 'should bind linkButtonView to link command', () => {
-			const command = editor.commands.get( 'link' );
+		it( 'should return promise', () => {
+			// @TODO: test resolved promise.
+			expect( linkFeature.showPanel() ).to.instanceof( Promise );
+		} );
 
-			command.isEnabled = true;
-			expect( linkButton.isEnabled ).to.be.true;
+		it( 'should add `formView` to the `ContextualBalloon` and attach panel to the selection when text fragment is selected', () => {
+			setModelData( editor.document, '<paragraph>f[o]o</paragraph>' );
+			const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 );
 
-			command.isEnabled = false;
-			expect( linkButton.isEnabled ).to.be.false;
+			return linkFeature.showPanel()
+				.then( () => {
+					expect( balloon.visibleView ).to.equal( formView );
+					sinon.assert.calledWithExactly( balloonAddSpy, {
+						view: formView,
+						position: {
+							target: selectedRange,
+							limiter: editorElement
+						}
+					} );
+				} );
 		} );
 
-		it( 'should add link form to the ContextualBalloon on execute event', () => {
-			linkButton.fire( 'execute' );
+		it( 'should add `formView` to the `ContextualBalloon` and attach panel to the selection when selection is collapsed', () => {
+			setModelData( editor.document, '<paragraph>f[]oo</paragraph>' );
+			const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 );
 
-			expect( balloon.visibleView ).to.equal( formView );
+			return linkFeature.showPanel()
+				.then( () => {
+					expect( balloon.visibleView ).to.equal( formView );
+					sinon.assert.calledWithExactly( balloonAddSpy, {
+						view: formView,
+						position: {
+							target: selectedRange,
+							limiter: editorElement
+						}
+					} );
+				} );
 		} );
 
-		it( 'should add link form to the ContextualBalloon and attach balloon to the link element ' +
-			'when collapsed selection is inside link element',
+		it( 'should add `formView` to the `ContextualBalloon` and attach panel to the link element when collapsed selection is inside ' +
+			'link element',
 		() => {
-			const balloonAddSpy = testUtils.sinon.spy( balloon, 'add' );
+			setModelData( editor.document, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
+			const linkElement = editorElement.querySelector( 'a' );
 
-			editor.document.schema.allow( { name: '$text', inside: '$root' } );
-			setModelData( editor.document, '<$text linkHref="url">some[] url</$text>' );
-			editor.editing.view.isFocused = true;
+			return linkFeature.showPanel()
+				.then( () => {
+					expect( balloon.visibleView ).to.equal( formView );
+					sinon.assert.calledWithExactly( balloonAddSpy, {
+						view: formView,
+						position: {
+							target: linkElement,
+							limiter: editorElement
+						}
+					} );
+				} );
+		} );
 
-			linkButton.fire( 'execute' );
+		it( 'should not focus `formView` at default', () => {
+			const spy = testUtils.sinon.spy( formView.urlInputView, 'select' );
 
-			const linkElement = editorElement.querySelector( 'a' );
+			return linkFeature.showPanel()
+				.then( () => {
+					sinon.assert.notCalled( spy );
+				} );
+		} );
 
-			sinon.assert.calledWithExactly( balloonAddSpy, {
-				view: formView,
-				position: {
-					target: linkElement,
-					limiter: editorElement
-				}
-			} );
+		it( 'should not focus `formView` when is called with `false` parameter', () => {
+			const spy = testUtils.sinon.spy( formView.urlInputView, 'select' );
+
+			return linkFeature.showPanel( false )
+				.then( () => {
+					sinon.assert.notCalled( spy );
+				} );
 		} );
 
-		it( 'should add link form to the ContextualBalloon and attach balloon to the selection, when selection is non-collapsed', () => {
-			const balloonAddSpy = testUtils.sinon.spy( balloon, 'add' );
+		it( 'should not focus `formView` when is called with `true` parameter while balloon is opened but link form is not visible', () => {
+			const spy = testUtils.sinon.spy( formView.urlInputView, 'select' );
+			const viewMock = {
+				ready: true,
+				init: () => {},
+				destroy: () => {}
+			};
 
-			editor.document.schema.allow( { name: '$text', inside: '$root' } );
-			setModelData( editor.document, 'so[me ur]l' );
-			editor.editing.view.isFocused = true;
+			return linkFeature.showPanel( false )
+				.then( () => balloon.add( { view: viewMock } ) )
+				.then( () => linkFeature.showPanel( true ) )
+				.then( () => {
+					sinon.assert.notCalled( spy );
+				} );
+		} );
 
-			linkButton.fire( 'execute' );
+		it( 'should focus `formView` when is called with `true` parameter', () => {
+			const spy = testUtils.sinon.spy( formView.urlInputView, 'select' );
 
-			const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 );
+			return linkFeature.showPanel( true )
+				.then( () => {
+					sinon.assert.calledOnce( spy );
+				} );
+		} );
 
-			sinon.assert.calledWithExactly( balloonAddSpy, {
-				view: formView,
-				position: {
-					target: selectedRange,
-					limiter: editorElement
-				}
-			} );
+		it( 'should focus `formView` when is called with `true` parameter while balloon is opened and linkForm is visible', () => {
+			const spy = testUtils.sinon.spy( formView.urlInputView, 'select' );
+
+			return linkFeature.showPanel( false )
+				.then( () => linkFeature.showPanel( true ) )
+				.then( () => {
+					sinon.assert.calledOnce( spy );
+				} );
 		} );
 
-		it( 'should select link input value when link balloon is opened', () => {
-			const selectUrlInputSpy = testUtils.sinon.spy( linkFeature.formView.urlInputView, 'select' );
+		it( 'should keep editor ui focused when panel is shown with selected form', () => {
+			editor.ui.focusTracker.isFocused = false;
 
-			editor.editing.view.isFocused = true;
+			// Open balloon panel with link inside.
+			return linkFeature.showPanel( true )
+				.then( () => {
+					// Check if editor ui is focused.
+					expect( editor.ui.focusTracker.isFocused ).to.true;
+				} );
+		} );
+	} );
+
+	describe( 'hidePanel()', () => {
+		beforeEach( () => {
+			return balloon.add( { view: formView } );
+		} );
+
+		it( 'should remove `formView` from the `ContextualBalloon` component', () => {
+			linkFeature.hidePanel();
+			expect( balloon.hasView( formView ) ).to.false;
+		} );
+
+		it( 'should not focus `editable` at default', () => {
+			const spy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+
+			linkFeature.hidePanel();
+			sinon.assert.notCalled( spy );
+		} );
+
+		it( 'should not focus `editable` when is called with `false` parameter', () => {
+			const spy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+
+			linkFeature.hidePanel( false );
+			sinon.assert.notCalled( spy );
+		} );
+
+		it( 'should focus `editable` when is called with `true` parameter', () => {
+			const spy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+
+			linkFeature.hidePanel( true );
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should do not throw an error when `formView` is not added to the `balloon`', () => {
+			linkFeature.hidePanel( true );
+
+			expect( () => {
+				linkFeature.hidePanel( true );
+			} ).to.not.throw();
+		} );
+
+		it( 'should clear `render` listener from ViewDocument', () => {
+			const spy = sinon.spy();
+
+			linkFeature.listenTo( editor.editing.view, 'render', spy );
+
+			linkFeature.hidePanel();
+
+			editor.editing.view.render();
+
+			sinon.assert.notCalled( spy );
+		} );
+	} );
+
+	describe( 'link toolbar button', () => {
+		it( 'should register link button', () => {
+			expect( linkButton ).to.instanceOf( ButtonView );
+		} );
+
+		it( 'should bind linkButtonView to link command', () => {
+			const command = editor.commands.get( 'link' );
+
+			command.isEnabled = true;
+			expect( linkButton.isEnabled ).to.be.true;
+
+			command.isEnabled = false;
+			expect( linkButton.isEnabled ).to.be.false;
+		} );
+
+		it( 'should show panel on execute event with selected `formView`', () => {
+			// Method is stubbed because it returns internal promise which can't be returned in test.
+			const spy = testUtils.sinon.stub( linkFeature, 'showPanel', () => {} );
 
 			linkButton.fire( 'execute' );
 
-			// Focus of url input is called async after internal promise resolve and we are
-			// not able to return this promise.
-			return wait().then( () => {
-				expect( selectUrlInputSpy.calledOnce ).to.true;
-			} );
+			sinon.assert.calledWithExactly( spy, true );
 		} );
 	} );
 
@@ -172,42 +303,17 @@ describe( 'Link', () => {
 		} );
 	} );
 
-	describe( 'ContextualBalloon', () => {
-		let focusEditableSpy;
-
-		beforeEach( () => {
-			focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
-		} );
-
-		it( 'should not be added to ContextualBalloon at default', () => {
-			expect( balloon.visibleView ).to.null;
-		} );
-
-		it( 'should be added to ContextualBalloon and form should be selected on `CTRL+K` keystroke', () => {
-			const selectUrlInputSpy = testUtils.sinon.spy( formView.urlInputView, 'select' );
+	describe( 'keyboard support', () => {
+		it( 'should show panel with selected `formView` on `CTRL+K` keystroke', () => {
+			// Method is stubbed because it returns internal promise which can't be returned in test.
+			const spy = testUtils.sinon.stub( linkFeature, 'showPanel', () => {} );
 
 			editor.keystrokes.press( { keyCode: keyCodes.k, ctrlKey: true } );
 
-			expect( balloon.visibleView ).to.equal( formView );
-
-			// Focus of url input is called async after internal promise resolve and we are
-			// not able to return this promise.
-			return wait().then( () => {
-				expect( selectUrlInputSpy.calledOnce ).to.true;
-			} );
-		} );
-
-		it( 'should not add panel to ContextualBalloon more than once', () => {
-			// Add panel to balloon by pressing toolbar button.
-			linkButton.fire( 'execute' );
-
-			// Press button once again.
-			expect( () => {
-				linkButton.fire( 'execute' );
-			} ).to.not.throw();
+			sinon.assert.calledWithExactly( spy, true );
 		} );
 
-		it( 'should focus the link form on Tab key press', () => {
+		it( 'should focus the `formView` on `Tab` key press when panel is open', () => {
 			const keyEvtData = {
 				keyCode: keyCodes.tab,
 				preventDefault: sinon.spy(),
@@ -225,7 +331,7 @@ describe( 'Link', () => {
 			sinon.assert.notCalled( spy );
 
 			// Balloon is visible, form focused.
-			return balloon.add( { view: formView } )
+			return linkFeature.showPanel( true )
 				.then( () => {
 					formView.focusTracker.isFocused = true;
 
@@ -244,122 +350,109 @@ describe( 'Link', () => {
 				} );
 		} );
 
-		it( 'should keep editor ui focused when link form has focus', () => {
-			editor.ui.focusTracker.isFocused = false;
-
-			// Open balloon panel with link inside.
-			linkButton.fire( 'execute' );
+		it( 'should hide panel after Esc key press (from editor) and not focus editable', () => {
+			const spy = testUtils.sinon.spy( linkFeature, 'hidePanel' );
+			const keyEvtData = {
+				keyCode: keyCodes.esc,
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
 
-			// Be sure that form view is focused.
-			formView.element.dispatchEvent( new Event( 'focus' ) );
+			// Balloon is visible.
+			return linkFeature.showPanel( false ).then( () => {
+				editor.keystrokes.press( keyEvtData );
 
-			// Check if editor ui is focused.
-			expect( editor.ui.focusTracker.isFocused ).to.true;
+				sinon.assert.calledWithExactly( spy );
+			} );
 		} );
 
-		describe( 'close listeners', () => {
-			describe( 'keyboard', () => {
-				it( 'should close after Esc key press (from editor) and not focus editable', () => {
-					const keyEvtData = {
-						keyCode: keyCodes.esc,
-						preventDefault: sinon.spy(),
-						stopPropagation: sinon.spy()
-					};
+		it( 'should not hide panel after Esc key press (from editor) when panel is open but is not visible', () => {
+			const spy = testUtils.sinon.spy( linkFeature, 'hidePanel' );
+			const keyEvtData = {
+				keyCode: keyCodes.esc,
+				preventDefault: () => {},
+				stopPropagation: () => {}
+			};
 
-					// Balloon is visible.
-					return balloon.add( { view: formView } ).then( () => {
-						editor.keystrokes.press( keyEvtData );
+			const viewMock = {
+				ready: true,
+				init: () => {},
+				destroy: () => {}
+			};
 
-						expect( balloon.visibleView ).to.null;
-						sinon.assert.notCalled( focusEditableSpy );
-					} );
-				} );
+			return linkFeature.showPanel( false )
+				.then( () => balloon.add( { view: viewMock } ) )
+				.then( () => {
+					editor.keystrokes.press( keyEvtData );
 
-				it( 'should not close after Esc key press (from editor) when panel is in stack but not visible', () => {
-					const keyEvtData = {
-						keyCode: keyCodes.esc,
-						preventDefault: () => {},
-						stopPropagation: () => {}
-					};
-
-					const viewMock = {
-						init: () => {},
-						destroy: () => {}
-					};
-
-					return balloon.add( { view: formView } )
-						.then( () => {
-							return balloon.add( { view: viewMock } );
-						} )
-						.then( () => {
-							editor.keystrokes.press( keyEvtData );
-
-							expect( balloon.visibleView ).to.equal( viewMock );
-							expect( balloon.hasView( formView ) ).to.true;
-							sinon.assert.notCalled( focusEditableSpy );
-						} );
+					sinon.assert.notCalled( spy );
 				} );
+		} );
 
-				it( 'should close after Esc key press (from the form) and focus editable', () => {
-					const keyEvtData = {
-						keyCode: keyCodes.esc,
-						preventDefault: sinon.spy(),
-						stopPropagation: sinon.spy()
-					};
+		it( 'should hide panel after Esc key press (from the form) and focus editable', () => {
+			const spy = testUtils.sinon.spy( linkFeature, 'hidePanel' );
+			const keyEvtData = {
+				keyCode: keyCodes.esc,
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
 
-					return balloon.add( { view: formView } )
-						.then( () => {
-							formView.keystrokes.press( keyEvtData );
+			return linkFeature.showPanel( true )
+				.then( () => {
+					formView.keystrokes.press( keyEvtData );
 
-							expect( balloon.visibleView ).to.null;
-							sinon.assert.calledOnce( focusEditableSpy );
-						} );
+					sinon.assert.calledWithExactly( spy, true );
 				} );
-			} );
+		} );
+	} );
+
+	describe( 'mouse support', () => {
+		it( 'should hide panel and not focus editable on click outside the panel', () => {
+			const spy = testUtils.sinon.spy( linkFeature, 'hidePanel' );
 
-			describe( 'mouse', () => {
-				it( 'should close and not focus editable on click outside the panel', () => {
-					return balloon.add( { view: formView } )
-						.then( () => {
-							document.body.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
+			return linkFeature.showPanel( true )
+				.then( () => {
+					document.body.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
 
-							expect( balloon.visibleView ).to.null;
-							expect( focusEditableSpy.notCalled ).to.true;
-						} );
+					sinon.assert.calledWithExactly( spy );
 				} );
+		} );
+
+		it( 'should not hide panel on click inside the panel', () => {
+			const spy = testUtils.sinon.spy( linkFeature, 'hidePanel' );
 
-				it( 'should not close on click inside the panel', () => {
-					return balloon.add( { view: formView } )
-						.then( () => {
-							balloon.view.element.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
+			return linkFeature.showPanel( true )
+				.then( () => {
+					balloon.view.element.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
 
-							expect( balloon.visibleView ).to.equal( formView );
-						} );
+					sinon.assert.notCalled( spy );
 				} );
-			} );
 		} );
 
-		describe( 'click on editable', () => {
-			it( 'should open with not selected url input when collapsed selection is inside link element', () => {
-				const selectUrlInputSpy = testUtils.sinon.spy( formView.urlInputView, 'select' );
-				const observer = editor.editing.view.getObserver( ClickObserver );
+		describe( 'clicking on editable', () => {
+			let observer;
+
+			beforeEach( () => {
+				observer = editor.editing.view.getObserver( ClickObserver );
+			} );
+
+			it( 'should open with not selected formView when collapsed selection is inside link element', () => {
+				// Method is stubbed because it returns internal promise which can't be returned in test.
+				const spy = testUtils.sinon.stub( linkFeature, 'showPanel', () => {} );
 
 				editor.document.schema.allow( { name: '$text', inside: '$root' } );
 				setModelData( editor.document, '<$text linkHref="url">fo[]o</$text>' );
 
 				observer.fire( 'click', { target: document.body } );
 
-				expect( balloon.visibleView ).to.equal( formView );
-
-				// Focus of url input is called async after internal promise resolve and we are
-				// not able to return this promise.
-				return wait().then( () => {
-					expect( selectUrlInputSpy.notCalled ).to.true;
-				} );
+				sinon.assert.calledWithExactly( spy );
 			} );
 
 			it( 'should keep open and update position until collapsed selection stay inside the same link element', () => {
-				const observer = editor.editing.view.getObserver( ClickObserver );
+				// Method is stubbed because it returns internal promise which can't be returned in test.
+				const showSpy = testUtils.sinon.stub( linkFeature, 'showPanel', () => {} );
+				const hideSpy = testUtils.sinon.stub( linkFeature, 'hidePanel' );
+				const updatePositionSpy = testUtils.sinon.stub( balloon, 'updatePosition', () => {} );
 
 				editor.document.schema.allow( { name: '$text', inside: '$root' } );
 				setModelData( editor.document, '<$text linkHref="url">b[]ar</$text>' );
@@ -369,22 +462,24 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: document.body } );
 
-				expect( balloon.visibleView ).to.equal( formView );
-
-				const updatePositionSpy = testUtils.sinon.spy( balloon, 'updatePosition' );
+				// Panel is shown.
+				sinon.assert.calledOnce( showSpy );
 
 				// Move selection.
 				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 1 ) ], true );
 				editor.editing.view.render();
 
-				// Check if balloon is still open and position was updated.
-				expect( balloon.visibleView ).to.equal( formView );
-				expect( updatePositionSpy.calledOnce ).to.true;
+				// Check if balloon is still opened (wasn't hide).
+				sinon.assert.notCalled( hideSpy );
+				// And position was updated
+				sinon.assert.calledOnce( updatePositionSpy );
 			} );
 
 			it( 'should not duplicate `render` listener on `ViewDocument`', () => {
-				const observer = editor.editing.view.getObserver( ClickObserver );
-				const updatePositionSpy = testUtils.sinon.spy( balloon, 'updatePosition' );
+				const updatePositionSpy = testUtils.sinon.stub( balloon, 'updatePosition', () => {} );
+
+				// Method is stubbed because it returns internal promise which can't be returned in test.
+				testUtils.sinon.stub( linkFeature, 'showPanel', () => {} );
 
 				editor.document.schema.allow( { name: '$text', inside: '$root' } );
 				setModelData( editor.document, '<$text linkHref="url">b[]ar</$text>' );
@@ -408,7 +503,10 @@ describe( 'Link', () => {
 			} );
 
 			it( 'should close when selection goes outside the link element', () => {
-				const observer = editor.editing.view.getObserver( ClickObserver );
+				const hideSpy = testUtils.sinon.stub( linkFeature, 'hidePanel' );
+
+				// Method is stubbed because it returns internal promise which can't be returned in test.
+				testUtils.sinon.stub( linkFeature, 'showPanel', () => {} );
 
 				editor.document.schema.allow( { name: '$text', inside: '$root' } );
 				setModelData( editor.document, 'foo <$text linkHref="url">b[]ar</$text>' );
@@ -418,16 +516,19 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: document.body } );
 
-				expect( balloon.visibleView ).to.equal( formView );
+				sinon.assert.notCalled( hideSpy );
 
 				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 3, text, 3 ) ], true );
 				editor.editing.view.render();
 
-				expect( balloon.visibleView ).to.null;
+				sinon.assert.calledOnce( hideSpy );
 			} );
 
 			it( 'should close when selection goes to the other link element with the same href', () => {
-				const observer = editor.editing.view.getObserver( ClickObserver );
+				const hideSpy = testUtils.sinon.stub( linkFeature, 'hidePanel' );
+
+				// Method is stubbed because it returns internal promise which can't be returned in test.
+				testUtils.sinon.stub( linkFeature, 'showPanel', () => {} );
 
 				editor.document.schema.allow( { name: '$text', inside: '$root' } );
 				setModelData( editor.document, '<$text linkHref="url">f[]oo</$text> bar <$text linkHref="url">biz</$text>' );
@@ -437,16 +538,19 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: document.body } );
 
-				expect( balloon.visibleView ).to.equal( formView );
+				sinon.assert.notCalled( hideSpy );
 
 				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 1 ) ], true );
 				editor.editing.view.render();
 
-				expect( balloon.visibleView ).to.null;
+				sinon.assert.calledOnce( hideSpy );
 			} );
 
 			it( 'should close when selection becomes non-collapsed', () => {
-				const observer = editor.editing.view.getObserver( ClickObserver );
+				const hideSpy = testUtils.sinon.stub( linkFeature, 'hidePanel' );
+
+				// Method is stubbed because it returns internal promise which can't be returned in test.
+				testUtils.sinon.stub( linkFeature, 'showPanel', () => {} );
 
 				editor.document.schema.allow( { name: '$text', inside: '$root' } );
 				setModelData( editor.document, '<$text linkHref="url">f[]oo</$text>' );
@@ -456,58 +560,31 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: {} } );
 
-				expect( balloon.visibleView ).to.equal( formView );
-
 				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 2 ) ] );
 				editor.editing.view.render();
 
-				expect( balloon.visibleView ).to.null;
-			} );
-
-			it( 'should stop updating position after close', () => {
-				const observer = editor.editing.view.getObserver( ClickObserver );
-
-				editor.document.schema.allow( { name: '$text', inside: '$root' } );
-				setModelData( editor.document, '<$text linkHref="url">b[]ar</$text>' );
-
-				const root = editor.editing.view.getRoot();
-				const text = root.getChild( 0 ).getChild( 0 );
-
-				observer.fire( 'click', { target: {} } );
-
-				expect( balloon.visibleView ).to.equal( formView );
-
-				// Close balloon by dispatching `cancel` event on formView.
-				formView.fire( 'cancel' );
-
-				const updatePositionSpy = testUtils.sinon.spy( balloon, 'updatePosition' );
-
-				// Move selection inside link element.
-				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 2, text, 2 ) ], true );
-				editor.editing.view.render();
-
-				expect( updatePositionSpy.notCalled ).to.true;
+				sinon.assert.calledOnce( hideSpy );
 			} );
 
 			it( 'should not open when selection is not inside link element', () => {
-				const observer = editor.editing.view.getObserver( ClickObserver );
+				const showSpy = testUtils.sinon.stub( linkFeature, 'showPanel' );
 
 				setModelData( editor.document, '[]' );
 
 				observer.fire( 'click', { target: {} } );
 
-				expect( balloon.visibleView ).to.null;
+				sinon.assert.notCalled( showSpy );
 			} );
 
 			it( 'should not open when selection is non-collapsed', () => {
-				const observer = editor.editing.view.getObserver( ClickObserver );
+				const showSpy = testUtils.sinon.stub( linkFeature, 'showPanel' );
 
 				editor.document.schema.allow( { name: '$text', inside: '$root' } );
 				setModelData( editor.document, '<$text linkHref="url">f[o]o</$text>' );
 
-				observer.fire( 'click', { target: document.body } );
+				observer.fire( 'click', { target: {} } );
 
-				expect( balloon.visibleView ).to.null;
+				sinon.assert.notCalled( showSpy );
 			} );
 		} );
 	} );
@@ -543,7 +620,7 @@ describe( 'Link', () => {
 			} );
 
 			it( 'should hide and focus editable on formView#submit event', () => {
-				return balloon.add( { view: formView } )
+				return linkFeature.showPanel()
 					.then( () => {
 						formView.fire( 'submit' );
 
@@ -562,7 +639,7 @@ describe( 'Link', () => {
 			} );
 
 			it( 'should hide and focus editable on formView#unlink event', () => {
-				return balloon.add( { view: formView } )
+				return linkFeature.showPanel()
 					.then( () => {
 						formView.fire( 'unlink' );
 
@@ -572,7 +649,7 @@ describe( 'Link', () => {
 			} );
 
 			it( 'should hide and focus editable on formView#cancel event', () => {
-				return balloon.add( { view: formView } )
+				return linkFeature.showPanel()
 					.then( () => {
 						formView.fire( 'cancel' );
 
@@ -583,9 +660,3 @@ describe( 'Link', () => {
 		} );
 	} );
 } );
-
-function wait( delay = 1 ) {
-	return new Promise( ( resolve ) => {
-		setTimeout( () => resolve(), delay );
-	} );
-}