8
0
Quellcode durchsuchen

Created ViewCollection class.

Aleksander Nowodzinski vor 9 Jahren
Ursprung
Commit
6f23d373d3

+ 241 - 0
packages/ckeditor5-ui/src/viewcollection.js

@@ -0,0 +1,241 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import CKEditorError from '../utils/ckeditorerror.js';
+import ObservableMixin from '../utils/observablemixin.js';
+import Collection from '../utils/collection.js';
+import mix from '../utils/mix.js';
+
+/**
+ * Collects {@link ui.View} instances.
+ *
+ * @memberOf ui
+ * @extends utils.Collection
+ */
+export default class ViewCollection extends Collection {
+	/**
+	 * Creates a new {@link ui.ViewCollection} instance.
+	 *
+	 * @param {utils.Locale} [locale] The {@link core.editor.Editor#locale editor's locale} instance.
+	 */
+	constructor( locale ) {
+		super();
+
+		// Handle {@link ui.View#element} in DOM when a new view is added to the collection.
+		this.on( 'add', ( evt, view, index ) => {
+			if ( this.ready && view.element ) {
+				this._parentElement.insertBefore( view.element, this._parentElement.children[ index ] );
+			}
+		} );
+
+		// Handle {@link ui.View#element} in DOM when a view is removed from the collection.
+		this.on( 'remove', ( evt, view ) => {
+			if ( this.ready && view.element ) {
+				view.element.remove();
+			}
+		} );
+
+		/**
+		 * The {@link core.editor.Editor#locale editor's locale} instance.
+		 *
+		 * @member {utils.Locale} ui.ViewCollection#locale
+		 */
+		this.locale = locale;
+
+		/**
+		 * Set `true` once parent {@link ui.View#ready} is true, which means
+		 * that all the views in the collection are also ready (which can be asynchronous).
+		 *
+		 * @readonly
+		 * @observable
+		 * @member {Boolean} ui.ViewCollection#ready
+		 */
+		this.set( 'ready', false );
+
+		/**
+		 * A parent element within which child views are rendered and managed in DOM.
+		 *
+		 * @protected
+		 * @member {HTMLElement} ui.ViewCollection#_parentElement
+		 */
+		this._parentElement = null;
+
+		/**
+		 * A helper mapping between bound collection items passed to {@link ui.ViewCollection#bindTo}
+		 * and view instances. Speeds up the view management.
+		 *
+		 * @protected
+		 * @member {HTMLElement} ui.ViewCollection#_boundItemsToViewsMap
+		 */
+		this._boundItemsToViewsMap = new Map();
+	}
+
+	/**
+	 * Adds a child view to the collection. If {@link ui.ViewCollection#ready}, the child view
+	 * is also initialized when added.
+	 *
+	 * @param {ui.View} view A child view.
+	 * @param {Number} [index] Index at which the child will be added to the collection.
+	 * @returns {Promise} A Promise resolved when the child {@link ui.View#init} is done.
+	 */
+	add( view, index ) {
+		super.add( view, index );
+
+		// {@link ui.View#init} returns `Promise`.
+		let promise = Promise.resolve();
+
+		if ( this.ready && !view.ready ) {
+			promise = promise.then( () => {
+				return view.init();
+			} );
+		}
+
+		return promise;
+	}
+
+	/**
+	 * Sets {@link ui.ViewCollection#parent} of this collection.
+	 *
+	 * @param {HTMLElement} element A new parent.
+	 */
+	setParent( elementOrDocFragment ) {
+		this._parentElement = elementOrDocFragment;
+	}
+
+	/**
+	 * Binds a view collection to {@link utils.Collection} of items to create
+	 * a factory of view instances.
+	 *
+	 * TODO: Example and longer explanation. Probably imported from ControllerCollection#bind.
+	 *
+	 * @param {utils.Collection} collection A collection to be bound.
+	 * @returns {Function} return.CallbackOrViewClass Specifies the constructor of the view to be used or
+	 * a custom callback function which produces views.
+	 */
+	bindTo( collection ) {
+		return {
+			as: ( CallbackOrViewClass ) => {
+				let createView;
+
+				if ( CallbackOrViewClass.prototype ) {
+					createView = ( item ) => {
+						const viewInstance = new CallbackOrViewClass( this.locale, item );
+
+						this._boundItemsToViewsMap.set( item, viewInstance );
+
+						return viewInstance;
+					};
+				} else {
+					createView = ( item ) => {
+						const viewInstance = CallbackOrViewClass( item );
+
+						this._boundItemsToViewsMap.set( item, viewInstance );
+
+						return viewInstance;
+					};
+				}
+
+				// Load the initial content of the collection.
+				for ( let item of collection ) {
+					this.add( createView( item ) );
+				}
+
+				// Synchronize views as new items are added to the collection.
+				collection.on( 'add', ( evt, item, index ) => {
+					this.add( createView( item ), index );
+				} );
+
+				// Synchronize views as items are removed from the collection.
+				collection.on( 'remove', ( evt, item ) => {
+					this.remove( this._boundItemsToViewsMap.get( item ) );
+
+					this._boundItemsToViewsMap.delete( item );
+				} );
+			}
+		};
+	}
+
+	/**
+	 * Delegates selected events coming from within the collection to desired {@link utils.Emitter}.
+	 *
+	 * For instance:
+	 *
+	 *		const viewA = new View();
+	 *		const viewB = new View();
+	 *		const viewC = new View();
+	 *
+	 *		const views = new ViewCollection( 'name' );
+	 *
+	 *		views.delegate( 'eventX' ).to( viewB );
+	 *		views.delegate( 'eventX', 'eventY' ).to( viewC );
+	 *
+	 *		views.add( viewA );
+	 *
+	 * then `eventX` is delegated (fired by) `viewB` and `viewC` along with `customData`:
+	 *
+	 *		viewA.fire( 'eventX', customData );
+	 *
+	 * and `eventY` is delegated (fired by) `viewC` along with `customData`:
+	 *
+	 *		viewA.fire( 'eventY', customData );
+	 *
+	 * See {@link utils.EmitterMixin#delegate}.
+	 *
+	 * @param {...String} events {@link ui.View} event names to be delegated to another {@link utils.Emitter}.
+	 * @returns {ui.ViewCollection#delegate#to}
+	 */
+	delegate( ...events ) {
+		if ( !events.length || !isStringArray( events ) ) {
+			/**
+			 * All event names must be strings.
+			 *
+			 * @error ui-viewcollection-delegate-wrong-events
+			 */
+			throw new CKEditorError( 'ui-viewcollection-delegate-wrong-events: All event names must be strings.' );
+		}
+
+		return {
+			/**
+			 * Selects destination for {@link utils.EmitterMixin#delegate} events.
+			 *
+			 * @method ui.ViewCollection.delegate#to
+			 * @param {utils.EmitterMixin} dest An `EmitterMixin` instance which is the destination for delegated events.
+			 */
+			to: ( dest ) => {
+				// Activate delegating on existing views in this collection.
+				for ( let view of this ) {
+					for ( let evtName of events ) {
+						view.delegate( evtName ).to( dest );
+					}
+				}
+
+				// Activate delegating on future views in this collection.
+				this.on( 'add', ( evt, view ) => {
+					for ( let evtName of events ) {
+						view.delegate( evtName ).to( dest );
+					}
+				} );
+
+				// Deactivate delegating when view is removed from this collection.
+				this.on( 'remove', ( evt, view ) => {
+					for ( let evtName of events ) {
+						view.stopDelegating( evtName, dest );
+					}
+				} );
+			}
+		};
+	}
+}
+
+mix( Collection, ObservableMixin );
+
+// Check if all entries of the array are of `String` type.
+//
+// @private
+// @param {Array} arr An array to be checked.
+// @returns {Boolean}
+function isStringArray( arr ) {
+	return arr.every( a => typeof a == 'string' );
+}

