Kaynağa Gözat

Merge pull request #131 from ckeditor/t/125

Feature: `Collectio.bindTo` method now is not only available in the `ViewCollection` but in all `Collection`s. Closes #125.
Piotr Jasiun 8 yıl önce
ebeveyn
işleme
b264182f64

+ 159 - 0
packages/ckeditor5-utils/src/collection.js

@@ -55,6 +55,15 @@ export default class Collection {
 		 * @member {String}
 		 */
 		this._idProperty = options && options.idProperty || 'id';
+
+		/**
+		 * A helper mapping external items from bound collection ({@link #bindTo})
+		 * and actual items of the collection.
+		 *
+		 * @protected
+		 * @member {Map}
+		 */
+		this._boundItemsMap = new Map();
 	}
 
 	/**
@@ -271,6 +280,156 @@ export default class Collection {
 	}
 
 	/**
+	 * Binds and synchronizes the collection with another one.
+	 *
+	 * The binding can be a simple factory:
+	 *
+	 *		class FactoryClass {
+	 *			constructor( data ) {
+	 *				this.label = data.label;
+	 *			}
+	 *		}
+	 *
+	 *		const source = new Collection( { idProperty: 'label' } );
+	 *		const target = new Collection();
+	 *
+	 *		target.bindTo( source ).as( FactoryClass );
+	 *
+	 *		source.add( { label: 'foo' } );
+	 *		source.add( { label: 'bar' } );
+	 *
+	 *		console.log( target.length ); // 2
+	 *		console.log( target.get( 1 ).label ); // 'bar'
+	 *
+	 *		source.remove( 0 );
+	 *		console.log( target.length ); // 1
+	 *		console.log( target.get( 0 ).label ); // 'bar'
+	 *
+	 * or the factory driven by a custom callback:
+	 *
+	 *		class FooClass {
+	 *			constructor( data ) {
+	 *				this.label = data.label;
+	 *			}
+	 *		}
+	 *
+	 *		class BarClass {
+	 *			constructor( data ) {
+	 *				this.label = data.label;
+	 *			}
+	 *		}
+	 *
+	 *		const source = new Collection( { idProperty: 'label' } );
+	 *		const target = new Collection();
+	 *
+	 *		target.bindTo( source ).using( ( item ) => {
+	 *			if ( item.label == 'foo' ) {
+	 *				return new FooClass( item );
+	 *			} else {
+	 *				return new BarClass( item );
+	 *			}
+	 *		} );
+	 *
+	 *		source.add( { label: 'foo' } );
+	 *		source.add( { label: 'bar' } );
+	 *
+	 *		console.log( target.length ); // 2
+	 *		console.log( target.get( 0 ) instanceof FooClass ); // true
+	 *		console.log( target.get( 1 ) instanceof BarClass ); // true
+	 *
+	 * or the factory out of property name:
+	 *
+	 *		const source = new Collection( { idProperty: 'label' } );
+	 *		const target = new Collection();
+	 *
+	 *		target.bindTo( source ).using( 'label' );
+	 *
+	 *		source.add( { label: { value: 'foo' } } );
+	 *		source.add( { label: { value: 'bar' } } );
+	 *
+	 *		console.log( target.length ); // 2
+	 *		console.log( target.get( 0 ).value ); // 'foo'
+	 *		console.log( target.get( 1 ).value ); // 'bar'
+	 *
+	 * @param {module:utils/collection~Collection} collection A collection to be bound.
+	 * @returns {module:ui/viewcollection~ViewCollection#bindTo#using}
+	 */
+	bindTo( collection ) {
+		// Sets the actual binding using provided factory.
+		//
+		// @private
+		// @param {Function} factory A collection item factory returning collection items.
+		const bind = ( factory ) => {
+			// Load the initial content of the collection.
+			for ( let item of collection ) {
+				this.add( factory( item ) );
+			}
+
+			// Synchronize the with collection as new items are added.
+			this.listenTo( collection, 'add', ( evt, item, index ) => {
+				this.add( factory( item ), index );
+			} );
+
+			// Synchronize the with collection as new items are removed.
+			this.listenTo( collection, 'remove', ( evt, item ) => {
+				this.remove( this._boundItemsMap.get( item ) );
+
+				this._boundItemsMap.delete( item );
+			} );
+		};
+
+		return {
+			/**
+			 * Creates the class factory binding.
+			 *
+			 * @static
+			 * @param {Function} Class Specifies which class factory is to be initialized.
+			 */
+			as: ( Class ) => {
+				bind( ( item ) => {
+					const instance = new Class( item );
+
+					this._boundItemsMap.set( item, instance );
+
+					return instance;
+				} );
+			},
+
+			/**
+			 * Creates callback or property binding.
+			 *
+			 * @static
+			 * @param {Function|String} callbackOrProperty When the function is passed, it is used to
+			 * produce the items. When the string is provided, the property value is used to create
+			 * the bound collection items.
+			 */
+			using: ( callbackOrProperty ) => {
+				let factory;
+
+				if ( typeof callbackOrProperty == 'function' ) {
+					factory = ( item ) => {
+						const instance = callbackOrProperty( item );
+
+						this._boundItemsMap.set( item, instance );
+
+						return instance;
+					};
+				} else {
+					factory = ( item ) => {
+						const instance = item[ callbackOrProperty ];
+
+						this._boundItemsMap.set( item, instance );
+
+						return instance;
+					};
+				}
+
+				bind( factory );
+			}
+		};
+	}
+
+	/**
 	 * Collection iterator.
 	 */
 	[ Symbol.iterator ]() {

+ 227 - 9
packages/ckeditor5-utils/tests/collection.js

@@ -24,7 +24,7 @@ describe( 'Collection', () => {
 		collection = new Collection();
 	} );
 
-	describe( 'constructor', () => {
+	describe( 'constructor()', () => {
 		it( 'allows to change the id property used by the collection', () => {
 			let item1 = { id: 'foo', name: 'xx' };
 			let item2 = { id: 'foo', name: 'yy' };
@@ -40,7 +40,7 @@ describe( 'Collection', () => {
 		} );
 	} );
 
-	describe( 'add', () => {
+	describe( 'add()', () => {
 		it( 'should be chainable', () => {
 			expect( collection.add( {} ) ).to.equal( collection );
 		} );
@@ -260,7 +260,7 @@ describe( 'Collection', () => {
 		} );
 	} );
 
-	describe( 'get', () => {
+	describe( 'get()', () => {
 		it( 'should return an item', () => {
 			let item = getItem( 'foo' );
 			collection.add( item );
@@ -281,7 +281,7 @@ describe( 'Collection', () => {
 		} );
 	} );
 
-	describe( 'getIndex', () => {
+	describe( 'getIndex()', () => {
 		it( 'should return index of given item', () => {
 			const item1 = { foo: 'bar' };
 			const item2 = { bar: 'biz' };
@@ -317,7 +317,7 @@ describe( 'Collection', () => {
 		} );
 	} );
 
-	describe( 'remove', () => {
+	describe( 'remove()', () => {
 		it( 'should remove the model by index', () => {
 			collection.add( getItem( 'bom' ) );
 			collection.add( getItem( 'foo' ) );
@@ -444,7 +444,7 @@ describe( 'Collection', () => {
 		} );
 	} );
 
-	describe( 'map', () => {
+	describe( 'map()', () => {
 		it( 'uses native map', () => {
 			let spy = testUtils.sinon.stub( Array.prototype, 'map', () => {
 				return [ 'foo' ];
@@ -460,7 +460,7 @@ describe( 'Collection', () => {
 		} );
 	} );
 
-	describe( 'find', () => {
+	describe( 'find()', () => {
 		it( 'uses native find', () => {
 			let needl = getItem( 'foo' );
 
@@ -478,7 +478,7 @@ describe( 'Collection', () => {
 		} );
 	} );
 
-	describe( 'filter', () => {
+	describe( 'filter()', () => {
 		it( 'uses native filter', () => {
 			let needl = getItem( 'foo' );
 
@@ -496,7 +496,7 @@ describe( 'Collection', () => {
 		} );
 	} );
 
-	describe( 'clear', () => {
+	describe( 'clear()', () => {
 		it( 'removes all items', () => {
 			const items = [ {}, {}, {} ];
 			const spy = sinon.spy();
@@ -512,6 +512,224 @@ describe( 'Collection', () => {
 		} );
 	} );
 
+	describe( 'bindTo()', () => {
+		class FactoryClass {
+			constructor( data ) {
+				this.data = data;
+			}
+		}
+
+		it( 'provides "using()" and "as()" interfaces', () => {
+			const returned = collection.bindTo( {} );
+
+			expect( returned ).to.have.keys( 'using', 'as' );
+			expect( returned.using ).to.be.a( 'function' );
+			expect( returned.as ).to.be.a( 'function' );
+		} );
+
+		describe( 'as()', () => {
+			let items;
+
+			beforeEach( () => {
+				items = new Collection();
+			} );
+
+			it( 'does not chain', () => {
+				const returned = collection.bindTo( new Collection() ).as( FactoryClass );
+
+				expect( returned ).to.be.undefined;
+			} );
+
+			it( 'creates a binding (initial content)', () => {
+				items.add( { id: '1' } );
+				items.add( { id: '2' } );
+
+				collection.bindTo( items ).as( FactoryClass );
+
+				expect( collection ).to.have.length( 2 );
+				expect( collection.get( 0 ) ).to.be.instanceOf( FactoryClass );
+				expect( collection.get( 1 ) ).to.be.instanceOf( FactoryClass );
+				expect( collection.get( 1 ).data ).to.equal( items.get( 1 ) );
+			} );
+
+			it( 'creates a binding (new content)', () => {
+				collection.bindTo( items ).as( FactoryClass );
+
+				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( FactoryClass );
+				expect( collection.get( 1 ) ).to.be.instanceOf( FactoryClass );
+				expect( collection.get( 1 ).data ).to.equal( items.get( 1 ) );
+			} );
+
+			it( 'creates a binding (item removal)', () => {
+				collection.bindTo( items ).as( FactoryClass );
+
+				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( FactoryClass );
+				expect( collection.get( 1 ) ).to.be.instanceOf( FactoryClass );
+				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 );
+			} );
+		} );
+
+		describe( 'using()', () => {
+			let items;
+
+			beforeEach( () => {
+				items = new Collection();
+			} );
+
+			it( 'does not chain', () => {
+				const returned = collection.bindTo( new Collection() ).using( () => {} );
+
+				expect( returned ).to.be.undefined;
+			} );
+
+			describe( 'callback', () => {
+				it( 'creates a binding (arrow function)', () => {
+					collection = new Collection();
+					collection.bindTo( items ).using( ( item ) => {
+						return new FactoryClass( 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( FactoryClass );
+					expect( collection.get( 1 ) ).to.be.instanceOf( FactoryClass );
+					expect( collection.get( 1 ).data ).to.equal( items.get( 1 ) );
+				} );
+
+				// https://github.com/ckeditor/ckeditor5-ui/issues/113
+				it( 'creates a binding (normal function)', () => {
+					collection.bindTo( items ).using( function( item ) {
+						return new FactoryClass( item );
+					} );
+
+					items.add( { id: '1' } );
+
+					expect( collection ).to.have.length( 1 );
+
+					const view = collection.get( 0 );
+
+					// Wrong args will be passed to the callback if it's treated as the view constructor.
+					expect( view ).to.be.instanceOf( FactoryClass );
+					expect( view.data ).to.equal( items.get( 0 ) );
+				} );
+
+				it( 'creates a 1:1 binding', () => {
+					collection.bindTo( items ).using( item => item );
+
+					expect( collection ).to.have.length( 0 );
+
+					const item1 = { id: '100' };
+					const item2 = { id: '200' };
+
+					items.add( item1 );
+					items.add( item2 );
+
+					expect( collection ).to.have.length( 2 );
+					expect( collection.get( 0 ) ).to.equal( item1 );
+					expect( collection.get( 1 ) ).to.equal( item2 );
+				} );
+
+				it( 'creates a conditional binding', () => {
+					class CustomClass {
+						constructor( data ) {
+							this.data = data;
+						}
+					}
+
+					collection.bindTo( items ).using( item => {
+						if ( item.id == 'FactoryClass' ) {
+							return new FactoryClass( item );
+						} else {
+							return new CustomClass( item );
+						}
+					} );
+
+					expect( collection ).to.have.length( 0 );
+
+					const item1 = { id: 'FactoryClass' };
+					const item2 = { id: 'CustomClass' };
+
+					items.add( item1 );
+					items.add( item2 );
+
+					expect( collection ).to.have.length( 2 );
+					expect( collection.get( 0 ) ).to.be.instanceOf( FactoryClass );
+					expect( collection.get( 1 ) ).to.be.instanceOf( CustomClass );
+				} );
+
+				it( 'creates a binding to a property name', () => {
+					collection.bindTo( items ).using( item => item.prop );
+
+					expect( collection ).to.have.length( 0 );
+
+					items.add( { prop: { value: 'foo' } } );
+					items.add( { prop: { value: 'bar' } } );
+
+					expect( collection ).to.have.length( 2 );
+					expect( collection.get( 0 ).value ).to.equal( 'foo' );
+					expect( collection.get( 1 ).value ).to.equal( 'bar' );
+				} );
+			} );
+
+			describe( 'property name', () => {
+				it( 'creates a binding', () => {
+					collection.bindTo( items ).using( 'prop' );
+
+					expect( collection ).to.have.length( 0 );
+
+					items.add( { prop: { value: 'foo' } } );
+					items.add( { prop: { value: 'bar' } } );
+
+					expect( collection ).to.have.length( 2 );
+					expect( collection.get( 0 ).value ).to.equal( 'foo' );
+					expect( collection.get( 1 ).value ).to.equal( 'bar' );
+				} );
+
+				it( 'creates a binding (item removal)', () => {
+					collection.bindTo( items ).using( 'prop' );
+
+					expect( collection ).to.have.length( 0 );
+
+					items.add( { prop: { value: 'foo' } } );
+					items.add( { prop: { value: 'bar' } } );
+
+					expect( collection ).to.have.length( 2 );
+					expect( collection.get( 0 ).value ).to.equal( 'foo' );
+					expect( collection.get( 1 ).value ).to.equal( 'bar' );
+
+					items.remove( 1 );
+					expect( collection ).to.have.length( 1 );
+					expect( collection.get( 0 ).value ).to.equal( 'foo' );
+
+					items.remove( 0 );
+					expect( collection ).to.have.length( 0 );
+				} );
+			} );
+		} );
+	} );
+
 	describe( 'iterator', () => {
 		it( 'covers the whole collection', () => {
 			let item1 = getItem( 'foo' );