API Docs for:
Show:

File: src/assets/js/models.js

/**
 * @module backbone-survey
 */
var BackboneSurvey = BackboneSurvey || {};

(function() {
  /**
   * @class Section
   * @extends {Backbone.Model}
   */
  var Section = BackboneSurvey.Section = Backbone.Model.extend({
    constructor: function() {
      Backbone.Model.apply(this, arguments);
    }

  , defaults: {
      page: 0
    , routeDependencies: []
    , type: BackboneSurvey.QuestionType.NONE
    , contents: {}
    , fields: [] // multi fields
    , options: [] // select options
    , singleOptions: [] // option keys that disable the other keys
    , defaultAnswers: []
    , rules: []
    , answers: []
    , subAnswer: {}
    }

  , set: function(key, val, options) {
      if (key === null) return this;

      if (typeof key === 'object') {
        attrs = key;
        options = val;
      } else {
        (attrs = {})[key] = val;
      }

      // :id must be a string
      if (typeof(attrs.id) !== "undefined") {
        attrs.id = attrs.id.toString();
      }
      // :page must be a number
      if (typeof(attrs.page) !== "undefined") {
        attrs.page = _.isNumber(attrs.page) ? parseInt(attrs.page, 10) : 0;
      }

      // Convert :contents must be an object
      if (typeof(attrs.contents) !== "undefined") {
          if (typeof(attrs.contents) !== "object") {
            attrs.contents = {};
          }
      }

      // Convert :options string to object
      if (typeof(attrs.options) !== "undefined") {
        var opts = attrs.options || [];
        attrs.options = [];
        _.each(opts, function(v) {
          if (typeof(v) !== "object") {
            v = { value: v, label: v };
          }
          v.label = v.label || v.value;
          v.value = v.value.toString();
          v.label = v.label.toString();
          if (typeof(v.route) !== "undefined") {
            v.route = v.route.toString();
          }
          attrs.options.push(v);
        });
      }

      Backbone.Model.prototype.set.call(this, attrs, options);
    }

  , validate: function(attr, options) {
      var errors = [];
      var answers = this.answers(attr);
      var me = this;
      var fields = this.get("fields") || [];
      if (this.get("type") === BackboneSurvey.QuestionType.MULTI) {
        _.each(fields, function(field, i) {
          var rules = field.rules || [];
          var err = [];
          _.each(rules, function(rule) {
            if (err.length > 0) return;
            var result = rule.validate([answers[i]], me.attributes);
            if (!result.valid) errors.push(result.message);
          });
          _.union(errors, err);
        });
      } else if (this.get("type").answerType() === BackboneSurvey.AnswerType.MATRIX) {
        _.each(fields, function(field, i) {
          var rules = field.rules || [];
          var err = [];
          _.each(rules, function(rule) {
            if (err.length > 0) return;
            var result = rule.validate(answers[i], me.attributes);
            if (!result.valid) errors.push(result.message);
          });
          _.union(errors, err);
        });
      } else {
        _.each(this.attributes.rules, function(rule) {
          if (errors.length > 0) return;
          var result = rule.validate(answers, me.attributes);
          if (!result.valid) errors.push(result.message);
        });
      }
      if (errors.length > 0) return errors;
    }

    /**
     * Pick the answers according to the section type.
     *
     * @method answers
     * @param {Object} [attr] If it's undefined, Use this.attributes.
     * @return {Array}
     */
  , answers: function(attr) {
      attr = attr || this.attributes;
      return attr.answers;
    }

    /**
     * Clear all answer attributes.
     *
     * @method clearAnswers
     */
  , clearAnswers: function() {
      this.set({ answers: [] }, { silent: true });
    }

    /**
     * Returns the route keys.
     *
     * @method answeredRoutes
     * @return {Array}
     */
  , answeredRoutes: function() {
      var vs = [];
      var opts = this.get("options");
      switch (this.get("type")) {
        case BackboneSurvey.QuestionType.RADIO:
        case BackboneSurvey.QuestionType.CHECKBOX:
          var ans = this.answers();
          _.each(ans, function(a) {
            _.each(_.where(opts, { value: a }), function(o) {
              if (typeof(o.route) === "string" && !_.isEmpty(o.route)) {
                vs.push(o.route);
              }
            });
          });
          break;
        default:
          break;
      }
      return _.uniq(vs);
    }
  });

  /**
   * @class Sections
   * @extends {Backbone.Collection}
   */
  var Sections = BackboneSurvey.Sections = Backbone.Collection.extend({
    model: Section

    /**
     * @method fistPage
     * @return {Number}
     */
  , firstPage: function() {
      return this.reduce(function(memo, model) {
        var p = model.get("page");
        return (memo === 0 || memo > p) ? p : memo;
      }, 0);
    }

    /**
     * @method lastPage
     * @return {Number}
     */
  , lastPage: function() {
      return this.reduce(function(memo, model) {
        var p = model.get("page");
        return (memo === 0 || memo < p) ? p : memo;
      }, 0);
    }

    /**
     * @method prevPage
     * @param {Number} currentPage
     * @return {Number}
     */
  , prevPage: function(currentPage) {
      var ps = [];
      this.each(function(model) {
        var p = model.get("page");
        if (p < currentPage) {
          ps.push(p);
        }
      });
      ps = _.sortBy(ps, function(n) { return -1 * n; });
      return (ps.length > 0) ? ps[0] :
          (currentPage === 0) ? this.firstPage() : currentPage;
    }

    /**
     * @method nextPage
     * @param {Number} currentPage
     * @return {Number}
     */
  , nextPage: function(currentPage) {
      var ps = [];
      this.each(function(model) {
        var p = model.get("page");
        if (p > currentPage) {
          ps.push(p);
        }
      });
      ps = _.sortBy(ps, function(n) { return n; });
      var last = this.lastPage();
      return (ps.length > 0) ? ps[0] :
          (currentPage === 0 || last < currentPage) ? last : currentPage;
    }
  });

  /**
   * @class Survey
   * @extends {Backbone.Model}
   */
  var Survey = BackboneSurvey.Survey = Backbone.Model.extend({
    constructor: function() {
      /**
       * @property sections
       * @type {Sections}
       */
      this.sections = new Sections();
      Backbone.Model.apply(this, arguments);
    }

  , defaults: {
      title: ""
    , page: 0
    , answeredSectionIds : []
    , options : {}
    }

    /**
     * Parser method for using `{ parse: true }` option.
     *
<pre><code>var survey = new BackboneSurvey.Survey({
    survey: {
      // Survey.attributes ....
    }
  , sections: [
      // An array of Section.attributes ....
    ]
}, { parse: true });
</code></pre>
     *
     * @method parse
     * @param {Object} resp
     * @param {Object} options
     * @return {Object} attributes
     */
  , parse: function(resp, options) {
      this.sections.reset(_.filter(resp.sections || [], function(s) {
          // Remove invalid id section
          return s.id.toString().match(/^[-_0-9a-zA-Z]+$/); }));
      return resp.survey || {};
    }

    /**
     * Initialize all the answers and the status.
     *
     * @method initAnswers
     * @param  {Number} p A current page
     * @param  {Object} option Survey#set option
     */
  , initAnswers: function(p, option) {
      p = p || 0;
      option = option || {};
      var sectionIds = this.get("answeredSectionIds");
      this.sections.each(function(section) {
        if (section.get("page") > p) {
          _.without(sectionIds, section.id);
          section.clearAnswers();
          section.set({
            answers: section.get("defaultAnswers")
          }, { silent: true });
        }
      });
      this.set({
        page: p
      , answeredSectionIds: sectionIds
      }, option);
    }

    /**
     * Move to a first page, and reset all answers.
     *
     * @method startPage
     */
  , startPage: function() {
      this.initAnswers(0, { silent: false });
      var p = this.sections.firstPage();
      this.set({ page: p });
    }

    /**
     * Move to the previous page.
     *
     * @method prevPage
     */
  , prevPage: function() {
      // Select the previous page number
      var cp = this.get("page");
      var pages = _.sortBy(this.availablePages(), function(n) { return n; });
      pages.reverse();
      var p = cp;
      for (var i = 0; i < pages.length; i++) {
        if (pages[i] < p) {
          p = pages[i];
          break;
        }
      }
      if (p != this.get("page")) {
        var resetOn =this.get("options").resetOn || [];
        if (_.contains(resetOn, "prev")) {
          this.initAnswers(p);
        } else {
          this.set({ page: p });
        }
      }
    }

    /**
     * Move to the next page.
     *
     * @method nextPage
     */
  , nextPage: function() {
      var me = this;
      // Add answered sectionIds
      var sectionIds = _.pluck(this.currentSections(), "id");
      _.each(sectionIds, function(sectionId) {
        me.addAnsweredSectionId(sectionId);
      });
      // Fire "completed" if all set
      if (this.isLastPage()) {
        this.trigger("completed");
        return;
      }
      // Select the next page number
      var cp = this.get("page");
      var pages = _.sortBy(this.availablePages(), function(n) { return n; });
      var p = this.sections.firstPage();
      for (var i = 0; i < pages.length; i++) {
        if (pages[i] > cp) {
          p = pages[i];
          break;
        }
      }
      if (p > cp) {
        var resetOn =this.get("options").resetOn || [];
        if (_.contains(resetOn, "next")) {
          this.initAnswers(cp, { silent: true});
        }
        this.set({ page: p });
      } else {
        this.trigger("completed");
      }
    }

    /**
     * @method isFirstPage
     * @return {Boolean}
     */
  , isFirstPage: function() {
      return (this.get("page") === this.sections.firstPage());
    }

    /**
     * @method isLastPage
     * @return {Boolean}
     */
  , isLastPage: function() {
      return (this.get("page") === this.sections.lastPage());
    }

    /**
     * Returns an array of Section in a current page.
     *
     * @method currentSections
     * @return {Array}
     */
  , currentSections: function() {
      return this.sections.where({ page: this.get("page") });
    }

    /**
     * Returns all answers.
     *
     * @method answers
     * @return {Array}
     */
  , answers: function() {
      var me = this;
      var ans = [];
      var sectionIds = this.get("answeredSectionIds") || [];
      _.each(sectionIds, function(sectionId) {
        var section = me.sections.get(sectionId);
        if (!section) return;
        if (section.get("type") === BackboneSurvey.QuestionType.NONE) return;
        ans.push({
          id: section.id
        , answers: section.get("answers")
        , subAnswer: section.get("subAnswer")
        });
      });
      return ans;
    }

    /**
     * Add an answered section ID.
     *
     * @method addAnsweredSectionId
     * @param {String} Section.id
     */
  , addAnsweredSectionId: function(id) {
      var section = this.sections.get(id);
      if (!section) return;
      var p = section.get("page");
      var ids = [];
      var answeredIds = this.get("answeredSectionIds") || [];
      // Remove greater sectionIds
      var me = this;
      _.each(answeredIds, function(answeredId) {
        var s = me.sections.get(answeredId);
        if (!s) return;
        if (s.get("page") <= p) ids.push(answeredId);
      });
      // Add new sectionIds
      ids.push(id);
      this.set("answeredSectionIds", _.uniq(ids), { silent: true });
    }

    /**
     * Returns the route keys in the sections.
     *
     * @method answeredRoutes
     * @return {Array}
     */
  , answeredRoutes: function() {
      var vs = [];
      var ids = this.get("answeredSectionIds") || [];
      var me = this;
      _.each(ids, function(id) {
        var section = me.sections.get(id);
        if (section) vs.push({
          id: id
        , routes: section.answeredRoutes()
        });
      });
      return vs;
    }

    /**
     * Returns available page numbers.
     *
     * @method availablePages
     * @return {Array}
     */
  , availablePages: function() {
      var routes = this.answeredRoutes();
      var cp = this.get("page");
      var p = this.sections.firstPage();
      var pages = [p];

      if (p > cp) return pages;

      do {
        p = this.sections.nextPage(p);
        var sections = this.sections.where({ page: p });
        var num = sections.length;
        for (var i = 0; i < sections.length; i++) {
          var resolver =
            sections[i].get("resolver") ||
            new BackboneSurvey.SectionResolver(sections[i].get("routeDependencies") || []);
          if (!resolver.resolve(routes)) num--;
        }
        if (num > 0) {
          pages.push(p);
          if (p > cp) break;
        }
      } while (p < this.sections.lastPage());

      return _.sortBy(pages, function(n) { return n; });
    }

    /**
     * Serialize the survey status.
     *
     * @method serializeStatus
     * @return {String}
     */
  , serializeStatus: function() {
      var data = {};
      data.page = this.get("page") || 0;
      data.answeredSectionIds = this.get("answeredSectionIds") || [];
      data.answers = [];
      this.sections.each(function(section) {
        data.answers.push({
          id: section.id
        , answers: section.get("answers")
        , subAnswer: section.get("subAnswer")
        });
      });
      return JSON.stringify(data);
    }

    /**
     * Unserialize a survey status.
     *
     * @method unserializeStatus
     * @param {String} serialized A serialized string Survey#serializeStatus returns
     * @param {Object} option Survey#set option
     * @return {Boolean}
     */
  , unserializeStatus: function(serialized, option) {
      var i;

      option = option || {};
      this.initAnswers(0, { silent: true });

      var data;
      try {
        data = JSON.parse(serialized);
      } catch (e) {
        console.log(e);
        return false;
      }
      if (typeof data !== "object" || !data) return false;

      data.page = data.page || 0;
      if (this.sections.lastPage() < data.page) return false;

      data.answeredSectionIds = data.answeredSectionIds || [];
      for (i = 0; i < data.answeredSectionIds.length; i++) {
        if (!this.sections.get(data.answeredSectionIds[i])) return false;
      }

      data.answers = data.answers || [];
      for (i = 0; i < data.answers.length; i++) {
        var section = this.sections.get(data.answers[i].id);
        if (!section) return false;
        var attr = {
          answers: data.answers[i].answers
        , subAnswer: data.answers[i].subAnswer
        };
        if (_.contains(data.answeredSectionIds, section.id)) {
          var errors = section.validate(attr);
          if (errors) {
            this.initAnswers(0, { silent: true });
            return false;
          }
        }
        section.set(attr, { silent: true });
      }

      this.set({
        page: data.page
      , answeredSectionIds: data.answeredSectionIds
      }, option);

      return true;
    }
  });
})();