+ 452 - 0
packages/ckeditor5-ui/tests/viewcollection.js

@@ -0,0 +1,452 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document */
+/* bender-tags: ui */
+
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+import Collection from '/ckeditor5/utils/collection.js';
+import testUtils from '/tests/core/_utils/utils.js';
+import View from '/ckeditor5/ui/view.js';
+import ViewCollection from '/ckeditor5/ui/viewcollection.js';
+import Template from '/ckeditor5/ui/template.js';
+import normalizeHtml from '/tests/utils/_utils/normalizehtml.js';
+
+let collection;
+
+testUtils.createSinonSandbox();
+
+describe( 'ViewCollection', () => {
+	beforeEach( createTestCollection );
+
+	describe( 'constructor', () => {
+		it( 'sets basic properties and attributes', () => {
+			expect( collection.locale ).to.be.undefined;
+			expect( collection.ready ).to.be.false;
+			expect( collection._parentElement ).to.be.null;
+			expect( collection._boundItemsToViewsMap ).to.be.instanceOf( Map );
+		} );
+
+		it( 'accepts locale and defines the locale property', () => {
+			const locale = { t() {} };
+
+			expect( new ViewCollection( locale ).locale ).to.equal( locale );
+		} );
+
+		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.ready = true;
+				collection.add( viewA );
+				collection.remove( viewA );
+			} ).to.not.throw();
+
+			viewA.element = document.createElement( 'b' );
+			collection.add( viewA );
+
+			expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '<p><b></b></p>' );
+
+			const viewB = new View();
+			viewB.element = document.createElement( 'i' );
+
+			collection.add( viewB, 0 );
+			expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '<p><i></i><b></b></p>' );
+
+			collection.remove( viewA );
+			expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '<p><i></i></p>' );
+
+			collection.remove( viewB );
+			expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '<p></p>' );
+		} );
+	} );
+
+	describe( 'add', () => {
+		it( 'returns a promise', () => {
+			expect( collection.add( {} ) ).to.be.instanceof( Promise );
+		} );
+
+		it( 'initializes the new view in the collection', () => {
+			let view = new View();
+			let spy = testUtils.sinon.spy( view, 'init' );
+
+			expect( collection.ready ).to.be.false;
+			expect( view.ready ).to.be.false;
+
+			return collection.add( view ).then( () => {
+				expect( collection.ready ).to.be.false;
+				expect( view.ready ).to.be.false;
+
+				sinon.assert.notCalled( spy );
+
+				view = new View();
+				spy = testUtils.sinon.spy( view, 'init' );
+
+				collection.ready = true;
+
+				return collection.add( view ).then( () => {
+					expect( view.ready ).to.be.true;
+
+					sinon.assert.calledOnce( spy );
+				} );
+			} );
+		} );
+	} );
+
+	describe( 'setParent', () => {
+		it( 'sets #_parentElement', () => {
+			const el = {};
+			expect( collection._parentElement ).to.be.null;
+
+			collection.setParent( el );
+			expect( collection._parentElement ).to.equal( el );
+		} );
+	} );
+
+	describe( 'bindTo', () => {
+		class ViewClass extends View {
+			constructor( locale, data ) {
+				super( locale );
+
+				this.template = new Template( {
+					tag: 'b'
+				} );
+
+				this.data = data;
+			}
+		}
+
+		it( 'provides "as" interface', () => {
+			const returned = collection.bindTo( {} );
+
+			expect( returned ).to.have.keys( 'as' );
+			expect( returned.as ).to.be.a( 'function' );
+		} );
+
+		describe( 'as', () => {
+			it( 'does not chain', () => {
+				const returned = collection.bindTo( new Collection() ).as( ViewClass );
+
+				expect( returned ).to.be.undefined;
+			} );
+
+			it( 'binds collection as a view factory – initial content', () => {
+				const locale = {};
+				const items = new Collection();
+
+				items.add( { id: '1' } );
+				items.add( { id: '2' } );
+
+				collection = new ViewCollection( locale );
+				collection.bindTo( items ).as( ViewClass );
+
+				expect( collection ).to.have.length( 2 );
+				expect( collection.get( 0 ) ).to.be.instanceOf( ViewClass );
+				expect( collection.get( 1 ) ).to.be.instanceOf( ViewClass );
+				expect( collection.get( 0 ).locale ).to.equal( locale );
+				expect( collection.get( 1 ).data ).to.equal( items.get( 1 ) );
+			} );
+
+			it( 'binds collection as a view factory – new content', () => {
+				const locale = {};
+				const items = new Collection();
+
+				collection = new ViewCollection( locale );
+				collection.bindTo( items ).as( ViewClass );
+
+				expect( collection ).to.have.length( 0 );
+
+				items.add( { id: '1' } );
+				items.add( { id: '2' } );
+
+				expect( collection ).to.have.length( 2 );
+				expect( collection.get( 0 ) ).to.be.instanceOf( ViewClass );
+				expect( collection.get( 1 ) ).to.be.instanceOf( ViewClass );
+				expect( collection.get( 0 ).locale ).to.equal( locale );
+				expect( collection.get( 1 ).data ).to.equal( items.get( 1 ) );
+			} );
+
+			it( 'binds collection as a view factory – item removal', () => {
+				const locale = {};
+				const items = new Collection();
+
+				collection = new ViewCollection( locale );
+				collection.bindTo( items ).as( ViewClass );
+
+				expect( collection ).to.have.length( 0 );
+
+				items.add( { id: '1' } );
+				items.add( { id: '2' } );
+
+				expect( collection ).to.have.length( 2 );
+				expect( collection.get( 0 ) ).to.be.instanceOf( ViewClass );
+				expect( collection.get( 1 ) ).to.be.instanceOf( ViewClass );
+				expect( collection.get( 0 ).locale ).to.equal( locale );
+				expect( collection.get( 1 ).data ).to.equal( items.get( 1 ) );
+
+				items.remove( 1 );
+				expect( collection.get( 0 ).data ).to.equal( items.get( 0 ) );
+
+				items.remove( 0 );
+				expect( collection ).to.have.length( 0 );
+			} );
+
+			it( 'binds collection as a view factory – custom factory', () => {
+				const locale = {};
+				const items = new Collection();
+
+				collection = new ViewCollection( locale );
+				collection.bindTo( items ).as( ( item ) => {
+					return new ViewClass( locale, item );
+				} );
+
+				expect( collection ).to.have.length( 0 );
+
+				items.add( { id: '1' } );
+				items.add( { id: '2' } );
+
+				expect( collection ).to.have.length( 2 );
+				expect( collection.get( 0 ) ).to.be.instanceOf( ViewClass );
+				expect( collection.get( 1 ) ).to.be.instanceOf( ViewClass );
+				expect( collection.get( 0 ).locale ).to.equal( locale );
+				expect( collection.get( 1 ).data ).to.equal( items.get( 1 ) );
+			} );
+		} );
+	} );
+
+	describe( 'delegate', () => {
+		it( 'should throw when event names are not strings', () => {
+			expect( () => {
+				collection.delegate();
+			} ).to.throw( CKEditorError, /ui-viewcollection-delegate-wrong-events/ );
+
+			expect( () => {
+				collection.delegate( new Date() );
+			} ).to.throw( CKEditorError, /ui-viewcollection-delegate-wrong-events/ );
+
+			expect( () => {
+				collection.delegate( 'color', new Date() );
+			} ).to.throw( CKEditorError, /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 );
+}