mentionui.js 29 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037
  1. /**
  2. * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /* global window, document, setTimeout, Event */
  6. import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
  7. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  8. import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
  9. import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
  10. import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
  11. import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
  12. import global from '@ckeditor/ckeditor5-utils/src/dom/global';
  13. import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
  14. import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
  15. import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
  16. import MentionUI from '../src/mentionui';
  17. import MentionEditing from '../src/mentionediting';
  18. import MentionsView from '../src/ui/mentionsview';
  19. describe( 'MentionUI', () => {
  20. let editor, model, doc, editingView, mentionUI, editorElement, mentionsView, panelView, listView;
  21. const staticConfig = {
  22. feeds: [
  23. { feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] }
  24. ]
  25. };
  26. testUtils.createSinonSandbox();
  27. beforeEach( () => {
  28. editorElement = document.createElement( 'div' );
  29. document.body.appendChild( editorElement );
  30. } );
  31. afterEach( () => {
  32. sinon.restore();
  33. editorElement.remove();
  34. return editor.destroy();
  35. } );
  36. it( 'should create a plugin instance', () => {
  37. return createClassicTestEditor().then( () => {
  38. expect( mentionUI ).to.instanceOf( Plugin );
  39. expect( mentionUI ).to.instanceOf( MentionUI );
  40. } );
  41. } );
  42. describe( 'pluginName', () => {
  43. it( 'should return plugin by its name', () => {
  44. return createClassicTestEditor().then( () => {
  45. expect( editor.plugins.get( 'MentionUI' ) ).to.equal( mentionUI );
  46. } );
  47. } );
  48. } );
  49. describe( 'child views', () => {
  50. beforeEach( () => createClassicTestEditor() );
  51. describe( 'panelView', () => {
  52. it( 'should create a view instance', () => {
  53. expect( panelView ).to.instanceof( BalloonPanelView );
  54. } );
  55. it( 'should be added to the ui.view.body collection', () => {
  56. expect( Array.from( editor.ui.view.body ) ).to.include( panelView );
  57. } );
  58. it( 'should have disabled arrow', () => {
  59. expect( panelView.withArrow ).to.be.false;
  60. } );
  61. it( 'should have added MentionView as a child', () => {
  62. expect( panelView.content.get( 0 ) ).to.be.instanceof( MentionsView );
  63. } );
  64. } );
  65. } );
  66. describe( 'position', () => {
  67. let pinSpy;
  68. const caretRect = {
  69. bottom: 118,
  70. height: 18,
  71. left: 500,
  72. right: 501,
  73. top: 100,
  74. width: 1
  75. };
  76. const balloonRect = {
  77. bottom: 150,
  78. height: 150,
  79. left: 0,
  80. right: 200,
  81. top: 0,
  82. width: 200
  83. };
  84. beforeEach( () => {
  85. return createClassicTestEditor( staticConfig ).then( () => {
  86. pinSpy = sinon.spy( panelView, 'pin' );
  87. } );
  88. } );
  89. it( 'should properly calculate position data', () => {
  90. setData( model, '<paragraph>foo []</paragraph>' );
  91. stubSelectionRects( [ caretRect ] );
  92. model.change( writer => {
  93. writer.insertText( '@', doc.selection.getFirstPosition() );
  94. } );
  95. return waitForDebounce()
  96. .then( () => {
  97. const pinArgument = pinSpy.firstCall.args[ 0 ];
  98. const { target, positions } = pinArgument;
  99. expect( target() ).to.deep.equal( caretRect );
  100. expect( positions ).to.have.length( 4 );
  101. const caretSouthEast = positions[ 0 ];
  102. const caretNorthEast = positions[ 1 ];
  103. const caretSouthWest = positions[ 2 ];
  104. const caretNorthWest = positions[ 3 ];
  105. expect( caretSouthEast( caretRect, balloonRect ) ).to.deep.equal( {
  106. left: 501,
  107. name: 'caret_se',
  108. top: 123
  109. } );
  110. expect( caretNorthEast( caretRect, balloonRect ) ).to.deep.equal( {
  111. left: 501,
  112. name: 'caret_ne',
  113. top: -55
  114. } );
  115. expect( caretSouthWest( caretRect, balloonRect ) ).to.deep.equal( {
  116. left: 301,
  117. name: 'caret_sw',
  118. top: 123
  119. } );
  120. expect( caretNorthWest( caretRect, balloonRect ) ).to.deep.equal( {
  121. left: 301,
  122. name: 'caret_nw',
  123. top: -55
  124. } );
  125. } );
  126. } );
  127. it( 'should re-calculate position on typing', () => {
  128. setData( model, '<paragraph>foo []</paragraph>' );
  129. stubSelectionRects( [ caretRect ] );
  130. model.change( writer => {
  131. writer.insertText( '@', doc.selection.getFirstPosition() );
  132. } );
  133. return waitForDebounce()
  134. .then( () => {
  135. sinon.assert.calledOnce( pinSpy );
  136. model.change( writer => {
  137. writer.insertText( 't', doc.selection.getFirstPosition() );
  138. } );
  139. } )
  140. .then( waitForDebounce )
  141. .then( () => {
  142. sinon.assert.calledTwice( pinSpy );
  143. } );
  144. } );
  145. } );
  146. describe( 'typing integration', () => {
  147. it( 'should show panel for matched marker after typing minimum characters', () => {
  148. return createClassicTestEditor( { feeds: [ Object.assign( { minimumCharacters: 2 }, staticConfig.feeds[ 0 ] ) ] } )
  149. .then( () => {
  150. setData( model, '<paragraph>foo []</paragraph>' );
  151. model.change( writer => {
  152. writer.insertText( '@', doc.selection.getFirstPosition() );
  153. } );
  154. } )
  155. .then( () => {
  156. model.change( writer => {
  157. writer.insertText( 'B', doc.selection.getFirstPosition() );
  158. } );
  159. } )
  160. .then( waitForDebounce )
  161. .then( () => {
  162. expect( panelView.isVisible ).to.be.false;
  163. } )
  164. .then( waitForDebounce )
  165. .then( () => {
  166. model.change( writer => {
  167. writer.insertText( 'a', doc.selection.getFirstPosition() );
  168. } );
  169. } )
  170. .then( waitForDebounce )
  171. .then( () => {
  172. expect( panelView.isVisible ).to.be.true;
  173. expect( listView.items ).to.have.length( 1 );
  174. model.change( writer => {
  175. writer.insertText( 'r', doc.selection.getFirstPosition() );
  176. } );
  177. } )
  178. .then( waitForDebounce )
  179. .then( () => {
  180. expect( panelView.isVisible ).to.be.true;
  181. expect( listView.items ).to.have.length( 1 );
  182. } );
  183. } );
  184. describe( 'static list with default trigger', () => {
  185. beforeEach( () => {
  186. return createClassicTestEditor( staticConfig );
  187. } );
  188. it( 'should show panel for matched marker', () => {
  189. setData( model, '<paragraph>foo []</paragraph>' );
  190. model.change( writer => {
  191. writer.insertText( '@', doc.selection.getFirstPosition() );
  192. } );
  193. return waitForDebounce()
  194. .then( () => {
  195. expect( panelView.isVisible ).to.be.true;
  196. expect( listView.items ).to.have.length( 5 );
  197. } );
  198. } );
  199. it( 'should show panel for matched marker at the beginning of paragraph', () => {
  200. setData( model, '<paragraph>[] foo</paragraph>' );
  201. model.change( writer => {
  202. writer.insertText( '@', doc.selection.getFirstPosition() );
  203. } );
  204. return waitForDebounce()
  205. .then( () => {
  206. expect( panelView.isVisible ).to.be.true;
  207. expect( listView.items ).to.have.length( 5 );
  208. } );
  209. } );
  210. it( 'should not show panel for marker in the middle of other word', () => {
  211. setData( model, '<paragraph>foo[]</paragraph>' );
  212. model.change( writer => {
  213. writer.insertText( '@', doc.selection.getFirstPosition() );
  214. } );
  215. return waitForDebounce()
  216. .then( () => {
  217. expect( panelView.isVisible ).to.be.false;
  218. } );
  219. } );
  220. it( 'should not show panel when selection is inside a mention', () => {
  221. setData( model, '<paragraph>foo [@John] bar</paragraph>' );
  222. model.change( writer => {
  223. writer.setAttribute( 'mention', { name: 'John', _marker: '@', _id: 1234 }, doc.selection.getFirstRange() );
  224. } );
  225. model.change( writer => {
  226. writer.setSelection( doc.getRoot().getChild( 0 ), 7 );
  227. } );
  228. return waitForDebounce()
  229. .then( () => {
  230. expect( panelView.isVisible ).to.be.false;
  231. } );
  232. } );
  233. it( 'should not show panel when selection is at the end of a mention', () => {
  234. setData( model, '<paragraph>foo [@John] bar</paragraph>' );
  235. model.change( writer => {
  236. writer.setAttribute( 'mention', { name: 'John', _marker: '@', _id: 1234 }, doc.selection.getFirstRange() );
  237. } );
  238. model.change( writer => {
  239. writer.setSelection( doc.getRoot().getChild( 0 ), 9 );
  240. } );
  241. return waitForDebounce()
  242. .then( () => {
  243. expect( panelView.isVisible ).to.be.false;
  244. } );
  245. } );
  246. it( 'should not show panel when selection is not collapsed', () => {
  247. setData( model, '<paragraph>foo []</paragraph>' );
  248. model.change( writer => {
  249. writer.insertText( '@', doc.selection.getFirstPosition() );
  250. } );
  251. return waitForDebounce()
  252. .then( () => {
  253. expect( panelView.isVisible ).to.be.true;
  254. model.change( () => {
  255. model.modifySelection( doc.selection, { direction: 'backward', unit: 'character' } );
  256. } );
  257. } )
  258. .then( waitForDebounce )
  259. .then( () => {
  260. expect( panelView.isVisible ).to.be.false;
  261. } );
  262. } );
  263. it( 'should not show panel when selection is after existing mention', () => {
  264. setData( model, '<paragraph>foo [@John] bar[]</paragraph>' );
  265. model.change( writer => {
  266. writer.setAttribute( 'mention', { name: 'John', _marker: '@', _id: 1234 }, doc.selection.getFirstRange() );
  267. } );
  268. return waitForDebounce()
  269. .then( () => {
  270. expect( panelView.isVisible ).to.be.false;
  271. model.change( writer => {
  272. writer.setSelection( doc.getRoot().getChild( 0 ), 8 );
  273. } );
  274. } )
  275. .then( waitForDebounce )
  276. .then( () => {
  277. expect( panelView.isVisible ).to.be.false;
  278. } );
  279. } );
  280. it( 'should show filtered results for matched text', () => {
  281. setData( model, '<paragraph>foo []</paragraph>' );
  282. model.change( writer => {
  283. writer.insertText( '@', doc.selection.getFirstPosition() );
  284. } );
  285. model.change( writer => {
  286. writer.insertText( 'T', doc.selection.getFirstPosition() );
  287. } );
  288. return waitForDebounce()
  289. .then( () => {
  290. expect( panelView.isVisible ).to.be.true;
  291. expect( listView.items ).to.have.length( 1 );
  292. } );
  293. } );
  294. it( 'should focus the first item in panel', () => {
  295. setData( model, '<paragraph>foo []</paragraph>' );
  296. model.change( writer => {
  297. writer.insertText( '@', doc.selection.getFirstPosition() );
  298. } );
  299. return waitForDebounce()
  300. .then( () => {
  301. const button = listView.items.get( 0 ).children.get( 0 );
  302. expect( button.isOn ).to.be.true;
  303. } );
  304. } );
  305. it( 'should hide panel if no matched items', () => {
  306. setData( model, '<paragraph>foo []</paragraph>' );
  307. model.change( writer => {
  308. writer.insertText( '@', doc.selection.getFirstPosition() );
  309. } );
  310. return waitForDebounce()
  311. .then( () => expect( panelView.isVisible ).to.be.true )
  312. .then( () => {
  313. model.change( writer => {
  314. writer.insertText( 'x', doc.selection.getFirstPosition() );
  315. } );
  316. } )
  317. .then( waitForDebounce )
  318. .then( () => {
  319. expect( panelView.isVisible ).to.be.false;
  320. expect( listView.items ).to.have.length( 0 );
  321. } );
  322. } );
  323. it( 'should hide panel when text was unmatched', () => {
  324. setData( model, '<paragraph>foo []</paragraph>' );
  325. model.change( writer => {
  326. writer.insertText( '@', doc.selection.getFirstPosition() );
  327. } );
  328. return waitForDebounce()
  329. .then( () => expect( panelView.isVisible ).to.be.true )
  330. .then( () => {
  331. model.change( writer => {
  332. const end = doc.selection.getFirstPosition();
  333. const start = end.getShiftedBy( -1 );
  334. writer.remove( writer.createRange( start, end ) );
  335. } );
  336. } )
  337. .then( waitForDebounce )
  338. .then( () => expect( panelView.isVisible ).to.be.false );
  339. } );
  340. } );
  341. describe( 'asynchronous list with custom trigger', () => {
  342. beforeEach( () => {
  343. const issuesNumbers = [ '100', '101', '102', '103' ];
  344. return createClassicTestEditor( {
  345. feeds: [
  346. {
  347. marker: '#',
  348. feed: feedText => {
  349. return new Promise( resolve => {
  350. setTimeout( () => {
  351. resolve( issuesNumbers.filter( number => number.includes( feedText ) ) );
  352. }, 20 );
  353. } );
  354. }
  355. }
  356. ]
  357. } );
  358. } );
  359. it( 'should show panel for matched marker', () => {
  360. setData( model, '<paragraph>foo []</paragraph>' );
  361. model.change( writer => {
  362. writer.insertText( '#', doc.selection.getFirstPosition() );
  363. } );
  364. return waitForDebounce()
  365. .then( () => {
  366. expect( panelView.isVisible ).to.be.true;
  367. expect( listView.items ).to.have.length( 4 );
  368. } );
  369. } );
  370. it( 'should show filtered results for matched text', () => {
  371. setData( model, '<paragraph>foo []</paragraph>' );
  372. model.change( writer => {
  373. writer.insertText( '#', doc.selection.getFirstPosition() );
  374. } );
  375. model.change( writer => {
  376. writer.insertText( '2', doc.selection.getFirstPosition() );
  377. } );
  378. return waitForDebounce()
  379. .then( () => {
  380. expect( panelView.isVisible ).to.be.true;
  381. expect( listView.items ).to.have.length( 1 );
  382. } );
  383. } );
  384. it( 'should hide panel if no matched items', () => {
  385. setData( model, '<paragraph>foo []</paragraph>' );
  386. model.change( writer => {
  387. writer.insertText( '#', doc.selection.getFirstPosition() );
  388. } );
  389. return waitForDebounce()
  390. .then( () => expect( panelView.isVisible ).to.be.true )
  391. .then( () => {
  392. model.change( writer => {
  393. writer.insertText( 'x', doc.selection.getFirstPosition() );
  394. } );
  395. } )
  396. .then( waitForDebounce )
  397. .then( () => {
  398. expect( panelView.isVisible ).to.be.false;
  399. expect( listView.items ).to.have.length( 0 );
  400. } );
  401. } );
  402. it( 'should hide panel when text was unmatched', () => {
  403. setData( model, '<paragraph>foo []</paragraph>' );
  404. model.change( writer => {
  405. writer.insertText( '#', doc.selection.getFirstPosition() );
  406. } );
  407. return waitForDebounce()
  408. .then( () => expect( panelView.isVisible ).to.be.true )
  409. .then( () => {
  410. model.change( writer => {
  411. const end = doc.selection.getFirstPosition();
  412. const start = end.getShiftedBy( -1 );
  413. writer.remove( writer.createRange( start, end ) );
  414. } );
  415. } )
  416. .then( waitForDebounce )
  417. .then( () => expect( panelView.isVisible ).to.be.false );
  418. } );
  419. } );
  420. } );
  421. describe( 'panel behavior', () => {
  422. it( 'should close the opened panel on esc', () => {
  423. return createClassicTestEditor( staticConfig )
  424. .then( () => {
  425. setData( model, '<paragraph>foo []</paragraph>' );
  426. model.change( writer => {
  427. writer.insertText( '@', doc.selection.getFirstPosition() );
  428. } );
  429. } )
  430. .then( waitForDebounce )
  431. .then( () => {
  432. expect( panelView.isVisible ).to.be.true;
  433. fireKeyDownEvent( {
  434. keyCode: keyCodes.esc,
  435. preventDefault: sinon.spy(),
  436. stopPropagation: sinon.spy()
  437. } );
  438. expect( panelView.isVisible ).to.be.false;
  439. } );
  440. } );
  441. it( 'should close the opened panel when click outside the panel', () => {
  442. return createClassicTestEditor( staticConfig )
  443. .then( () => {
  444. setData( model, '<paragraph>foo []</paragraph>' );
  445. model.change( writer => {
  446. writer.insertText( '@', doc.selection.getFirstPosition() );
  447. } );
  448. } )
  449. .then( waitForDebounce )
  450. .then( () => {
  451. expect( panelView.isVisible ).to.be.true;
  452. document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
  453. expect( panelView.isVisible ).to.be.false;
  454. } );
  455. } );
  456. it( 'should hide the panel when click outside', () => {
  457. return createClassicTestEditor( staticConfig )
  458. .then( () => {
  459. setData( model, '<paragraph>foo []</paragraph>' );
  460. model.change( writer => {
  461. writer.insertText( '@', doc.selection.getFirstPosition() );
  462. } );
  463. } )
  464. .then( waitForDebounce )
  465. .then( () => {
  466. expect( panelView.isVisible ).to.be.true;
  467. model.change( writer => {
  468. // Place position at the begging of a paragraph.
  469. writer.setSelection( doc.getRoot().getChild( 0 ), 0 );
  470. } );
  471. expect( panelView.isVisible ).to.be.false;
  472. } );
  473. } );
  474. describe( 'default list item', () => {
  475. const feedItems = staticConfig.feeds[ 0 ].feed.map( name => ( { name } ) );
  476. beforeEach( () => {
  477. return createClassicTestEditor( staticConfig );
  478. } );
  479. describe( 'on arrows', () => {
  480. it( 'should cycle down on arrow down', () => {
  481. setData( model, '<paragraph>foo []</paragraph>' );
  482. model.change( writer => {
  483. writer.insertText( '@', doc.selection.getFirstPosition() );
  484. } );
  485. return waitForDebounce()
  486. .then( () => {
  487. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  488. const keyEvtData = {
  489. keyCode: keyCodes.arrowdown,
  490. preventDefault: sinon.spy(),
  491. stopPropagation: sinon.spy()
  492. };
  493. fireKeyDownEvent( keyEvtData );
  494. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  495. fireKeyDownEvent( keyEvtData );
  496. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  497. fireKeyDownEvent( keyEvtData );
  498. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  499. fireKeyDownEvent( keyEvtData );
  500. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  501. fireKeyDownEvent( keyEvtData );
  502. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  503. } );
  504. } );
  505. it( 'should cycle up on arrow up', () => {
  506. setData( model, '<paragraph>foo []</paragraph>' );
  507. model.change( writer => {
  508. writer.insertText( '@', doc.selection.getFirstPosition() );
  509. } );
  510. return waitForDebounce()
  511. .then( () => {
  512. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  513. const keyEvtData = {
  514. keyCode: keyCodes.arrowup,
  515. preventDefault: sinon.spy(),
  516. stopPropagation: sinon.spy()
  517. };
  518. fireKeyDownEvent( keyEvtData );
  519. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  520. fireKeyDownEvent( keyEvtData );
  521. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  522. fireKeyDownEvent( keyEvtData );
  523. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  524. fireKeyDownEvent( keyEvtData );
  525. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  526. fireKeyDownEvent( keyEvtData );
  527. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  528. } );
  529. } );
  530. } );
  531. describe( 'on "execute" keys', () => {
  532. testExecuteKey( 'enter', keyCodes.enter, feedItems );
  533. testExecuteKey( 'tab', keyCodes.tab, feedItems );
  534. testExecuteKey( 'space', keyCodes.space, feedItems );
  535. } );
  536. } );
  537. describe( 'custom list item', () => {
  538. const issues = [
  539. { id: '1002', title: 'Some bug in editor.' },
  540. { id: '1003', title: 'Introduce this feature.' },
  541. { id: '1004', title: 'Missing docs.' },
  542. { id: '1005', title: 'Another bug.' },
  543. { id: '1006', title: 'More bugs' }
  544. ];
  545. beforeEach( () => {
  546. return createClassicTestEditor( {
  547. feeds: [
  548. {
  549. marker: '@',
  550. feed: feedText => {
  551. return Promise.resolve( issues.filter( issue => issue.id.includes( feedText ) ) );
  552. },
  553. itemRenderer: item => {
  554. const span = global.document.createElementNS( 'http://www.w3.org/1999/xhtml', 'span' );
  555. span.innerHTML = `<span id="issue-${ item.id }">@${ item.title }</span>`;
  556. return span;
  557. }
  558. }
  559. ]
  560. } );
  561. } );
  562. it( 'should show panel for matched marker', () => {
  563. setData( model, '<paragraph>foo []</paragraph>' );
  564. model.change( writer => {
  565. writer.insertText( '@', doc.selection.getFirstPosition() );
  566. } );
  567. return waitForDebounce()
  568. .then( () => {
  569. expect( panelView.isVisible ).to.be.true;
  570. expect( listView.items ).to.have.length( 5 );
  571. } );
  572. } );
  573. describe( 'keys', () => {
  574. describe( 'on arrows', () => {
  575. it( 'should cycle down on arrow down', () => {
  576. setData( model, '<paragraph>foo []</paragraph>' );
  577. model.change( writer => {
  578. writer.insertText( '@', doc.selection.getFirstPosition() );
  579. } );
  580. return waitForDebounce()
  581. .then( () => {
  582. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  583. const keyEvtData = {
  584. keyCode: keyCodes.arrowdown,
  585. preventDefault: sinon.spy(),
  586. stopPropagation: sinon.spy()
  587. };
  588. fireKeyDownEvent( keyEvtData );
  589. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  590. fireKeyDownEvent( keyEvtData );
  591. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  592. fireKeyDownEvent( keyEvtData );
  593. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  594. fireKeyDownEvent( keyEvtData );
  595. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  596. fireKeyDownEvent( keyEvtData );
  597. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  598. } );
  599. } );
  600. it( 'should cycle up on arrow up', () => {
  601. setData( model, '<paragraph>foo []</paragraph>' );
  602. model.change( writer => {
  603. writer.insertText( '@', doc.selection.getFirstPosition() );
  604. } );
  605. return waitForDebounce()
  606. .then( () => {
  607. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  608. const keyEvtData = {
  609. keyCode: keyCodes.arrowup,
  610. preventDefault: sinon.spy(),
  611. stopPropagation: sinon.spy()
  612. };
  613. fireKeyDownEvent( keyEvtData );
  614. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  615. fireKeyDownEvent( keyEvtData );
  616. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  617. fireKeyDownEvent( keyEvtData );
  618. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  619. fireKeyDownEvent( keyEvtData );
  620. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  621. fireKeyDownEvent( keyEvtData );
  622. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  623. } );
  624. } );
  625. } );
  626. describe( 'on "execute" keys', () => {
  627. testExecuteKey( 'enter', keyCodes.enter, issues );
  628. testExecuteKey( 'tab', keyCodes.tab, issues );
  629. testExecuteKey( 'space', keyCodes.space, issues );
  630. } );
  631. describe( 'mouse', () => {
  632. it( 'should execute selected button on mouse click', () => {
  633. setData( model, '<paragraph>foo []</paragraph>' );
  634. model.change( writer => {
  635. writer.insertText( '@', doc.selection.getFirstPosition() );
  636. } );
  637. const command = editor.commands.get( 'mention' );
  638. const spy = testUtils.sinon.spy( command, 'execute' );
  639. return waitForDebounce()
  640. .then( () => {
  641. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  642. const element = panelView.element.querySelector( '#issue-1004' );
  643. element.dispatchEvent( new Event( 'click', { bubbles: true } ) );
  644. sinon.assert.calledOnce( spy );
  645. const commandOptions = spy.getCall( 0 ).args[ 0 ];
  646. const item = issues[ 2 ];
  647. expect( commandOptions ).to.have.property( 'mention' ).that.deep.equal( item );
  648. expect( commandOptions ).to.have.property( 'marker', '@' );
  649. expect( commandOptions ).to.have.property( 'range' );
  650. const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
  651. const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
  652. expect( commandOptions.range.isEqual( expectedRange ) ).to.be.true;
  653. } );
  654. } );
  655. } );
  656. } );
  657. } );
  658. function testExecuteKey( name, keyCode, feedItems ) {
  659. it( 'should execute selected button on ' + name, () => {
  660. setData( model, '<paragraph>foo []</paragraph>' );
  661. model.change( writer => {
  662. writer.insertText( '@', doc.selection.getFirstPosition() );
  663. } );
  664. const command = editor.commands.get( 'mention' );
  665. const spy = testUtils.sinon.spy( command, 'execute' );
  666. return waitForDebounce()
  667. .then( () => {
  668. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  669. fireKeyDownEvent( {
  670. keyCode: keyCodes.arrowup,
  671. preventDefault: sinon.spy(),
  672. stopPropagation: sinon.spy()
  673. } );
  674. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  675. fireKeyDownEvent( {
  676. keyCode,
  677. preventDefault: sinon.spy(),
  678. stopPropagation: sinon.spy()
  679. } );
  680. sinon.assert.calledOnce( spy );
  681. assertCommandOptions( spy.getCall( 0 ).args[ 0 ], '@', feedItems[ 4 ] );
  682. const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
  683. const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
  684. expect( spy.getCall( 0 ).args[ 0 ].range.isEqual( expectedRange ) ).to.be.true;
  685. } );
  686. } );
  687. it( 'should do nothing if panel is not visible on ' + name, () => {
  688. setData( model, '<paragraph>foo []</paragraph>' );
  689. model.change( writer => {
  690. writer.insertText( '@', doc.selection.getFirstPosition() );
  691. } );
  692. const command = editor.commands.get( 'mention' );
  693. const spy = testUtils.sinon.spy( command, 'execute' );
  694. return waitForDebounce()
  695. .then( () => {
  696. expect( panelView.isVisible ).to.be.true;
  697. fireKeyDownEvent( {
  698. keyCode: keyCodes.esc,
  699. preventDefault: sinon.spy(),
  700. stopPropagation: sinon.spy()
  701. } );
  702. expect( panelView.isVisible ).to.be.false;
  703. fireKeyDownEvent( {
  704. keyCode,
  705. preventDefault: sinon.spy(),
  706. stopPropagation: sinon.spy()
  707. } );
  708. sinon.assert.notCalled( spy );
  709. expect( panelView.isVisible ).to.be.false;
  710. } );
  711. } );
  712. }
  713. } );
  714. describe( 'execute', () => {
  715. beforeEach( () => createClassicTestEditor( staticConfig ) );
  716. it( 'should call the mention command with proper options', () => {
  717. setData( model, '<paragraph>foo []</paragraph>' );
  718. model.change( writer => {
  719. writer.insertText( '@', doc.selection.getFirstPosition() );
  720. } );
  721. const command = editor.commands.get( 'mention' );
  722. const spy = testUtils.sinon.spy( command, 'execute' );
  723. return waitForDebounce()
  724. .then( () => {
  725. listView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
  726. sinon.assert.calledOnce( spy );
  727. const commandOptions = spy.getCall( 0 ).args[ 0 ];
  728. assertCommandOptions( commandOptions, '@', { name: 'Barney' } );
  729. const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
  730. const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
  731. expect( commandOptions.range.isEqual( expectedRange ) ).to.be.true;
  732. } );
  733. } );
  734. it( 'should hide panel on execute', () => {
  735. setData( model, '<paragraph>foo []</paragraph>' );
  736. model.change( writer => {
  737. writer.insertText( '@', doc.selection.getFirstPosition() );
  738. } );
  739. return waitForDebounce()
  740. .then( () => {
  741. listView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
  742. expect( panelView.isVisible ).to.be.false;
  743. } );
  744. } );
  745. it( 'should focus view after command execution', () => {
  746. const focusSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
  747. setData( model, '<paragraph>foo []</paragraph>' );
  748. model.change( writer => {
  749. writer.insertText( '@', doc.selection.getFirstPosition() );
  750. } );
  751. return waitForDebounce()
  752. .then( () => {
  753. listView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
  754. sinon.assert.calledOnce( focusSpy );
  755. } );
  756. } );
  757. } );
  758. function createClassicTestEditor( mentionConfig ) {
  759. return ClassicTestEditor
  760. .create( editorElement, {
  761. plugins: [ Paragraph, MentionEditing, MentionUI ],
  762. mention: mentionConfig
  763. } )
  764. .then( newEditor => {
  765. editor = newEditor;
  766. model = editor.model;
  767. doc = model.document;
  768. editingView = editor.editing.view;
  769. mentionUI = editor.plugins.get( MentionUI );
  770. panelView = mentionUI.panelView;
  771. mentionsView = mentionUI._mentionsView;
  772. listView = mentionsView.listView;
  773. editingView.attachDomRoot( editorElement );
  774. // Focus the engine.
  775. editingView.document.isFocused = true;
  776. editingView.getDomRoot().focus();
  777. // Remove all selection ranges from DOM before testing.
  778. window.getSelection().removeAllRanges();
  779. } );
  780. }
  781. function waitForDebounce() {
  782. return new Promise( resolve => {
  783. setTimeout( () => {
  784. resolve();
  785. }, 50 );
  786. } );
  787. }
  788. function fireKeyDownEvent( options ) {
  789. const eventInfo = new EventInfo( editingView.document, 'keydown' );
  790. const eventData = new DomEventData( editingView.document, {
  791. target: document.body
  792. }, options );
  793. editingView.document.fire( eventInfo, eventData );
  794. }
  795. function stubSelectionRects( rects ) {
  796. const originalViewRangeToDom = editingView.domConverter.viewRangeToDom;
  797. // Mock selection rect.
  798. sinon.stub( editingView.domConverter, 'viewRangeToDom' ).callsFake( ( ...args ) => {
  799. const domRange = originalViewRangeToDom.apply( editingView.domConverter, args );
  800. sinon.stub( domRange, 'getClientRects' )
  801. .returns( rects );
  802. return domRange;
  803. } );
  804. }
  805. function expectChildViewsIsOnState( expectedState ) {
  806. const childViews = [ ...listView.items ].map( listView => listView.children.get( 0 ) );
  807. expect( childViews.map( child => child.isOn ) ).to.deep.equal( expectedState );
  808. }
  809. function assertCommandOptions( commandOptions, marker, item ) {
  810. expect( commandOptions ).to.have.property( 'marker', marker );
  811. expect( commandOptions ).to.have.property( 'range' );
  812. expect( commandOptions ).to.have.property( 'mention' );
  813. const mentionForCommand = commandOptions.mention;
  814. for ( const key of Object.keys( item ) ) {
  815. expect( mentionForCommand[ key ] ).to.equal( item[ key ] );
  816. }
  817. }
  818. } );