lib/components/c_facets/List.js

Summary

Maintainability
B
6 hrs
Test Coverage
'use strict';

var ComponentFacet = require('../c_facet')
    , Component = require('../c_class')
    , facetsRegistry = require('./cf_registry')
    , miloCore = require('milo-core')
    , _ = miloCore.proto
    , miloBinder = require('../../binder')
    , logger = miloCore.util.logger
    , doT = miloCore.util.doT
    , check = miloCore.util.check
    , Match = check.Match
    , domUtils = require('../../util/dom')
    , componentName = require('../../util/component_name')
    , miloConfig = require('../../config');


var LIST_SAMPLE_CSS_CLASS = 'ml-list-item-sample';

/**
 * `milo.registry.facets.get('List')`
 * Facet enabling list functionality
 */
var List = _.createSubclass(ComponentFacet, 'List');

_.extendProto(List, {
    init: List$init,
    start: List$start,
    destroy: List$destroy,

    require: ['Container', 'Dom', 'Data'],
    _itemPreviousComponent: _itemPreviousComponent,

    item: List$item,
    getItems: List$getItems,
    count: List$count,
    contains: List$contains,
    addItem: List$addItem,
    addItems: List$addItems,
    replaceItem: List$replaceItem,
    moveItem: List$moveItem,
    removeItem: List$removeItem,
    extractItem: List$extractItem,
    each: List$each,
    map: List$map,
    setSample: List$setSample,
    _setItem: List$_setItem,
    _removeItem: List$_removeItem,
    _addItem: List$_addItem,
    _addItems: List$_addItems,
    _createCacheTemplate: List$_createCacheTemplate,
    _updateDataPaths: List$_updateDataPaths
});

facetsRegistry.add(List);

module.exports = List;


/**
 * Facet instance method
 * Initialized List facet instance and sets up item properties.
 */
function List$init() {
    ComponentFacet.prototype.init.apply(this, arguments);

    _.defineProperties(this, {
        _listItems: [],
        _listItemsHash: {}
    });
    _.defineProperty(this, 'itemSample', null, _.WRIT);
}


/**
 * Facet instance method
 * Starts the List facet instance, finds child with Item facet.
 */
function List$start() {
    ComponentFacet.prototype.start.apply(this, arguments);
    // Fired by __binder__ when all children of component are bound
    this.owner.on('childrenbound', onChildrenBound);
}


function onChildrenBound() {
    // get items already in the list
    var children = this.dom.children()
        , items = this.list._listItems
        , itemsHash = this.list._listItemsHash;

    if (children) children.forEach(function(childEl) {
        var comp = Component.getComponent(childEl);
        if (comp && comp.item) {
            items.push(comp);
            itemsHash[comp.name] = comp;
            comp.item.list = this.list;
        }
    }, this);

    resetSample.call(this);
}


function resetSample (providedSample) {
    var items = this.list._listItems,
        itemsHash = this.list._listItemsHash,
        foundItem = providedSample;

    if (! foundItem && items.length) {
        foundItem = items[0];
        items.splice(0, 1);
        delete itemsHash[foundItem.name];
        items.forEach(function(item, index) {
            item.item.setIndex(index);
        });
    }

    // Component must have one child with an Item facet
    if (! foundItem) return logger.error('List_onChildrenBound: No child component has Item facet');

    this.list.itemSample = foundItem;

    // After keeping a reference to the item sample, it must be hidden and removed from scope.  The item sample will
    // remain in the DOM and as such is marked with a CSS class allowing other code to ignore this element if required.
    foundItem.dom.hide();
    foundItem.remove(true);
    foundItem.dom.addCssClasses(LIST_SAMPLE_CSS_CLASS);

    // remove references to components from sample item
    foundItem.walkScopeTree(function(comp) {
        delete comp.el[miloConfig.componentRef];
    });

    this.list._createCacheTemplate();
}


function List$setSample (comp, schema) {
    comp.item.list = this.list;
    this.owner.dom.prepend(comp.el);
    this.itemSample.destroy();
    resetSample.call(this.owner, comp);
    this.itemSchema = schema;
}


