/** * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ /* global window, document, setTimeout, Event, console */ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import global from '@ckeditor/ckeditor5-utils/src/dom/global'; import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon'; import env from '@ckeditor/ckeditor5-utils/src/env'; import MentionUI, { createRegExp } from '../src/mentionui'; import MentionEditing from '../src/mentionediting'; import MentionsView from '../src/ui/mentionsview'; import { assertCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; describe( 'MentionUI', () => { let editor, model, doc, editingView, mentionUI, editorElement, mentionsView, panelView; const staticConfig = { feeds: [ { feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ], marker: '@' } ] }; testUtils.createSinonSandbox(); beforeEach( () => { editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); } ); afterEach( () => { sinon.restore(); editorElement.remove(); if ( editor ) { return editor.destroy(); } } ); it( 'should create a plugin instance', () => { return createClassicTestEditor().then( () => { expect( mentionUI ).to.instanceOf( Plugin ); expect( mentionUI ).to.instanceOf( MentionUI ); } ); } ); it( 'should load ContextualBalloon plugin', () => { return createClassicTestEditor().then( () => { expect( editor.plugins.get( ContextualBalloon ) ).to.be.instanceOf( ContextualBalloon ); } ); } ); describe( 'init()', () => { it( 'should throw if marker was not provided for feed', () => { return createClassicTestEditor( { feeds: [ { feed: [ 'a' ] } ] } ).catch( error => { assertCKEditorError( error, /mentionconfig-incorrect-marker/, null, { marker: undefined } ); } ); } ); it( 'should throw if marker is empty string', () => { return createClassicTestEditor( { feeds: [ { marker: '', feed: [ 'a' ] } ] } ).catch( error => { assertCKEditorError( error, /mentionconfig-incorrect-marker/, null, { marker: '' } ); } ); } ); it( 'should throw if marker is longer then 1 character', () => { return createClassicTestEditor( { feeds: [ { marker: '$$', feed: [ 'a' ] } ] } ).catch( error => { assertCKEditorError( error, /mentionconfig-incorrect-marker/, null, { marker: '$$' } ); } ); } ); } ); describe( 'pluginName', () => { it( 'should return plugin by its name', () => { return createClassicTestEditor().then( () => { expect( editor.plugins.get( 'MentionUI' ) ).to.equal( mentionUI ); } ); } ); } ); describe( 'contextual balloon', () => { let balloonAddSpy; beforeEach( () => { return createClassicTestEditor( staticConfig ) .then( () => { setData( model, 'foo []' ); const contextualBalloon = editor.plugins.get( ContextualBalloon ); balloonAddSpy = sinon.spy( contextualBalloon, 'add' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ); } ); it( 'should disable arrow', () => { sinon.assert.calledOnce( balloonAddSpy ); sinon.assert.calledWithExactly( balloonAddSpy, sinon.match( data => data.singleViewMode ) ); expect( panelView.isVisible ).to.be.true; expect( panelView.withArrow ).to.be.false; } ); it( 'should add MentionView to a panel', () => { expect( editor.plugins.get( ContextualBalloon ).visibleView ).to.be.instanceof( MentionsView ); } ); } ); describe( 'position', () => { let pinSpy; const caretRect = { bottom: 118, height: 18, left: 500, right: 501, top: 100, width: 1 }; const balloonRect = { bottom: 150, height: 150, left: 0, right: 200, top: 0, width: 200 }; beforeEach( () => { return createClassicTestEditor( staticConfig ).then( () => { pinSpy = sinon.spy( panelView, 'pin' ); } ); } ); it( 'should properly calculate position data', () => { const editableElement = editingView.document.selection.editableElement; setData( model, 'foo []' ); stubSelectionRects( [ caretRect ] ); expect( editor.model.markers.has( 'mention' ) ).to.be.false; model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { const pinArgument = pinSpy.firstCall.args[ 0 ]; const { target, positions, limiter, fitInViewport } = pinArgument; expect( positions ).to.have.length( 4 ); // Mention UI should set limiter to the editable area. expect( limiter() ).to.equal( editingView.domConverter.mapViewToDom( editableElement ) ); expect( fitInViewport ).to.be.undefined; expect( editor.model.markers.has( 'mention' ) ).to.be.true; const mentionMarker = editor.model.markers.get( 'mention' ); const focus = doc.selection.focus; const expectedRange = editor.model.createRange( focus.getShiftedBy( -1 ), focus ); // It should create a model marker for matcher marker character ('@'). expect( expectedRange.isEqual( mentionMarker.getRange() ) ).to.be.true; const toViewRangeSpy = sinon.spy( editor.editing.mapper, 'toViewRange' ); expect( target() ).to.deep.equal( caretRect ); sinon.assert.calledOnce( toViewRangeSpy ); const range = toViewRangeSpy.firstCall.args[ 0 ]; expect( mentionMarker.getRange().isEqual( range ), 'Should position to mention marker.' ); const caretSouthEast = positions[ 0 ]; const caretSouthWest = positions[ 1 ]; const caretNorthEast = positions[ 2 ]; const caretNorthWest = positions[ 3 ]; expect( caretSouthEast( caretRect, balloonRect ) ).to.deep.equal( { left: 501, name: 'caret_se', top: 121 } ); expect( caretSouthWest( caretRect, balloonRect ) ).to.deep.equal( { left: 301, name: 'caret_sw', top: 121 } ); expect( caretNorthEast( caretRect, balloonRect ) ).to.deep.equal( { left: 501, name: 'caret_ne', top: -53 } ); expect( caretNorthWest( caretRect, balloonRect ) ).to.deep.equal( { left: 301, name: 'caret_nw', top: -53 } ); } ); } ); it( 'should re-calculate position on typing and stay on selected position', () => { setData( model, 'foo []' ); stubSelectionRects( [ caretRect ] ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); let positionAfterFirstShow; return waitForDebounce() .then( () => { sinon.assert.calledOnce( pinSpy ); const pinArgument = pinSpy.firstCall.args[ 0 ]; const { positions } = pinArgument; expect( positions ).to.have.length( 4 ); positionAfterFirstShow = mentionsView.position; model.change( writer => { writer.insertText( 't', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { sinon.assert.calledTwice( pinSpy ); const pinArgument = pinSpy.secondCall.args[ 0 ]; const { positions } = pinArgument; expect( positions, 'should reuse first matched position' ).to.have.length( 1 ); expect( positions[ 0 ].name ).to.equal( positionAfterFirstShow ); } ); } ); it( 'does not fail if selection has no #editableElement', () => { setData( model, 'foo []' ); stubSelectionRects( [ caretRect ] ); expect( editor.model.markers.has( 'mention' ) ).to.be.false; model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { const pinArgument = pinSpy.firstCall.args[ 0 ]; const { limiter } = pinArgument; sinon.stub( editingView.document.selection, 'editableElement' ).value( null ); // Should not break; expect( limiter() ).to.be.null; } ); } ); } ); describe( 'typing integration', () => { it( 'should show panel for matched marker after typing minimum characters', () => { return createClassicTestEditor( { feeds: [ Object.assign( { minimumCharacters: 2 }, staticConfig.feeds[ 0 ] ) ] } ) .then( () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); } ) .then( () => { model.change( writer => { writer.insertText( 'B', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; } ) .then( waitForDebounce ) .then( () => { model.change( writer => { writer.insertText( 'a', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 1 ); model.change( writer => { writer.insertText( 'r', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 1 ); } ); } ); it( 'should update the marker if the selection was moved from one valid position to another', () => { const spy = sinon.spy(); return createClassicTestEditor( staticConfig ) .then( () => { setData( model, 'foo @ bar []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; } ) .then( () => { editor.model.markers.on( 'update', spy ); model.change( writer => { writer.setSelection( doc.getRoot().getChild( 0 ), 5 ); } ); sinon.assert.calledOnce( spy ); expect( editor.model.markers.has( 'mention' ) ).to.be.true; } ); } ); it( 'should not show panel when command is disabled', () => { return createClassicTestEditor( staticConfig ) .then( () => { setData( model, 'foo []' ); const mentionCommand = editor.commands.get( 'mention' ); mentionCommand.forceDisabled( 'mentionCommandDisableTest' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; } ); } ); describe( 'static list with large set of results', () => { const bigList = { marker: '@', feed: [ '@a01', '@a02', '@a03', '@a04', '@a05', '@a06', '@a07', '@a08', '@a09', '@a10', '@a11', '@a12' ] }; beforeEach( () => { return createClassicTestEditor( { feeds: [ bigList ] } ); } ); it( 'should show panel with no more then 10 items for default static feed', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.true; expect( mentionsView.items ).to.have.length( 10 ); } ); } ); it( 'should scroll mention panel to the selected item', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); const arrowDownEvtData = { keyCode: keyCodes.arrowdown, preventDefault: sinon.spy(), stopPropagation: sinon.spy() }; const arrowUpEvtData = { keyCode: keyCodes.arrowup, preventDefault: sinon.spy(), stopPropagation: sinon.spy() }; return waitForDebounce() .then( () => { // The scroll test highly depends on browser styles. // Some CI test environments might not load theme which will result that tests will not render on CI as locally. // To make this test repeatable across different environments it enforces mentions view size to 100px... const reset = 'padding:0px;margin:0px;border:0 none;line-height: 1em;'; const mentionElementSpy = testUtils.sinon.spy( mentionsView.element, 'scrollTop', [ 'set' ] ); mentionsView.element.style = `min-height:100px;height:100px;max-height:100px;${ reset };`; // ...and each list view item size to 25px... Array.from( mentionsView.items ).forEach( item => { const listItemElement = item.children.get( 0 ).element; listItemElement.style = `min-height:unset;height:25px;max-height:25px;${ reset };min-width:12em;`; } ); // ...so after those changes it is safe to assume that: // - base offset is 0 // - only 4 items are visible at once // - if scrolled to the last element scrollTop will be set to 150px. The 150px is the offset of the 7th item in the // list as last four (7, 8, 9 & 10) will be visible. expect( panelView.isVisible ).to.be.true; expectChildViewsIsOnState( [ true, false, false, false, false, false, false, false, false, false ] ); sinon.assert.callCount( mentionElementSpy.set, 0 ); fireKeyDownEvent( arrowDownEvtData ); expectChildViewsIsOnState( [ false, true, false, false, false, false, false, false, false, false ] ); expect( mentionsView.element.scrollTop ).to.equal( 0 ); sinon.assert.callCount( mentionElementSpy.set, 0 ); fireKeyDownEvent( arrowUpEvtData ); expectChildViewsIsOnState( [ true, false, false, false, false, false, false, false, false, false ] ); expect( mentionsView.element.scrollTop ).to.equal( 0 ); sinon.assert.callCount( mentionElementSpy.set, 0 ); fireKeyDownEvent( arrowUpEvtData ); expectChildViewsIsOnState( [ false, false, false, false, false, false, false, false, false, true ] ); // We want 150, but sometimes we get e.g. 151. expect( mentionsView.element.scrollTop ).to.be.within( 140, 160, 'last item highlighted' ); sinon.assert.callCount( mentionElementSpy.set, 1 ); fireKeyDownEvent( arrowDownEvtData ); expectChildViewsIsOnState( [ true, false, false, false, false, false, false, false, false, false ] ); // We want 0, but sometimes we get e.g. 1. (Firefox) expect( mentionsView.element.scrollTop ).to.be.within( 0, 10 ); sinon.assert.callCount( mentionElementSpy.set, 2 ); } ); } ); } ); describe( 'ES2018 RegExp Unicode property escapes fallback', () => { let regExpStub; // Cache the original value to restore it after the tests. const originalGroupSupport = env.features.isRegExpUnicodePropertySupported; before( () => { env.features.isRegExpUnicodePropertySupported = false; } ); beforeEach( () => { return createClassicTestEditor( staticConfig ) .then( editor => { regExpStub = sinon.stub( window, 'RegExp' ); return editor; } ); } ); after( () => { env.features.isRegExpUnicodePropertySupported = originalGroupSupport; } ); it( 'returns a simplified RegExp for browsers not supporting Unicode punctuation groups', () => { env.features.isRegExpUnicodePropertySupported = false; createRegExp( '@', 2 ); sinon.assert.calledOnce( regExpStub ); sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\(\\[{"\'])([@])([\\S]{2,})$', 'u' ); } ); it( 'returns a ES2018 RegExp for browsers supporting Unicode punctuation groups', () => { env.features.isRegExpUnicodePropertySupported = true; createRegExp( '@', 2 ); sinon.assert.calledOnce( regExpStub ); sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\p{Ps}\\p{Pi}"\'])([@])([\\S]{2,})$', 'u' ); } ); } ); describe( 'static list with default trigger', () => { beforeEach( () => { return createClassicTestEditor( staticConfig ); } ); it( 'should show panel for matched marker', () => { setData( model, 'foo []' ); expect( editor.model.markers.has( 'mention' ) ).to.be.false; model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 5 ); } ); } ); it( 'should show panel for matched marker at the beginning of paragraph', () => { setData( model, '[] foo' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 5 ); } ); } ); it( 'should show panel for matched marker after a ', () => { model.schema.register( 'softBreak', { allowWhere: '$text', isInline: true } ); editor.conversion.for( 'upcast' ) .elementToElement( { model: 'softBreak', view: 'br' } ); editor.conversion.for( 'downcast' ) .elementToElement( { model: 'softBreak', view: ( modelElement, { writer } ) => writer.createEmptyElement( 'br' ) } ); setData( model, 'abc[] foo' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 5 ); } ); } ); // Opening parenthesis type characters that should be supported on all environments. for ( const character of [ '(', '\'', '"', '[', '{' ] ) { testOpeningPunctuationCharacter( character ); } // Excerpt of opening parenthesis type characters that tests ES2018 Unicode property escapes on supported environment. for ( const character of [ // Belongs to Ps (Punctuation, Open) group: '〈', '„', '﹛', '⦅', '{', // Belongs to Pi (Punctuation, Initial quote) group: '«', '‹', '⸌', ' ⸂', '⸠' ] ) { testOpeningPunctuationCharacter( character, !env.features.isRegExpUnicodePropertySupported ); } it( 'should not show panel for marker in the middle of other word', () => { setData( model, 'foo[]' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; } ); } ); it( 'should not show panel when selection is inside a mention', () => { setData( model, 'foo @Lily bar[]' ); model.change( writer => { const range = writer.createRange( writer.createPositionAt( doc.getRoot().getChild( 0 ), 4 ), writer.createPositionAt( doc.getRoot().getChild( 0 ), 9 ) ); writer.setAttribute( 'mention', { id: '@Lily', uid: 1234 }, range ); } ); return waitForDebounce() .then( () => { model.change( writer => { writer.setSelection( doc.getRoot().getChild( 0 ), 0 ); } ); expect( panelView.isVisible ).to.be.false; model.change( writer => { writer.setSelection( doc.getRoot().getChild( 0 ), 7 ); } ); } ) .then( () => { expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; } ); } ); it( 'should not show panel when selection is at the end of a mention', () => { setData( model, 'foo @Lily bar[]' ); model.change( writer => { const range = writer.createRange( writer.createPositionAt( doc.getRoot().getChild( 0 ), 4 ), writer.createPositionAt( doc.getRoot().getChild( 0 ), 9 ) ); writer.setAttribute( 'mention', { id: '@Lily', uid: 1234 }, range ); } ); return waitForDebounce() .then( () => { model.change( writer => { writer.setSelection( doc.getRoot().getChild( 0 ), 9 ); } ); } ) .then( () => { expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; } ); } ); it( 'should not show panel when selection is not collapsed', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; model.change( () => { model.modifySelection( doc.selection, { direction: 'backward', unit: 'character' } ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; } ); } ); it( 'should not show panel when selection is changing (non-collapsed)', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; model.change( () => { model.modifySelection( doc.selection, { direction: 'backward', unit: 'character' } ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; } ) .then( () => { model.change( () => { model.modifySelection( doc.selection, { direction: 'backward', unit: 'character' } ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; } ); } ); it( 'should not show panel when selection is after existing mention', () => { setData( model, 'foo [@Lily] bar[]' ); model.change( writer => { writer.setAttribute( 'mention', { id: '@Lily', uid: 1234 }, doc.selection.getFirstRange() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.false; model.change( writer => { writer.setSelection( doc.getRoot().getChild( 0 ), 8 ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.false; } ); } ); it( 'should not show panel when selection moves inside existing mention', () => { setData( model, 'foo @Lily bar[]' ); model.change( writer => { const range = writer.createRange( writer.createPositionAt( doc.getRoot().getChild( 0 ), 4 ), writer.createPositionAt( doc.getRoot().getChild( 0 ), 9 ) ); writer.setAttribute( 'mention', { id: '@Lily', uid: 1234 }, range ); } ); return waitForDebounce() .then( () => { model.change( writer => { writer.setSelection( doc.getRoot().getChild( 0 ), 9 ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.false; model.change( writer => { writer.setSelection( doc.getRoot().getChild( 0 ), 8 ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.false; } ); } ); it( 'should show filtered results for matched text', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); model.change( writer => { writer.insertText( 'T', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 1 ); } ); } ); it( 'should focus the first item in panel', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { const button = mentionsView.items.get( 0 ).children.get( 0 ); expect( button.isOn ).to.be.true; } ); } ); it( 'should hide panel if no matched items', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => expect( panelView.isVisible ).to.be.true ) .then( () => { model.change( writer => { writer.insertText( 'x', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; expect( mentionsView.items ).to.have.length( 0 ); } ); } ); it( 'should hide panel when text was unmatched', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => expect( panelView.isVisible ).to.be.true ) .then( () => { model.change( writer => { const end = doc.selection.getFirstPosition(); const start = end.getShiftedBy( -1 ); writer.remove( writer.createRange( start, end ) ); } ); } ) .then( waitForDebounce ) .then( () => expect( panelView.isVisible ).to.be.false ); } ); } ); describe( 'unicode', () => { beforeEach( () => { return createClassicTestEditor( { feeds: [ { // Always return 5 items feed: [ '@תַפּוּחַ', '@אַגָס', '@apple', '@pear' ], marker: '@' } ] } ); } ); it( 'should open panel for unicode character ב', function() { if ( !env.features.isRegExpUnicodePropertySupported ) { this.skip(); } setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@ס', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible, 'panel is visible' ).to.be.true; expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true; expect( mentionsView.items ).to.have.length( 1 ); } ); } ); } ); describe( 'callback function using data from editor', () => { beforeEach( () => { return createClassicTestEditor( { feeds: [ { marker: '#', feed() { expect( this ).to.equal( editor ); return Promise.resolve( [ 'foo', 'bar' ] ); } } ] } ); } ); it( 'should bind the instance panel for matched marker', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '#', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 2 ); } ); } ); } ); describe( 'asynchronous list with custom trigger', () => { const issuesNumbers = [ '#100', '#101', '#102', '#103' ]; let feedCallbackStub, feedCallbackTimeout, feedCallbackCallTimes; beforeEach( () => { feedCallbackTimeout = 20; feedCallbackCallTimes = 0; function feedCallback( feedText ) { return new Promise( resolve => { setTimeout( () => { feedCallbackCallTimes++; resolve( issuesNumbers.filter( number => number.includes( feedText ) ) ); }, feedCallbackTimeout ); } ); } feedCallbackStub = testUtils.sinon.stub().callsFake( feedCallback ); return createClassicTestEditor( { feeds: [ { marker: '#', feed: feedCallbackStub } ] } ); } ); it( 'should show panel for matched marker', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '#', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 4 ); } ); } ); it( 'should fire requestFeed:response when request feed return a response', () => { setData( model, 'foo []' ); const eventSpy = sinon.spy(); mentionUI.on( 'requestFeed:response', eventSpy ); model.change( writer => { writer.insertText( '#', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { sinon.assert.calledOnce( eventSpy ); sinon.assert.calledWithExactly( eventSpy, sinon.match.any, { feed: issuesNumbers, marker: '#', feedText: '' } ); expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 4 ); } ); } ); it( 'should show filtered results for matched text', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '#', doc.selection.getFirstPosition() ); } ); model.change( writer => { writer.insertText( '2', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 1 ); } ); } ); it( 'should hide panel if no matched items', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '#', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => expect( panelView.isVisible ).to.be.true ) .then( () => { model.change( writer => { writer.insertText( 'x', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; expect( mentionsView.items ).to.have.length( 0 ); } ); } ); it( 'should hide panel when text was unmatched', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '#', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => expect( panelView.isVisible ).to.be.true ) .then( () => { model.change( writer => { const end = doc.selection.getFirstPosition(); const start = end.getShiftedBy( -1 ); writer.remove( writer.createRange( start, end ) ); } ); } ) .then( waitForDebounce ) .then( () => expect( panelView.isVisible ).to.be.false ); } ); it( 'should show panel debounced', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '#', doc.selection.getFirstPosition() ); } ); sinon.assert.notCalled( feedCallbackStub ); return Promise.resolve() .then( wait( 20 ) ) .then( () => { sinon.assert.notCalled( feedCallbackStub ); model.change( writer => { writer.insertText( '1', doc.selection.getFirstPosition() ); } ); } ) .then( wait( 20 ) ) .then( () => { sinon.assert.notCalled( feedCallbackStub ); model.change( writer => { writer.insertText( '0', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { sinon.assert.calledOnce( feedCallbackStub ); // Should be called with all typed letters before debounce. sinon.assert.calledWithExactly( feedCallbackStub, '10' ); expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 4 ); } ); } ); it( 'should discard requested feed if they came out of order', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '#', doc.selection.getFirstPosition() ); } ); sinon.assert.notCalled( feedCallbackStub ); const panelShowSpy = sinon.spy( panelView, 'show' ); // Increase the response time to extend the debounce time out. feedCallbackTimeout = 300; return Promise.resolve() .then( wait( 20 ) ) .then( () => { sinon.assert.notCalled( feedCallbackStub ); model.change( writer => { writer.insertText( '1', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { sinon.assert.calledOnce( feedCallbackStub ); sinon.assert.calledWithExactly( feedCallbackStub, '1' ); expect( panelView.isVisible, 'panel is hidden' ).to.be.false; expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true; // Make second callback resolve before first. feedCallbackTimeout = 50; model.change( writer => { writer.insertText( '0', doc.selection.getFirstPosition() ); } ); } ) .then( wait( 300 ) ) // Wait longer so the longer callback will be resolved. .then( () => { sinon.assert.calledTwice( feedCallbackStub ); sinon.assert.calledWithExactly( feedCallbackStub.getCall( 1 ), '10' ); sinon.assert.calledOnce( panelShowSpy ); expect( feedCallbackCallTimes ).to.equal( 2 ); expect( panelView.isVisible, 'panel is visible' ).to.be.true; expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true; expect( mentionsView.items ).to.have.length( 4 ); } ); } ); it( 'should fire requestFeed:discarded event when requested feed came out of order', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '#', doc.selection.getFirstPosition() ); } ); sinon.assert.notCalled( feedCallbackStub ); const panelShowSpy = sinon.spy( panelView, 'show' ); const eventSpy = sinon.spy(); mentionUI.on( 'requestFeed:discarded', eventSpy ); // Increase the response time to extend the debounce time out. feedCallbackTimeout = 300; return Promise.resolve() .then( wait( 20 ) ) .then( () => { sinon.assert.notCalled( feedCallbackStub ); model.change( writer => { writer.insertText( '1', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { sinon.assert.calledOnce( feedCallbackStub ); sinon.assert.calledWithExactly( feedCallbackStub, '1' ); expect( panelView.isVisible, 'panel is hidden' ).to.be.false; expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true; // Make second callback resolve before first. feedCallbackTimeout = 50; model.change( writer => { writer.insertText( '0', doc.selection.getFirstPosition() ); } ); } ) .then( wait( 300 ) ) // Wait longer so the longer callback will be resolved. .then( () => { sinon.assert.calledTwice( feedCallbackStub ); sinon.assert.calledWithExactly( feedCallbackStub.getCall( 1 ), '10' ); sinon.assert.calledOnce( panelShowSpy ); sinon.assert.calledOnce( eventSpy ); sinon.assert.calledWithExactly( eventSpy, sinon.match.any, { feed: issuesNumbers, marker: '#', feedText: '1' } ); expect( feedCallbackCallTimes ).to.equal( 2 ); expect( panelView.isVisible, 'panel is visible' ).to.be.true; expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true; expect( mentionsView.items ).to.have.length( 4 ); } ); } ); it( 'should discard requested feed if mention UI is hidden', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '#', doc.selection.getFirstPosition() ); } ); sinon.assert.notCalled( feedCallbackStub ); feedCallbackTimeout = 200; return Promise.resolve() .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.false; // Should be still hidden; // Should be called with empty string. sinon.assert.calledWithExactly( feedCallbackStub, '' ); model.change( writer => { writer.setSelection( doc.getRoot().getChild( 0 ), 0 ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; } ); } ); it( 'should fire requestFeed:error and log warning if requested feed failed', () => { setData( model, 'foo []' ); feedCallbackStub.returns( Promise.reject( 'Request timeout' ) ); const warnSpy = sinon.stub( console, 'warn' ); const eventSpy = sinon.spy(); mentionUI.on( 'requestFeed:error', eventSpy ); model.change( writer => { writer.insertText( '#', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible, 'panel is hidden' ).to.be.false; expect( editor.model.markers.has( 'mention' ), 'there is no marker' ).to.be.false; sinon.assert.calledWithExactly( warnSpy, sinon.match( /^mention-feed-callback-error/ ), sinon.match( { marker: '#' } ), sinon.match.string // Link to the documentation ); sinon.assert.calledOnce( eventSpy ); } ); } ); it( 'should not fail if marker was removed', () => { setData( model, 'foo []' ); const selectFirstMentionSpy = sinon.spy( mentionsView, 'selectFirst' ); model.change( writer => { writer.insertText( '#', doc.selection.getFirstPosition() ); } ); sinon.assert.notCalled( feedCallbackStub ); // Increase the response time to extend the debounce time out. feedCallbackTimeout = 500; return Promise.resolve() .then( waitForDebounce ) .then( wait( 20 ) ) .then( () => { model.change( writer => { writer.setSelection( doc.getRoot().getChild( 0 ), 2 ); } ); } ) .then( wait( 20 ) ) .then( () => { feedCallbackTimeout = 1000; model.change( writer => { writer.setSelection( doc.getRoot().getChild( 0 ), 'end' ); } ); } ) .then( wait( 500 ) ) .then( () => { expect( panelView.isVisible, 'panel is visible' ).to.be.true; // If there were any errors this will not get called. // The errors might come from unhandled promise rejections errors. sinon.assert.calledOnce( selectFirstMentionSpy ); } ); } ); it( 'should not show panel if selection was moved during fetching a feed', () => { setData( model, 'foo [#101] bar' ); model.change( writer => { writer.setAttribute( 'mention', { id: '#101', uid: 1234 }, doc.selection.getFirstRange() ); } ); // Increase the response time to extend the debounce time out. feedCallbackTimeout = 300; model.change( writer => { writer.setSelection( doc.getRoot().getChild( 1 ), 0 ); writer.insertText( '#', doc.selection.getFirstPosition() ); } ); sinon.assert.notCalled( feedCallbackStub ); return Promise.resolve() .then( waitForDebounce ) .then( () => { sinon.assert.calledOnce( feedCallbackStub ); model.change( writer => { writer.setSelection( doc.getRoot().getChild( 0 ), 6 ); } ); expect( panelView.isVisible ).to.be.false; } ) .then( waitForDebounce ) .then( wait( 20 ) ) .then( () => { expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; } ); } ); } ); function testOpeningPunctuationCharacter( character, skip = false ) { it( `should show panel for matched marker after a "${ character }" character`, function() { if ( skip ) { this.skip(); } setData( model, '[] foo' ); model.change( writer => { writer.insertText( character, doc.selection.getFirstPosition() ); } ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible, 'panel is visible' ).to.be.true; expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true; expect( mentionsView.items ).to.have.length( 5 ); } ); } ); } } ); describe( 'panel behavior', () => { it( 'should close the opened panel on esc', () => { return createClassicTestEditor( staticConfig ) .then( () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; fireKeyDownEvent( { keyCode: keyCodes.esc, preventDefault: sinon.spy(), stopPropagation: sinon.spy() } ); expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; } ); } ); it( 'should close the opened panel when click outside the panel', () => { return createClassicTestEditor( staticConfig ) .then( () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; } ); } ); it( 'should hide the panel on selection change', () => { return createClassicTestEditor( staticConfig ) .then( () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; model.change( writer => { // Place position at the beginning of a paragraph. writer.setSelection( doc.getRoot().getChild( 0 ), 0 ); } ); expect( panelView.isVisible ).to.be.false; expect( mentionsView.position ).to.be.undefined; expect( editor.model.markers.has( 'mention' ) ).to.be.false; } ); } ); it( 'should hide the panel on selection change triggered by mouse click', () => { return createClassicTestEditor( staticConfig ) .then( () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; // This happens when user clicks outside the panel view and selection is changed. // Two panel closing mechanisms are run: // - clickOutsideHandler // - unmatched text in text watcher // which may fail when trying to remove mention marker twice. document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); model.change( writer => { // Place position at the beginning of a paragraph. writer.setSelection( doc.getRoot().getChild( 0 ), 0 ); } ); expect( panelView.isVisible ).to.be.false; expect( mentionsView.position ).to.be.undefined; expect( editor.model.markers.has( 'mention' ) ).to.be.false; } ); } ); describe( 'default list item', () => { // Create map of expected feed items as objects as they will be stored internally. const feedItems = staticConfig.feeds[ 0 ].feed.map( text => ( { text: `${ text }`, id: `${ text }` } ) ); beforeEach( () => { return createClassicTestEditor( staticConfig ); } ); describe( 'on arrows', () => { it( 'should cycle down on arrow down', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expectChildViewsIsOnState( [ true, false, false, false, false ] ); const keyEvtData = { keyCode: keyCodes.arrowdown, preventDefault: sinon.spy(), stopPropagation: sinon.spy() }; fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, true, false, false, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, true, false, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, false, true, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, false, false, true ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ true, false, false, false, false ] ); } ); } ); it( 'should cycle up on arrow up', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expectChildViewsIsOnState( [ true, false, false, false, false ] ); const keyEvtData = { keyCode: keyCodes.arrowup, preventDefault: sinon.spy(), stopPropagation: sinon.spy() }; fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, false, false, true ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, false, true, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, true, false, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, true, false, false, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ true, false, false, false, false ] ); } ); } ); it( 'should not cycle with only one item in the list', () => { setData( model, 'foo []' ); const keyDownEvtData = { keyCode: keyCodes.arrowdown, preventDefault: sinon.spy(), stopPropagation: sinon.spy() }; const keyUpEvtData = { keyCode: keyCodes.arrowdown, preventDefault: sinon.spy(), stopPropagation: sinon.spy() }; model.change( writer => { writer.insertText( '@T', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expectChildViewsIsOnState( [ true ] ); fireKeyDownEvent( keyDownEvtData ); expectChildViewsIsOnState( [ true ] ); fireKeyDownEvent( keyUpEvtData ); expectChildViewsIsOnState( [ true ] ); } ); } ); } ); describe( 'on "execute" keys', () => { testExecuteKey( 'enter', keyCodes.enter, feedItems ); testExecuteKey( 'tab', keyCodes.tab, feedItems ); } ); describe( 'on other keys', () => { it( 'should do nothing on space', async () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); const command = editor.commands.get( 'mention' ); const spy = testUtils.sinon.spy( command, 'execute' ); await waitForDebounce(); expectChildViewsIsOnState( [ true, false, false, false, false ] ); fireKeyDownEvent( { keyCodes: keyCodes.space, preventDefault: sinon.spy(), stopPropagation: sinon.spy() } ); sinon.assert.notCalled( spy ); } ); } ); } ); describe( 'default list item with custom feed', () => { const issues = [ { id: '@Ted' }, { id: '@Barney' }, { id: '@Robin' }, { id: '@Lily' }, { id: '@Marshal' } ]; beforeEach( () => { return createClassicTestEditor( { feeds: [ { marker: '@', feed: feedText => issues.filter( issue => issue.id.includes( feedText ) ) } ] } ); } ); it( 'should show panel for matched marker', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 5 ); } ); } ); } ); describe( 'custom list item (string)', () => { const issues = [ { id: '@1002', title: 'Some bug in editor.' }, { id: '@1003', title: 'Introduce this feature.' }, { id: '@1004', title: 'Missing docs.' }, { id: '@1005', title: 'Another bug.' }, { id: '@1006', title: 'More bugs' } ]; beforeEach( () => { return createClassicTestEditor( { feeds: [ { marker: '@', feed: issues, itemRenderer: item => item.title } ] } ); } ); it( 'should show panel for matched marker', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 5 ); } ); } ); describe( 'keys', () => { describe( 'on arrows', () => { it( 'should cycle down on arrow down', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expectChildViewsIsOnState( [ true, false, false, false, false ] ); const keyEvtData = { keyCode: keyCodes.arrowdown, preventDefault: sinon.spy(), stopPropagation: sinon.spy() }; fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, true, false, false, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, true, false, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, false, true, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, false, false, true ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ true, false, false, false, false ] ); } ); } ); it( 'should cycle up on arrow up', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expectChildViewsIsOnState( [ true, false, false, false, false ] ); const keyEvtData = { keyCode: keyCodes.arrowup, preventDefault: sinon.spy(), stopPropagation: sinon.spy() }; fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, false, false, true ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, false, true, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, true, false, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, true, false, false, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ true, false, false, false, false ] ); } ); } ); } ); describe( 'on "execute" keys', () => { testExecuteKey( 'enter', keyCodes.enter, issues ); testExecuteKey( 'tab', keyCodes.tab, issues ); } ); } ); } ); describe( 'custom list item (DOM Element)', () => { const issues = [ { id: '@1002', title: 'Some bug in editor.' }, { id: '@1003', title: 'Introduce this feature.' }, { id: '@1004', title: 'Missing docs.' }, { id: '@1005', title: 'Another bug.' }, { id: '@1006', title: 'More bugs' } ]; beforeEach( () => { return createClassicTestEditor( { feeds: [ { marker: '@', feed: feedText => { return Promise.resolve( issues.filter( issue => issue.id.includes( feedText ) ) ); }, itemRenderer: item => { const span = global.document.createElementNS( 'http://www.w3.org/1999/xhtml', 'span' ); span.innerHTML = `@${ item.title }`; return span; } } ] } ); } ); it( 'should show panel for matched marker', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 5 ); } ); } ); describe( 'keys', () => { describe( 'on arrows', () => { it( 'should cycle down on arrow down', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expectChildViewsIsOnState( [ true, false, false, false, false ] ); const keyEvtData = { keyCode: keyCodes.arrowdown, preventDefault: sinon.spy(), stopPropagation: sinon.spy() }; fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, true, false, false, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, true, false, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, false, true, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, false, false, true ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ true, false, false, false, false ] ); } ); } ); it( 'should cycle up on arrow up', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expectChildViewsIsOnState( [ true, false, false, false, false ] ); const keyEvtData = { keyCode: keyCodes.arrowup, preventDefault: sinon.spy(), stopPropagation: sinon.spy() }; fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, false, false, true ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, false, true, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, false, true, false, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ false, true, false, false, false ] ); fireKeyDownEvent( keyEvtData ); expectChildViewsIsOnState( [ true, false, false, false, false ] ); } ); } ); } ); describe( 'on "execute" keys', () => { testExecuteKey( 'enter', keyCodes.enter, issues ); testExecuteKey( 'tab', keyCodes.tab, issues ); } ); describe( 'mouse', () => { it( 'should execute selected button on mouse click', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); const command = editor.commands.get( 'mention' ); const spy = testUtils.sinon.spy( command, 'execute' ); return waitForDebounce() .then( () => { expectChildViewsIsOnState( [ true, false, false, false, false ] ); const element = panelView.element.querySelector( '#issue-1004' ); element.dispatchEvent( new Event( 'click', { bubbles: true } ) ); sinon.assert.calledOnce( spy ); const commandOptions = spy.getCall( 0 ).args[ 0 ]; const item = issues[ 2 ]; expect( commandOptions ).to.have.property( 'mention' ).that.deep.equal( item ); expect( commandOptions ).to.have.property( 'marker', '@' ); expect( commandOptions ).to.have.property( 'range' ); const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 ); const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) ); expect( commandOptions.range.isEqual( expectedRange ) ).to.be.true; } ); } ); } ); } ); } ); describe( 'multiple feeds configuration', () => { beforeEach( () => { return createClassicTestEditor( { feeds: [ { marker: '@', feed: [ '@a1', '@a2', '@a3' ] }, { marker: '$', feed: [ '$a1', '$a2', '$a3', '$a4', // A case of mention with a marker character from other feed. See #6398. '$a@' ] } ] } ); } ); it( 'should show panel for matched marker', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 3 ); mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; model.change( writer => { writer.insertText( '$', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 5 ); mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 3 ); } ); } ); it( 'should show panel for matched marker if it contains the other configured marker', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; expect( mentionsView.items ).to.have.length( 3 ); mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; model.change( writer => { writer.insertText( '$a', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); } ) .then( waitForDebounce ) .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; } ); } ); } ); function testExecuteKey( name, keyCode, feedItems ) { it( 'should execute selected button on ' + name, () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); const command = editor.commands.get( 'mention' ); const spy = testUtils.sinon.spy( command, 'execute' ); return waitForDebounce() .then( () => { expectChildViewsIsOnState( [ true, false, false, false, false ] ); fireKeyDownEvent( { keyCode: keyCodes.arrowup, preventDefault: sinon.spy(), stopPropagation: sinon.spy() } ); expectChildViewsIsOnState( [ false, false, false, false, true ] ); fireKeyDownEvent( { keyCode, preventDefault: sinon.spy(), stopPropagation: sinon.spy() } ); sinon.assert.calledOnce( spy ); assertCommandOptions( spy.getCall( 0 ).args[ 0 ], '@', feedItems[ 4 ] ); const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 ); const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) ); expect( spy.getCall( 0 ).args[ 0 ].range.isEqual( expectedRange ) ).to.be.true; } ); } ); it( 'should do nothing if panel is not visible on ' + name, () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); const command = editor.commands.get( 'mention' ); const spy = testUtils.sinon.spy( command, 'execute' ); return waitForDebounce() .then( () => { expect( panelView.isVisible ).to.be.true; expect( editor.model.markers.has( 'mention' ) ).to.be.true; fireKeyDownEvent( { keyCode: keyCodes.esc, preventDefault: sinon.spy(), stopPropagation: sinon.spy() } ); expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; fireKeyDownEvent( { keyCode, preventDefault: sinon.spy(), stopPropagation: sinon.spy() } ); sinon.assert.notCalled( spy ); expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; } ); } ); } } ); describe( 'execute', () => { beforeEach( () => createClassicTestEditor( staticConfig ) ); it( 'should call the mention command with proper options', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); const command = editor.commands.get( 'mention' ); const spy = testUtils.sinon.spy( command, 'execute' ); return waitForDebounce() .then( () => { mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' ); sinon.assert.calledOnce( spy ); const commandOptions = spy.getCall( 0 ).args[ 0 ]; assertCommandOptions( commandOptions, '@', { id: '@Barney', text: '@Barney' } ); const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 ); const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) ); expect( commandOptions.range.isEqual( expectedRange ) ).to.be.true; } ); } ); it( 'should hide panel on execute', () => { setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' ); expect( panelView.isVisible ).to.be.false; expect( editor.model.markers.has( 'mention' ) ).to.be.false; } ); } ); it( 'should focus view after command execution', () => { const focusSpy = testUtils.sinon.spy( editor.editing.view, 'focus' ); setData( model, 'foo []' ); model.change( writer => { writer.insertText( '@', doc.selection.getFirstPosition() ); } ); return waitForDebounce() .then( () => { mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' ); sinon.assert.calledOnce( focusSpy ); } ); } ); } ); function createClassicTestEditor( mentionConfig ) { return ClassicTestEditor .create( editorElement, { plugins: [ Paragraph, MentionEditing, MentionUI ], mention: mentionConfig } ) .then( newEditor => { editor = newEditor; model = editor.model; doc = model.document; editingView = editor.editing.view; mentionUI = editor.plugins.get( MentionUI ); panelView = editor.plugins.get( ContextualBalloon ).view; mentionsView = mentionUI._mentionsView; } ); } function wait( timeout ) { return () => new Promise( resolve => { setTimeout( () => { resolve(); }, timeout ); } ); } function waitForDebounce() { return wait( 180 )(); } function fireKeyDownEvent( options ) { const eventInfo = new EventInfo( editingView.document, 'keydown' ); const eventData = new DomEventData( editingView.document, { target: document.body }, options ); editingView.document.fire( eventInfo, eventData ); } function stubSelectionRects( rects ) { const originalViewRangeToDom = editingView.domConverter.viewRangeToDom; // Mock selection rect. sinon.stub( editingView.domConverter, 'viewRangeToDom' ).callsFake( ( ...args ) => { const domRange = originalViewRangeToDom.apply( editingView.domConverter, args ); sinon.stub( domRange, 'getClientRects' ) .returns( rects ); return domRange; } ); } function expectChildViewsIsOnState( expectedState ) { const childViews = [ ...mentionsView.items ].map( item => item.children.get( 0 ) ); expect( childViews.map( child => child.isOn ) ).to.deep.equal( expectedState ); } function assertCommandOptions( commandOptions, marker, item ) { expect( commandOptions ).to.have.property( 'marker', marker ); expect( commandOptions ).to.have.property( 'range' ); expect( commandOptions ).to.have.property( 'mention' ); const mentionForCommand = commandOptions.mention; for ( const key of Object.keys( item ) ) { expect( mentionForCommand[ key ] ).to.equal( item[ key ] ); } } } );