Source: argos-sdk/src/View.js

/* Copyright (c) 2010, Sage Software, Inc. All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import declare from 'dojo/_base/declare';
import lang from 'dojo/_base/lang';
import _WidgetBase from 'dijit/_WidgetBase';
import _ActionMixin from './_ActionMixin';
import _CustomizationMixin from './_CustomizationMixin';
import _Templated from './_Templated';
import Adapter from './Models/Adapter';
import getResource from './I18n';
import { insertHistory } from './actions/index';


const resource = getResource('view');

/**
 * @class argos.View
 * @classdesc View is the root Class for all views and incorporates all the base features,
 * events, and hooks needed to successfully render, hide, show, and transition.
 *
 * All Views are dijit Widgets, namely utilizing its: widgetTemplate, connections, and attributeMap
 * @mixins argos._ActionMixin
 * @mixins argos._CustomizationMixin
 * @mixins argos._Templated
 */
const __class = declare('argos.View', [_WidgetBase, _ActionMixin, _CustomizationMixin, _Templated], /** @lends argos.View# */{
  /**
   * This map provides quick access to HTML properties, most notably the selected property of the container
   */
  attributeMap: {
    title: {
      node: 'domNode',
      type: 'attribute',
      attribute: 'title',
    },
    selected: {
      node: 'domNode',
      type: 'attribute',
      attribute: 'selected',
    },
  },
  /**
   * The widgetTemplate is a Simplate that will be used as the main HTML markup of the View.
   * @property {Simplate}
   */
  widgetTemplate: new Simplate([
    '<ul id="{%= $.id %}" title="{%= $.titleText %}" class="overthrow {%= $.cls %}">',
    '</ul>',
  ]),
  _loadConnect: null,
  /**
   * The id is used to uniquely define a view and is used in navigating, history and for HTML markup.
   * @property {String}
   */
  id: 'generic_view',
  /**
   * The titleText string will be applied to the top toolbar during {@link #show show}.
   */
  titleText: resource.titleText,
  /**
   * This views toolbar layout that defines all toolbar items in all toolbars.
   * @property {Object}
   */
  tools: null,
  /**
   * May be defined along with {@link App#hasAccessTo Application hasAccessTo} to incorporate View restrictions.
   */
  security: null,
  /**
   * A reference to the globa App object
   */
  app: null,

  /**
   * Registered model name to use.
   */
  modelName: '',

  /**
   * View type (detail, edit, list, etc)
   */
  viewType: 'view',
  /**
   * May be used to specify the service name to use for data requests. Setting false will force the use of the default service.
   * @property {String/Boolean}
   */
  serviceName: false,
  connectionName: false,
  connectionState: null,
  enableOfflineSupport: false,
  previousState: null,
  enableCustomizations: true,

  /**
   * @property {Object}
   * Localized error messages. One general error message, and messages by HTTP status code.
   */
  errorText: {
    general: resource.general,
    status: {},
  },
  /**
   * @property {Object}
   * Http Error Status codes. See http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
   */
  HTTP_STATUS: {
    BAD_REQUEST: 400,
    UNAUTHORIZED: 401,
    PAYMENT_REQUIRED: 402,
    FORBIDDEN: 403,
    NOT_FOUND: 404,
    METHOD_NOT_ALLOWED: 405,
    NOT_ACCEPTABLE: 406,
    PROXY_AUTH_REQUIRED: 407,
    REQUEST_TIMEOUT: 408,
    CONFLICT: 409,
    GONE: 410,
    LENGTH_REQUIRED: 411,
    PRECONDITION_FAILED: 412,
    REQUEST_ENTITY_TOO_LARGE: 413,
    REQUEST_URI_TOO_LONG: 414,
    UNSUPPORTED_MEDIA_TYPE: 415,
    REQUESTED_RANGE_NOT_SATISFIABLE: 416,
    EXPECTATION_FAILED: 417,
  },
  /**
   * @property {Array} errorHandlers
   * Array of objects that should contain a name string property, test function, and handle function.
   *
   */
  errorHandlers: null,
  constructor: function constructor(options) {
    this.app = (options && options.app) || window.App;
  },
  startup: function startup() {
    this.inherited(arguments);
  },
  select: function select(node) {
    $(node).attr('selected', 'true');
  },
  unselect: function unselect(node) {
    $(node).removeAttr('selected');
  },
  /**
   * Called from {@link App#_viewTransitionTo Applications view transition handler} and returns
   * the fully customized toolbar layout.
   * @return {Object} The toolbar layout
   */
  getTools: function getTools() {
    const tools = this._createCustomizedLayout(this.createToolLayout(), 'tools');
    this.onToolLayoutCreated(tools);
    return tools;
  },
  /**
   * Called after toolBar layout is created;
   *
   */
  onToolLayoutCreated: function onToolLayoutCreated(/* tools*/) {},
  /**
   * Returns the tool layout that defines all toolbar items for the view
   * @return {Object} The toolbar layout
   */
  createToolLayout: function createToolLayout() {
    return this.tools || {};
  },
  /**
   * Called on loading of the application.
   */
  init: function init(store) {
    this.initStore(store);
    this.startup();
    this.initConnects();
    this.initModel();
  },
  initStore: function initStore(store) {
    this.appStore = store;
    this.appStore.subscribe(this._onStateChange.bind(this));
  },
  _updateConnectionState: function _updateConnectionState(state) {
    if (this.connectionState === state) {
      return;
    }

    this.initModel();

    const oldState = this.connectionState;
    this.connectionState = state;
    if (oldState !== null) {
      this.onConnectionStateChange(state);
    }
  },
  onConnectionStateChange: function onConnectionStateChange(state) { // eslint-disable-line
  },
  _onStateChange: function _onStateChange() {
    const state = this.appStore.getState();
    this._updateConnectionState(state.sdk.online);
    this.onStateChange(state);
    this.previousState = state;
  },
  onStateChange: function onStateChange(val) {}, // eslint-disable-line
  /**
   * Initializes the model instance that is returned with the current view.
   */
  initModel: function initModel() {
    const model = this.getModel();
    if (model) {
      this._model = model;
      this._model.init();
    }
  },
  /**
   * Returns a new instance of a model for the view.
   */
  getModel: function getModel() {
    const model = Adapter.getModel(this.modelName);
    return model;
  },
  /**
   * Establishes this views connections to various events
   */
  initConnects: function initConnects() {
    this._loadConnect = this.connect(this.domNode, 'onload', this._onLoad);
  },
  _onLoad: function _onLoad(evt, el, o) {
    this.disconnect(this._loadConnect);
    this.load(evt, el, o);
  },
  /**
   * Called once the first time the view is about to be transitioned to.
   * @deprecated
   */
  load: function load() {
    // todo: remove load entirely?
  },
  /**
   * Called in {@link #show show()} before route is invoked.
   * @param {Object} options Navigation options passed from the previous view.
   * @return {Boolean} True indicates view needs to be refreshed.
   */
  refreshRequiredFor: function refreshRequiredFor(options) {
    if (this.options) {
      return !!options; // if options provided, then refresh
    }

    return true;
  },
  /**
   * @return {Array} Returns an array of error handlers
   */
  createErrorHandlers: function createErrorHandlers() {
    return this.errorHandlers || [];
  },
  /**
   * Starts matching and executing errorHandlers.
   * @param {Error} error Error to pass to the errorHandlers
   */
  handleError: function handleError(error) {
    if (!error) {
      return;
    }

    function noop() {}

    const matches = this.errorHandlers.filter((handler) => {
      return handler.test && handler.test.call(this, error);
    });

    const len = matches.length;

    const getNext = function getNext(index) {
      // next() chain has ended, return a no-op so calling next() in the last chain won't error
      if (index === len) {
        return noop;
      }

      // Return a closure with index and matches captured.
      // The handle function can call its "next" param to continue the chain.
      return function next() {
        const nextHandler = matches[index];
        const nextFn = nextHandler && nextHandler.handle;

        nextFn.call(this, error, getNext.call(this, index + 1));
      }.bind(this);
    }.bind(this);

    if (len > 0 && matches[0].handle) {
      // Start the handle chain, the handle can call next() to continue the iteration
      matches[0].handle.call(this, error, getNext.call(this, 1));
    }
  },
  /**
   * Gets the general error message, or the error message for the status code.
   */
  getErrorMessage: function getErrorMessage(error) {
    let message = this.errorText.general;

    if (error) {
      message = this.errorText.status[error.status] || this.errorText.general;
    }

    return message;
  },
  /**
   * Should refresh the view, such as but not limited to:
   * Emptying nodes, requesting data, rendering new content
   */
  refresh: function refresh() {},
  /**
   * The onBeforeTransitionAway event.
   * @param self
   */
  onBeforeTransitionAway: function onBeforeTransitionAway(/* self*/) {},
  /**
   * The onBeforeTransitionTo event.
   * @param self
   */
  onBeforeTransitionTo: function onBeforeTransitionTo(/* self*/) {},
  /**
   * The onTransitionAway event.
   * @param self
   */
  onTransitionAway: function onTransitionAway(/* self*/) {},
  /**
   * The onTransitionTo event.
   * @param self
   */
  onTransitionTo: function onTransitionTo(/* self*/) {},
  /**
   * The onActivate event.
   * @param self
   */
  onActivate: function onActivate(/* self*/) {},
  /**
   * The onShow event.
   * @param self
   */
  onShow: function onShow(/* self*/) {},
  activate: function activate(tag, data) {
    // todo: use tag only?
    if (data && this.refreshRequiredFor(data.options)) {
      this.refreshRequired = true;
    }

    this.options = (data && data.options) || this.options || {};

    if (this.options.title) {
      this.set('title', this.options.title);
    } else {
      this.set('title', this.titleText);
    }

    this.onActivate(this);
  },
  _getScrollerAttr: function _getScrollerAttr() {
    return this.scrollerNode || this.domNode;
  },
  _transitionOptions: null,
  /**
   * Shows the view using pagejs in order to transition to the new element.
   * @param {Object} options The navigation options passed from the previous view.
   * @param transitionOptions {Object} Optional transition object that is forwarded to open.
   */
  show: function show(options, transitionOptions) {
    this.errorHandlers = this._createCustomizedLayout(this.createErrorHandlers(), 'errorHandlers');

    if (this.onShow(this) === false) {
      return;
    }

    if (this.refreshRequiredFor(options)) {
      this.refreshRequired = true;
    }

    this.options = options || this.options || {};

    if (this.options.title) {
      this.set('title', this.options.title);
    } else {
      this.set('title', this.titleText);
    }

    const tag = this.getTag();
    const data = this.getContext();

    const to = lang.mixin(transitionOptions || {}, {
      tag,
      data,
    });
    this._transitionOptions = to;
    page(this.buildRoute());
  },
  hashPrefix: '#!',
  currentHash: '',
  transitionComplete: function transitionComplete(_page, options) {
    if (options.track !== false) {
      this.currentHash = location.hash;

      if (options.trimmed !== true) {
        const data = {
          hash: this.currentHash,
          page: this.id,
          tag: options.tag,
          data: options.data,
        };
        App.context.history.push(data);
        this.appStore.dispatch(insertHistory(data));
      }
    }
  },
  transition: function transition(from, to, options) {
    function complete() {
      this.transitionComplete(to, options);
      $('body').removeClass('transition');

      $(from).trigger({
        out: true,
        tag: options.tag,
        data: options.data,
        bubbles: true,
        cancelable: true,
        type: 'aftertransition',
      });
      $(to).trigger({
        out: false,
        tag: options.tag,
        data: options.data,
        bubbles: true,
        cancelable: true,
        type: 'aftertransition',
      });

      if (options.complete) {
        options.complete(from, to, options);
      }
    }

    $('body').addClass('transition');

    // dispatch an 'show' event to let the page be aware that is being show as the result of an external
    // event (i.e. browser back/forward navigation).
    if (options.external) {
      $(to).trigger({
        tag: options.tag,
        data: options.data,
        bubbles: true,
        cancelable: true,
        type: 'show',
      });
    }

    $(from).trigger({
      out: true,
      tag: options.tag,
      data: options.data,
      bubbles: true,
      cancelable: true,
      type: 'beforetransition',
    });

    $(to).trigger({
      out: false,
      tag: options.tag,
      data: options.data,
      bubbles: true,
      cancelable: true,
      type: 'beforetransition',
    });

    this.unselect(from);
    this.select(to);
    complete.apply(this);
  },
  setPrimaryTitle: function setPrimaryTitle() {
    App.setPrimaryTitle(this.get('title'));
  },
  /**
  * Available Options:
  *   horizontal: True if the transition is horizontal, False otherwise.
  *   reverse: True if the transition is a reverse transition (right/down), False otherwise.
  *   track: False if the transition should not be tracked in history, True otherwise.
  *   update: False if the transition should not update title and back button, True otherwise.
  *   scroll: False if the transition should not scroll to the top, True otherwise.
  */
  open: function open() {
    const p = this.domNode;
    const options = this._transitionOptions || {};

    if (!p) {
      return;
    }

    this.setPrimaryTitle();

    if (options.track !== false) {
      const count = App.context.history.length;
      let position = count - 1;

      if (options.returnTo) {
        if (typeof options.returnTo === 'function') {
          for (position = count - 1; position >= 0; position--) {
            if (options.returnTo(App.context.history[position])) {
              break;
            }
          }
        } else if (options.returnTo < 0) {
          position = (count - 1) + options.returnTo;
        }

        if (position > -1) {
          // we fix up the history, but do not flag as trimmed, since we do want the new view to be pushed.
          App.context.history = App.context.history.splice(0, position + 1);

          this.currentHash = App.context.history[App.context.history.length - 1] && App.context.history[App.context.history.length - 1].hash;
        }

        options.returnTo = null;
      }
    }

    // don't auto-scroll by default if reversing
    if (options.reverse && typeof options.scroll === 'undefined') {
      options.scroll = !options.reverse;
    }

    $(p).trigger({
      bubbles: false,
      cancelable: true,
      type: 'load',
    });

    const from = App.getCurrentPage();

    if (from) {
      $(from).trigger({
        bubbles: false,
        cancelable: true,
        type: 'blur',
      });
    }

    App.setCurrentPage(p);

    $(p).trigger({
      bubbles: false,
      cancelable: true,
      type: 'focus',
    });

    if (from && $(p).attr('selected') !== 'true') {
      if (options.reverse) {
        $(p).trigger({
          bubbles: false,
          cancelable: true,
          type: 'unload',
        });
      }

      window.setTimeout(this.transition.bind(this), App.checkOrientationTime, from, p, options);
    } else {
      $(p).trigger({
        out: false,
        tag: options.tag,
        data: options.data,
        bubbles: true,
        cancelable: true,
        type: 'beforetransition',
      });

      this.select(p);

      this.transitionComplete(p, options);

      $(p).trigger({
        out: false,
        tag: options.tag,
        data: options.data,
        bubbles: true,
        cancelable: true,
        type: 'aftertransition',
      });
    }
  },
  /**
   * Expands the passed expression if it is a function.
   * @param {String/Function} expression Returns string directly, if function it is called and the result returned.
   * @return {String} String expression.
   */
  expandExpression: function expandExpression(expression) {
    if (typeof expression === 'function') {
      return expression.apply(this, Array.prototype.slice.call(arguments, 1));
    }

    return expression;
  },
  /**
   * Called before the view is transitioned (slide animation complete) to.
   */
  beforeTransitionTo: function beforeTransitionTo() {
    this.onBeforeTransitionTo(this);
  },
  /**
   * Called before the view is transitioned (slide animation complete) away from.
   */
  beforeTransitionAway: function beforeTransitionAway() {
    this.onBeforeTransitionAway(this);
  },
  /**
   * Called after the view has been transitioned (slide animation complete) to.
   */
  transitionTo: function transitionTo() {
    if (this.refreshRequired) {
      this.refreshRequired = false;
      this.isRefreshing = false;
      this.refresh();
    }

    this.onTransitionTo(this);
  },
  /**
   * Called after the view has been transitioned (slide animation complete) away from.
   */
  transitionAway: function transitionAway() {
    this.onTransitionAway(this);
  },
  /**
   * Returns the primary SDataService instance for the view.
   * @return {Object} The Sage.SData.Client.SDataService instance.
   */
  getService: function getService() {
    return this.app.getService(this.serviceName); /* if false is passed, the default service will be returned */
  },
  getConnection: function getConnection() {
    return this.getService();
  },
  getTag: function getTag() {},
  /**
   * Returns the options used for the View {@link #getContext getContext()}.
   * @return {Object} Options to be used for context.
   */
  getOptionsContext: function getOptionsContext() {
    if (this.options && this.options.negateHistory) {
      return {
        negateHistory: true,
      };
    }
    return this.options;
  },
  /**
   * Returns the context of the view which is a small summary of key properties.
   * @return {Object} Vital View properties.
   */
  getContext: function getContext() {
    // todo: should we track options?
    return {
      id: this.id,
      options: this.getOptionsContext(),
    };
  },
  /**
   * Returns the defined security.
   * @param access
   */
  getSecurity: function getSecurity(/* access*/) {
    return this.security;
  },
  /**
  * @property {String}
  * Route passed into the router. RegEx expressions are also accepted.
  */
  route: '',
  /**
  * Gets the route associated with this view. Returns this.id if no route is defined.
  */
  getRoute: function getRoute() {
    if ((typeof this.route === 'string' && this.route.length > 0) || this.route instanceof RegExp) {
      return this.route;
    }

    return this.id;
  },
  /**
  * Show method calls this to build a route that it can navigate to. If you add a custom route,
  * this should change to build a route that can match that.
  * @returns {String}
  */
  buildRoute: function buildRoute() {
    return this.id;
  },
  /**
  * Fires first when a route is triggered. Any pre-loading should happen here.
  * @param {Object} ctx
  * @param {Function} next
  */
  routeLoad: function routeLoad(ctx, next) {
    next();
  },
  /**
  * Fires second when a route is triggered. Any pre-loading should happen here.
  * @param {Object} ctx
  * @param {Function} next
  */
  routeShow: function routeShow(ctx, next) { // eslint-disable-line
    this.open();
  },
 /*
  * Required for binding to ScrollContainer which utilizes iScroll that requires to be refreshed when the
  * content (therefor scrollable area) changes.
  */
  onContentChange: function onContentChange() {
  },
  /**
   * Returns true if view is disabled.
   * @return {Boolean}.
   */
  isDisabled: function isDisabled() {
    return false;
  },
});

export default __class;