wb/mgr/MgrStateHandler.js

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

/* jshint browser: true */

/**
 * API Status: **Development**
 *
 * MgrStateHandler provides the ability to save some state for a `Manager` view, allowing, at
 * the most basic level, a `Manager` to preserve the state of its hidden/visible columns
 * and the visibility of the learn table when moving between views. Managers may also
 * optionally use it to remember other state, such as the items that were found during
 * the last discovery action.
 *
 * Note that although it provides similar functionality to the Java MgrState type, it differs
 * in that this type is not used to preserve the state on its own instance. This type provides
 * the functionality to serialize and deserialize the data to JSON; the object instance itself
 * does not store any state and is not preserved between hyperlinks or page reloads.
 *
 * A Manager may provide its own functions that can be used to save and restore custom data
 * for a particular Manager type. See the description of the `save()` function for information.
 *
 * @module nmodule/webEditors/rc/wb/mgr/MgrStateHandler
 */
define([ 'baja!',
        'bajaux/mixin/mixinUtils',
        'Promise',
        'underscore',
        'nmodule/webEditors/rc/fe/baja/util/compUtils',
        'nmodule/webEditors/rc/util/SyncedSessionStorage',
        'nmodule/webEditors/rc/wb/table/model/Column' ], function (
        baja,
        mixinUtils,
        Promise,
        _,
        compUtils,
        SyncedSessionStorage,
        Column) {

  "use strict";

  const { hasMixin } = mixinUtils;
  const CACHE_STORAGE_KEY = 'niagara.mgrstate.cache';

  //kick it off immediately to save a few ms
  SyncedSessionStorage.getInstance();

  function mgrHasFunction(mgr, name) {
    return (mgr[name]) && (typeof mgr[name] === 'function');
  }

  /**
   * Serialize the given object to JSON, before it is placed into the session
   * storage.
   *
   * @param {Object} obj - an object containing the state to be serialized to storage.
   * @returns {String}
   */
  function serialize(obj) {
    return JSON.stringify(obj);
  }

  /**
   * Restore a serialized object that we have obtained back from the session
   * storage before we deconstruct it to get the state to apply back to the
   * manager.
   *
   * @param json - the JSON serialized state to be restored.
   * @returns {Object}
   */
  function deserialize(json) {
    return JSON.parse(json);
  }

////////////////////////////////////////////////////////////////
// MgrStateHandler
////////////////////////////////////////////////////////////////

  /**
   * Constructor not to be called directly. Call `.make()` instead.
   *
   * @alias module:nmodule/webEditors/rc/wb/mgr/MgrStateHandler
   * @class
   */
  const MgrStateHandler = function MgrStateHandler(params) {
    params = baja.objectify(params, 'key');
    this.$mgrStateKey = String(params.key);
  };

  /**
   * Takes a key string that will be used to index the
   * state information in the storage. This key is usually derived from
   * the Manager widget's `moduleName` and `keyName` parameters.
   *
   * Note that `MgrStateHandler` relies upon `SyncedSessionStorage` which can
   * take up to 1000ms to initialize, so this may take that long to resolve.
   *
   * @param {string|Object} params - the parameters object or a string containing the
   * key parameter.
   * @param {String} params.key - the key name used to index the saved state
   * information. Usually derived from the Manager's moduleName and keyName
   * parameters.
   * @returns {Promise.<module:nmodule/webEditors/rc/wb/mgr/MgrStateHandler>}
   */
  MgrStateHandler.make = function (params) {
    return SyncedSessionStorage.getInstance()
      .then(function (storage) {
        const handler = new MgrStateHandler(params);
        handler.$storage = storage;
        return handler;
      });
  };

  /**
   * Get the key for use in the browser's session storage. We will
   * prepend the user name to the root storage key.
   *
   * @private
   * @returns {String} the full key to be used for session storage.
   */
  MgrStateHandler.prototype.getKeyForSessionStorage = function () {
    return baja.getUserName() + '.' + CACHE_STORAGE_KEY + '.' + this.getMgrTypeKey();
  };

  /**
   * Return the manager specific part of the key used to store/retrieve state information
   * in session storage. This is a substring of the total key, which will have extra
   * information added to it. This will return the key provided in the constructor.
   *
   * @private
   * @returns {String} the manager specific part of the storage key
   */
  MgrStateHandler.prototype.getMgrTypeKey = function () {
    return this.$mgrStateKey;
  };

////////////////////////////////////////////////////////////////
// Learn Mode Helpers
////////////////////////////////////////////////////////////////

  function hasLearnSupport(mgr) {
    return hasMixin(mgr, 'MGR_LEARN');
  }

  function toggleLearnMode(mgr, selected) {
    mgr.setLearnModeEnabled(selected);
  }

////////////////////////////////////////////////////////////////
// 'All Descendants' Helpers
////////////////////////////////////////////////////////////////

  function hasFolderSupport(mgr) {
    return hasMixin(mgr, 'MGR_FOLDER');
  }

  function toggleAllDescendants(mgr, selected) {
    mgr.setAllDescendantsSelected(selected);
  }

  /**
   * Static method for use in the `Manager`'s load process. This will be called
   * to allow the model to know whether it should restore initially in a flattened state.
   *
   * @static
   * @private
   *
   * @param {Object} deserialized - a deserialized state object returned from
   * an earlier call to `deserializeFromStorage()`.
   */
  MgrStateHandler.shouldRestoreAllDescendants = function (deserialized) {
    return deserialized && deserialized.$allDescendantsSelected;
  };

////////////////////////////////////////////////////////////////
// Save
////////////////////////////////////////////////////////////////

  /**
   * Save the state of the `Manager` to session storage. This will perform three steps:
   * first the manager's state is saved as properties upon a state object, secondly the
   * object is serialized to JSON, and finally the JSON string is placed in session storage.
   *
   * The default save implementation will save the common basic state of the manager;
   * that is the visibility of the table columns and the visibility of the discovery table.
   * The manager can also provide functions to save state, which will be called by
   * this type, if defined. The manager may provide a `saveStateForKey` and or a `saveStateForOrd`
   * function. The `saveStateForOrd` function will be used to store information against a particular
   * ord loaded in the manager, typically to save discovery data. Only the last ord that was loaded
   * for a particular manager type will have its ord data saved.  Any previous ord data for the
   * same manager type will not be reloaded and thus will be erased when the data is saved again.
   * The `saveStateForKey` function can be used to save generic data for the type of Manager.
   * Both of these functions should return an Object containing the state to save.
   * The returned value will be added to the data to be serialized. The Manager should also provide
   * corresponding restoreStateForKey` and/or `restoreStateForOrd` functions that will receive a
   * deserialized version of the object.
   *
   * @example
   * <caption>
   *   Add a function on the Manager to save the items found in the last discovery.
   * </caption>
   * MyDeviceMgr.prototype.saveStateForOrd = function () {
   *   return {
   *     discoveries: this.discoveredItems // These objects will be serialized as JSON
   *   };
   * };
   *
   * @param {module:nmodule/webEditors/rc/wb/mgr/Manager} mgr - the Manager instance requiring its state to be saved.
   * @returns {Promise}
   */
  MgrStateHandler.prototype.save = function (mgr) {
    const key = this.getKeyForSessionStorage();
    const state = {};

    return getOrdFromMgr(mgr)
      .then((ord) => {
        if (ord) { state.$ord = ord.toString(); }

        this.doSave(mgr, state);

        this.$storage.setItem(key, serialize(state));
      });
  };

  /**
   * Save the Manager's state to the given object, prior to serialization.
   * This will save the basic state supported for all Manager views, and then
   * try to see if the Manager provides its own functions for saving custom
   * data.
   *
   * @param {module:nmodule/webEditors/rc/wb/mgr/Manager} mgr - the Manager instance being saved.
   * @param {Object} state - an object instance that will contain the state to be serialized.
   */
  MgrStateHandler.prototype.doSave = function (mgr, state) {
    this.$saveBasicState(mgr, state);
    this.saveForKey(mgr, state);
    this.saveForOrd(mgr, state);
  };

  /**
   * Save which columns in the main table are hidden. If learn support is
   * enabled, it will do the same for the discovery table, also saving
   * whether the discovery table is currently visible.
   *
   * @private
   *
   * @param {module:nmodule/webEditors/rc/wb/mgr/Manager} mgr - the Manager instance being saved.
   * @param {Object} state - an object instance that will contain the state to be serialized.
   */
  MgrStateHandler.prototype.$saveBasicState = function (mgr, state) {
    const model = mgr.getModel();
    const columns = model.getColumns();

    state.$modelColumnsUnseen = encodeUnseenColumns(columns);

    if (hasLearnSupport(mgr)) {
      state.$learnModeSelected = mgr.isLearnModeEnabled();

      const learnModel = mgr.getLearnModel();
      if (learnModel) {
        state.$learnColumnsUnseen = encodeUnseenColumns(learnModel.getColumns());
      }
    }

    if (hasFolderSupport(mgr)) {
      state.$allDescendantsSelected = mgr.isAllDescendantsSelected();
    }
  };

  /**
   * Test whether the Manager has a `saveStateForKey` function, and invoke it, if found.
   *
   * @param {module:nmodule/webEditors/rc/wb/mgr/Manager} mgr - the Manager instance being saved.
   * @param {Object} state - an object instance that will contain the state to be serialized.
   */
  MgrStateHandler.prototype.saveForKey = function (mgr, state) {
    if (mgrHasFunction(mgr, 'saveStateForKey')) {
      state.$mgrKeyState = mgr.saveStateForKey();
    }
  };

  /**
   * Test whether the Manager has a `saveStateForOrd` function, and invoke it, if found.
   *
   * @param {module:nmodule/webEditors/rc/wb/mgr/Manager} mgr - the Manager instance being saved.
   * @param {Object} state - an object instance that will contain the state to be serialized.
   */
  MgrStateHandler.prototype.saveForOrd = function (mgr, state) {
    if (mgrHasFunction(mgr, 'saveStateForOrd')) {
      state.$mgrOrdState = mgr.saveStateForOrd();
    }
  };

////////////////////////////////////////////////////////////////
// Restore
////////////////////////////////////////////////////////////////

  /**
   * Test whether the stored state was for the same Ord the manager currently has loaded.
   * If so, the restoreForOrd() function will be called during the restore process. This
   * provides equivalent behavior to the Java abstract manager framework, which will restore
   * the ord data, if the current ord matches the last one saved.
   *
   * @param {Object} obj - the deserialized state object.
   * @param {module:nmodule/webEditors/rc/wb/mgr/Manager} mgr - the manager
   *
   * @returns {Promise.<Boolean>} returns true if the stored state was for the same ord as the model.
   */
  function isSameOrdForRestore(obj, mgr) {
    if (obj.$ord) {
      return getOrdFromMgr(mgr)
        .then(function (ord) {
          return baja.Ord.make(obj.$ord).equals(ord);
        });
    }

    return Promise.resolve(false);
  }

  /**
   * Retrieve the state from storage and return the state object. This will be called
   * early in the manager's load process in order for it to be able to access relevant
   * state before the model is created. The object returned from this method will be
   * passed back to the restore function later in the load process.
   *
   * @returns {Object} - the stored state deserialized from JSON.
   */
  MgrStateHandler.prototype.deserializeFromStorage = function () {
    const key = this.getKeyForSessionStorage();
    const json = this.$storage.getItem(key);

    if (json) {
      return deserialize(json);
    }

    return null;
  };

  /**
   * Restore the state of a `Manager`. This function will retrieve the stored
   * state information from session storage using the Manager's key. It takes a
   * deserialized state object returned from an earlier call to `deserializeFromStorage`.
   * The properties of that object will then be used to restore the prior
   * state.
   *
   * The default `restore` implementation will restore the visibility of the table
   * columns and the discovery tables. If the Manager provides `restoreStateForKey` and/or
   * `restoreStateForOrd` functions to correspond to the save functions, these will be
   * called with the deserialized versions of the objects the save functions returned.
   *
   * @example
   * <caption>
   *   Add a function on the Manager to restore the items found in the last discovery.
   * </caption>
   * MyDeviceMgr.prototype.restoreStateForOrd = function (state) {
   *   if (state.discoveries) {
   *     this.discoveredItems = state.discoveries;
   *   }
   *   return this.reloadLearnModel(); // Returns a Promise that will reload the table
   * };
   *
   * @param {module:nmodule/webEditors/rc/wb/mgr/Manager} mgr - the Manager instance being restored.
   * @param {Object} state - a deserialized state object with properties containing the state to be restored.
   *
   * @returns {Promise} - A promise resolved when the state restoration has completed.
   */
  MgrStateHandler.prototype.restore = function (mgr, state) {
    if (!mgr.getModel()) {
      return Promise.reject(new Error('Manager must be loaded before restoring'));
    }

    return state ? this.doRestore(mgr, state) : Promise.resolve();
  };

  /**
   * Use the deserialized object to restore the state of the manager.
   * This will restore the basic state, then invoke the custom functions on the
   * manager itself, if they are defined.
   *
   * @param {module:nmodule/webEditors/rc/wb/mgr/Manager} mgr - the Manager instance being restored.
   * @param {Object} obj - the object containing the state to be restored
   *
   * @returns {Promise}
   */
  MgrStateHandler.prototype.doRestore = function (mgr, obj) {
    const model = mgr.getModel();

    if (!model) { return Promise.reject(new Error('Cannot restore state without a model')); }

    this.$restoreBasicState(mgr, obj);

    return Promise.resolve(this.restoreForKey(mgr, obj))
      .then(() => {

        // If the last manager instance saved against that key was for the same ord
        // as the one we are restoring, call restoreForOrd().

        return isSameOrdForRestore(obj, mgr);
      })
      .then((isSameOrd) => {
        if (isSameOrd) {
          return this.restoreForOrd(mgr, obj);
        }
      })
      .then(() => {
        return this.postRestore(mgr, obj);
      });
  };

  /**
   * Restore the basic state for the manager. This is the visible columns
   * and whether the discovery table is showing.
   *
   * @private
   *
   * @param {module:nmodule/webEditors/rc/wb/mgr/Manager} mgr - the Manager instance being restored.
   * @param {Object} obj - the deserialized object containing the state to be restored.
   */
  MgrStateHandler.prototype.$restoreBasicState = function (mgr, obj) {
    const model = mgr.getModel();
    const columns = model.getColumns();
    const unseen = decodeColumnFlags(obj.$modelColumnsUnseen, columns.length);

    restoreColumnUnseenFlags(model.getColumns(), unseen);

    if (hasLearnSupport(mgr)) {

      toggleLearnMode(mgr, !!obj.$learnModeSelected);

      const learnModel = mgr.getLearnModel();
      if (learnModel && obj.$learnColumnsUnseen) {
        const learnColumns = learnModel.getColumns();
        const learnUnseen = decodeColumnFlags(obj.$learnColumnsUnseen, learnColumns.length);

        restoreColumnUnseenFlags(learnColumns, learnUnseen);
      }
    }

    if (hasFolderSupport(mgr)) {
      toggleAllDescendants(mgr, !!obj.$allDescendantsSelected);
    }
  };

  /**
   * Test whether the Manager has a `restoreStateForKey` function, and invoke it, if found.
   *
   * @param {module:nmodule/webEditors/rc/wb/mgr/Manager} mgr - the Manager instance being restored.
   * @param {Object} obj - the object containing the state to be restored
   *
   * @returns {Promise} - The Promise returned by the Manager's function, or undefined
   * if the manager does not provide the function.
   */
  MgrStateHandler.prototype.restoreForKey = function (mgr, obj) {
    if (obj.$mgrKeyState && mgrHasFunction(mgr, 'restoreStateForKey')) {
      return Promise.resolve(mgr.restoreStateForKey(obj.$mgrKeyState));
    }

    return Promise.resolve();
  };

  /**
   * Test whether the Manager has a `restoreStateForOrd` function, and invoke it, if found.
   *
   * @param {module:nmodule/webEditors/rc/wb/mgr/Manager} mgr - the Manager instance being restored.
   * @param {Object} obj
   * @returns {Promise} - The Promise returned by the Manager's function, or undefined
   * if the manager does not provide the function.
   */
  MgrStateHandler.prototype.restoreForOrd = function (mgr, obj) {
    if (obj.$mgrOrdState && Object.keys(obj.$mgrOrdState).length !== 0 && mgrHasFunction(mgr, 'restoreStateForOrd')) {
      return Promise.resolve(mgr.restoreStateForOrd(obj.$mgrOrdState));
    }

    return Promise.resolve();
  };

  /**
   * Test whether the Manager has a `postRestore` function, and invoke it, if found.
   * This allows for additional actions such as clean up or other calls to be handled after
   * restoring the state.
   *
   * @since Niagara 4.12
   *
   * @param {module:nmodule/webEditors/rc/wb/mgr/Manager} mgr - the Manager instance being restored.
   * @param {Object} obj
   * @returns {Promise} - The Promise returned by the Manager's function
   * if the manager does not provide the function.
   */
  MgrStateHandler.prototype.postRestore = function (mgr, obj) {
    if (mgrHasFunction(mgr, 'postRestore')) {
      return Promise.resolve(mgr.postRestore(obj));
    }

    return Promise.resolve();
  };

////////////////////////////////////////////////////////////////
// Columns
////////////////////////////////////////////////////////////////

  /**
   * Save the state of the columns for one of the table models. This
   * will save the state of the 'UNSEEN' flag, so columns that have
   * been hidden by the show/hide menu will be restored to the previous
   * state.
   *
   * @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Column>} columns - the `Column`
   * instances from either the main database model or the learn model.
   *
   * @returns {String} the column flags encoded as a string.
   */
  function encodeUnseenColumns(columns) {
    return encodeColumnFlags(_.map(columns, function (col) {
      return col.hasFlags(Column.flags.UNSEEN);
    }));
  }

  /**
   * Restore the flags on the columns indicating whether the column should be seen
   * or not.
   *
   * @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Column>} columns - the columns
   * from the main table or learn table model.
   * @param {Array.<Boolean>} values - an array of boolean values that will be used to set
   * the UNSEEN flag on the columns.
   */
  function restoreColumnUnseenFlags(columns, values) {
    for (let i = 0; i < columns.length; i++) {
      columns[i].setUnseen(values[i]);
    }
  }

////////////////////////////////////////////////////////////////
// Util
////////////////////////////////////////////////////////////////

  /**
   * Return a promise that resolves to the ord of the manager's current component.
   * Used to determine whether a manager is being restored for the same ord that
   * it was last saved for.
   * @param {module:nmodule/webEditors/rc/wb/mgr/Manager} mgr
   * @returns {Promise}
   */
  function getOrdFromMgr(mgr) {
    return compUtils.getNavOrd(mgr.value());
  }

  /**
   * Encode an array of boolean values representing the state of a particular
   * flag for a model's columns into a hex string.
   */
  function encodeColumnFlags(arr) {
    const max = arr.length - 1;
    let counter = 3;
    let bits = 0;
    let str = '';

    for (let i = 0; i <= max; i++) {
      bits |= ((arr[i] ? 1 : 0) << counter);
      if (counter === 0 || i === max) {
        str += bits.toString(16);
        counter = 3;
        bits = 0;
      } else {
        counter--;
      }
    }

    return str;
  }

  /**
   * Convert a hexadecimal string that was encoded with `encodeColumnFlags()`
   * back to an array of boolean values. Has a `count` parameter that can
   * strip off extra `false` values at the end of the array, when the source
   * array's length was not a multiple of four.
   */
  function decodeColumnFlags(str, count) {
    let res = _.reduce(str, function (memo, ch) {
      const bits = parseInt(ch, 16);
      memo.push(!!(bits & 0x8));
      memo.push(!!(bits & 0x4));
      memo.push(!!(bits & 0x2));
      memo.push(!!(bits & 0x1));

      return memo;
    }, []);

    if ((count) && (res.length > count)) {
      res = res.slice(0, count);
    }

    return res;
  }

  return (MgrStateHandler);
});