Gapminder/vizabi

View on GitHub
src/base/model.js

Summary

Maintainability
F
3 days
Test Coverage
import * as utils from "base/utils";
import EventSource, { DefaultEvent, ChangeEvent } from "base/events";
import Intervals from "base/intervals";

const ModelLeaf = EventSource.extend({

  _name: "",
  _parent: null,
  _persistent: true,

  init(name, value, parent, binds, persistent) {

    // getter and setter for the value
    Object.defineProperty(this, "value", {
      get: this.get,
      set: this.set
    });
    Object.defineProperty(this, "persistent", {
      get() { return this._persistent; }
    });

    this._super();

    this._name = name;
    this._parent = parent;
    this._root = parent._root;
    this.set(value, false, persistent);
    this.on(binds); // after super so there is an .events object
  },

  // if they want a persistent value and the current value is not persistent, return the last persistent value
  get(persistent) {
    return (persistent && !this._persistent) ? this._persistentVal : this._val;
  },

  set(val, force, persistent) {
    if (this.isSetAllowed(val, force)) {
      // persistent defaults to true
      persistent = (typeof persistent !== "undefined") ? persistent : true;

      // set leaf properties
      if (persistent) this._persistentVal = val; // set persistent value if change is persistent.
      this._previousVal = utils.deepClone(this._val);
      this._val = val;
      this._persistent = persistent;

      // trigger change event
      this.trigger(new ChangeEvent(this), this._name);

      return true;
    }
    return false;
  },

  isSetAllowed(val, force) {
    return force || (this._val !== val && JSON.stringify(this._val) !== JSON.stringify(val));
  },

  // duplicate from Model. Should be in a shared parent class.
  setTreeFreezer(freezerStatus) {
    if (freezerStatus) {
      this.freeze(["hook_change"]);
    } else {
      this.unfreeze();
    }
  }

});