function List$_createCacheTemplate() {
    if (!this.itemSample) return false;

    var itemSample = this.itemSample;

    // create item template to insert many items at once
    var itemElCopy = itemSample.el.cloneNode(true);
    itemElCopy.classList.remove(LIST_SAMPLE_CSS_CLASS);

    var attr = itemSample.componentInfo.attr;

    attr.compName = '{{= it.componentName() }}';
    attr.el = itemElCopy;
    attr.decorate();

    var itemsTemplateStr =
          '{{ var i = it.count; while(i--) { }}'
        + itemElCopy.outerHTML
        + '{{ } }}';

    this.itemsTemplate = doT.compile(itemsTemplateStr);
}


/**
 * Facet instance method
 * Retrieve a particular child item by index
 * @param {Integer} index The index of the child item to get.
 * @return {Component} The component found
 */
function List$item(index) {
    return this._listItems[index];
}


/**
 * Facet instance method
 * Get a shallow copy of the items in this list.  Changes to this array will not be reflected in the list.
 *
 * @returns {Array.<{Component}>}
 */
function List$getItems() {
    return this._listItems.concat();
}


/**
 * Facet instance method
 * Gets the total number of child items
 * @return {Integer} The total
 */
function List$count() {
    return this._listItems.length;
}


function List$_setItem(index, component) {
    this._listItems.splice(index, 0, component);
    this._listItemsHash[component.name] = component;
    component.item.list = this;
    component.item.setIndex(+index);
}


/**
 * Facet instance method
 * Returns true if a particular child item exists in the list
 * @param {Component} component The component to look for.
 * @return {Boolean}
 */
function List$contains(component) {
    return this._listItemsHash[component.name] == component;
}


/**
 * Facet instance method
 * Adds a new child component at a particular index and returns the new component.
 * This method uses data facet, so notification will be emitted on data facet.
 * @param {Integer} index The index to add at
 * @return {Component} The newly created component
 */
function List$addItem(index, itemData) {
    if (!this.itemSample) return logger.error('List$addItem: Item sample missing.');
    index = isNaN(+index) ? this.count() : +index;
    this.owner.data.splice(index, 0, itemData || {});
    return this.item(index);
}


/**
 * Facet instance method
 * Adds a new child component at a particular index and returns the new component
 * @param {Integer} index The index to add at
 * @return {Component} The newly created component
 */
function List$_addItem(index) {
    if (this.item(index))
        throw Error('attempt to create item with ID of existing item');

    if (!this.itemSample)
        return logger.error('List$_addItem: Item sample missing.');

    // Copy component
    var component = Component.copy(this.itemSample, true);

    this.config.embellishItem && this.config.embellishItem.call(this, component, this.itemSchema);

    var prevComponent = this._itemPreviousComponent(index);

    if (!prevComponent.el.parentNode)
        return logger.warn('list item sample was removed from DOM, probably caused by wrong data. Reset list data with array');

    // Add it to the DOM
    prevComponent.dom.insertAfter(component.el);

    // Add to list items
    this._setItem(index, component);

    // Show the list item component
    component.el.style.display = '';
    component.el.classList.remove(LIST_SAMPLE_CSS_CLASS);

    _updateItemsIndexes.call(this, index + 1);

    return component;
}


function _updateItemsIndexes(fromIndex, toIndex) {
    fromIndex = fromIndex || 0;
    toIndex = toIndex || this.count();
    for (var i = fromIndex; i < toIndex; i++) {
        var component = this._listItems[i];
        if (component)
            component.item.setIndex(i);
        else
            logger.warn('List: no item at position', i);
    }
}

function List$addItems(itemsData, index) {
    if (!this.itemSample) return logger.error('List$addItems: Item sample missing.');
    var spliceArgs = [index === undefined ? this.count() : index, 0].concat(itemsData);
    var dataFacet = this.owner.data;
    dataFacet.splice.apply(dataFacet, spliceArgs);
}


/**
 * List facet instance method
 * Adds a given number of items using template rendering rather than adding elements one by one
 *
 * @param {Integer} count number of items to add
 * @param {Integer} [index] optional index of item after which to add
 */
