viewcollection.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. /**
  2. * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /* global document */
  6. /* bender-tags: ui */
  7. import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
  8. import Collection from 'ckeditor5/utils/collection.js';
  9. import testUtils from 'tests/core/_utils/utils.js';
  10. import View from 'ckeditor5/ui/view.js';
  11. import ViewCollection from 'ckeditor5/ui/viewcollection.js';
  12. import Template from 'ckeditor5/ui/template.js';
  13. import normalizeHtml from 'tests/utils/_utils/normalizehtml.js';
  14. let collection;
  15. testUtils.createSinonSandbox();
  16. describe( 'ViewCollection', () => {
  17. beforeEach( createTestCollection );
  18. describe( 'constructor()', () => {
  19. it( 'sets basic properties and attributes', () => {
  20. expect( collection.locale ).to.be.undefined;
  21. expect( collection.ready ).to.be.false;
  22. expect( collection._parentElement ).to.be.null;
  23. expect( collection._boundItemsToViewsMap ).to.be.instanceOf( Map );
  24. expect( collection._idProperty ).to.equal( 'viewUid' );
  25. } );
  26. it( 'accepts locale and defines the locale property', () => {
  27. const locale = { t() {} };
  28. expect( new ViewCollection( locale ).locale ).to.equal( locale );
  29. } );
  30. it( 'manages view#element of the children in DOM', () => {
  31. const parentElement = document.createElement( 'p' );
  32. collection.setParent( parentElement );
  33. const viewA = new View();
  34. expect( () => {
  35. collection.add( viewA );
  36. collection.remove( viewA );
  37. } ).to.not.throw();
  38. expect( () => {
  39. collection.ready = true;
  40. collection.add( viewA );
  41. collection.remove( viewA );
  42. } ).to.not.throw();
  43. viewA.element = document.createElement( 'b' );
  44. collection.add( viewA );
  45. expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '<p><b></b></p>' );
  46. const viewB = new View();
  47. viewB.element = document.createElement( 'i' );
  48. collection.add( viewB, 0 );
  49. expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '<p><i></i><b></b></p>' );
  50. collection.remove( viewA );
  51. expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '<p><i></i></p>' );
  52. collection.remove( viewB );
  53. expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '<p></p>' );
  54. } );
  55. } );
  56. describe( 'init()', () => {
  57. it( 'should return a promise', () => {
  58. expect( collection.init() ).to.be.instanceof( Promise );
  59. } );
  60. it( 'should throw if already initialized', () => {
  61. return collection.init()
  62. .then( () => {
  63. collection.init();
  64. throw new Error( 'This should not be executed.' );
  65. } )
  66. .catch( err => {
  67. expect( err ).to.be.instanceof( CKEditorError );
  68. expect( err.message ).to.match( /ui-viewcollection-init-reinit/ );
  69. } );
  70. } );
  71. it( 'calls #init on all views in the collection', () => {
  72. const viewA = new View();
  73. const viewB = new View();
  74. viewA.element = document.createElement( 'a' );
  75. viewB.element = document.createElement( 'b' );
  76. const spyA = testUtils.sinon.spy( viewA, 'init' );
  77. const spyB = testUtils.sinon.spy( viewB, 'init' );
  78. collection.setParent( document.body );
  79. collection.add( viewA );
  80. collection.add( viewB );
  81. return collection.init().then( () => {
  82. sinon.assert.calledOnce( spyA );
  83. sinon.assert.calledOnce( spyB );
  84. sinon.assert.callOrder( spyA, spyB );
  85. expect( viewA.element.parentNode ).to.equal( collection._parentElement );
  86. expect( viewA.element.nextSibling ).to.equal( viewB.element );
  87. expect( collection.ready ).to.be.true;
  88. } );
  89. } );
  90. } );
  91. describe( 'destroy()', () => {
  92. it( 'should return a promise', () => {
  93. expect( collection.destroy() ).to.be.instanceof( Promise );
  94. } );
  95. it( 'calls #destroy on all views in the collection', () => {
  96. const viewA = new View();
  97. const viewB = new View();
  98. const spyA = testUtils.sinon.spy( viewA, 'destroy' );
  99. const spyB = testUtils.sinon.spy( viewB, 'destroy' );
  100. collection.add( viewA );
  101. collection.add( viewB );
  102. return collection.destroy().then( () => {
  103. sinon.assert.calledOnce( spyA );
  104. sinon.assert.calledOnce( spyB );
  105. sinon.assert.callOrder( spyA, spyB );
  106. } );
  107. } );
  108. } );
  109. describe( 'add()', () => {
  110. it( 'returns a promise', () => {
  111. expect( collection.add( {} ) ).to.be.instanceof( Promise );
  112. } );
  113. it( 'initializes the new view in the collection', () => {
  114. let view = new View();
  115. let spy = testUtils.sinon.spy( view, 'init' );
  116. expect( collection.ready ).to.be.false;
  117. expect( view.ready ).to.be.false;
  118. return collection.add( view ).then( () => {
  119. expect( collection.ready ).to.be.false;
  120. expect( view.ready ).to.be.false;
  121. sinon.assert.notCalled( spy );
  122. view = new View();
  123. spy = testUtils.sinon.spy( view, 'init' );
  124. collection.ready = true;
  125. return collection.add( view ).then( () => {
  126. expect( view.ready ).to.be.true;
  127. sinon.assert.calledOnce( spy );
  128. } );
  129. } );
  130. } );
  131. it( 'works for a view with a Number view#id attribute', () => {
  132. const view = new View();
  133. view.set( 'id', 1 );
  134. return collection.add( view ).then( () => {
  135. expect( view.id ).to.equal( 1 );
  136. expect( view.viewUid ).to.be.a( 'string' );
  137. } );
  138. } );
  139. } );
  140. describe( 'setParent', () => {
  141. it( 'sets #_parentElement', () => {
  142. const el = {};
  143. expect( collection._parentElement ).to.be.null;
  144. collection.setParent( el );
  145. expect( collection._parentElement ).to.equal( el );
  146. } );
  147. } );
  148. describe( 'bindTo()', () => {
  149. class ViewClass extends View {
  150. constructor( locale, data ) {
  151. super( locale );
  152. this.template = new Template( {
  153. tag: 'b'
  154. } );
  155. this.data = data;
  156. }
  157. }
  158. it( 'provides "as()" interface', () => {
  159. const returned = collection.bindTo( {} );
  160. expect( returned ).to.have.keys( 'as' );
  161. expect( returned.as ).to.be.a( 'function' );
  162. } );
  163. describe( 'as()', () => {
  164. it( 'does not chain', () => {
  165. const returned = collection.bindTo( new Collection() ).as( ViewClass );
  166. expect( returned ).to.be.undefined;
  167. } );
  168. it( 'binds collection as a view factory – initial content', () => {
  169. const locale = {};
  170. const items = new Collection();
  171. items.add( { id: '1' } );
  172. items.add( { id: '2' } );
  173. collection = new ViewCollection( locale );
  174. collection.bindTo( items ).as( ViewClass );
  175. expect( collection ).to.have.length( 2 );
  176. expect( collection.get( 0 ) ).to.be.instanceOf( ViewClass );
  177. expect( collection.get( 1 ) ).to.be.instanceOf( ViewClass );
  178. expect( collection.get( 0 ).locale ).to.equal( locale );
  179. expect( collection.get( 1 ).data ).to.equal( items.get( 1 ) );
  180. } );
  181. it( 'binds collection as a view factory – new content', () => {
  182. const locale = {};
  183. const items = new Collection();
  184. collection = new ViewCollection( locale );
  185. collection.bindTo( items ).as( ViewClass );
  186. expect( collection ).to.have.length( 0 );
  187. items.add( { id: '1' } );
  188. items.add( { id: '2' } );
  189. expect( collection ).to.have.length( 2 );
  190. expect( collection.get( 0 ) ).to.be.instanceOf( ViewClass );
  191. expect( collection.get( 1 ) ).to.be.instanceOf( ViewClass );
  192. expect( collection.get( 0 ).locale ).to.equal( locale );
  193. expect( collection.get( 1 ).data ).to.equal( items.get( 1 ) );
  194. } );
  195. it( 'binds collection as a view factory – item removal', () => {
  196. const locale = {};
  197. const items = new Collection();
  198. collection = new ViewCollection( locale );
  199. collection.bindTo( items ).as( ViewClass );
  200. expect( collection ).to.have.length( 0 );
  201. items.add( { id: '1' } );
  202. items.add( { id: '2' } );
  203. expect( collection ).to.have.length( 2 );
  204. expect( collection.get( 0 ) ).to.be.instanceOf( ViewClass );
  205. expect( collection.get( 1 ) ).to.be.instanceOf( ViewClass );
  206. expect( collection.get( 0 ).locale ).to.equal( locale );
  207. expect( collection.get( 1 ).data ).to.equal( items.get( 1 ) );
  208. items.remove( 1 );
  209. expect( collection.get( 0 ).data ).to.equal( items.get( 0 ) );
  210. items.remove( 0 );
  211. expect( collection ).to.have.length( 0 );
  212. } );
  213. it( 'binds collection as a view factory – custom factory (arrow function)', () => {
  214. const locale = {};
  215. const items = new Collection();
  216. collection = new ViewCollection( locale );
  217. collection.bindTo( items ).as( ( item ) => {
  218. return new ViewClass( locale, item );
  219. } );
  220. expect( collection ).to.have.length( 0 );
  221. items.add( { id: '1' } );
  222. items.add( { id: '2' } );
  223. expect( collection ).to.have.length( 2 );
  224. expect( collection.get( 0 ) ).to.be.instanceOf( ViewClass );
  225. expect( collection.get( 1 ) ).to.be.instanceOf( ViewClass );
  226. expect( collection.get( 0 ).locale ).to.equal( locale );
  227. expect( collection.get( 1 ).data ).to.equal( items.get( 1 ) );
  228. } );
  229. // https://github.com/ckeditor/ckeditor5-ui/issues/113
  230. it( 'binds collection as a view factory – custom factory (normal function)', () => {
  231. const locale = { locale: true };
  232. const items = new Collection();
  233. collection = new ViewCollection( locale );
  234. collection.bindTo( items ).as( function( item ) {
  235. return new ViewClass( locale, item );
  236. } );
  237. items.add( { id: '1' } );
  238. expect( collection ).to.have.length( 1 );
  239. const view = collection.get( 0 );
  240. // Wrong args will be passed to the callback if it's treated as the view constructor.
  241. expect( view ).to.be.instanceOf( ViewClass );
  242. expect( view.locale ).to.equal( locale );
  243. expect( view.data ).to.equal( items.get( 0 ) );
  244. } );
  245. } );
  246. } );
  247. describe( 'delegate()', () => {
  248. it( 'should throw when event names are not strings', () => {
  249. expect( () => {
  250. collection.delegate();
  251. } ).to.throw( CKEditorError, /ui-viewcollection-delegate-wrong-events/ );
  252. expect( () => {
  253. collection.delegate( new Date() );
  254. } ).to.throw( CKEditorError, /ui-viewcollection-delegate-wrong-events/ );
  255. expect( () => {
  256. collection.delegate( 'color', new Date() );
  257. } ).to.throw( CKEditorError, /ui-viewcollection-delegate-wrong-events/ );
  258. } );
  259. it( 'returns object', () => {
  260. expect( collection.delegate( 'foo' ) ).to.be.an( 'object' );
  261. } );
  262. it( 'provides "to()" interface', () => {
  263. const delegate = collection.delegate( 'foo' );
  264. expect( delegate ).to.have.keys( 'to' );
  265. expect( delegate.to ).to.be.a( 'function' );
  266. } );
  267. describe( 'to()', () => {
  268. it( 'does not chain', () => {
  269. const returned = collection.delegate( 'foo' ).to( {} );
  270. expect( returned ).to.be.undefined;
  271. } );
  272. it( 'forwards an event to another observable – existing view', ( done ) => {
  273. const target = new View();
  274. const view = new View();
  275. collection.add( view );
  276. collection.delegate( 'foo' ).to( target );
  277. target.on( 'foo', ( ...args ) => {
  278. assertDelegated( args, {
  279. expectedName: 'foo',
  280. expectedSource: view,
  281. expectedPath: [ view, target ],
  282. expectedData: []
  283. } );
  284. done();
  285. } );
  286. view.fire( 'foo' );
  287. } );
  288. it( 'forwards an event to another observable – new view', ( done ) => {
  289. const target = new View();
  290. const view = new View();
  291. collection.delegate( 'foo' ).to( target );
  292. collection.add( view );
  293. target.on( 'foo', ( ...args ) => {
  294. assertDelegated( args, {
  295. expectedName: 'foo',
  296. expectedSource: view,
  297. expectedPath: [ view, target ],
  298. expectedData: []
  299. } );
  300. done();
  301. } );
  302. view.fire( 'foo' );
  303. } );
  304. it( 'forwards multiple events to another observable', () => {
  305. const target = new View();
  306. const viewA = new View();
  307. const viewB = new View();
  308. const viewC = new View();
  309. const spyFoo = sinon.spy();
  310. const spyBar = sinon.spy();
  311. const spyBaz = sinon.spy();
  312. collection.delegate( 'foo', 'bar', 'baz' ).to( target );
  313. collection.add( viewA );
  314. collection.add( viewB );
  315. collection.add( viewC );
  316. target.on( 'foo', spyFoo );
  317. target.on( 'bar', spyBar );
  318. target.on( 'baz', spyBaz );
  319. viewA.fire( 'foo' );
  320. sinon.assert.calledOnce( spyFoo );
  321. sinon.assert.notCalled( spyBar );
  322. sinon.assert.notCalled( spyBaz );
  323. assertDelegated( spyFoo.args[ 0 ], {
  324. expectedName: 'foo',
  325. expectedSource: viewA,
  326. expectedPath: [ viewA, target ],
  327. expectedData: []
  328. } );
  329. viewB.fire( 'bar' );
  330. sinon.assert.calledOnce( spyFoo );
  331. sinon.assert.calledOnce( spyBar );
  332. sinon.assert.notCalled( spyBaz );
  333. assertDelegated( spyBar.args[ 0 ], {
  334. expectedName: 'bar',
  335. expectedSource: viewB,
  336. expectedPath: [ viewB, target ],
  337. expectedData: []
  338. } );
  339. viewC.fire( 'baz' );
  340. sinon.assert.calledOnce( spyFoo );
  341. sinon.assert.calledOnce( spyBar );
  342. sinon.assert.calledOnce( spyBaz );
  343. assertDelegated( spyBaz.args[ 0 ], {
  344. expectedName: 'baz',
  345. expectedSource: viewC,
  346. expectedPath: [ viewC, target ],
  347. expectedData: []
  348. } );
  349. viewC.fire( 'not-delegated' );
  350. sinon.assert.calledOnce( spyFoo );
  351. sinon.assert.calledOnce( spyBar );
  352. sinon.assert.calledOnce( spyBaz );
  353. } );
  354. it( 'does not forward events which are not supposed to be delegated', () => {
  355. const target = new View();
  356. const view = new View();
  357. const spyFoo = sinon.spy();
  358. const spyBar = sinon.spy();
  359. const spyBaz = sinon.spy();
  360. collection.delegate( 'foo', 'bar', 'baz' ).to( target );
  361. collection.add( view );
  362. target.on( 'foo', spyFoo );
  363. target.on( 'bar', spyBar );
  364. target.on( 'baz', spyBaz );
  365. view.fire( 'foo' );
  366. view.fire( 'bar' );
  367. view.fire( 'baz' );
  368. view.fire( 'not-delegated' );
  369. sinon.assert.callOrder( spyFoo, spyBar, spyBaz );
  370. sinon.assert.callCount( spyFoo, 1 );
  371. sinon.assert.callCount( spyBar, 1 );
  372. sinon.assert.callCount( spyBaz, 1 );
  373. } );
  374. it( 'stops forwarding when view removed from the collection', () => {
  375. const target = new View();
  376. const view = new View();
  377. const spy = sinon.spy();
  378. collection.delegate( 'foo' ).to( target );
  379. target.on( 'foo', spy );
  380. collection.add( view );
  381. view.fire( 'foo' );
  382. sinon.assert.callCount( spy, 1 );
  383. collection.remove( 0 );
  384. view.fire( 'foo' );
  385. sinon.assert.callCount( spy, 1 );
  386. } );
  387. it( 'supports deep event delegation', ( done ) => {
  388. const target = new View();
  389. const viewA = new View();
  390. const viewAA = new View();
  391. const data = {};
  392. const deepCollection = viewA.createCollection();
  393. collection.add( viewA );
  394. collection.delegate( 'foo' ).to( target );
  395. deepCollection.add( viewAA );
  396. deepCollection.delegate( 'foo' ).to( viewA );
  397. target.on( 'foo', ( ...args ) => {
  398. assertDelegated( args, {
  399. expectedName: 'foo',
  400. expectedSource: viewAA,
  401. expectedPath: [ viewAA, viewA, target ],
  402. expectedData: [ data ]
  403. } );
  404. done();
  405. } );
  406. viewAA.fire( 'foo', data );
  407. } );
  408. } );
  409. } );
  410. } );
  411. function createTestCollection() {
  412. collection = new ViewCollection();
  413. }
  414. function assertDelegated( evtArgs, { expectedName, expectedSource, expectedPath, expectedData } ) {
  415. const evtInfo = evtArgs[ 0 ];
  416. expect( evtInfo.name ).to.equal( expectedName );
  417. expect( evtInfo.source ).to.equal( expectedSource );
  418. expect( evtInfo.path ).to.deep.equal( expectedPath );
  419. expect( evtArgs.slice( 1 ) ).to.deep.equal( expectedData );
  420. }