const Model = EventSource.extend({

  getClassDefaults: () => ({}),

  /**
   * A leaf model which has an object as value.
   * Needed when parsing plain JS objects. Enables distinction between models and leafs with object values.
   **/
  objectLeafs: [],

  /**
   * Initializes the model.
   * @param {Object} values The initial values of this model
   * @param {Object} parent reference to parent
   * @param {Object} bind Initial events to bind
   * @param {Boolean} freeze block events from being dispatched
   */
  init(name, values, parent, bind) {
    this._type = this._type || "model";
    this._id = this._id || utils.uniqueId("m");
    this._data = {};
    //holds attributes of this model
    this._parent = parent;
    this._root = parent ? parent._root : this;
    this._name = name;
    this._inError = false;
    this._ready = false;
    this._readyOnce = false;
    //has this model ever been ready?
    this._loadedOnce = false;
    //array of processes that are loading
    this._intervals = getIntervals(this);

    //will the model be hooked to data?
    this._space = {};

    this._dataId = false;
    this._limits = {};
    //stores limit values
    this._super();

    // initial values
    // add defaults to initialValues
    const initialValues = utils.deepExtend({}, this.getClassDefaults(), values);
    this.set(initialValues);

    // bind initial events
    // bind after setting, so no events are fired by setting initial values
    if (bind) {
      this.on(bind);
    }
  },

  /* ==========================
   * Getters and Setters
   * ==========================
   */

  /**
   * Gets an attribute from this model or all fields.
   * @param attribute Optional attribute
   * @returns attr value or all values if attr is undefined
   */
  get(attribute) {
    if (attribute) {
      const model = this._data[attribute];

      return Model.isModel(model) ?
        model :
        model.value;
    }

    return this._data;
  },

  /**
   * Sets an attribute or multiple for this model (inspired by Backbone)
   * @param attr property name
   * @param val property value (object or value)
   * @param {Boolean} force force setting of property to value and triggers set event
   * @param {Boolean} persistent true if the change is a persistent change
   * @param {Function} caller — the outer function that has called this setter might want to identify itself to avoid recursive call
   * @returns defer defer that will be resolved when set is done
   */
  set(attr, val, force, persistent, caller) {
    const setting = this._setting;
    let attrs;
    let freezeCall = false; // boolean, indicates if this .set()-call froze the modelTree

    //expect object as default
    if (!utils.isPlainObject(attr)) {
      (attrs = {})[attr] = val;
    } else {
      // move all arguments one place
      attrs = attr;
      persistent = force;
      force = val;
    }

    //do nothing if setting an empty object
    if (Object.keys(attrs).length === 0) return;

    //we are currently setting the model
    this._setting = true;

    // Freeze the whole model tree if not frozen yet, so no events are fired while setting
    if (!this._freeze) {
      freezeCall = true;
      this.setTreeFreezer(true);
    }

    // init/set all given values
    const changes = [];
    for (const attribute in attrs) {
      val = attrs[attribute];

      const bothModel = utils.isPlainObject(val) && this._data[attribute] instanceof Model;
      const bothModelLeaf = (!utils.isPlainObject(val) || this.isObjectLeaf(attribute)) && this._data[attribute] instanceof ModelLeaf;

      if (this._data[attribute] && (bothModel || bothModelLeaf)) {
        // data type does not change (model or leaf and can be set through set-function)
        const prevValue = this._data[attribute].value;
        const setSuccess = this._data[attribute].set(val, force, persistent);
        if (bothModelLeaf && setSuccess) {
          changes.push(attribute);
        }

        //if a hook has "setAttribute" funation, then call it from here, except when the Caller is that particular function
        //avoiding double calling prevents side effects
        //TODO: this was introduced to handle back button, but it looks like a hack really
        const fn = "set" + utils.capitalize(attribute[0]) + attribute.slice(1);
        if (this.isHook() && utils.isFunction(this[fn]) && caller !== this[fn] && val !== prevValue) {
          this[fn](attribute === "which" ? { concept: val } : val);
        }

      } else {
        // data type has changed or is new, so initializing the model/leaf
        this._data[attribute] = initSubmodel(attribute, val, this, persistent);
        bindSetterGetter(this, attribute);
      }
    }

    if (!setting) {
      this.checkDataChanges(changes);
      if (this.validate) {
        this.validate();
      }
    }

    if (!setting || force) {
      this._setting = false;
      if (freezeCall && (!this.isHook() || !this.isLoading())) {
        this.setTreeFreezer(false);
      }
      if (!this.isHook() && !this.isLoading()) {
        this.setReady();
      }
    }

    // if this set()-call was the one freezing the tree, now the tree can be unfrozen (i.e. all setting is done)

  },

  // standard model doesn't do anything with data
  // overloaded by hook/entities
  checkDataChanges() { },

  setTreeFreezer(freezerStatus) {
    // first traverse down
    // this ensures deepest events are triggered first
    utils.forEach(this._data, submodel => {
      submodel.setTreeFreezer(freezerStatus);
    });

    // then freeze/unfreeze
    if (freezerStatus) {
      this.freeze(["hook_change"]);
    } else {
      this.unfreeze();
    }
  },

  /**
   * Gets the type of this model
   * @returns {String} type of the model
   */
  getType() {
    return this._type;
  },

  /**
   * Gets all submodels of the current model
   * @param {Object} object [object=false] Should it return an object?
   * @param {Function} validationFunction Validation function
   * @returns {Array} submodels
   */
  getSubmodels(object = false, validationFunction = () => true) {
    const submodels = (object) ? {} : [];
    utils.forEach(this._data, (subModel, name) => {
      if (subModel && typeof subModel._id !== "undefined" && Model.isModel(subModel) && validationFunction(subModel)) {
        if (object) {
          submodels[name] = subModel;
        } else {
          submodels.push(subModel);
        }
      }
    });
    return submodels;
  },

  /**
   * Gets the current model and submodel values as a JS object
   * @returns {Object} All model as JS object, leafs will return their values
   */
  getPlainObject(persistent) {
    const obj = {};
    const _this = this;
    utils.forEach(this._data, (dataItem, i) => {
      // if it's a submodel
      if (dataItem instanceof Model) {
        obj[i] = dataItem.getPlainObject(persistent);
      }
      // if it's a modelLeaf
      else {
        //if asked for persistent then add value to result only if modelLeaf state is
        //persistent
        if (!persistent || dataItem.persistent) {
          let leafValue = dataItem.get(persistent);
          if (utils.isDate(leafValue))
            leafValue = _this.formatDate(leafValue);
          obj[i] = leafValue;
        }
      }
    });
    return obj;
  },

  formatDate(dateObject) {
    return dateObject.toString();
  },

  /**
   * Gets the requested object, including the leaf-object, not the value
   * @returns {Object} Model or ModelLeaf object.
   */
  getModelObject(name) {
    return name ?
      this._data[name] :
      this;
  },

  /**
   * Clears this model, submodels, data and events
   */
  clear() {
    const submodels = this.getSubmodels();
    for (const i in submodels) {
      submodels[i].clear();
    }
    this.setReady(false);
    this.off();
    this._intervals.clearAllIntervals();
    this._data = {};
  },

  /**
   * Validates data.
   * Interface for the validation function implemented by a model
   * @returns Promise or nothing
   */
  validate() {},

  /* ==========================
   * Model loading
   * ==========================
   */

  // normal model is never loading
  _isLoading() {
    return false;
  },

  /**
   * checks whether this model is loading anything
   * @param {String} optional process id (to check only one)
   * @returns {Boolean} is it loading?
   */
  isLoading() {
    if (this._isLoading())
      return true;

    //if not loading anything, check submodels
    const submodels = this.getSubmodels();
    let i;
    for (i = 0; i < submodels.length; i += 1) {
      if (submodels[i].isLoading()) {
        return true;
      }
    }

    return false;
  },

  /**
   * Sets the model as ready or not depending on its loading status
   */
  setReady(value) {
    if (value === false) {
      this._ready = false;
      if (this._parent && this._parent.setReady) {
        this._parent.setReady(false);
      }
      return;
    }
    //only ready if nothing is loading at all
    const prev_ready = this._ready;
    this._ready = !this.isLoading() && !this._setting;
    // if now ready and wasn't ready yet
    if (this._ready && prev_ready !== this._ready) {
      if (!this._readyOnce) {
        this._readyOnce = true;
        this.trigger("readyOnce");
      }
      this.trigger("ready");
    }
  },

  setInterModelListeners() {
    utils.forEach(this.getSubmodels(),
      subModel => subModel.setInterModelListeners()
    );
  },

  startPreload() {

    const promises = [];
    promises.push(this.preloadData());

    utils.forEach(this.getSubmodels(),
      subModel => promises.push(subModel.startPreload())
    );

    return Promise.all(promises);
  },

  preloadData() {
    return Promise.resolve();
  },

  /**
   * loads data (if hook)
   * Hooks loads data, models ask children to load data
   * Basically, this method:
   * loads is theres something to be loaded:
   * does not load if there's nothing to be loaded
   * @param {Object} options (includes splashScreen)
   * @returns defer
   */
  startLoading(opts) {
    this.trigger("startLoading");

    const promises = [];
    promises.push(this.loadData(opts));

    utils.forEach(this.getSubmodels(),
      subModel => promises.push(subModel.startLoading(opts))
    );

    return Promise.all(promises)
      .then(this.onSuccessfullLoad.bind(this))
      .catch(error => {
        utils.error("error in model " + this._name);
        this.triggerLoadError(error);
      });
  },

  loadData(opts) {
    if (this.isHook()) utils.warn("Hook " + this._name + " is not loading because it's not extending Hook prototype.");
    return Promise.resolve();
  },

  loadSubmodels(options) {
    const promises = [];
    const subModels = this.getSubmodels();
    utils.forEach(subModels, subModel => {
      promises.push(subModel.startLoading(options));
    });
    return promises.length > 0 ? Promise.all(promises) : Promise.resolve();
  },

  onSuccessfullLoad() {

    this.validate();
    utils.timeStamp("Vizabi Model: Model loaded: " + this._name + "(" + this._id + ")");
    //end this load call
    this._loadedOnce = true;

    this._loadCall = false;
    this.setTreeFreezer(false);

    //we need to defer to make sure all other submodels
    //have a chance to call loading for the second time
    utils.defer(
      () => this.setReady()
    );
  },

  handleLoadError(error = this.getDefaultError()) {
    this.triggerLoadError(error);
  },

  getDefaultError() {
    const error = new Error;
    error.message = this.getClosestModel("locale").getTFunction()("connection/error");
    return error;
  },

  triggerLoadError(error) {
    EventSource.unfreezeAll();
    if (this._inError) return utils.error("Model " + this._name + " already in error");
    this._inError = true;
    this._root.trigger("load_error", error);
    this._root.trigger("load_error1", error);
  },

  /**
   * executes after preloading processing is done
   */
  afterPreload() {
    const submodels = this.getSubmodels();
    utils.forEach(submodels, s => {
      s.afterPreload();
    });
  },

  /* ===============================
   * Hooking model to external data
   * ===============================
   */

  /**
   * is this model hooked to data?
   */
  isHook() {
    return Boolean(this.use);
  },

  /**
   * Gets all submodels of the current model that are hooks
   * @param object [object=false] Should it return an object?
   * @returns {Array|Object} hooks array or object
   */
  getSubhooks(object) {
    return this.getSubmodels(object, s => s.isHook());
  },

  /**
   * gets all sub values for a certain hook
   * only hooks have the "hook" attribute.
   * @param {String} type specific type to lookup
   * @returns {Array} all unique values with specific hook use
   */
  getHookWhich(type) {
    let values = [];
    if (this.use && this.use === type) {
      values.push(this.which);
    }
    //repeat for each submodel
    utils.forEach(this.getSubmodels(), s => {
      values = utils.unique(values.concat(s.getHookWhich(type)));
    });
    //now we have an array with all values in a type of hook for hooks.
    return values;
  },

  /**
   * gets all sub values for indicators in this model
   * @returns {Array} all unique values of indicator hooks
   */
  getIndicators() {
    return this.getHookWhich("indicator");
  },

  /**
   * gets all sub values for indicators in this model
   * @returns {Array} all unique values of property hooks
   */
  getProperties() {
    return this.getHookWhich("property");
  },

  /**
   * Gets the dimension of this model if it has one
   * @returns {String|Boolean} dimension
   */
  getDimension() {
    return this.dim || false; //defaults to dim if it exists
  },

  /**
   * Gets the dimension (if entity) or which (if hook) of this model
   * @returns {String|Boolean} dimension
   */
  getDimensionOrWhich() {
    return this.dim || (this.use != "constant" ? this.which : false); //defaults to dim or which if it exists
  },

  /**
   * Gets the filter for this model if it has one
   * @returns {Object} filters
   */
  getFilter() {
    return {}; //defaults to no filter
  },


  /**
   * maps the value to this hook's specifications
   * @param value Original value
   * @returns hooked value
   */
  mapValue(value) {
    return value;
  },

  /**
   * Gets formatter for this model
   * @returns {Function} formatter function
   */
  getParser() {
    return null;
  },

  /**
   * @return {Object} defaults of this model, and when available overwritten by submodel defaults
   */
  getDefaults() {
    return utils.deepExtend({}, this.getClassDefaults(), this.getSubmodelDefaults());
  },

  /**
   * @return {Object} All defaults coming from submodels
   */
  getSubmodelDefaults() {
    const d = {};
    utils.forEach(this.getSubmodels(true), (model, name) => {
      d[name] = model.getDefaults();
    });
    return d;
  },

  /**
   * @param  {name} name of the child to check
   * @return {Boolean} if the child is a leaf with a plain object as value
   */
  isObjectLeaf(name) {
    return (this.objectLeafs.indexOf(name) !== -1);
  },

  /**
   * gets closest prefix model moving up the model tree
   * @param {String} prefix
   * @returns {Object} submodel
   */
  getClosestModel(name) {
    const model = this.findSubmodel(name);
    if (model) {
      return model;
    } else if (this._parent) {
      return this._parent.getClosestModel(name);
    }
    return null;
  },

  /**
   * find submodel with name that starts with prefix
   * @param {String} prefix
   * @returns {Object} submodel or false if nothing is found
   */
  findSubmodel(name) {
    for (const i in this._data) {
      //found submodel
      if (i === name && Model.isModel(this._data[i])) {
        return this._data[i];
      }
    }
    return null;
  },

  /**
   * is this entities type model ?
   */
  isEntities() {
    return false;
  }

});

