Source: component/component.js

/*global setMethods, getComponent, copyComponent, addComponent */
'use strict';

module.exports = Component;
var extend = require('extend');
require('./methods').static(global);

/**
 * A Component can be defined application-wide or be a part of a page.
 * You can define elements and methods on a Component.
 *
 * @class Component
 * @constructor
 * @mixes ComponentTree
 * @param {Component~ComponentDefinition} definition
 */
function Component(definition) {
    var self = this;

    this.$ = definition;

    if ('methods' in definition) {
        setMethods.call(this);
    }

    if ('components' in definition) {
        definition.components.forEach(function(componentDefinition) {
            self.$component(componentDefinition);
        });
    }
}

/**
 * Provides methods that are appropriate for supporting Component
 *
 * @private
 * @mixin
 */
var ComponentTree = {
    $parent: function() {
        return this.$.parent;
    },

    /**
     * Travels up the Component tree and returns an array containing the
     * Component and all of its parents, in the order found.
     *
     * @return {Array.<Component|View|Page>}
     */
    $hierarchy: function() {
        var hierarchy = [];
        // jscs:disable safeContextKeyword
        var node = this;
        // jscs:enable

        while (node) {
            hierarchy.unshift(node);
            node = node.$.parent;
        }

        return hierarchy;
    },

    /**
     * Add a new component to this component
     *
     * @param {Component~ComponentDefinition} definition
     *
    *//**
     *
     * Get a previously defined component
     *
     * @param {String} name
     * @returns {Component}
     * @throws {ReferenceError} If the requested component doesn't exist
     *
    *//**
     *
     * Copy an existing component onto this component.
     * The locator for the copied component and its children will become relative to this component.
     * Warning: this method also copies any methods, which may still have reference to the original component.
     *
     * @param {Component} component
     * @param {Component~ComponentDefinition} [definition]
     * @returns {Component} A deep copy of the component
     * @throws {Error} If first argument is not a Component
     */
    $component: function(component) {
        if (typeof component === 'string') {
            return getComponent.apply(this, arguments);
        }

        if (component instanceof Component) {
            return copyComponent.apply(this, arguments);
        }

        return addComponent.apply(this, arguments);
    }
};

/**
 * Sets the properties for a Component. Changes are recursed down to children.
 *
 * @method set
 * @memberof Component.prototype
 * @param {Component~ComponentDefinition} definition
 *
 * @example
 * component.set({name: 'list', locator: '.itemList', parent: null});
 */
Component.prototype.$set = function(definition) {
    definition = extend({}, this.$, definition);
    this.constructor(definition);
};

/**
 * Provides access to methods that are appropriate for supporting
 * Component in other classes such as View
 *
 * @param {Constructor} inheritor
 */
Component.mixin = function(inheritor) {
    var methods = Object.keys(ComponentTree);

    methods.forEach(function(method) {
        inheritor.prototype[method] = ComponentTree[method];
    });
};

/**
 * Determines whether two components are equal by deep comparison
 *
 * @method equals
 * @memberof Component
 * @param  {Component} a
 * @param  {Component} b
 * @return {Boolean}
 */
Component.equals = function(a, b) {
    if (!(a instanceof Component && b instanceof Component)) {
        return false;
    }

    if (!isEquivalentComponents(a, b)) {
        return false;
    }

    if (!isEquivalentMethods(a.$.methods, b.$.methods)) {
        return false;
    }

    return a.$.locator === b.$.locator;
};

/**
 * @ignore
 * @param  {Component}  a
 * @param  {Component}  b
 * @return {Boolean}
 */
function isEquivalentComponents(a, b) {
    var name;

    if (a.$.components && b.$.components) {
        if (a.$.components.length !== b.$.components.length) {
            return false;
        }

        for (var i = a.$.components.length - 1; i >= 0; i--) {
            name = a.$.components[i].name;

            if (!Component.equals(a[name], b[name])) {
                return false;
            }
        }
    } else if (a.$.components || b.$.components) {
        return false;
    }

    return true;
}

/**
 * @ignore
 * @param  {Object.<String,Function>}  aMethods
 * @param  {Object.<String,Function>}  bMethods
 * @return {Boolean}
 */
function isEquivalentMethods(aMethods, bMethods) {
    if ((aMethods || bMethods) && !(aMethods && bMethods)) {
        return false;
    } else if (!(aMethods || bMethods)) {
        return true;
    }

    var aNames = Object.keys(aMethods);
    var bNames = Object.keys(bMethods);

    if (aNames.length !== bNames.length) {
        return false;
    }

    for (var i = aNames.length - 1, name; i >= 0; i--) {
        name = aNames[i];
        if (aMethods[name].toString() !== bMethods[name].toString()) {
            return false;
        }
    }

    return true;
}

Component.mixin(Component);
extend(Component.prototype, require('./dsl'));