function List$_addItems(count, index) {
    check(count, Match.Integer);

    if (!this.itemSample)
        return logger.error('List$_addItems: Item sample missing.');

    if (count < 0)
        throw new Error('can\'t add negative number of items');

    if (count === 0) return;

    var itemsHTML = this.itemsTemplate({
        componentName: componentName,
        count: count
    });

    var wrapEl = document.createElement(this.owner.el.tagName);
    wrapEl.innerHTML = itemsHTML;

    miloBinder(wrapEl, this.owner.container.scope);
    var children = domUtils.children(wrapEl);

    if (count != children.length)
        logger.error('number of items added is different from requested');

    if (children && children.length) {
        var listLength = this.count();
        var spliceIndex = index < 0
                            ? 0
                            : typeof index == 'undefined' || index > listLength
                                ? listLength
                                : index;

        var prevComponent = spliceIndex === 0
                                ? this.itemSample
                                : this._listItems[spliceIndex - 1];

        var frag = document.createDocumentFragment()
            , newComponents = [];

        children.forEach(function(el, i) {
            var component = Component.getComponent(el);
            if (! component)
                return logger.error('List: element in new items is not a component');
            newComponents.push(component);
            this._setItem(spliceIndex++, component);
            frag.appendChild(el);
            el.style.display = '';
        }, this);

        _updateItemsIndexes.call(this, spliceIndex);

        if (!prevComponent.el.parentNode)
            return logger.warn('list item sample was removed from DOM, probably caused by wrong data. Reset list data with array');

        // Add it to the DOM
        prevComponent.dom.insertAfter(frag);
        _.deferMethod(newComponents, 'forEach', function(comp) {
            this.config.embellishItem && this.config.embellishItem.call(this, comp, this.itemSchema);
            comp.broadcast('stateready');
        }.bind(this));
    }
}


/**
 * List facet instance method
 * @param {Integer} index The index of the item to remove
 * @return {Array[Object]} The spliced data
 */
function List$removeItem(index) {
    return this.owner.data.splice(index, 1);
}


/**
 * List facet instance method
 * @param {Integer} index The index of the item to extract
 * @return {Component} The extracted item
 */
function List$extractItem(index) {
    var itemComp = this._removeItem(index, false);
    this._updateDataPaths(index, this.count());
    return itemComp;
}


/**
 * List facet instance method
 * Removes item, returns the removed item that is destroyed by default.
 *
 * @param  {Number} index item index
 * @param  {Boolean} doDestroyItem optional false to prevent item destruction, true by default
 * @return {Component}
 */
function List$_removeItem(index, doDestroyItem) {
    var comp = this.item(index);

    if (! comp)
        return logger.warn('attempt to remove list item with id that does not exist');

    this._listItems[index] = undefined;
    delete this._listItemsHash[comp.name];
    if (doDestroyItem !== false) comp.destroy();
    else {
        comp.remove();
        comp.dom.remove();
    }

    this._listItems.splice(index, 1);
    _updateItemsIndexes.call(this, index);

    return comp;
}


function List$replaceItem(index, newItem){
    var oldItem = this.item(index);
    oldItem.dom.insertAfter(newItem.el);
    this._removeItem(index);
    this._setItem(index, newItem);
}


function List$moveItem(fromIndex, toIndex) {
    var componentToMove = this.extractItem(fromIndex);
    var toComponent = this.item(toIndex);

    componentToMove.insertInto(this.owner.el, toComponent ? toComponent.el : null);

    this._setItem(toIndex, componentToMove);
    _updateItemsIndexes.call(this, 0);
}


// Returns the previous item component given an index
function _itemPreviousComponent(index) {
    while (index >= 0 && ! this._listItems[index])
        index--;

    return index >= 0
                ? this._listItems[index]
                : this.itemSample;
}


// toIndex is not included
// no range checking is made
function List$_updateDataPaths(fromIndex, toIndex) {
    for (var i = fromIndex; i < toIndex; i++) {
        var item = this.item(i);
        if (item)
            item.data._path = '[' + i + ']';
        else
            logger.warn('Data: no item for index', i);
    }
}


/**
 * Facet instance method
 * Similar to forEach method of Array, iterates each of the child items.
 * @param {Function} callback An iterator function to be called on each child item.
 * @param {Any} [thisArg]  Context to set `this`.
 */
function List$each(callback, thisArg) {
    this._listItems.forEach(function(item, index) {
        if (item) callback.apply(this, arguments); // passes item, index to callback
        else logger.warn('List$each: item', index, 'is undefined');
    }, thisArg || this);
}


function List$map(callback, thisArg) {
    return this._listItems.map(function(item, index) {
        if (item) return callback.apply(this, arguments); // passes item, index to callback
        else logger.warn('List$map: item', index, 'is undefined');
    }, thisArg || this);
}


/**
 * Facet instance method
 * Destroys the list
 */
function List$destroy() {
    if (this.itemSample) this.itemSample.destroy(true);
    ComponentFacet.prototype.destroy.apply(this, arguments);
}