Browse Source

Major code refactoring, documentaion and tests for Controller class.

Aleksander Nowodzinski 10 years ago
parent
commit
fe9e7a34c1

+ 202 - 72
packages/ckeditor5-engine/src/ui/controller.js

@@ -19,132 +19,262 @@ CKEDITOR.define( [
 
 			/**
 			 * Model of this controller.
+			 *
+			 * @property {Model}
 			 */
 			this.model = model;
 
 			/**
 			 * View of this controller.
+			 *
+			 * @property {View}
 			 */
 			this.view = view;
 
 			/**
-			 * @property {Boolean} ready
+			 * Set `true` after {@link #init}.
+			 *
+			 * @property {Boolean}
 			 */
+			this.ready = false;
 
 			/**
-			 * Collections of child controller regions.
+			 * Collections of child controllers.
+			 *
+			 * @private
+			 * @property {Collection}
 			 */
-			this.regions = new Collection( {
+			this._collections = new Collection( {
 				idProperty: 'name'
 			} );
 		}
 
 		/**
-		 * @param
-		 * @returns
+		 * Initializes the controller instance. The process includes:
+		 *  * initialization of the child {@link #view}.
+		 *  * initialization of child controllers in {@link #_collections}.
+		 *  * setting {@link #ready} flag `true`.
+		 *
+		 * @returns {Promise} A Promise resolved when the initialization process is finished.
 		 */
 		init() {
 			if ( this.ready ) {
-				/**
-				 * This Controller has already been initialized.
-				 *
-				 * @error ui-controller-init
-				 * @param {Controller} controller
-				 */
-				throw new CKEditorError(
-					'ui-controller-init: This Controller has already been initialized.',
-					{ view: this }
-				);
+				throw new CKEditorError( 'ui-controller-init-reinit: This Controller already been initialized.' );
 			}
 
 			return Promise.resolve()
-				// Note: Because this.view.init() can be sync as well as async,
-				// this method is not returning this.view.init() directly.
+				.then( this._initView.bind( this ) )
+				.then( this._initCollections.bind( this ) )
 				.then( () => {
-					return this.view.init();
-				} )
-				.then( () => {
-					let promises = [];
-					let region, controller;
+					this.ready = true;
+				} );
+		}
+
+		/**
+		 * Initializes the {@link #view} of this controller instance.
+		 *
+		 * @protected
+		 * @returns {Promise} A Promise resolved when initialization process is finished.
+		 */
+		_initView() {
+			let promise = Promise.resolve();
+
+			if ( this.view ) {
+				promise = promise.then( this.view.init.bind( this.view ) );
+			}
 
-					for ( region of this.regions ) {
-						for ( controller of region ) {
-							promises.push( controller.init() );
-						}
+			return promise;
+		}
+
+		/**
+		 * Initializes the {@link #_collections} of this controller instance.
+		 *
+		 * @protected
+		 * @returns {Promise} A Promise resolved when initialization process is finished.
+		 */
+		_initCollections() {
+			const promises = [];
+			let collection, childController;
+
+			for ( collection of this._collections ) {
+				for ( childController of collection ) {
+					if ( this.view, childController.view ) {
+						this.view.addChild( collection.name, childController.view );
 					}
 
-					return Promise.all( promises );
-				} )
-				.then( () => {
-					this.ready = true;
-				} );
+					promises.push( childController.init() );
+				}
+			}
+
+			return Promise.all( promises );
 		}
 
 		/**
-		 * @param
-		 * @returns
+		 * Adds a child controller to one of the {@link #_collections}.
+		 * If this controller instance is ready, the child view will be initialized when added.
+		 * If this controller and child controller have views, the child view will be added
+		 * to corresponding region in this controller's view.
+		 *
+		 * @param {String} collectionName One of {@link _collections} the child should be added to.
+		 * @param {Controller} childController A child controller.
+		 * @param {Number} [index] Index at which the child will be added to the collection.
+		 * @returns {Promise} A Promise resolved when the child is added.
 		 */
-		addChild( controller, regionName ) {
-			let region = this.regions.get( regionName );
+		addChild( collectionName, childController, index ) {
+			if ( !collectionName ) {
+				throw new CKEditorError( 'ui-controller-addchild-badcname' );
+			}
+
+			const collection = this._collections.get( collectionName );
 
-			if ( !region ) {
-				region = this._createRegion( regionName );
+			if ( !collection ) {
+				throw new CKEditorError( 'ui-controller-addchild-nocol' );
 			}
 
-			region.add( controller );
+			if ( !childController || !( childController instanceof Controller ) ) {
+				throw new CKEditorError( 'ui-controller-addchild-badtype' );
+			}
+
+			// ChildController.init() returns Promise.
+			let promise = Promise.resolve();
+
+			collection.add( childController, index );
 
-			// If this Controller has already been inited, then every single new
-			// child controller must be inited separately when added.
 			if ( this.ready ) {
-				return controller.init();
+				if ( childController.view ) {
+					this.view.addChild( collectionName, childController.view, index );
+				}
+
+				if ( !childController.ready ) {
+					promise = promise.then( () => {
+						return childController.init();
+					} );
+				}
 			}
-			// If this Controller.init() hasn't been called yet, then the child
-			// controller will be initialized by init().
-			else {
-				return Promise.resolve();
+
+			return promise;
+		}
+
+		/**
+		 * Removes a child controller from one of the {@link #_collections}.
+		 * If this controller and child controller have views, the child view will be removed
+		 * from corresponding region in this controller's view.
+		 *
+		 * @param {String} collectionName One of {@link _collections} the child should be removed from.
+		 * @param {Controller} childController A child controller.
+		 * @returns {Controller} A child controller instance after removal.
+		 */
+		removeChild( collectionName, childController ) {
+			if ( !collectionName ) {
+				throw new CKEditorError( 'ui-controller-removechild-badcname' );
+			}
+
+			const collection = this._collections.get( collectionName );
+
+			if ( !collection ) {
+				throw new CKEditorError( 'ui-controller-removechild-nocol' );
 			}
+
+			if ( !childController || !( childController instanceof Controller ) ) {
+				throw new CKEditorError( 'ui-controller-removechild-badtype' );
+			}
+
+			collection.remove( childController );
+
+			if ( this.ready && childController.view ) {
+				this.view.removeChild( collectionName, childController.view );
+			}
+
+			return childController;
 		}
 
-		_createRegion( regionName ) {
-			const region = new Collection();
-			region.name = regionName;
+		/**
+		 * Returns a child controller from one of the {@link #_collections} at given `index`.
+		 *
+		 * @param {String} collectionName One of {@link _collections} the child should be retrieved from.
+		 * @param {Number} [index] An index of desired controller.
+		 * @returns {Controller} A child controller instance.
+		 */
+		getChild( collectionName, index ) {
+			const collection = this._collections.get( collectionName );
+
+			if ( !collection ) {
+				throw new CKEditorError( 'ui-controller-getchild-nocol' );
+			}
 
-			region.on( 'add', ( evt, controller, index ) => {
-				this.view.addChild( controller.view, regionName, index );
-			} );
+			return collection.get( index );
+		}
 
-			region.on( 'remove', ( evt, controller ) => {
-				this.view.removeChild( controller.view, regionName );
-			} );
+		/**
+		 * Registers a collection in {@link #_collections}.
+		 *
+		 * @param {String} collectionName The name of the collection to be registered.
+		 * @param {Collection} collection Collection to be registered.
+		 * @param {Boolean} [override] When set `true` it will allow overriding of registered collections.
+		 */
+		register( collectionName, collection, override ) {
+			const registered = this._collections.get( collectionName );
+			const that = this;
+
+			if ( !( collection instanceof Collection ) ) {
+				throw new CKEditorError( 'ui-controller-register-badtype' );
+			}
+
+			if ( !registered ) {
+				add( collection );
+			} else {
+				if ( registered !== collection ) {
+					if ( !override ) {
+						throw new CKEditorError( 'ui-controller-register-noverride' );
+					}
 
-			this.regions.add( region );
+					that._collections.remove( registered );
+					add( collection );
+				}
+			}
 
-			return region;
+			function add() {
+				collection.name = collectionName;
+				that._collections.add( collection );
+			}
 		}
 
 		/**
-		 * @param
-		 * @returns
+		 * Destroys the controller instance. The process includes:
+		 *  * destruction of the child {@link #view}.
+		 *  * destruction of child controllers in {@link #_collections}.
+		 *
+		 * @returns {Promise} A Promise resolved when the destruction process is finished.
 		 */
 		destroy() {
-			return Promise.resolve()
-				.then( () => {
-					let promises = [];
-					let region, controller;
+			let promises = [];
+			let collection, childController;
 
-					for ( region of this.regions ) {
-						for ( controller of this.regions.remove( region ) ) {
-							promises.push( region.remove( controller ).destroy() );
-						}
+			for ( collection of this._collections ) {
+				for ( childController of collection ) {
+					if ( this.view && childController.view ) {
+						this.view.removeChild( collection.name, childController.view );
 					}
 
-					return Promise.all( promises );
-				} )
-				// Note: Because this.view.destroy() can be sync as well as async,
-				// it is wrapped in promise.
-				.then( () => {
+					promises.push( childController.destroy() );
+
+					collection.remove( childController );
+				}
+
+				this._collections.remove( collection );
+			}
+
+			if ( this.view ) {
+				promises.push( Promise.resolve().then( () => {
 					return this.view.destroy();
-				} );
+				} ) );
+			}
+
+			promises.push( Promise.resolve().then( () => {
+				this.model = this.view = this._collections = null;
+			} ) );
+
+			return Promise.all( promises );
 		}
 	}
 

+ 515 - 0
packages/ckeditor5-engine/tests/ui/controller.js

@@ -0,0 +1,515 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: core, ui */
+
+'use strict';
+
+const modules = bender.amd.require( 'ckeditor',
+	'ui/view',
+	'ui/controller',
+	'ui/region',
+	'ckeditorerror',
+	'model',
+	'collection',
+	'eventinfo'
+);
+
+let View, Controller, Model, CKEditorError, Collection;
+let ParentView;
+
+bender.tools.createSinonSandbox();
+
+describe( 'Controller', () => {
+	beforeEach( updateModuleReference );
+
+	describe( 'constructor', () => {
+		it( 'should accept model and view', () => {
+			const model = new Model();
+			const view = new View();
+			const controller = new Controller( model, view );
+
+			expect( controller.model ).to.be.equal( model );
+			expect( controller.view ).to.be.equal( view );
+		} );
+
+		it( 'should not initialize the controller', () => {
+			const controller = new Controller();
+
+			expect( controller.ready ).to.be.false;
+		} );
+	} );
+
+	describe( 'init', () => {
+		it( 'should throw when already initialized', () => {
+			const controller = new Controller();
+
+			return controller.init()
+				.then( () => {
+					controller.init();
+
+					throw new Error( 'This should not be executed.' );
+				} )
+				.catch( ( err ) => {
+					expect( err ).to.be.instanceof( CKEditorError );
+					expect( err.message ).to.match( /ui-controller-init-re/ );
+				} );
+		} );
+
+		it( 'should set #ready flag', () => {
+			const controller = new Controller();
+
+			return controller.init().then( () => {
+				expect( controller.ready ).to.be.true;
+			} );
+		} );
+
+		it( 'should initialize own view', () => {
+			const view = new View();
+			const controller = new Controller( null, view );
+			const spy = bender.sinon.spy( view, 'init' );
+
+			return controller.init().then( () => {
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+
+		it( 'should initialize child controllers in own collections', () => {
+			const parentController = new Controller();
+			parentController.register( 'buttons', new Collection() );
+
+			const childController1 = new Controller();
+			const childController2 = new Controller();
+			const spy1 = bender.sinon.spy( childController1, 'init' );
+			const spy2 = bender.sinon.spy( childController2, 'init' );
+
+			parentController.addChild( 'buttons', childController1 );
+			parentController.addChild( 'buttons', childController2 );
+
+			return parentController.init().then( () => {
+				expect( parentController.getChild( 'buttons', 0 ) ).to.be.equal( childController1 );
+				expect( parentController.getChild( 'buttons', 1 ) ).to.be.equal( childController2 );
+
+				sinon.assert.calledOnce( spy1 );
+				sinon.assert.calledOnce( spy2 );
+			} );
+		} );
+	} );
+
+	describe( 'register', () => {
+		it( 'should throw when bad type of argument', () => {
+			const controller = new Controller();
+
+			controller.register( 'x', new Collection() );
+
+			expect( () => {
+				controller.register();
+			} ).to.throw;
+
+			expect( () => {
+				controller.register( 'x' );
+			} ).to.throw( CKEditorError, /ui-controller-register-badtype/ );
+
+			expect( () => {
+				controller.register( 'x', new Date() );
+			} ).to.throw( CKEditorError, /ui-controller-register-badtype/ );
+		} );
+
+		it( 'should throw when already registered but no override flag', () => {
+			const controller = new Controller();
+
+			controller.register( 'x', new Collection() );
+
+			expect( () => {
+				controller.register( 'x', new Collection() );
+			} ).to.throw( CKEditorError, /ui-controller-register-noverride/ );
+		} );
+
+		it( 'should register a collection', () => {
+			const controller = new Controller();
+			const collection = new Collection();
+
+			controller.register( 'x', collection );
+
+			expect( controller._collections.get( 'x' ) ).to.be.equal( collection );
+		} );
+
+		it( 'should override existing collection with override flag', () => {
+			const controller = new Controller();
+			const newCollection = new Collection();
+
+			controller.register( 'x', new Collection() );
+			controller.register( 'x', newCollection, true );
+
+			expect( controller._collections.get( 'x' ) ).to.be.equal( newCollection );
+		} );
+
+		it( 'should override existing collection with the same collection', () => {
+			const controller = new Controller();
+			const newCollection = new Collection();
+
+			controller.register( 'x', newCollection );
+			controller.register( 'x', newCollection );
+
+			expect( controller._collections.get( 'x' ) ).to.be.equal( newCollection );
+		} );
+	} );
+
+	describe( 'addChild', () => {
+		beforeEach( defineParentViewClass );
+
+		it( 'should throw when no collection name', () => {
+			const controller = new Controller();
+
+			controller.register( 'x', new Collection() );
+
+			expect( () => {
+				controller.addChild();
+			} ).to.throw( CKEditorError, /ui-controller-addchild-badcname/ );
+		} );
+
+		it( 'should throw when collection of given name does not exist', () => {
+			const controller = new Controller();
+
+			controller.register( 'x', new Collection() );
+
+			expect( () => {
+				controller.addChild( 'y', new Controller() );
+			} ).to.throw( CKEditorError, /ui-controller-addchild-nocol/ );
+		} );
+
+		it( 'should throw when controller or wrong controller is passed', () => {
+			const controller = new Controller();
+
+			controller.register( 'x', new Collection() );
+
+			expect( () => {
+				controller.addChild( 'x' );
+			} ).to.throw( CKEditorError, /ui-controller-addchild-badtype/ );
+
+			expect( () => {
+				controller.addChild( 'x', new Date() );
+			} ).to.throw( CKEditorError, /ui-controller-addchild-badtype/ );
+		} );
+
+		it( 'should add a child controller to given collection and return promise', () => {
+			const parentController = new Controller();
+			const childController = new Controller();
+			const collection = new Collection();
+
+			parentController.register( 'x', collection );
+
+			const returned = parentController.addChild( 'x', childController );
+
+			expect( returned ).to.be.an.instanceof( Promise );
+			expect( collection.get( 0 ) ).to.be.equal( childController );
+		} );
+
+		it( 'should add a child controller at given position', () => {
+			const parentController = new Controller();
+			const childController1 = new Controller();
+			const childController2 = new Controller();
+			const collection = new Collection();
+
+			parentController.register( 'x', collection );
+
+			parentController.addChild( 'x', childController1 );
+			parentController.addChild( 'x', childController2, 0 );
+
+			expect( collection.get( 0 ) ).to.be.equal( childController2 );
+			expect( collection.get( 1 ) ).to.be.equal( childController1 );
+		} );
+
+		it( 'should add a child controller which has no view', () => {
+			const parentController = new Controller( null, new ParentView() );
+			const childController = new Controller();
+
+			parentController.register( 'x', new Collection() );
+
+			return parentController.init()
+				.then( () => {
+					return parentController.addChild( 'x', childController );
+				} )
+				.then( () => {
+					expect( parentController.getChild( 'x', 0 ) ).to.be.equal( childController );
+				} );
+		} );
+
+		it( 'should append child controller\'s view to parent controller\'s view', () => {
+			const parentView = new ParentView();
+			const parentController = new Controller( null, parentView );
+			const childController = new Controller( null, new View() );
+
+			const spy1 = bender.sinon.spy( parentView, 'addChild' );
+
+			parentController.register( 'x', new Collection() );
+			parentController.addChild( 'x', childController );
+
+			sinon.assert.notCalled( spy1 );
+
+			parentController.removeChild( 'x', childController );
+
+			return parentController.init()
+				.then( () => {
+					return parentController.addChild( 'x', childController );
+				} )
+				.then( () => {
+					sinon.assert.calledOnce( spy1 );
+					sinon.assert.calledWithExactly( spy1, 'x', childController.view, undefined );
+				} );
+		} );
+
+		it( 'should append child controller\'s view to parent controller\'s view at given index', () => {
+			const parentController = new Controller( null, new ParentView() );
+
+			const childView1 = new View();
+			const childController1 = new Controller( null, childView1 );
+			const childView2 = new View();
+			const childController2 = new Controller( null, childView2 );
+
+			parentController.register( 'x', new Collection() );
+
+			return parentController.init()
+				.then( () => {
+					return parentController.addChild( 'x', childController1 ).then( () => {
+						return parentController.addChild( 'x', childController2, 0 );
+					} );
+				} )
+				.then( () => {
+					expect( parentController.view.getChild( 'x', 0 ) ).to.be.equal( childView2 );
+					expect( parentController.view.getChild( 'x', 1 ) ).to.be.equal( childView1 );
+				} );
+		} );
+
+		it( 'should initialize child controller if parent is ready', () => {
+			const parentController = new Controller( null, new ParentView() );
+			const childController = new Controller( null, new View() );
+			const spy = bender.sinon.spy( childController, 'init' );
+
+			parentController.register( 'x', new Collection() );
+			parentController.addChild( 'x', childController );
+			parentController.removeChild( 'x', childController );
+
+			sinon.assert.notCalled( spy );
+
+			return parentController.init()
+				.then( () => {
+					return parentController.addChild( 'x', childController );
+				} )
+				.then( () => {
+					sinon.assert.calledOnce( spy );
+				} );
+		} );
+
+		it( 'should not initialize child controller twice', () => {
+			const parentController = new Controller( null, new ParentView() );
+			const childController = new Controller( null, new View() );
+			const spy = bender.sinon.spy( childController, 'init' );
+
+			parentController.register( 'x', new Collection() );
+
+			return parentController.init()
+				.then( () => {
+					return childController.init();
+				} )
+				.then( () => {
+					return parentController.addChild( 'x', childController );
+				} )
+				.then( () => {
+					sinon.assert.calledOnce( spy );
+				} );
+		} );
+	} );
+
+	describe( 'removeChild', () => {
+		beforeEach( defineParentViewClass );
+
+		it( 'should throw when no collection name', () => {
+			const controller = new Controller();
+
+			controller.register( 'x', new Collection() );
+
+			expect( () => {
+				controller.removeChild();
+			} ).to.throw( CKEditorError, /ui-controller-removechild-badcname/ );
+		} );
+
+		it( 'should throw when collection of given name does not exist', () => {
+			const controller = new Controller();
+
+			controller.register( 'x', new Collection() );
+
+			expect( () => {
+				controller.removeChild( 'y' );
+			} ).to.throw( CKEditorError, /ui-controller-removechild-nocol/ );
+		} );
+
+		it( 'should throw when controller or wrong controller is passed', () => {
+			const controller = new Controller();
+
+			controller.register( 'x', new Collection() );
+
+			expect( () => {
+				controller.removeChild( 'x' );
+			} ).to.throw( CKEditorError, /ui-controller-removechild-badtype/ );
+
+			expect( () => {
+				controller.removeChild( 'x', new Date() );
+			} ).to.throw( CKEditorError, /ui-controller-removechild-badtype/ );
+		} );
+
+		it( 'should remove child controller and return it', () => {
+			const parentController = new Controller();
+			const childController = new Controller();
+			const collection = new Collection();
+
+			parentController.register( 'x', collection );
+
+			parentController.addChild( 'x', childController );
+			const returned = parentController.removeChild( 'x', childController );
+
+			expect( returned ).to.be.equal( childController );
+			expect( collection.length ).to.be.equal( 0 );
+		} );
+
+		it( 'should remove child controller\'s view from parent controller\'s view', () => {
+			const parentView = new ParentView();
+			const parentController = new Controller( null, parentView );
+			const childController = new Controller( null, new View() );
+
+			const spy = bender.sinon.spy( parentView, 'removeChild' );
+
+			parentController.register( 'x', new Collection() );
+			parentController.addChild( 'x', childController );
+
+			sinon.assert.notCalled( spy );
+
+			return parentController.init()
+				.then( () => {
+					parentController.removeChild( 'x', childController );
+					sinon.assert.calledOnce( spy );
+					sinon.assert.calledWithExactly( spy, 'x', childController.view );
+				} );
+		} );
+	} );
+
+	describe( 'getChild', () => {
+		beforeEach( defineParentViewClass );
+
+		it( 'should throw when collection of given name does not exist', () => {
+			const controller = new Controller();
+
+			controller.register( 'x', new Collection() );
+
+			expect( () => {
+				controller.getChild( 'y', 0 );
+			} ).to.throw( CKEditorError, /ui-controller-getchild-nocol/ );
+		} );
+
+		it( 'should get child controller by index', () => {
+			const parentController = new Controller();
+			const childController = new Controller();
+			const collection = new Collection();
+
+			parentController.register( 'x', collection );
+			parentController.addChild( 'x', childController );
+
+			expect( parentController.getChild( 'x', 0 ) ).to.be.equal( childController );
+		} );
+	} );
+
+	describe( 'destroy', () => {
+		beforeEach( defineParentViewClass );
+
+		it( 'should destroy the controller', () => {
+			const view = new View();
+			const controller = new Controller( null, view );
+			const spy = bender.sinon.spy( view, 'destroy' );
+
+			return controller.init()
+				.then( () => {
+					return controller.destroy();
+				} )
+				.then( () => {
+					sinon.assert.calledOnce( spy );
+					expect( controller.model ).to.be.null;
+					expect( controller.view ).to.be.null;
+					expect( controller._collections ).to.be.null;
+				} );
+		} );
+
+		it( 'should destroy the controller which has no view', () => {
+			const controller = new Controller( null, null );
+
+			return controller.init()
+				.then( () => {
+					return controller.destroy();
+				} )
+				.then( () => {
+					expect( controller.model ).to.be.null;
+					expect( controller.view ).to.be.null;
+					expect( controller._collections ).to.be.null;
+				} );
+		} );
+
+		it( 'should destroy child controllers in collections with their views', () => {
+			const parentController = new Controller( null, new ParentView() );
+			const childView = new View();
+			const childController = new Controller( null, childView );
+			const spy = bender.sinon.spy( childView, 'destroy' );
+
+			parentController.register( 'x', new Collection() );
+			parentController.addChild( 'x', childController );
+
+			return parentController.init()
+				.then( () => {
+					return parentController.destroy();
+				} )
+				.then( () => {
+					sinon.assert.calledOnce( spy );
+					expect( childController.model ).to.be.null;
+					expect( childController.view ).to.be.null;
+					expect( childController._collections ).to.be.null;
+				} );
+		} );
+
+		it( 'should destroy child controllers in collections when they have no views', () => {
+			const parentController = new Controller( null, new ParentView() );
+			const childController = new Controller( null, null );
+
+			parentController.register( 'x', new Collection() );
+			parentController.addChild( 'x', childController );
+
+			return parentController.init()
+				.then( () => {
+					return parentController.destroy();
+				} )
+				.then( () => {
+					expect( childController.model ).to.be.null;
+					expect( childController.view ).to.be.null;
+					expect( childController._collections ).to.be.null;
+				} );
+		} );
+	} );
+} );
+
+function updateModuleReference() {
+	View = modules[ 'ui/view' ];
+	Controller = modules[ 'ui/controller' ];
+	Model = modules.model;
+	Collection = modules.collection;
+	CKEditorError = modules.ckeditorerror;
+}
+
+function defineParentViewClass() {
+	ParentView = class extends View {
+		constructor() {
+			super();
+
+			this.el = document.createElement( 'span' );
+			this.register( 'x', true );
+		}
+	};
+}