Source: enyo-x/source/views/assignment_box.js

/*jshint indent:2, curly:true, eqeqeq:true, immed:true, latedef:true,
newcap:true, noarg:true, regexp:true, undef:true, trailing:true*/
/*global enyo:true, XM:true, XT:true, _:true, Backbone:true */


(function () {

  /**
   @name XV.AssignmentBox
   @class An assignment box is a groupbox that manages the assignment of a set of
   available options to an object. For example, setting up the privileges that
   are associated with a role.<br />
   Derived from <a href="http://enyojs.com/api/#enyo.Scroller">enyo.Scroller</a>.
   @extends enyo.Scroller
   */
  enyo.kind(/** @lends XV.AssignmentBox# */{
    name: "XV.AssignmentBox",
    kind: "Scroller",
    fit: true,
    horizontal: "hidden",
    handlers: {
      onValueChange: "checkboxChange"
    },

    /**
     * Published fields
     * @type {Object}
     *
     * @property {XM.Collection} assignedCollection
     * The collection that backs this box. The model of the collection is the
     *    assignment (link) model.
     *
     * @property {Array} assignedIds
     * The ids of the assignable models. Cached for performance and recached whenever
     *    the assignedCollection is changed.
     *
     * @property {String} cacheName
     * The name of the cached collection if the collection is stored in the XM cache.
     *
     * @property {String} nameAttribute
     * The attribute of the label field to appear in the checkbox widget.
     *
     * @property {Array} restrictedValues
     * An array of the values that we want to see in this assignment box. Values not in
     *    this array will be suppressed. If this is null (as by default) then no suppression
     *    will occur.
     *
     * @property {Array} segmentedCollections
     * An array of collections, each of whom are a subset of totalCollection.
     *
     * @property {Array} segments
     * We allow the assignable checkboxes to be grouped by segment.
     *    If this array is length one then there is no segmentation, and the one value
     *    of the array becomes the header of the box. If the array is length zero then
     *    we dynamically generate the segments based on the incoming data. It must be
     *    overridden by the implementation to be something other than null, though.
     *
     * @property {String} modelName
     * The collection of all possible assignable models.
     *
     * @property {XM.Collection} totalCollection
     * The collection of all possible assignable models.
     *
     * @property {String} totalCollectionName
     * The name in the the XM namespace of the collection. Used to making new
     * segmentedCollections.
     *
     * @property {Boolean} translateLabels
     * We want to translate the labels if they are hardcoded into our system (such as privileges)
     *    but not if they are user-defined.
     *
     * @property {String} type
     * Camelized name of assignable model. Used for drilling down from the assignment
     *    (link) model to the assignable model. It is important that this be the attribute
     *    in the assignment model that contains the assignable model.
     *
     * @property {Object} query
     * A query to filter the collection by
     */
    published: {
      assignedCollection: null,
      assignedIds: null,
      attr: null,
      cacheName: "",
      nameAttribute: "name",
      restrictedValues: null,
      segmentedCollections: null,
      segments: null,
      modelName: null,
      totalCollection: null,
      totalCollectionName: "",
      translateLabels: true,
      type: "",
      query: null,
      disabled: false
    },

    components: [
      {kind: "Repeater", name: "segmentRepeater", fit: true, onSetupItem: "setupSegment", segmentIndex: 0, components: [
        {kind: "onyx.GroupboxHeader", name: "segmentHeader", content: ""},
        {kind: "Repeater", name: "checkboxRepeater", fit: true, onSetupItem: "setupCheckbox", components: [
          {kind: "XV.CheckboxWidget", name: "checkbox" }
        ]}
      ]}
    ],
    /**
     * Applies special formatting to a checkbox after it has been clicked, if applicable.
     * This method should be overriden by the subkind if applicable.
     */
    applyPostCheckFormatting: function (checkbox, model) {
    },
    /**
     * Handles bubbled checkbox event changes and prevents them from bubbling further.
     */
    checkboxChange: function (inSender, inEvent) {
      var that = this,
        checkbox = inEvent.originator,
        originatorName = checkbox.name,
        value = inEvent.value,
        checkedModel,
        newModel,
        nameAttr = this.getNameAttribute();

      //
      // The record type in totalCollection is XM.Privilege and the
      // record type in assignedCollection is XM.UserAccountPrivilegeAssignment,
      // so we have to navigate this.
      //
      if (value) {
        // filter returns an array and we want a model: that's why I [0]
        // assumption: no duplicate originator names
        checkedModel = _.filter(this.getTotalCollection().models, function (model) {
          return model.getValue(nameAttr) === originatorName;
        })[0];
        newModel = this.getAssignmentModel(checkedModel);

        this.getAssignedCollection().add(newModel);

      } else {
        checkedModel = _.filter(this.getAssignedCollection().models, function (model) {
          // we don't want to redestroy a destroyed model, because there's probably a living one
          // behind it that actually is the one to destroy
          return !(model.getStatus() & XM.Model.DESTROYED) &&
            model.get(that.getType()) &&
            model.get(that.getType()).getValue(nameAttr) === originatorName;
        })[0];
        if (!checkedModel) {
          XT.log("No model to destroy. This is probably a bug.");
        }
        checkedModel.destroy();
      }

      // force a refresh of the mapIds cache
      this.mapIds();
      this.tryToRender();
      this.applyPostCheckFormatting(checkbox, checkedModel);
      return true;
    },

    /**
     * Populates totalCollection field (either from the cache or through a fetch)
     * and calls for the totalCollection to be segmentized.
     */
    create: function () {
      var Coll;

      this.inherited(arguments);

      //
      // Get the collection from the cache if it exists
      //
      if (this.getCacheName() && XT.getObjectByName(this.getCacheName())) {
        this.setTotalCollection(XT.getObjectByName(this.getCacheName()));
        this.segmentizeTotalCollection();
      } else {
        Coll = Backbone.Relational.store.getObjectByName(this.getTotalCollectionName());
        this.setTotalCollection(new Coll());
        this.fetch();
      }
    },
    disabledChanged: function () {
      this.tryToRender();
    },
    /**
     * Creates a new assignment model to add to the assignedCollection.
     * We rely on the subkinds to override this function, as implemetations are varied.
     */
    getAssignmentModel: function (value) {
      var attrs = {},
        Klass = XT.getObjectByName(this.getModelName());
      attrs[this.getType()] = value;
      return new Klass(attrs, {isNew: true});
    },

    /**
      Refresh the `totalCollection`.
    */
    fetch: function () {
      var that = this,
        options = {
          query: this.getQuery(),
          success: function () {
            that.segmentizeTotalCollection();
          }
        };
      this.getTotalCollection().fetch(options);
    },

    /**
     * Updates the cache of assignedIds based on the assignedCollection.
     */
    mapIds: function () {
      var that = this;

      this.setAssignedIds(this.getAssignedCollection().map(function (model) {
        if (!model.get(that.getType())) {
          // I have seen cases where assignments point to models that don't exist.
          // So ignore these. XXX should I return -9999 instead of null? I'd hate
          // for there to be nulls in there that might somehow get "matched up on"
          // by null checkboxes somehow.
          return null;
        }

        if (model.getStatus() & XM.Model.DESTROYED) {
          // don't add destroyed models to cache
          return null;
        }
        return model.get(that.getType()).id;
      }));
    },
    /**
     * Called by the segmentRepeater. Sets the header of the segment and awakens
     * the checkboxRepeater.
     */
    setupSegment: function (inSender, inEvent) {
      var index = inEvent.index,
        row = inEvent.item,
        header = row.$.segmentHeader,
        segment,
        camelize = function (str) {
          var ret = str.replace((/([\s|\-|\_|\n])([^\s|\-|\_|\n]?)/g),
            function (str, separater, character) {
              return character ? character.toUpperCase() : '';
            });
          var first = ret.charAt(0),
            lower = first.toLowerCase();
          return first !== lower ? lower + ret.slice(1) : ret;
        };

      if (inEvent.originator.name !== 'segmentRepeater') {
        // not sure why the checkbox repeater is bringing us here, but ignore
        // the return true at the bottom of setupCheckbox should have fixed this
        return;
      }

      inSender.segmentIndex = index;

      if (header && (this.getSegments().length < 2 || this.getSegmentedCollections()[index].length === 0)) {
        //
        // Suppress the header if there's just one segment, or if the segment is empty of boxes to assign
        //
        header.parent.removeChild(header);

      } else if (header) {
        segment = this.getSegments()[index] || "setup";
        header.setContent(("_" + camelize(segment)).loc());
      }

      row.$.checkboxRepeater.setCount(this.getSegmentedCollections()[index].length);

      return true;
    },
    /**
     * Sets up a checkbox: label, value, checkiness.
     */
    setupCheckbox: function (inSender, inEvent) {
      var index = inEvent.item.indexInContainer(), //inEvent.index,
        parentSegmentRepeater = inSender.parent.parent,
        segmentIndex = parentSegmentRepeater.segmentIndex,
        data = this.getSegmentedCollections()[segmentIndex].at(index),
        row = inEvent.item.$.checkbox,
        name = data.getValue(this.getNameAttribute()),
        title = name.camelize(),
        label = this.getTranslateLabels() ? ("_" + title).loc() : name,
        disabled = this.getDisabled();

      row.setLabel(label);
      row.setName(name);
      row.setDisabled(disabled);
      if (_.indexOf(this.getAssignedIds(), data.id) >= 0) {
        row.setValue(true, { silent: true });
      }
      return true;
    },
    /**
     * This is the method that the parent container will typically call to
     * create this box. All logic and rendering flows from here.
     */
    setValue: function (value) {
      this.setAssignedCollection(value);
      this.mapIds();
      /*
       * We wait to do this until we have the granted collection
       */
      this.tryToRender();
    },

    segmentizeTotalCollection: function () {
      var i, group, model, name, lowercaseSegments, pertinentSegment, Coll,
        nameAttr = this.getNameAttribute(),
        comparator = function (model) {
          return model.getValue(nameAttr);
        };
      this.setSegmentedCollections([]);

      if (this.getSegments().length === 0) {
        // generate the segments dynamically based on the incoming models' modules
        for (i = 0; i < this.getTotalCollection().length; i++) {
          model = this.getTotalCollection().models[i];
          group = model.get("module");
          if (this.getModelSegment) {
            group = this.getModelSegment(model.getValue(nameAttr), group);
          }
          if (this.getSegments().indexOf(group) < 0) {
            this.getSegments().push(group);
          }
          // alphebetize
          this.getSegments().sort();
        }
      }
      lowercaseSegments = _.map(this.getSegments(), function (segment) {
        return segment.toLowerCase();
      });


      for (i = 0; i < this.getSegments().length; i++) {
        Coll = Backbone.Relational.store.getObjectByName(this.getTotalCollectionName());
        this.getSegmentedCollections()[i] = new Coll();
        this.getSegmentedCollections()[i].comparator = comparator;
      }
      for (i = 0; i < this.getTotalCollection().length; i++) {
        model = this.getTotalCollection().models[i];
        name = model.getValue(nameAttr);
        group = model.get("module") || "";
        if (this.getModelSegment) {
          group = this.getModelSegment(name, group);
        }
        if (!this.getRestrictedValues() || this.getRestrictedValues().indexOf(name) >= 0) {
          pertinentSegment = Math.max(0, lowercaseSegments.indexOf(group.toLowerCase()));
          this.getSegmentedCollections()[pertinentSegment].add(model);
        }
      }
      this.tryToRender();
    },

    /**
     * Render this AssignmentBox by firing off the segment repeater.
     * We can only render if we know *both* what the options and and also
     * what's already assigned. These both can happen asynchronously,
     * which is why we have to check and only execute when both are done.
     */
    tryToRender: function () {
      var checkbox;
      if (this.getAssignedCollection() &&
          this.getSegmentedCollections() !== null &&
          this.getSegmentedCollections().length) {
        this.$.segmentRepeater.setCount(this.getSegments().length);
      }
    },
    /**
     * Apply a half-ghosty underchecking style to the checkbox if we want to. Used here to
     * denote that a privilege is grated via a role but not directly to a user.
     */
    undercheckCheckbox: function (checkbox, isUnderchecked) {
      if (!checkbox.$.input) {
        // harmless bug: do nothing
        // TODO: check this out
      } else if (isUnderchecked && !checkbox.$.input.checked) {
        checkbox.$.input.addClass("xv-half-check");
      } else {
        checkbox.$.input.removeClass("xv-half-check");
      }
    }
  });
}());