/** * @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 document */ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import View from '../src/view'; import ViewCollection from '../src/viewcollection'; import normalizeHtml from '@ckeditor/ckeditor5-utils/tests/_utils/normalizehtml'; import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; let collection; describe( 'ViewCollection', () => { testUtils.createSinonSandbox(); beforeEach( createTestCollection ); describe( 'constructor()', () => { it( 'sets basic properties and attributes', () => { expect( collection._parentElement ).to.be.null; expect( collection._idProperty ).to.equal( 'viewUid' ); } ); it( 'allows setting initial collection items', () => { const view1 = new View(); const view2 = new View(); const collection = new ViewCollection( [ view1, view2 ] ); expect( collection ).to.have.length( 2 ); expect( collection.get( 0 ) ).to.equal( view1 ); expect( collection.get( 1 ) ).to.equal( view2 ); } ); describe( 'child view management in DOM', () => { it( 'manages view#element of the children in DOM', () => { const parentElement = document.createElement( 'p' ); collection.setParent( parentElement ); const viewA = new View(); expect( () => { collection.add( viewA ); collection.remove( viewA ); } ).to.not.throw(); expect( () => { collection.add( viewA ); collection.remove( viewA ); } ).to.not.throw(); viewA.element = document.createElement( 'b' ); collection.add( viewA ); expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '

' ); const viewB = new View(); viewB.element = document.createElement( 'i' ); collection.add( viewB, 0 ); expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '

' ); collection.remove( viewA ); expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '

' ); collection.remove( viewB ); expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '

' ); } ); // #145 it( 'always keeps the collection synchronized with DOM', () => { const view = new View(); const viewA = getView( 'A' ); const viewB = getView( 'B' ); function getView( text ) { const view = new View(); view.setTemplate( { tag: 'li', children: [ { text } ] } ); return view; } // Fill the collection with children. collection.add( viewA ); collection.add( viewB ); // Put the collection in the template. view.setTemplate( { tag: 'ul', children: collection } ); const viewC = getView( 'C' ); // Modify the collection, while the view#element has already // been rendered but the view has **not** been inited yet. collection.add( viewC ); collection.remove( 1 ); view.render(); expect( view.element.childNodes ).to.have.length( 2 ); expect( view.element.childNodes[ 0 ] ).to.equal( viewA.element ); expect( view.element.childNodes[ 1 ] ).to.equal( viewC.element ); } ); } ); } ); describe( 'destroy()', () => { it( 'calls #destroy on all views in the collection', () => { const viewA = new View(); const viewB = new View(); const spyA = testUtils.sinon.spy( viewA, 'destroy' ); const spyB = testUtils.sinon.spy( viewB, 'destroy' ); collection.add( viewA ); collection.add( viewB ); collection.destroy(); sinon.assert.calledOnce( spyA ); sinon.assert.calledOnce( spyB ); sinon.assert.callOrder( spyA, spyB ); } ); } ); describe( 'add()', () => { it( 'renders the new view in the collection', () => { const view = new View(); const spy = testUtils.sinon.spy( view, 'render' ); expect( view.isRendered ).to.be.false; collection.add( view ); expect( view.isRendered ).to.be.true; sinon.assert.calledOnce( spy ); } ); it( 'works for a view with a Number view#id attribute', () => { const view = new View(); view.set( 'id', 1 ); collection.add( view ); expect( view.id ).to.equal( 1 ); expect( view.viewUid ).to.be.a( 'string' ); } ); } ); describe( 'setParent()', () => { it( 'sets #_parentElement', () => { const el = {}; expect( collection._parentElement ).to.be.null; collection.setParent( el ); expect( collection._parentElement ).to.equal( el ); } ); it( 'udpates initial collection items in DOM', () => { const view1 = new View(); view1.element = document.createElement( 'i' ); sinon.spy( view1, 'render' ); const view2 = new View(); view2.element = document.createElement( 'b' ); sinon.spy( view2, 'render' ); const collection = new ViewCollection( [ view1, view2 ] ); const parentElement = document.createElement( 'div' ); expect( collection ).to.have.length( 2 ); expect( collection.get( 0 ) ).to.equal( view1 ); expect( collection.get( 1 ) ).to.equal( view2 ); collection.setParent( parentElement ); expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '
' ); sinon.assert.calledOnce( view1.render ); sinon.assert.calledOnce( view2.render ); } ); } ); describe( 'delegate()', () => { it( 'should throw when event names are not strings', () => { expectToThrowCKEditorError( () => { collection.delegate(); }, /ui-viewcollection-delegate-wrong-events/ ); expectToThrowCKEditorError( () => { collection.delegate( new Date() ); }, /ui-viewcollection-delegate-wrong-events/ ); expectToThrowCKEditorError( () => { collection.delegate( 'color', new Date() ); }, /ui-viewcollection-delegate-wrong-events/ ); } ); it( 'returns object', () => { expect( collection.delegate( 'foo' ) ).to.be.an( 'object' ); } ); it( 'provides "to()" interface', () => { const delegate = collection.delegate( 'foo' ); expect( delegate ).to.have.keys( 'to' ); expect( delegate.to ).to.be.a( 'function' ); } ); describe( 'to()', () => { it( 'does not chain', () => { const returned = collection.delegate( 'foo' ).to( {} ); expect( returned ).to.be.undefined; } ); it( 'forwards an event to another observable – existing view', done => { const target = new View(); const view = new View(); collection.add( view ); collection.delegate( 'foo' ).to( target ); target.on( 'foo', ( ...args ) => { assertDelegated( args, { expectedName: 'foo', expectedSource: view, expectedPath: [ view, target ], expectedData: [] } ); done(); } ); view.fire( 'foo' ); } ); it( 'forwards an event to another observable – new view', done => { const target = new View(); const view = new View(); collection.delegate( 'foo' ).to( target ); collection.add( view ); target.on( 'foo', ( ...args ) => { assertDelegated( args, { expectedName: 'foo', expectedSource: view, expectedPath: [ view, target ], expectedData: [] } ); done(); } ); view.fire( 'foo' ); } ); it( 'forwards multiple events to another observable', () => { const target = new View(); const viewA = new View(); const viewB = new View(); const viewC = new View(); const spyFoo = sinon.spy(); const spyBar = sinon.spy(); const spyBaz = sinon.spy(); collection.delegate( 'foo', 'bar', 'baz' ).to( target ); collection.add( viewA ); collection.add( viewB ); collection.add( viewC ); target.on( 'foo', spyFoo ); target.on( 'bar', spyBar ); target.on( 'baz', spyBaz ); viewA.fire( 'foo' ); sinon.assert.calledOnce( spyFoo ); sinon.assert.notCalled( spyBar ); sinon.assert.notCalled( spyBaz ); assertDelegated( spyFoo.args[ 0 ], { expectedName: 'foo', expectedSource: viewA, expectedPath: [ viewA, target ], expectedData: [] } ); viewB.fire( 'bar' ); sinon.assert.calledOnce( spyFoo ); sinon.assert.calledOnce( spyBar ); sinon.assert.notCalled( spyBaz ); assertDelegated( spyBar.args[ 0 ], { expectedName: 'bar', expectedSource: viewB, expectedPath: [ viewB, target ], expectedData: [] } ); viewC.fire( 'baz' ); sinon.assert.calledOnce( spyFoo ); sinon.assert.calledOnce( spyBar ); sinon.assert.calledOnce( spyBaz ); assertDelegated( spyBaz.args[ 0 ], { expectedName: 'baz', expectedSource: viewC, expectedPath: [ viewC, target ], expectedData: [] } ); viewC.fire( 'not-delegated' ); sinon.assert.calledOnce( spyFoo ); sinon.assert.calledOnce( spyBar ); sinon.assert.calledOnce( spyBaz ); } ); it( 'does not forward events which are not supposed to be delegated', () => { const target = new View(); const view = new View(); const spyFoo = sinon.spy(); const spyBar = sinon.spy(); const spyBaz = sinon.spy(); collection.delegate( 'foo', 'bar', 'baz' ).to( target ); collection.add( view ); target.on( 'foo', spyFoo ); target.on( 'bar', spyBar ); target.on( 'baz', spyBaz ); view.fire( 'foo' ); view.fire( 'bar' ); view.fire( 'baz' ); view.fire( 'not-delegated' ); sinon.assert.callOrder( spyFoo, spyBar, spyBaz ); sinon.assert.callCount( spyFoo, 1 ); sinon.assert.callCount( spyBar, 1 ); sinon.assert.callCount( spyBaz, 1 ); } ); it( 'stops forwarding when view removed from the collection', () => { const target = new View(); const view = new View(); const spy = sinon.spy(); collection.delegate( 'foo' ).to( target ); target.on( 'foo', spy ); collection.add( view ); view.fire( 'foo' ); sinon.assert.callCount( spy, 1 ); collection.remove( 0 ); view.fire( 'foo' ); sinon.assert.callCount( spy, 1 ); } ); it( 'supports deep event delegation', done => { const target = new View(); const viewA = new View(); const viewAA = new View(); const data = {}; const deepCollection = viewA.createCollection(); collection.add( viewA ); collection.delegate( 'foo' ).to( target ); deepCollection.add( viewAA ); deepCollection.delegate( 'foo' ).to( viewA ); target.on( 'foo', ( ...args ) => { assertDelegated( args, { expectedName: 'foo', expectedSource: viewAA, expectedPath: [ viewAA, viewA, target ], expectedData: [ data ] } ); done(); } ); viewAA.fire( 'foo', data ); } ); } ); } ); } ); function createTestCollection() { collection = new ViewCollection(); } function assertDelegated( evtArgs, { expectedName, expectedSource, expectedPath, expectedData } ) { const evtInfo = evtArgs[ 0 ]; expect( evtInfo.name ).to.equal( expectedName ); expect( evtInfo.source ).to.equal( expectedSource ); expect( evtInfo.path ).to.deep.equal( expectedPath ); expect( evtArgs.slice( 1 ) ).to.deep.equal( expectedData ); }