wb/mgr/MgrTypeInfo.js

/**
 * @copyright 2016 Tridium, Inc. All Rights Reserved.
 */

/* jshint browser: true */

/**
 * @module nmodule/webEditors/rc/wb/mgr/MgrTypeInfo
 */
define([ 'baja!',
        'Promise',
        'underscore',
        'nmodule/webEditors/rc/fe/baja/util/typeUtils' ], function (
        baja,
        Promise,
        _,
        typeUtils) {

  'use strict';

  var isComponent = typeUtils.isComponent,
      getTypeDisplayName = typeUtils.getTypeDisplayName;

////////////////////////////////////////////////////////////////
// Support
////////////////////////////////////////////////////////////////

  /**
   * Test whether the given parameter is an object with a type spec property.
   *
   * @param {Object} o
   * @returns {boolean} true if a type spec can be obtained by reading a string property.
   */
  function hasTypeSpecString(o) {
    return (o.hasOwnProperty('typeSpec')) && (typeof o.typeSpec === 'string');
  }

  /**
   * Test whether the given parameter is an object with a `getTypeSpec()` function.
   *
   * @param {Object} o
   * @returns {boolean} true if a type spec can be obtained by calling the getTypeSpec() function.
   */
  function hasTypeSpecFunction(o) {
    return o && (typeof o === 'object') && ('getTypeSpec' in o) && (typeof o.getTypeSpec === 'function');
  }

////////////////////////////////////////////////////////////////
// TypeImpl
////////////////////////////////////////////////////////////////

  /**
   * TypeImpl provides an implementation of the MgrTypeInfo functionality
   * based on a BajaScript `Type` instance. This constructor is used when
   * the MgrTypeInfo is being created from a type spec string, a `Type`
   * instance, or an agent info.
   *
   * @inner
   * @private
   * @class
   */
  var TypeImpl = function TypeImpl(type, displayName, duplicate) {
    this.$type = type;
    this.$displayName = displayName;
    this.$duplicate = !!duplicate;
  };

  /**
   * TypeImpl provides a different implementation of getDisplayName(), where
   * the module name will be appended to the resulting string in the case where
   * the type name is a duplicate.
   *
   * @returns {string}
   */
  TypeImpl.prototype.getDisplayName = function () {
    var that = this,
        str = that.$displayName;

    if (that.$duplicate) { str = str + ' (' + that.$type.getModuleName() + ')'; }
    return str;
  };

  /**
   * Returns the icon configured for a type, based on the module's lexicon.
   * @returns {baja.Icon} The icon configured for the type.
   */
  TypeImpl.prototype.getIcon = function () {
    return this.$type.getIcon();
  };

  /**
   * Returns a Promise to create a new instance of the type represented by this instance.
   * @returns {Promise.<baja.Component>}
   */
  TypeImpl.prototype.newInstance = function () {
    return Promise.resolve(baja.$(this.$type));
  };

  /**
   * Test whether the wrapped type can be matched against the given database component's type.
   */
  TypeImpl.prototype.isMatchable = function (db) {
    return db && db.getType().is(this.$type);
  };

  /**
   * Return the string used for comparison against another MgrTypeInfo.
   * @returns {String}
   */
  TypeImpl.prototype.getCompareString = function () {
    return this.$type.toString();
  };

  /**
   * Get the underlying type wrapped by this instance.
   * @returns {baja.Type}
   */
  TypeImpl.prototype.getType = function () {
    return this.$type;
  };

  /**
   * Mark or unmark this instance as a duplicate, meaning that we have
   * two or more types with the same type name in different modules. If
   * the parameter is true, the string returned by `getDisplayName()`
   * will have the module name appended to make it unique.
   *
   * @param {Boolean} duplicate
   */
  TypeImpl.prototype.$setDuplicate = function (duplicate) {
    this.$duplicate = !!duplicate;
  };

////////////////////////////////////////////////////////////////
// PrototypeImpl
////////////////////////////////////////////////////////////////

  /**
   * TypeImpl provides an implementation of the MgrTypeInfo functionality
   * based on an existing prototype BComponent.
   *
   * @inner
   * @private
   * @class
   */
  var PrototypeImpl = function PrototypeImpl(proto, displayName) {
    this.$proto = proto;
    this.$displayName = displayName;
  };

  PrototypeImpl.prototype.constructor = PrototypeImpl;

  /**
   * Get the display name from the wrapped prototype component's type.
   * @returns {string}
   */
  PrototypeImpl.prototype.getDisplayName = function () {
    return this.$displayName;
  };

  /**
   * Return the icon obtained from the wrapped prototype component.
   * @returns {baja.Icon}
   */
  PrototypeImpl.prototype.getIcon = function () {
    return this.$proto.getIcon();
  };

  /**
   * Return a new `Component` instance, cloned as an exact copy
   * of the prototype.
   *
   * @returns {Promise.<baja.Component>}
   */
  PrototypeImpl.prototype.newInstance = function () {
    return Promise.resolve(this.$proto.newCopy(true));
  };

  /**
   * Test whether the prototype's type can be matched against the given database component's type.
   */
  PrototypeImpl.prototype.isMatchable = function (db) {
    return db.getType().is(this.$proto.getType());
  };

  /**
   * Return the string used for comparison against another MgrTypeInfo.
   * @returns {string}
   */
  PrototypeImpl.prototype.getCompareString = function () {
    return this.$proto.getType().toString();
  };

  /**
   * Return the type of the underlying component prototype.
   * @returns {*}
   */
  PrototypeImpl.prototype.getType = function () {
    return this.$proto.getType();
  };

////////////////////////////////////////////////////////////////
// MgrTypeInfo
////////////////////////////////////////////////////////////////

  /**
   *  API Status: **Development**
   *
   * MgrTypeInfo wraps information about what type to create
   * in the station database when doing a new or add operation.
   * This information may come from an exact type, a registry
   * query for concrete types given a base type, or from a component
   * instance used as a prototype.
   *
   * In addition to the basic type operations, this provides extra
   * functionality specific to the manager views, such as the ability
   * to compare and match types.
   *
   * This constructor should be not called directly. Client code should
   * use the static `.make()` function.
   *
   * @class
   * @alias module:nmodule/webEditors/rc/wb/mgr/MgrTypeInfo
   */
  var MgrTypeInfo = function MgrTypeInfo(impl) {
    this.$impl = impl;
  };

  /**
   * Static function to compare an array of MgrTypeInfos for duplicate type names.
   * This is used to alter the display name for any non-prototype created MgrTypeInfos,
   * where there may be two or more modules exposing types with the same display names.
   *
   * @static
   * @param {Array.<module:nmodule/webEditors/rc/wb/mgr/MgrTypeInfo>} infos
   */
  MgrTypeInfo.markDuplicates = function (infos) {
    if (!infos || infos.length === 0) { return; }

    var names = infos.map(function (inf) { return inf.getType().getTypeName(); }),
        counts = _.countBy(names, _.identity);

    _.each(infos, function (inf, i) {
      if ((counts[names[i]] > 1) && (inf.$impl instanceof TypeImpl)) {
        inf.$impl.$setDuplicate(true);
      }
    });
  };

  /**
   * Static `.make()` function, returning a `Promise` that will resolve
   * to a single `MgrTypeInfo` or array of `MgrTypeInfo`s, depending on the
   * arguments passed to the function. This is the normal way to obtain a
   * MgrTypeInfo instance; the type's constructor should not be used by client
   * code directly.
   *
   * @example
   * <caption>
   *   Make an array of MgrTypeInfos from an array of type spec strings.
   * </caption>
   * MgrTypeInfo.make({
   *   from: [ 'baja:AbsTime', 'baja:Date', 'baja.RelTime' ]
   * })
   * .then(function (mgrInfos) {
   *   // ... do something with the array of MgrTypeInfo
   * });
   *
   * @example
   * <caption>
   *   Get the concrete MgrTypeInfos for the abstract ControlPoint type.
   * </caption>
   * MgrTypeInfo.make({
   *   from: 'baja:ControlPoint',
   *   concreteTypes: true
   * })
   * .then(function (mgrInfos) {
   *   // ... do something with the array of MgrTypeInfo
   * });
   *
   * @example
   * <caption>
   *   Get the MgrTypeInfos for the agents on a type
   * </caption>
   *  baja.registry.getAgents("type:moduleName:TypeName")
   *    .then(function (agentInfos) {
   *      return MgrTypeInfo.make({
   *        from: agentInfos
   *      });
   *    })
   *    .then(function (mgrInfos) {
   *      // ... do something with the array of MgrTypeInfo
   *    });
   * @static
   *
   * @param {Object} params an Object containing the function's arguments
   *
   * @param {String|baja.Type|Array} params.from The type spec used to create the
   * type information. This may be a single type spec string, or a BajaScript `Type` instance,
   * or may be an array of spec strings or `Types`.
   *
   * @param {Boolean} [params.concreteTypes] true if the `from` parameter should be used
   * as a base type to create an Array of `MgrTypeInfo`s for its concrete types. If this parameter
   * is specified as true, the `from` parameter must contain a single base type.
   *
   * @param {baja.comm.Batch} [params.batch] An optional batch object, which if provided can be
   * used to batch network calls.
   *
   * @returns {Promise} Either a single `MgrTypeInfo` instance, or an array of MgrTypeInfos,
   * depending on the value passed in the 'from' parameter and the value of the 'concreteTypes'
   * parameter. If the 'from' parameter specifies a single type and 'concreteTypes' is either
   * false or undefined, the returned value will be a single `MgrTypeInfo`, otherwise the returned
   * value will be an array of `MgrTypeInfo`s.
   */
  MgrTypeInfo.make = function (params) {
    params = baja.objectify(params, 'from');

    if (!params.from) {
      throw new Error('MgrTypeInfo.make() requires a source type spec, agent or component.');
    }

    return makePromise(params);
  };

  MgrTypeInfo.prototype.constructor = MgrTypeInfo;

  /**
   * Get the display name of the type to create.
   * @returns {String}
   */
  MgrTypeInfo.prototype.getDisplayName = function () {
    return this.$impl.getDisplayName.apply(this.$impl, arguments);
  };

  /**
   * Get the BajaScript Type to be created by this MgrTypeInfo instance.
   * @returns {baja.Type}
   */
  MgrTypeInfo.prototype.getType = function () {
    return this.$impl.getType();
  };

  /**
   * Get the icon of the type to create.
   * @returns {baja.Icon}
   */
  MgrTypeInfo.prototype.getIcon = function () {
    return this.$impl.getIcon();
  };

  /**
   * Get the type as a slot name.  The default implementation
   * returns the display name stripped of spaces and escaped.
   *
   * @returns {String}
   */
  MgrTypeInfo.prototype.toSlotName = function () {
    return baja.SlotPath.escape(this.getDisplayName().replace(/ /g, ''));
  };

  /**
   * Returns a Promise that will create a new instance of a component
   * from the type information.
   *
   * @returns {Promise.<baja.Component>}
   */
  MgrTypeInfo.prototype.newInstance = function () {
    return this.$impl.newInstance();
  };

  /**
   * Return true if this type may be used to perform a learn match against the
   * specified database component.
   *
   * @param {baja.Component} db the database component
   * @returns {Boolean}
   */
  MgrTypeInfo.prototype.isMatchable = function (db) {
    return this.$impl.isMatchable(db);
  };

  /**
   * Equality comparison for two MgrTypeInfo instances based on a comparison
   * of the display name. This will return false if the other object is not a
   * MgrTypeInfo instance.
   *
   * @param {Object} other the object to be compared against
   * @returns {Boolean}
   */
  MgrTypeInfo.prototype.equals = function (other) {
    if (other && typeof other === 'object' && other instanceof MgrTypeInfo) {
      return this.$impl.getCompareString() === other.$impl.getCompareString();
    }
    return false;
  };

  /**
   * Returns the display name for the type represented by this instance.
   * @returns {String}
   */
  MgrTypeInfo.prototype.toString = function () {
    return this.getDisplayName();
  };

  /**
   * Helper function to be passed to an array sorting function to ensure MgrTypeInfo instances are
   * ordered according to the display name.
   *
   * @example
   * <caption>
   *   Sort the array of MgrTypeInfos obtained from the make() function.
   * </caption>
   * MgrTypeInfo.make({
   *   from: 'baja:ControlPoint',
   *   concreteTypes: true
   * })
   * .then(function (mgrInfos) {
   *   mgrInfos.sort(MgrTypeInfo.BY_DISPLAY_NAME);
   * });
   */
  MgrTypeInfo.BY_DISPLAY_NAME = function (a, b) {
    var nameA = a.getDisplayName(),
        nameB = b.getDisplayName();

    if (nameA < nameB) { return -1; }
    if (nameA > nameB) { return 1; }
    return 0;
  };

  /**
   * Make a new MgrTypeInfo instance from the given component prototype.
   * This creates an implementation instance that wraps the given component.
   *
   * @param {baja.Component} proto the prototype component
   * @returns {Promise}
   */
  function makeFromPrototype(proto) {
    return getTypeDisplayName(proto.getType())
      .then(function (displayName) {
        return new MgrTypeInfo(new PrototypeImpl(proto, displayName));
      });
  }

  /**
   * Make a new MgrTypeInfo from a BajaScript `Type` or an agent info object obtained
   * from the registry.
   *
   * @param type {baja.Type|Object} Either a loaded BajaScript `Type` or an AgentInfo object.
   * @returns {Promise}
   */
  function makeWithTypeImplementation(type) {
    return getTypeDisplayName(type)
      .then(function (displayName) {
        return new MgrTypeInfo(new TypeImpl(type, displayName));
      });
  }

  /**
   * Create and return the promise that will resolve to the MgrTypeInfo instance(s).
   *
   * @param {Object} params the parameter object passed to the make() function.
   * @returns {Promise}
   */
  function makePromise(params) {

    if ((_.isArray(params.from) && (params.from.length === 0))) {
      return Promise.resolve([]);
    } else if (isComponent(params.from)) {
      return makeFromPrototype(params.from);
    } else if (params.concreteTypes) {
      if (_.isArray(params.from)) {
        throw new Error('Must specify a single base type when getting concrete types.');
      }
      return getFromBaseType(params.from, params.batch);
    } else if (_.isArray(params.from)) {
      return getFromTypeSpecs(params.from, params.batch);
    } else {
      return getFromTypeSpecs([ params.from ], params.batch).then(_.first);
    }
  }

  /**
   * Make a new MgrTypeInfo instance from the given type. If the typespec
   * is not already loaded, this will try to import the types into the
   * registry.
   *
   * @param from
   * @param {baja.comm.Batch} batch An optional batch instance, used to combine network calls.
   * @returns {Promise}
   */
  function getFromTypeSpecs(from, batch) {

    function getSpecString(o) {
      if (hasTypeSpecFunction(o)) {
        return o.getTypeSpec();
      } else if (hasTypeSpecString(o)) {
        return o.typeSpec;
      } else {
        return String(o);
      }
    }

    var typeSpecs = _.map(from, getSpecString),
        importArgs = { typeSpecs: typeSpecs };

    if (batch) {
      importArgs.batch = batch;
    }

    return baja.registry.importTypes(importArgs)
      .then(function (types) {
        return Promise.all(types.map(makeWithTypeImplementation));
      });
  }

  /**
   * Load the concrete types from the registry, import the types and
   * map each one to a new MgrTypeInfo instance.
   *
   * @inner
   * @param baseType
   * @param {baja.comm.Batch} batch An optional batch instance, used to combine network calls.
   * @returns {Promise}
   */
  function getFromBaseType(baseType, batch) {
    var getTypeArgs = { type: baseType };

    if (batch) { getTypeArgs.batch = batch; }

    return baja.registry.getConcreteTypes(getTypeArgs)
      .then(function (concreteTypes) {
        return baja.registry.importTypes(concreteTypes);
      })
      .then(function (importedTypes) {
        return Promise.all(importedTypes.map(makeWithTypeImplementation));
      });
  }

  return (MgrTypeInfo);
});