diff --git a/src/collection.js b/src/collection.js index e358ab1..355720a 100644 --- a/src/collection.js +++ b/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(); } /** @@ -270,6 +279,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. */ diff --git a/tests/collection.js b/tests/collection.js index 97fb5dc..f27d0ea 100644 --- a/tests/collection.js +++ b/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' );