/* ===============================
 * Private Helper Functions
 * ===============================
 */

/**
 * Checks whether an object is a model or not
 * if includeLeaf is true, a leaf is also seen as a model
 */
Model.isModel = function(model, includeLeaf) {
  return model && (model.hasOwnProperty("_data") || (includeLeaf &&  model.hasOwnProperty("_val")));
};


function bindSetterGetter(model, prop) {
  Object.defineProperty(model, prop, {
    configurable: true,
    //allow reconfiguration
    get: (function(p) {
      return function() {
        return model.get(p);
      };
    })(prop),
    set: (function(p) {
      return function(value) {
        return model.set(p, value);
      };
    })(prop)
  });
}

/**
 * Loads a submodel, when necessaary
 * @param {String} attr Name of submodel
 * @param {Object} val Initial values
 * @param {Object} ctx context / parent model
 * @param {Boolean} persistent true if the change is a persistent change
 * @returns {Object} model new submodel
 */
function initSubmodel(attr, val, ctx, persistent) {

  let submodel;

  // if value is a value -> leaf
  if (!utils.isPlainObject(val) || utils.isArray(val) || ctx.isObjectLeaf(attr)) {

    const binds = {
      //the submodel has changed (multiple times)
      "change": onChange
    };
    submodel = new ModelLeaf(attr, val, ctx, binds, persistent);
  }

  // if value is an object -> model
  else {

    const binds = {
      //the submodel has changed (multiple times)
      "change": onChange,
      //loading has started in this submodel (multiple times)
      "hook_change": onHookChange,
      // error triggered in loading
      "load_error": (...args) => ctx.trigger(...args),
      // interpolation completed
      "dataLoaded": (...args) => ctx.trigger(...args),
      //loading has ended in this submodel (multiple times)
      "ready": onReady,
    };

    // if the value is an already instantiated submodel (Model or ModelLeaf)
    // this is the case for example when a new componentmodel is made (in Component._modelMapping)
    // it takes the submodels from the toolmodel and creates a new model for the component which refers
    // to the instantiated submodels (by passing them as model values, and thus they reach here)
    if (Model.isModel(val, true)) {
      submodel = val;
      submodel.on(binds);
    }
    // if it's just a plain object, create a new model
    else {
      // construct model
      const modelType = attr.split("_")[0];

      let Modl = Model.get(modelType, true);
      if (!Modl) {
        try {
          Modl = require("../models/" + modelType).default;
        } catch (err) {
          Modl = Model;
        }
      }

      submodel = new Modl(attr, val, ctx, binds);
      // model is still frozen but will be unfrozen at end of original .set()
    }
  }

  return submodel;

  // Default event handlers for models
  function onChange(evt, path) {
    if (!ctx._ready) return; //block change propagation if model isnt ready
    path = ctx._name + "." + path;
    ctx.trigger(evt, path);
  }
  function onHookChange(evt, vals) {
    ctx.trigger(evt, vals);
  }
  function onReady(evt, vals) {
    //trigger only for submodel
    ctx.setReady(false);
    //wait to make sure it's not set false again in the next execution loop
    utils.defer(() => {
      ctx.setReady();
    });
    //ctx.trigger(evt, vals);
  }
}

/**
 * gets closest interval from this model or parent
 * @returns {Object} Intervals object
 */
function getIntervals(ctx) {
  return ctx._intervals || (ctx._parent ? getIntervals(ctx._parent) : new Intervals());
}


export default Model;