diff --git a/src/collection.js b/src/collection.js index 432cbfa..8e8e72a 100644 --- a/src/collection.js +++ b/src/collection.js @@ -10,6 +10,7 @@ import EmitterMixin from './emittermixin'; import CKEditorError from './ckeditorerror'; import uid from './uid'; +import isIterable from './isiterable'; import mix from './mix'; /** @@ -28,10 +29,46 @@ export default class Collection { /** * Creates a new Collection instance. * - * @param {Object} [options={}] The options object. - * @param {String} [options.idProperty='id'] The name of the property which is considered to identify an item. + * You can provide an iterable of initial items the collection will be created with: + * + * const collection = new Collection( [ { id: 'John' }, { id: 'Mike' } ] ); + * + * console.log( collection.get( 0 ) ); // -> { id: 'John' } + * console.log( collection.get( 1 ) ); // -> { id: 'Mike' } + * console.log( collection.get( 'Mike' ) ); // -> { id: 'Mike' } + * + * Or you can first create a collection and then add new items using the {@link #add} method: + * + * const collection = new Collection(); + * + * collection.add( { id: 'John' } ); + * console.log( collection.get( 0 ) ); // -> { id: 'John' } + * + * Whatever option you choose, you can always pass a configuration object as the last argument + * of the constructor: + * + * const emptyCollection = new Collection( { idProperty: 'name' } ); + * emptyCollection.add( { name: 'John' } ); + * console.log( collection.get( 'John' ) ); // -> { name: 'John' } + * + * const nonEmptyCollection = new Collection( [ { name: 'John' } ], { idProperty: 'name' } ); + * nonEmptyCollection.add( { name: 'George' } ); + * console.log( collection.get( 'George' ) ); // -> { name: 'George' } + * console.log( collection.get( 'John' ) ); // -> { name: 'John' } + * + * @param {Iterable.|Object} initialItemsOrOptions The initial items of the collection or + * the options object. + * @param {Object} [options={}] The options object, when the first argument is an array of initial items. + * @param {String} [options.idProperty='id'] The name of the property which is used to identify an item. + * Items that do not have such a property will be assigned one when added to the collection. */ - constructor( options = {} ) { + constructor( initialItemsOrOptions = {}, options = {} ) { + const hasInitialItems = isIterable( initialItemsOrOptions ); + + if ( !hasInitialItems ) { + options = initialItemsOrOptions; + } + /** * The internal list of items in the collection. * @@ -88,6 +125,14 @@ export default class Collection { */ this._skippedIndexesFromExternal = []; + // Set the initial content of the collection (if provided in the constructor). + if ( hasInitialItems ) { + for ( const item of initialItemsOrOptions ) { + this._items.push( item ); + this._itemMap.set( this._getItemIdBeforeAdding( item ), item ); + } + } + /** * A collection instance this collection is bound to as a result * of calling {@link #bindTo} method. @@ -136,32 +181,7 @@ export default class Collection { * @fires add */ add( item, index ) { - let itemId; - const idProperty = this._idProperty; - - if ( ( idProperty in item ) ) { - itemId = item[ idProperty ]; - - if ( typeof itemId != 'string' ) { - /** - * This item's id should be a string. - * - * @error collection-add-invalid-id - */ - throw new CKEditorError( 'collection-add-invalid-id', this ); - } - - if ( this.get( itemId ) ) { - /** - * This item already exists in the collection. - * - * @error collection-add-item-already-exists - */ - throw new CKEditorError( 'collection-add-item-already-exists', this ); - } - } else { - item[ idProperty ] = itemId = uid(); - } + const itemId = this._getItemIdBeforeAdding( item ); // TODO: Use ES6 default function argument. if ( index === undefined ) { @@ -604,6 +624,46 @@ export default class Collection { } ); } + /** + * Returns an unique id property for a given `item`. + * + * The method will generate new id and assign it to the `item` if it doesn't have any. + * + * @private + * @param {Object} item Item to be added. + * @returns {String} + */ + _getItemIdBeforeAdding( item ) { + const idProperty = this._idProperty; + let itemId; + + if ( ( idProperty in item ) ) { + itemId = item[ idProperty ]; + + if ( typeof itemId != 'string' ) { + /** + * This item's id should be a string. + * + * @error collection-add-invalid-id + */ + throw new CKEditorError( 'collection-add-invalid-id', this ); + } + + if ( this.get( itemId ) ) { + /** + * This item already exists in the collection. + * + * @error collection-add-item-already-exists + */ + throw new CKEditorError( 'collection-add-item-already-exists', this ); + } + } else { + item[ idProperty ] = itemId = uid(); + } + + return itemId; + } + /** * Iterable interface. * diff --git a/tests/collection.js b/tests/collection.js index fccb8e3..a3bc905 100644 --- a/tests/collection.js +++ b/tests/collection.js @@ -25,18 +25,83 @@ describe( 'Collection', () => { } ); describe( 'constructor()', () => { - it( 'allows to change the id property used by the collection', () => { - const item1 = { id: 'foo', name: 'xx' }; - const item2 = { id: 'foo', name: 'yy' }; - const collection = new Collection( { idProperty: 'name' } ); + describe( 'setting initial collection items', () => { + it( 'should work using an array', () => { + const item1 = getItem( 'foo' ); + const item2 = getItem( 'bar' ); + const collection = new Collection( [ item1, item2 ] ); - collection.add( item1 ); - collection.add( item2 ); + expect( collection ).to.have.length( 2 ); - expect( collection ).to.have.length( 2 ); + expect( collection.get( 0 ) ).to.equal( item1 ); + expect( collection.get( 1 ) ).to.equal( item2 ); + expect( collection.get( 'foo' ) ).to.equal( item1 ); + expect( collection.get( 'bar' ) ).to.equal( item2 ); + } ); + + it( 'should work using an iterable', () => { + const item1 = getItem( 'foo' ); + const item2 = getItem( 'bar' ); + const itemsSet = new Set( [ item1, item2 ] ); + const collection = new Collection( itemsSet ); + + expect( collection ).to.have.length( 2 ); + + expect( collection.get( 0 ) ).to.equal( item1 ); + expect( collection.get( 1 ) ).to.equal( item2 ); + } ); + + it( 'should generate ids for items that doesn\'t have it', () => { + const item = {}; + const collection = new Collection( [ item ] ); + + expect( collection.get( 0 ).id ).to.be.a( 'string' ); + expect( collection.get( 0 ).id ).not.to.be.empty; + } ); + + it( 'should throw an error when an invalid item key is provided', () => { + const badIdItem = getItem( 1 ); // Number id is not supported. + + expectToThrowCKEditorError( () => { + return new Collection( [ badIdItem ] ); + }, /^collection-add-invalid-id/ ); + } ); + + it( 'should throw an error when two items have the same key', () => { + const item1 = getItem( 'foo' ); + const item2 = getItem( 'foo' ); + + expectToThrowCKEditorError( () => { + return new Collection( [ item1, item2 ] ); + }, /^collection-add-item-already-exists/ ); + } ); + } ); + + describe( 'options', () => { + it( 'should allow to change the id property used by the collection', () => { + const item1 = { id: 'foo', name: 'xx' }; + const item2 = { id: 'foo', name: 'yy' }; + const collection = new Collection( { idProperty: 'name' } ); + + collection.add( item1 ); + collection.add( item2 ); - expect( collection.get( 'xx' ) ).to.equal( item1 ); - expect( collection.remove( 'yy' ) ).to.equal( item2 ); + expect( collection ).to.have.length( 2 ); + + expect( collection.get( 'xx' ) ).to.equal( item1 ); + expect( collection.remove( 'yy' ) ).to.equal( item2 ); + } ); + + it( 'should allow to change the id property used by the collection (initial items passed to the constructor)', () => { + const item1 = { id: 'foo', name: 'xx' }; + const item2 = { id: 'foo', name: 'yy' }; + const collection = new Collection( [ item1, item2 ], { idProperty: 'name' } ); + + expect( collection ).to.have.length( 2 ); + + expect( collection.get( 'xx' ) ).to.equal( item1 ); + expect( collection.remove( 'yy' ) ).to.equal( item2 ); + } ); } ); } );