API Docs for:
Show:

File: app/views/utils.js

'use strict';

/**
 * The view utils.
 *
 * @module views
 * @submodule views.utils
 */

YUI.add('juju-view-utils', function(Y) {

  var views = Y.namespace('juju.views'),
      utils = Y.namespace('juju.views.utils');

  /*jshint bitwise: false*/
  /**
    Create a hash of a string. From stackoverflow: http://goo.gl/PEOgF

    @method generateHash
    @param {String} value The string to hash.
    @return {Integer} The hash of the string.
   */
  var generateHash = function(value) {
    return value.split('').reduce(
        function(hash, character) {
          hash = ((hash << 5) - hash) + character.charCodeAt(0);
          return hash & hash;
        },
        0
    );
  };
  /*jshint bitwise: true*/
  utils.generateHash = generateHash;

  /**
    Create a stable, safe DOM id given an arbitrary string.
    See details and discussion in
    https://bugs.launchpad.net/juju-gui/+bug/1167295

    @method generateSafeDOMId
    @param {String} value The string to hash.
    @return {String} The calculated DOM id.
   */
  var generateSafeDOMId = function(value) {
    return (
        'e-' + value.replace(/\W/g, '_') + '-' + generateHash(value));
  };
  utils.generateSafeDOMId = generateSafeDOMId;

  var timestrings = {
    prefixAgo: null,
    prefixFromNow: null,
    suffixAgo: 'ago',
    suffixFromNow: 'from now',
    seconds: 'less than a minute',
    minute: 'about a minute',
    minutes: '%d minutes',
    hour: 'about an hour',
    hours: 'about %d hours',
    day: 'a day',
    days: '%d days',
    month: 'about a month',
    months: '%d months',
    year: 'about a year',
    years: '%d years',
    wordSeparator: ' ',
    numbers: []
  };

  var humanizeNumber = function(n) {
    var units = [[1000, 'K'],
          [1000000, 'M'],
          [1000000000, 'B']],
        result = n;

    Y.each(units, function(sizer) {
      var threshold = sizer[0],
          unit = sizer[1];
      if (n > threshold) {
        result = (n / threshold);
        if (n % threshold !== 0) {
          result = result.toFixed(1);
        }
        result = result + unit;
      }
    });
    return result;
  };
  utils.humanizeNumber = humanizeNumber;

  /*
   * Utility methods for SVG regarding classes
   */
  var hasSVGClass = function(selector, class_name) {
    var classes = selector.getAttribute('class');
    if (!classes) {
      return false;
    }
    return classes.indexOf(class_name) !== -1;
  };
  utils.hasSVGClass = hasSVGClass;

  var addSVGClass = function(selector, class_name) {
    var self = this;
    if (!selector) {
      return;
    }

    if (typeof(selector) === 'string') {
      Y.all(selector).each(function(n) {
        var classes = this.getAttribute('class');
        if (!self.hasSVGClass(this, class_name)) {
          this.setAttribute('class', classes + ' ' + class_name);
        }
      });
    } else {
      var classes = selector.getAttribute('class');
      if (!self.hasSVGClass(selector, class_name)) {
        selector.setAttribute('class', classes + ' ' + class_name);
      }
    }
  };
  utils.addSVGClass = addSVGClass;

  var removeSVGClass = function(selector, class_name) {
    if (!selector) {
      return;
    }

    if (typeof(selector) === 'string') {
      Y.all(selector).each(function() {
        var classes = this.getAttribute('class');
        this.setAttribute('class', classes.replace(class_name, ''));
      });
    } else {
      var classes = selector.getAttribute('class');
      selector.setAttribute('class', classes.replace(class_name, ''));
    }
  };
  utils.removeSVGClass = removeSVGClass;

  var toggleSVGClass = function(selector, class_name) {
    if (this.hasSVGClass(selector, class_name)) {
      this.removeSVGClass(selector, class_name);
    } else {
      this.addSVGClass(selector, class_name);
    }
  };
  utils.toggleSVGClass = toggleSVGClass;

  var consoleManager = function() {
    var winConsole = window.console,
        // These are the available methods.
        // Add more to this list if necessary.
        noop = function() {},
        consoleNoop = {
          group: noop,
          groupEnd: noop,
          groupCollapsed: noop,
          time: noop,
          timeEnd: noop,
          log: noop,
          info: noop,
          error: noop,
          debug: noop,
          warn: noop
        };

    if (winConsole === undefined) {
      window.console = consoleNoop;
      winConsole = consoleNoop;
    }
    return {
      native: function() {
        window.console = winConsole;
      },
      noop: function() {
        window.console = consoleNoop;
      },
      console: function(x) {
        if (!arguments.length) {
          return consoleNoop;
        }
        consoleNoop = x;
        return x;
      }
    };
  };
  utils.consoleManager = consoleManager;
  // Also assign globally to manage the actual console.
  window.consoleManager = consoleManager();

  /*
 * Ported from https://github.com/rmm5t/jquery-timeago.git to YUI
 * w/o the watch/refresh code
 */
  var humanizeTimestamp = function(t) {
    var l = timestrings,
        prefix = l.prefixAgo,
        suffix = l.suffixAgo,
        distanceMillis = Y.Lang.now() - t,
        seconds = Math.abs(distanceMillis) / 1000,
        minutes = seconds / 60,
        hours = minutes / 60,
        days = hours / 24,
        years = days / 365;

    function substitute(stringOrFunction, number) {
      var string = Y.Lang.isFunction(stringOrFunction) ?
          stringOrFunction(number, distanceMillis) : stringOrFunction,
          value = (l.numbers && l.numbers[number]) || number;
      return string.replace(/%d/i, value);
    }

    var words = seconds < 45 && substitute(l.seconds, Math.round(seconds)) ||
        seconds < 90 && substitute(l.minute, 1) ||
        minutes < 45 && substitute(l.minutes, Math.round(minutes)) ||
        minutes < 90 && substitute(l.hour, 1) ||
        hours < 24 && substitute(l.hours, Math.round(hours)) ||
        hours < 42 && substitute(l.day, 1) ||
        days < 30 && substitute(l.days, Math.round(days)) ||
        days < 45 && substitute(l.month, 1) ||
        days < 365 && substitute(l.months, Math.round(days / 30)) ||
        years < 1.5 && substitute(l.year, 1) ||
        substitute(l.years, Math.round(years));

    return Y.Lang.trim([prefix, words, suffix].join(' '));
  };
  views.humanizeTimestamp = humanizeTimestamp;

  Y.Handlebars.registerHelper('humanizeTime', function(text) {
    if (!text || text === undefined) {return '';}
    return new Y.Handlebars.SafeString(humanizeTimestamp(Number(text)));
  });
  var JujuBaseView = Y.Base.create('JujuBaseView', Y.Base, [], {

    bindModelView: function(model) {
      model = model || this.get('model');
      // If this view has a model, bubble model events to the view.
      if (model) {
        model.addTarget(this);
      }

      // If the model gets swapped out, reset targets accordingly and rerender.
      this.after('modelChange', function(ev) {
        if (ev.prevVal) {
          ev.prevVal.removeTarget(this);
        }
        if (ev.newVal) {
          ev.newVal.addTarget(this);
        }
        this.render();
      });

      // Re-render this view when the model changes, and after it is loaded,
      // to support "loaded" flags.
      this.after(['*:change', '*:load'], this.render, this);
    },

    renderable_charm: function(charm_name, db, getModelURL) {
      var charm = db.charms.getById(charm_name);
      if (charm) {
        var result = charm.getAttrs();
        result.app_url = getModelURL(charm);
        return result;
      }
      return null;
    },

    humanizeNumber: function(n) {
      var units = [[1000, 'K'],
            [1000000, 'M'],
            [1000000000, 'B']],
          result = n;

      Y.each(units, function(sizer) {
        var threshold = sizer[0],
            unit = sizer[1];
        if (n > threshold) {
          result = (n / threshold);
          if (n % threshold !== 0) {
            result = result.toFixed(1);
          }
          result = result + unit;
        }
      });
      return result;
    },

    /*
     * Utility methods for SVG regarding classes
     */
    hasSVGClass: function(selector, class_name) {
      var classes = selector.getAttribute('class');
      if (!classes) {
        return false;
      }
      return classes.indexOf(class_name) !== -1;
    },

    addSVGClass: function(selector, class_name) {
      var self = this;
      if (!selector) {
        return;
      }

      if (typeof(selector) === 'string') {
        Y.all(selector).each(function(n) {
          var classes = this.getAttribute('class');
          if (!self.hasSVGClass(this, class_name)) {
            this.setAttribute('class', classes + ' ' + class_name);
          }
        });
      } else {
        var classes = selector.getAttribute('class');
        if (!self.hasSVGClass(selector, class_name)) {
          selector.setAttribute('class', classes + ' ' + class_name);
        }
      }
    },

    removeSVGClass: function(selector, class_name) {
      if (!selector) {
        return;
      }

      if (typeof(selector) === 'string') {
        Y.all(selector).each(function() {
          var classes = this.getAttribute('class');
          this.setAttribute('class', classes.replace(class_name, ''));
        });
      } else {
        var classes = selector.getAttribute('class');
        selector.setAttribute('class', classes.replace(class_name, ''));
      }
    },

    toggleSVGClass: function(selector, class_name) {
      if (this.hasSVGClass(selector, class_name)) {
        this.removeSVGClass(selector, class_name);
      } else {
        this.addSVGClass(selector, class_name);
      }
    }

  });

  views.JujuBaseView = JujuBaseView;


  views.createModalPanel = function(
      body_content, render_target, action_label, action_cb) {
    var panel = new Y.Panel({
      bodyContent: body_content,
      width: 400,
      zIndex: 5,
      centered: true,
      show: false,
      classNames: 'modal',
      modal: true,
      render: render_target,
      buttons: []
    });
    if (action_label && action_cb) {
      views.setModalButtons(panel, action_label, action_cb);
    }
    return panel;
  };

  views.setModalButtons = function(panel, action_label, action_cb) {
    panel.set('buttons', []);
    panel.addButton(
        { value: action_label,
          section: Y.WidgetStdMod.FOOTER,
          action: action_cb,
          classNames: ['btn-danger', 'btn']
        });
    panel.addButton(
        { value: 'Cancel',
          section: Y.WidgetStdMod.FOOTER,
          action: function(e) {
            e.preventDefault();
            panel.hide();
          },
          classNames: ['btn']
        });

    // The default YUI CSS conflicts with the CSS effect we want.
    panel.get('boundingBox').all('.yui3-button').removeClass('yui3-button');
    return panel;
  };

  views.highlightRow = function(row, err) {
    row.removeClass('highlighted'); // Whether we need to or not.
    var backgroundColor = 'palegreen',
        oldColor = row.one('td').getStyle('backgroundColor');
    if (err) {
      backgroundColor = 'pink';
    }
    // Handle tr:hover in bootstrap css.
    row.all('td').setStyle('backgroundColor', 'transparent');
    row.setStyle('backgroundColor', backgroundColor);
    row.transition(
        { easing: 'ease-out', duration: 3, backgroundColor: oldColor},
        function() {
          // Revert to following normal stylesheet rules.
          row.setStyle('backgroundColor', '');
          // Undo hover workaround.
          row.all('td').setStyle('backgroundColor', '');
        });
  };

  utils.updateLandscapeBottomBar = function(landscape, env, model, container) {
    // Landscape annotations are stored in a unit's annotations, but just on
    // the object in the case of services/environment.
    var annotations = model.annotations ? model.annotations : model;
    var envAnnotations = env.get ? env.get('annotations') : env;
    var controls = container.one('.landscape-controls').hide();
    var machine = controls.one('.machine-control').hide();
    var updates = controls.one('.updates-control').hide();
    var restart = controls.one('.restart-control').hide();

    if (envAnnotations['landscape-url']) {
      controls.show();
      var baseLandscapeURL = landscape.getLandscapeURL(model);
      if (baseLandscapeURL) {
        machine.show();
        machine.one('a').setAttribute('href', baseLandscapeURL);

        if (annotations['landscape-security-upgrades']) {
          updates.show();
          updates.one('a').setAttribute('href',
              landscape.getLandscapeURL(model, 'security'));
        }

        if (annotations['landscape-needs-reboot']) {
          restart.show();
          restart.one('a').setAttribute('href',
              landscape.getLandscapeURL(model, 'reboot'));
        }
      }
    }
  };

  function _addAlertMessage(container, alertClass, message) {
    var div = container.one('#message-area');

    // If the div cannot be found (often an issue with testing), give up and
    // return.
    if (!div) {
      return;
    }

    var errorDiv = div.one('#alert-area');

    if (!errorDiv) {
      errorDiv = Y.Node.create('<div/>')
        .set('id', 'alert-area')
        .addClass('alert')
        .addClass(alertClass);

      Y.Node.create('<span/>')
        .set('id', 'alert-area-text')
        .appendTo(errorDiv);

      var close = Y.Node.create('<a class="close">x</a>');

      errorDiv.appendTo(div);
      close.appendTo(errorDiv);

      close.on('click', function() {
        errorDiv.remove();
      });
    }

    errorDiv.one('#alert-area-text').setHTML(message);
    window.scrollTo(0, 0);
  }

  utils.showSuccessMessage = function(container, message) {
    _addAlertMessage(container, 'alert-success', message);
  };


  utils.buildRpcHandler = function(config) {
    var utils = Y.namespace('juju.views.utils'),
        container = config.container,
        scope = config.scope,
        finalizeHandler = config.finalizeHandler,
        successHandler = config.successHandler,
        errorHandler = config.errorHandler;

    function invokeCallback(callback) {
      if (callback) {
        if (scope) {
          callback.apply(scope);
        } else {
          callback();
        }
      }
    }

    return function(ev) {
      if (ev && ev.err) {
        _addAlertMessage(container, 'alert-error', utils.SERVER_ERROR_MESSAGE);
        invokeCallback(errorHandler);
      } else {
        // The usual result of a successful request is a page refresh.
        // Therefore, we need to set this delay in order to show the "success"
        // message after the page page refresh.
        setTimeout(function() {
          utils.showSuccessMessage(container, 'Settings updated');
        }, 1000);
        invokeCallback(successHandler);
      }
      invokeCallback(finalizeHandler);
    };
  };

  utils.SERVER_ERROR_MESSAGE = 'An error ocurred.';

  /**
     Check whether or not the given relationId represents a PyJuju relation.

     @method isPythonRelation
     @static
     @param {String} relationId The relation identifier.
     @return {Bool} True if the id represents a PyJuju relation,
      False otherwise.
   */
  utils.isPythonRelation = function(relationId) {
    var regex = /^relation-\d+$/;
    return regex.test(relationId);
  };

  utils.getRelationDataForService = function(db, service) {
    // Return a list of objects representing the `near` and `far`
    // endpoints for all of the relationships `rels`.  If it is a peer
    // relationship, then `far` will be undefined.
    var service_name = service.get('id');
    return Y.Array.map(
        db.relations.get_relations_for_service(service),
        function(relation) {
          var rel = relation.getAttrs(),
              near,
              far;
          if (rel.endpoints[0][0] === service_name) {
            near = rel.endpoints[0];
            far = rel.endpoints[1]; // undefined if a peer relationship.
          } else {
            near = rel.endpoints[1];
            far = rel.endpoints[0];
          }
          rel.near = {service: near[0], role: near[1].role, name: near[1].name};
          // far will be undefined or the far endpoint service.
          rel.far = far && {
            service: far[0], role: far[1].role, name: far[1].name};
          var relationId = rel.relation_id;
          if (utils.isPythonRelation(relationId)) {
            // This is a Python relation id.
            var relNumber = relationId.split('-')[1];
            rel.ident = near[1].name + ':' + parseInt(relNumber, 10);
          } else {
            // This is a Juju Core relation id.
            rel.ident = relationId;
          }
          rel.elementId = generateSafeDOMId(rel.relation_id);
          return rel;
        });
  };

  /*
   * Given a CSS selector, gather up form values and return in a mapping
   * (object).
   */
  utils.getElementsValuesMapping = function(container, selector) {
    var result = {};
    container.all(selector).each(function(el) {
      var value = null;
      if (el.getAttribute('type') === 'checkbox') {
        value = el.get('checked');
      } else {
        value = el.get('value');
      }

      if (value && typeof value === 'string' && value.trim() === '') {
        value = null;
      }

      result[el.get('name')] = value;
    });

    return result;
  };

  /*
   * Given a charm schema, return a template-friendly array describing it.
   */
  utils.extractServiceSettings = function(schema) {
    var settings = [];
    Y.Object.each(schema, function(field_def, field_name) {
      var entry = {
        'name': field_name
      };

      if (schema[field_name].type === 'boolean') {
        entry.isBool = true;

        if (schema[field_name]['default']) {
          // The "checked" string will be used inside an input tag
          // like <input id="id" type="checkbox" checked>
          entry.value = 'checked';
        } else {
          // The output will be <input id="id" type="checkbox">
          entry.value = '';
        }
      } else {
        entry.value = schema[field_name]['default'];
      }

      settings.push(Y.mix(entry, field_def));
    });
    return settings;
  };

  utils.stateToStyle = function(state, current) {
    // TODO: also check relations.
    var classes;
    switch (state) {
      case 'installed':
      case 'pending':
      case 'stopped':
        classes = 'state-pending';
        break;
      case 'started':
        classes = 'state-started';
        break;
      case 'install-error':
      case 'start-error':
      case 'stop-error':
        classes = 'state-error';
        break;
      default:
        Y.log('Unhandled agent state: ' + state, 'debug');
    }
    classes = current && classes + ' ' + current || classes;
    return classes;
  };

  utils.validate = function(values, schema) {
    var errors = {};

    /**
     * Translate a value into a string, translating non-values into an empty
     * string.
     *
     * @method toString
     * @param {Object} value The value to stringify.
     * @return {String} The input value translated into a string.
     */
    function toString(value) {
      if (value === null || value === undefined) {
        return '';
      }
      return (String(value)).trim();
    }

    function isInt(value) {
      return (/^[-+]?[0-9]+$/).test(value);
    }

    function isFloat(value) {
      return (/^[-+]?[0-9]+\.?[0-9]*$|^[0-9]*\.?[0-9]+$/).test(value);
    }

    Y.Object.each(schema, function(field_definition, name) {
      var value = toString(values[name]);

      if (field_definition.type === 'int') {
        if (!value) {
          if (field_definition['default'] === undefined) {
            errors[name] = 'This field is required.';
          }
        } else if (!isInt(value)) {
          // We don't use parseInt to validate integers because
          // it is far too lenient and the back-end code will generate
          // errors on some of the things it lets through.
          errors[name] = 'The value "' + value + '" is not an integer.';
        }
      } else if (field_definition.type === 'float') {
        if (!value) {
          if (field_definition['default'] === undefined) {
            errors[name] = 'This field is required.';
          }
        } else if (!isFloat(value)) {
          errors[name] = 'The value "' + value + '" is not a float.';
        }
      }

    });
    return errors;
  };


  /**
   * Utility object that encapsulates Y.Models and keeps their position
   * state within an SVG canvas.
   *
   * As a convenience attributes of the encapsulated model are exposed
   * directly as attributes.
   *
   * @class BoundingBox
   * @param {Module} module Service module.
   * @param {Model} model Service model.
   */

  // Internal base object
  var _box = {};

  // Internal descriptor generator.
  function positionProp(name) {
    return {
      get: function() {return this['_' + name];},
      set: function(value) {
        this['p' + name] = this['_' + name];
        this['_' + name] = parseFloat(value);
      }
    };
  }

  // Box Properties (and methods).
  Object.defineProperties(_box, {
    x: positionProp('x'),
    y: positionProp('y'),
    w: positionProp('w'),
    h: positionProp('h'),

    pos: {
      get: function() { return {x: this.x, y: this.y, w: this.w, h: this.h};},
      set: function(value) {
        Y.mix(this, value, true, ['x', 'y', 'w', 'h']);
      }
    },

    translateStr: {
      get: function() { return 'translate(' + this.x + ',' + this.y + ')';}
    },

    model: {
      get: function() {
        if (!this._modelName) { return null;}
        return this.topology.serviceForBox(this);
      },
      set: function(value) {
        if (Y.Lang.isValue(value)) {
          Y.mix(this, value.getAttrs(), true);
          this._modelName = value.name;
        }
      }
    },
    modelId: {
      get: function() { return this._modelName + '-' + this.id;}
    },
    node: {
      get: function() { return this.module.getServiceNode(this.id);}
    },
    topology: {
      get: function() { return this.module.get('component');}
    },
    xy: {
      get: function() { return [this.x, this.y];}
    },
    wh: {
      get: function() { return [this.w, this.h];}
    },

    /*
     * Extract margins from the supplied module.
     */
    margins: {
      get: function() {
        if (!this.module) {
          // Used in testing.
          return {top: 0, bottom: 0, left: 0, right: 0};
        }
        if (this.subordinate) {
          return this.module.subordinate_margins;
        }
        return this.module.service_margins;
      }
    },

    /*
     * Returns the center of the box with the origin being the upper-left
     * corner of the box.
     */
    relativeCenter: {
      get: function() {
        var margins = this.margins;
        return [
          (this.w / 2) + (margins &&
                          (margins.left * this.w / 2 -
                           margins.right * this.w / 2) || 0),
          (this.h / 2) - (margins &&
                          (margins.bottom * this.h / 2 -
                           margins.top * this.h / 2) || 0)
        ];}
    },

    /*
     * Returns the absolute center of the box on the canvas.
     */
    center: {
      get: function() {
        var c = this.relativeCenter;
        c[0] += this.x;
        c[1] += this.y;
        return c;
      }
    },

    /*
     * Returns true if a given point in the form [x, y] is within the box.
     * Transform could be extracted from the topology but the current
     * arguments ease testing.
     */
    containsPoint: {
      writable: true, // For test overrides.
      configurable: true,
      value: function(point, transform) {
        transform = transform || {
          scale: function() { return 1; },
          translate: function() { return [0, 0]; }
        };
        var tr = transform.translate(),
            s = transform.scale();

        return (point[0] >= this.x * s + tr[0] &&
                point[0] <= this.x * s + this.w * s + tr[0] &&
                point[1] >= this.y * s + tr[1] &&
                point[1] <= this.y * s + this.h * s + tr[1]);
      }
    },

    /*
     * Return the 50% points along each side as [x, y] pairs.
     */
    connectors: {
      get: function() {
        // Since the service nodes have a shadow that takes up a bit of
        // space on the sides and bottom of the actual node itself, add a bit
        // of a margin to the actual connecting points. The margin is specified
        // as a percentage of the width or height, as those are affected by the
        // scale. This is calculated by taking the distance of the shadow from
        // the edge of the actual shape and calculating it as a percentage of
        // the total height of the shape.
        var margins = this.margins;
        return {
          top: [
            this.x + (this.w / 2),
            this.y + (margins && (margins.top * this.h) || 0)
          ],
          right: [
            this.x + this.w - (margins && (margins.right * this.w) || 0),
            this.y + (this.h / 2) - (
                margins && (margins.bottom * this.h / 2 -
                            margins.top * this.h / 2) || 0)
          ],
          bottom: [
            this.x + (this.w / 2),
            this.y + this.h - (margins && (margins.bottom * this.h) || 0)
          ],
          left: [
            this.x + (margins && (margins.left * this.w) || 0),
            this.y + (this.h / 2) - (
                margins && (margins.bottom * this.h / 2 -
                            margins.top * this.h / 2) || 0)
          ]
        };
      }
    },

    _distance: {
      value: function(xy1, xy2) {
        return Math.sqrt(Math.pow(xy1[0] - xy2[0], 2) +
                         Math.pow(xy1[1] - xy2[1], 2));
      }
    },

    /*
     * Connectors are defined on four borders, find the one closes to
     * another BoundingBox
     */
    getNearestConnector: {
      value: function(box_or_xy) {
        var connectors = this.connectors,
            result = null,
            shortest_d = Infinity,
            source = box_or_xy;

        if (box_or_xy.xy !== undefined) {
          source = box_or_xy.xy;
        }
        Y.each(connectors, function(ep) {
          // Take the distance of each XY pair
          var d = this._distance(source, ep);
          if (!Y.Lang.isValue(result) || d < shortest_d) {
            shortest_d = d;
            result = ep;
          }
        }, this);
        return result;
      }
    },

    /*
     * Return [this.connector.XY, other.connector.XY] (in that order)
     * that as nearest to each other. This can be used to define start-end
     * points for routing.
     */
    getConnectorPair: {
      value: function(other_box) {
        var sc = this.connectors,
            oc = other_box.connectors,
            result = null,
            shortest_d = Infinity;

        Y.each(sc, function(ep1) {
          Y.each(oc, function(ep2) {
            // Take the distance of each XY pair
            var d = this._distance(ep1, ep2);
            if (!Y.Lang.isValue(result) || d < shortest_d) {
              shortest_d = d;
              result = [ep1, ep2];
            }
          }, other_box);
        }, this);
        return result;
      }
    }
  });

  /**
   * @method BoundingBox
   * @param {Module} module Typically service module.
   * @param {Model} model Model object.
   * @return {BoundingBox} A Box model.
   */
  function BoundingBox(module, model) {
    var b = Object.create(_box);
    b.module = module;
    b.model = model;

    return b;
  }

  views.BoundingBox = BoundingBox;

  /**
   * Covert an Array of services into BoundingBoxes. If
   * existing is supplied it should be a map of {id: box}
   * and will be updated in place by merging changed attribute
   * into the index.
   *
   * @method toBoundingBoxes
   * @param {ServiceModule} Module holding box canvas and context.
   * @param {ModelList} services Service modellist.
   * @param {Object} existing id:box mapping.
   * @return {Object} id:box mapping.
   */
  views.toBoundingBoxes = function(module, services, existing) {
    var result = existing || {};
    Y.each(result, function(val, key, obj) {
      if (!Y.Lang.isValue(services.getById(key))) {
        delete result[key];
      }
    });
    Y.each(services, function() {
      var id = this.get('id');
      if (result[id] !== undefined) {
        result[id].model = this;
      } else {
        result[id] = new BoundingBox(module, this);
      }
    });
    return result;
  };


  /**
   * Decorate a relation with some related/derived data.
   *
   * @method DecoratedRelation
   * @param {Object} relation The model object we will be based on.
   * @param {Object} source The service from which the relation originates.
   * @param {Object} target The service at which the relation terminates.
   * @return {Object} An object with attributes matching the result of
   *                  relation.getAttrs() plus "source", "target",
   *                  and other convenience data.
   */
  views.DecoratedRelation = function(relation, source, target) {
    var hasRelations = Y.Lang.isValue(relation.endpoints);
    var decorated = {
      source: source,
      target: target,
      compositeId: (
          source.modelId +
          (hasRelations ? ':' + relation.endpoints[0][1].name : '') +
          '-' + target.modelId +
          (hasRelations ? ':' + relation.endpoints[1][1].name : ''))
    };
    Y.mix(decorated, relation.getAttrs());
    decorated.isSubordinate = utils.isSubordinateRelation(decorated);
    return decorated;
  };

  utils.isSubordinateRelation = function(relation) {
    return relation.scope === 'container';
  };

  /* Given one of the many "real" states return a "UI" state.
   *
   * If a state ends in "-error" or is simply "error" then it is an error
   * state, if it is "started" then it is "running", otherwise it is "pending".
   */
  utils.simplifyState = function(unit, ignoreRelationErrors) {
    var state = unit.agent_state;
    if (state === 'started') {
      if (!ignoreRelationErrors &&
          unit.relation_errors &&
          Y.Object.size(unit.relation_errors)) {
        return 'error';
      }
      return 'running';
    }
    if ((/-?error$/).test(state)) {
      return 'error';
    }
    // "pending", "installed", and "stopped", plus anything unforeseen
    return 'pending';
  };

  utils.getEffectiveViewportSize = function(primary, minwidth, minheight) {
    // Attempt to get the viewport height minus the navbar at top and
    // control bar at the bottom.
    var containerHeight = Y.one('body').get(
        primary ? 'winHeight' : 'docHeight'),
        bottomNavbar = Y.one('.bottom-navbar'),
        navbar = Y.one('.navbar'),
        viewport = Y.one('#viewport'),
        result = {height: minheight || 0, width: minwidth || 0};
    if (containerHeight && navbar && viewport) {
      result.height = containerHeight -
          (bottomNavbar ? bottomNavbar.get('offsetHeight') : 0) -
          navbar.get('offsetHeight') - 1;

      result.width = Math.floor(parseFloat(
          viewport.getComputedStyle('width')));

      // Make sure we don't get sized any smaller than the minimum.
      result.height = Math.max(result.height, minheight || 0);
      result.width = Math.max(result.width, minwidth || 0);
    }
    return result;
  };

  /**
   * Determine if a service is the Juju GUI by inspecting the charm URL.
   *
   * @method isGuiCharmUrl
   * @param {String} charmUrl The service to inspect.
   * @return {Boolean} True if the charm URL is that of the Juju GUI.
   */
  utils.isGuiCharmUrl = function(charmUrl) {
    // Regular expression to match charm URLs.  Explanation generated by
    // http://rick.measham.id.au/paste/explain.pl and then touched up a bit to
    // fit in our maximum line width.  Note that the bit about an optional
    // newline matched by $ is a Perlism that JS does not share.
    //
    // NODE                     EXPLANATION
    // ------------------------------------------------------------------------
    //   ^                        the beginning of the string
    // ------------------------------------------------------------------------
    //   (?:                      group, but do not capture:
    // ------------------------------------------------------------------------
    //     [^:]+                    any character except: ':' (1 or more
    //                              times (matching the most amount
    //                              possible))
    // ------------------------------------------------------------------------
    //     :                        ':'
    // ------------------------------------------------------------------------
    //   )                        end of grouping
    // ------------------------------------------------------------------------
    //   (?:                      group, but do not capture (0 or more times
    //                            (matching the most amount possible)):
    // ------------------------------------------------------------------------
    //     [^\/]+                   any character except: '\/' (1 or more
    //                              times (matching the most amount
    //                              possible))
    // ------------------------------------------------------------------------
    //     \/                       '/'
    // ------------------------------------------------------------------------
    //   )*                       end of grouping
    // ------------------------------------------------------------------------
    //   juju-gui-                'juju-gui-'
    // ------------------------------------------------------------------------
    //   \d+                      digits (0-9) (1 or more times (matching
    //                            the most amount possible))
    // ------------------------------------------------------------------------
    //   $                        before an optional \n, and the end of the
    //                            string
    return (/^(?:[^:]+:)(?:[^\/]+\/)*juju-gui-\d+$/).test(charmUrl);
  };

  /**
   * Determine if a service is the Juju GUI by inspecting the charm URL.
   *
   * @method isGuiService
   * @param {Object} candidate The service to inspect.
   * @return {Boolean} True if the service is the Juju GUI.
   */
  utils.isGuiService = function(candidate) {
    // Some candidates have the charm URL as an attribute, others require a
    // "get".
    var charmUrl = candidate.charm || candidate.get('charm');
    return utils.isGuiCharmUrl(charmUrl);
  };

  Y.Handlebars.registerHelper('unitState', function(relation_errors,
      agent_state) {
        if ('started' === agent_state && relation_errors &&
            Y.Object.keys(relation_errors).length) {
          return 'relation-error';
        }
        return agent_state;
      });

  /*
   * Show the status and, if present, the status info of the given db instance.
   */
  Y.Handlebars.registerHelper('showStatus', function(instance) {
    // The "instance" argument is typically a unit or a machine model instance.
    var result = instance.agent_state;
    if (instance.agent_state_info) {
      result += ': ' + instance.agent_state_info;
    }
    return result;
  });

  Y.Handlebars.registerHelper('any', function() {
    var conditions = Y.Array(arguments, 0, true),
        options = conditions.pop();
    if (Y.Array.some(conditions, function(c) { return !!c; })) {
      return options.fn(this);
    } else {
      return options.inverse(this);
    }
  });

  Y.Handlebars.registerHelper('dateformat', function(date, format) {
    // See http://yuilibrary.com/yui/docs/datatype/ for formatting options.
    if (date) {
      return Y.Date.format(date, {format: format});
    }
    return '';
  });

  Y.Handlebars.registerHelper('iflat', function(iface_decl, options) {
    //console.log('helper', iface_decl, options, this);
    var result = [];
    var ret = '';
    Y.Object.each(iface_decl, function(value, name) {
      if (name) {
        result.push({
          name: name, 'interface': value['interface']
        });
      }
    });

    if (result && result.length > 0) {
      for (var x = 0, j = result.length; x < j; x += 1) {
        ret = ret + options.fn(result[x]);
      }
    } else {
      ret = 'None';
    }
    return ret;
  });

  Y.Handlebars.registerHelper('markdown', function(text) {
    if (!text || text === undefined) {return '';}
    return new Y.Handlebars.SafeString(
        Y.Markdown.toHTML(text));
  });

  /**
   * Generate a landscape badge using a partial internally.
   */
  Y.Handlebars.registerHelper('landscapeBadge', function(
      landscape, model, intent, hint) {
        if (!landscape) {
          return '';
        }
        var output = '';
        var badge = landscape.getLandscapeBadge(model, intent, hint);

        if (badge) {
          output += Y.Handlebars.render(
              '{{>landscape-badges}}', {badge: badge});
        }
        return new Y.Handlebars.SafeString(output);
      });

  /*
   * Build a list of relation types given a list of endpoints.
   */
  Y.Handlebars.registerHelper('relationslist', function(endpoints, options) {
    var out = '';
    endpoints.forEach(function(ep) {
      out += options.fn({start: ep[0], end: ep[1]});
    });
    return out;
  });


  Y.Handlebars.registerHelper('arrayObject', function(object, options) {
    var res = '';
    if (object) {
      Y.Array.each(Y.Object.keys(object), function(key) {
        res = res + options.fn({
          key: key,
          value: object[key]
        });
      });
    }
    return res;
  });

  /**
    * pluralize
    *
    * pluralize is a handlebar helper that handles pluralization of strings.
    * The requirement for pluralization is based on the passed in object,
    * which can be number, array, or object. If a number, it is directly
    * checked to see if pluralization is needed. Arrays and objects are
    * checked for length or size attributes, which are then used.
    *
    * By default, if pluralization is needed, an 's' is appended to the
    * string. This handles the regular case (e.g. cat => cats). Irregular
    * cases are handled by passing in a plural form (e.g. octopus => ocotopi).
    */
  Y.Handlebars.registerHelper('pluralize',
      function(word, object, plural_word, options) {
        var plural = false;
        if (typeof(object) === 'number') {
          plural = (object !== 1);
        }
        if (object) {
          if (object.size) {
            plural = (object.size() !== 1);
          } else if (object.length) {
            plural = (object.length !== 1);
          }
        }
        if (plural) {
          if (typeof(plural_word) === 'string') {
            return plural_word;
          } else {
            return word + 's';
          }
        } else {
          return word;
        }
      });

  /**
   * Truncate helper to keep text sizes to a specified limit.
   *
   * {{truncate field 100}}
   *
   */
  Y.Handlebars.registerHelper('truncate', function(string, length) {
    if (string && string.length > length) {
      return Y.Lang.trimRight(string.substring(0, length)) + '...';
    }
    else {
      return string;
    }
  });

}, '0.1.0', {
  requires: ['base-build',
             'handlebars',
             'node',
             'view',
             'panel',
             'json-stringify',
             'gallery-markdown',
             'datatype-date-format']
});