Explorar o código

Merge pull request #49 from ckeditor/t/34

t/34: Created API to sync list–like UI components with collections of models.
Piotrek Koszuliński %!s(int64=9) %!d(string=hai) anos
pai
achega
bf0049432d

+ 158 - 2
packages/ckeditor5-ui/src/controllercollection.js

@@ -14,9 +14,18 @@ import CKEditorError from '../utils/ckeditorerror.js';
  */
 export default class ControllerCollection extends Collection {
 	/**
-	 * Creates an instance of the ControllerCollection class, initializing it with a name.
+	 * Creates an instance of the controller collection, initializing it with a name:
+	 *
+	 *		const collection = new ControllerCollection( 'foo' );
+	 *		collection.add( someController );
+	 *
+	 * **Note**: If needed, controller collection can stay in sync with a collection of models to
+	 * manage list–like components. See {@link ui.ControllerCollection#bind} to learn more.
+	 *
+	 * @param {String} name Name of the collection.
+	 * @param {utils.Locale} [locale] The {@link ckeditor5.Editor#locale editor's locale} instance.
 	 */
-	constructor( name ) {
+	constructor( name, locale ) {
 		super();
 
 		if ( !name ) {
@@ -35,6 +44,14 @@ export default class ControllerCollection extends Collection {
 		 */
 		this.name = name;
 
+		/**
+		 * See {@link ui.View#locale}
+		 *
+		 * @readonly
+		 * @member {utils.Locale} ui.ControllerCollection#locale
+		 */
+		this.locale = locale;
+
 		/**
 		 * Parent controller of this collection.
 		 *
@@ -65,4 +82,143 @@ export default class ControllerCollection extends Collection {
 
 		return promise;
 	}
+
+	/**
+	 * Synchronizes controller collection with a {@link utils.Collection} of {@link ui.Model} instances.
+	 * The entire collection of controllers reflects changes to the collection of the models, working as a factory.
+	 *
+	 * This method helps bringing complex list–like UI components to life out of the data (like models). The process
+	 * can be automatic:
+	 *
+	 *		// This collection stores models.
+	 *		const models = new Collection( { idProperty: 'label' } );
+	 *
+	 *		// This controller collection will become a factory for the collection of models.
+	 *		const controllers = new ControllerCollection( 'foo', locale );
+	 *
+	 *		// Activate the binding – since now, this controller collection works like a **factory**.
+	 *		controllers.bind( models ).as( FooController, FooView );
+	 *
+	 *		// As new models arrive to the collection, each becomes an instance of FooController (FooView)
+	 *		// in the controller collection.
+	 *		models.add( new Model( { label: 'foo' } ) );
+	 *		models.add( new Model( { label: 'bar' } ) );
+	 *
+	 *		console.log( controllers.length == 2 );
+	 *
+	 *		// Controller collection is updated as the model is removed.
+	 *		models.remove( 0 );
+	 *		console.log( controllers.length == 1 );
+	 *
+	 * or the factory can be driven by a custom callback:
+	 *
+	 *		// This collection stores any kind of data.
+	 *		const data = new Collection();
+	 *
+	 *		// This controller collection will become a custom factory for the data.
+	 *		const controllers = new ControllerCollection( 'foo', locale );
+	 *
+	 *		// Activate the binding – the **factory** is driven by a custom callback.
+	 *		controllers.bind( data ).as( ( item, locale ) => {
+	 *			if ( item.foo == 'bar' ) {
+	 *				return new BarController( ..., BarView( locale ) );
+	 *			} else {
+	 *				return new DifferentController( ..., DifferentView( locale ) );
+	 *			}
+	 *		} );
+	 *
+	 *		// As new data arrive to the collection, each is handled individually by the callback.
+	 *		// This will produce BarController.
+	 *		data.add( { foo: 'bar' } );
+	 *
+	 *		// And this one will become DifferentController.
+	 *		data.add( { foo: 'baz' } );
+	 *
+	 *		console.log( controllers.length == 2 );
+	 *
+	 *		// Controller collection is updated as the data is removed.
+	 *		data.remove( 0 );
+	 *		console.log( controllers.length == 1 );
+	 *
+	 * @param {utils.Collection.<ui.Model>} models Models to be synchronized with this controller collection.
+	 * @returns {Function} The `as` function in the `bind( models ).as( ... )` chain.
+	 * It activates factory using controller and view classes or uses a custom callback to produce
+	 * controller (view) instances.
+	 * @param {Function} return.ControllerClassOrFunction Specifies the constructor of the controller to be used or
+	 * a custom callback function which produces controllers.
+	 * @param {Function} [return.ViewClass] Specifies constructor of the view to be used. If not specified,
+	 * `ControllerClassOrFunction` works as as custom callback function.
+	 */
+	bind( models ) {
+		const idProperty = models._idProperty;
+
+		return {
+			as: ( ControllerClassOrFunction, ViewClass ) => {
+				const createController = ViewClass ?
+						defaultControllerFactory( ControllerClassOrFunction, ViewClass, idProperty )
+					:
+						customControllerFactory( ControllerClassOrFunction, idProperty );
+
+				for ( let model of models ) {
+					this.add( createController( model, this.locale ) );
+				}
+
+				// Updated controller collection when a new model is added.
+				models.on( 'add', ( evt, model, index ) => {
+					this.add( createController( model, this.locale ), index );
+				} );
+
+				// Update controller collection when a model is removed.
+				models.on( 'remove', ( evt, model ) => {
+					this.remove( model[ idProperty ] );
+				} );
+			}
+		};
+	}
+}
+
+// Initializes controller factory with controller and view classes.
+//
+// @param {Function} ControllerClass Specifies the constructor of the controller to be used.
+// @param {Function} ViewClass Specifies constructor of the view.
+// @param {String} idProperty A property used to associate the controller with its model {@link utils.Collection._idProperty}.
+// @returns {Function}
+function defaultControllerFactory( ControllerClass, ViewClass, idProperty ) {
+	// Returns a controller instance (and its view) for given model and class names.
+	//
+	// @param {ui.Model} model A model of the controller.
+	// @param {utils.Locale} [locale] The {@link ckeditor5.Editor#locale editor's locale} instance.
+	// @returns {ui.Controller}
+	return ( model, locale ) => {
+		return flagController( new ControllerClass( model, new ViewClass( locale ) ), idProperty );
+	};
+}
+
+// Initializes controller factory which is fed by a custom callback.
+//
+// @param {Function} callback A callback which is to return an instance of {@link ui.Controller}.
+// @param {String} idProperty A property used to associate the controller with its model {@link utils.Collection._idProperty}.
+// @returns {Function}
+function customControllerFactory( callback, idProperty ) {
+	// Returns a controller instance (and its view) produced by the custom callback.
+	//
+	// @param {ui.Model} model A model of the controller.
+	// @param {utils.Locale} [locale] The {@link ckeditor5.Editor#locale editor's locale} instance.
+	// @returns {ui.Controller}
+	return ( ...args ) => {
+		return flagController( callback( ...args ), idProperty );
+	};
+}
+
+// Gives the controller an id corresponding with {@link utils.Collection#_idProperty} of the model.
+// It allows retrieving this controller instance by the model in the future
+// and avoids a brute–force search in the entire controller collection.
+//
+// @param {ui.Controller} controller An instance of controller.
+// @param {String} idProperty A property used to associate the controller with its model {@link utils.Collection._idProperty}.
+// @returns {ui.Controller}
+function flagController( controller, idProperty ) {
+	controller.id = controller.model[ idProperty ];
+
+	return controller;
 }

+ 196 - 2
packages/ckeditor5-ui/tests/controllercollection.js

@@ -8,14 +8,23 @@
 import testUtils from '/tests/ckeditor5/_utils/utils.js';
 import ControllerCollection from '/ckeditor5/ui/controllercollection.js';
 import Controller from '/ckeditor5/ui/controller.js';
+import Collection from '/ckeditor5/utils/collection.js';
+import Model from '/ckeditor5/ui/model.js';
 import View from '/ckeditor5/ui/view.js';
+import Template from '/ckeditor5/ui/template.js';
 
 testUtils.createSinonSandbox();
 
-let ParentView;
+let ParentView, ItemController, ItemView;
+let models;
 
 describe( 'ControllerCollection', () => {
-	beforeEach( defineParentViewClass );
+	beforeEach( () => {
+		defineParentViewClass();
+		defineItemControllerClass();
+		defineItemViewClass();
+		createModelCollection();
+	} );
 
 	describe( 'constructor', () => {
 		it( 'should throw when no name is passed', () => {
@@ -23,6 +32,13 @@ describe( 'ControllerCollection', () => {
 				new ControllerCollection();
 			} ).to.throw( /^ui-controllercollection-no-name/ );
 		} );
+
+		it( 'accepts locale', () => {
+			const locale = {};
+			const collection = new ControllerCollection( 'foo', locale );
+
+			expect( collection.locale ).to.equal( locale );
+		} );
 	} );
 
 	describe( 'add', () => {
@@ -95,6 +111,146 @@ describe( 'ControllerCollection', () => {
 				} );
 		} );
 	} );
+
+	describe( 'bind', () => {
+		it( 'returns object', () => {
+			expect( new ControllerCollection( 'foo' ).bind( {} ) ).to.be.an( 'object' );
+		} );
+
+		it( 'provides "as" interface', () => {
+			const bind = new ControllerCollection( 'foo' ).bind( {} );
+
+			expect( bind ).to.have.keys( 'as' );
+			expect( bind.as ).to.be.a( 'function' );
+		} );
+
+		describe( 'as', () => {
+			it( 'does not chain', () => {
+				const controllers = new ControllerCollection( 'synced' );
+				const returned = controllers.bind( models ).as( ItemController, ItemView );
+
+				expect( returned ).to.be.undefined;
+			} );
+
+			describe( 'standard factory', () => {
+				it( 'expands the initial collection of the models', () => {
+					const controllers = new ControllerCollection( 'synced' );
+
+					controllers.bind( models ).as( ItemController, ItemView );
+
+					expect( controllers ).to.have.length( 5 );
+					expect( controllers.get( 0 ).model.uid ).to.equal( '0' );
+					expect( controllers.get( 4 ).model.uid ).to.equal( '4' );
+				} );
+
+				it( 'uses the controller and view classes to expand the collection', () => {
+					const controllers = new ControllerCollection( 'synced' );
+
+					controllers.bind( models ).as( ItemController, ItemView );
+
+					expect( controllers.get( 0 ) ).to.be.instanceOf( ItemController );
+					expect( controllers.get( 0 ).view ).to.be.instanceOf( ItemView );
+				} );
+
+				it( 'supports adding new models to the collection', () => {
+					const controllers = new ControllerCollection( 'synced' );
+
+					controllers.bind( models ).as( ItemController, ItemView );
+
+					models.add( new Model( { uid: '6' } ) );
+					models.add( new Model( { uid: '5' } ), 5 );
+
+					expect( controllers.get( 5 ).model.uid ).to.equal( '5' );
+					expect( controllers.get( 6 ).model.uid ).to.equal( '6' );
+					expect( controllers ).to.have.length( 7 );
+				} );
+
+				it( 'supports removing models from the collection', () => {
+					const controllers = new ControllerCollection( 'synced' );
+
+					controllers.bind( models ).as( ItemController, ItemView );
+
+					models.remove( 2 );
+					models.remove( 3 );
+
+					expect( controllers.map( c => c.id ) ).to.have.members( [ '0', '1', '3' ] );
+				} );
+
+				it( 'passes controller collection\'s locale to the views', () => {
+					const locale = {};
+					const controllers = new ControllerCollection( 'synced', locale );
+
+					controllers.bind( models ).as( ItemController, ItemView );
+
+					expect( controllers.get( 0 ).view.locale ).to.equal( locale );
+				} );
+			} );
+
+			describe( 'custom factory', () => {
+				it( 'expands the initial collection of the models', () => {
+					const controllers = new ControllerCollection( 'synced' );
+
+					controllers.bind( models ).as( ( model, locale ) => {
+						return new ItemController( model, new ItemView( locale ) );
+					} );
+
+					expect( controllers ).to.have.length( 5 );
+					expect( controllers.get( 0 ).model.uid ).to.equal( '0' );
+					expect( controllers.get( 4 ).model.uid ).to.equal( '4' );
+				} );
+
+				it( 'uses the controller and view classes to expand the collection', () => {
+					const controllers = new ControllerCollection( 'synced' );
+
+					controllers.bind( models ).as( ( model, locale ) => {
+						return new ItemController( model, new ItemView( locale ) );
+					} );
+
+					expect( controllers.get( 0 ) ).to.be.instanceOf( ItemController );
+					expect( controllers.get( 0 ).view ).to.be.instanceOf( ItemView );
+				} );
+
+				it( 'supports adding new models to the collection', () => {
+					const controllers = new ControllerCollection( 'synced' );
+
+					controllers.bind( models ).as( ( model, locale ) => {
+						return new ItemController( model, new ItemView( locale ) );
+					} );
+
+					models.add( new Model( { uid: '6' } ) );
+					models.add( new Model( { uid: '5' } ), 5 );
+
+					expect( controllers.get( 5 ).model.uid ).to.equal( '5' );
+					expect( controllers.get( 6 ).model.uid ).to.equal( '6' );
+					expect( controllers ).to.have.length( 7 );
+				} );
+
+				it( 'supports removing models from the collection', () => {
+					const controllers = new ControllerCollection( 'synced' );
+
+					controllers.bind( models ).as( ( model, locale ) => {
+						return new ItemController( model, new ItemView( locale ) );
+					} );
+
+					models.remove( 2 );
+					models.remove( 3 );
+
+					expect( controllers.map( c => c.id ) ).to.have.members( [ '0', '1', '3' ] );
+				} );
+
+				it( 'passes controller collection\'s locale to the views', () => {
+					const locale = {};
+					const controllers = new ControllerCollection( 'synced', locale );
+
+					controllers.bind( models ).as( ( model, locale ) => {
+						return new ItemController( model, new ItemView( locale ) );
+					} );
+
+					expect( controllers.get( 0 ).view.locale ).to.equal( locale );
+				} );
+			} );
+		} );
+	} );
 } );
 
 function defineParentViewClass() {
@@ -107,3 +263,41 @@ function defineParentViewClass() {
 		}
 	};
 }
+
+function defineItemControllerClass() {
+	ItemController = class extends Controller {
+		constructor( model, view ) {
+			super( model, view );
+
+			view.model.bind( 'uid' ).to( model );
+		}
+	};
+}
+
+function defineItemViewClass() {
+	ItemView = class extends View {
+		constructor( locale ) {
+			super( locale );
+
+			const bind = this.bind;
+
+			this.template = new Template( {
+				tag: 'li',
+
+				attributes: {
+					id: bind.to( 'uid' )
+				}
+			} );
+		}
+	};
+}
+
+function createModelCollection() {
+	models = new Collection( { idProperty: 'uid' } );
+
+	for ( let i = 0; i < 5; i++ ) {
+		models.add( new Model( {
+			uid: Number( i ).toString()
+		} ) );
+	}
+}