fe/feDialogs.js

/**
 * @copyright 2015 Tridium, Inc. All Rights Reserved.
 * @author Logan Byam
 */

define([ 'baja!',
  'lex!webEditors',
  'log!nmodule.webEditors.rc.fe.feDialogs',
  'dialogs',
  'jquery',
  'Promise',
  'underscore',
  'bajaux/events',
  'bajaux/Widget',
  'bajaux/commands/Command',
  'bajaux/util/CommandButton',
  'nmodule/webEditors/rc/fe/fe',
  'nmodule/webEditors/rc/fe/BaseWidget',
  'nmodule/webEditors/rc/fe/baja/util/typeUtils' ], function (
  baja,
  lexs,
  log,
  dialogs,
  $,
  Promise,
  _,
  events,
  Widget,
  Command,
  CommandButton,
  fe,
  BaseWidget,
  typeUtils) {

  'use strict';

  const { LOAD_EVENT, MODIFY_EVENT } = events;
  const { VALUE_READY_EVENT } = BaseWidget;
  const [ webEditorsLex ] = lexs;
  const { isComplex } = typeUtils;

  const logError = log.severe.bind(log);
  const DEFAULT_DELAY = 200;
  const ENTER_KEY = 13;

  const errorDetailsHtml =
    '<div class="errorMessage"/>' +
    '<pre class="errorDetails" style="display: none;"/>' +
    '<div class="detailsButton" style="display: none;">' +
    '<button class="ux-btn"/>' +
    '</div>';

  /**
   * Functions for showing field editors in modal dialogs. Useful for prompting
   * the user to enter values, edit individual slots, and fire actions.
   *
   * @exports nmodule/webEditors/rc/fe/feDialogs
   */
  const feDialogs = {};


////////////////////////////////////////////////////////////////
// Support functions
////////////////////////////////////////////////////////////////

  //TODO: check for operator flag

  /**
   * Ensures that a mounted `component` and Action `slot` param are present.
   * If an actionArgument is provided ensure its at least a BValue.
   *
   * @private
   * @inner
   * @param {Object} params
   */
  function validateActionParams(params) {
    params = params || {};

    const component = params.component;

    if (!baja.hasType(component, 'baja:Component')) {
      throw new Error('component required');
    }

    if (!component.isMounted()) {
      throw new Error('component must be mounted');
    }

    const slot = component.getSlot(params.slot);

    if (!slot || !slot.isAction()) {
      throw new Error('Action slot required');
    }

    const actionArgument = params.actionArgument;

    if (actionArgument !== undefined && (!baja.hasType(actionArgument) || !actionArgument.getType().isValue())) {
      throw new Error('Action Arguments must be a Value');
    }
  }

  /**
   * Checks for `CONFIRM_REQUIRED` flag and shows confirmation dialog if
   * needed.
   *
   * @private
   * @inner
   * @param {baja.Complex} comp
   * @param {baja.Slot|String} slot
   * @returns {Promise} promise to be resolved if no confirmation was
   * needed or the user did confirm that the action should be invoked. Rejected
   * if the user did not confirm invocation.
   */
  function confirmInvoke(comp, slot) {
    // eslint-disable-next-line promise/avoid-new
    return new Promise((resolve, reject) => {
      if (!(comp.getFlags(slot) & baja.Flags.CONFIRM_REQUIRED)) {
        return resolve();
      }

      const display = comp.getDisplayName(slot);

      dialogs.showOkCancel({
        title: webEditorsLex.get('dialogs.confirmInvoke.title', display),
        content: webEditorsLex.getSafe('dialogs.confirmInvoke.content', display)
      })
        .ok(resolve)
        .cancel(reject);
    });
  }


  /**
   * Build the editor in the given dialog.
   *
   * As the editor is created, initialized and loaded, progress events with
   * those same names will be passed to the given progress handler. This way,
   * someone using `feDialogs.showFor` can get callbacks for the actual editor
   * instance as it is created, and add event handlers on it, for instance.
   *
   * @inner
   * @param {Object} params fe params
   * @param {JQuery} contentDiv
   * @param {Function} progress
   * @param {Dialog} dlg The Dialog instance.
   * @returns {*}
   */
  function buildEditor(params, contentDiv, progress, dlg) {
    const parent = contentDiv.parent();

    contentDiv.detach();

    return Promise.all([ fe.params(params), fe.makeFor(params) ])
      .then(([ feParams, ed ]) => {
        progress('created', ed);

        return ed.initialize(contentDiv)
          .then(() => {
            ed.$dlg = dlg;
            progress('initialized', ed);
            return ed.load(feParams.getValueToLoad());
          })
          .then(() => {
            progress('loaded', ed);
            contentDiv.prependTo(parent);
            return ed;
          });
      });
  }

  function readAndDestroy(ed, shouldSave) {
    const modified = ed.isModified();
    return Promise.resolve(shouldSave && ed.save())
      .catch((err) => {
        feDialogs.error(err).catch(logError);
        throw err; //failed to validate - keep dialog open
      })
      .then(() => ed.read())
      .then((value) => {
        return Promise.resolve(modified && emitValueReady(ed, value))
          .then(() => ed.destroy().catch(logError))
          .then(() => value);
      });
  }

  /**
   * @param {module:bajaux/Widget} ed
   * @param {*} value
   * @returns {Promise}
   */
  function emitValueReady(ed, value) {
    return Promise.all(ed.emit(VALUE_READY_EVENT, value));
  }

  function getErrorTitle(err) {
    return (err && err.javaClass) || webEditorsLex.get('dialogs.error');
  }

  function getErrorStack(err) {
    if (err instanceof baja.comm.BoxError) {
      return err.javaStackTrace;
    } else if (err && err.stack) {
      return String(err.stack);
    }
  }

////////////////////////////////////////////////////////////////
// ErrorDetailsWidget
////////////////////////////////////////////////////////////////

  /**
   * Widget for showing error details.
   *
   * @private
   * @class
   * @extends module:bajaux/Widget
   */
  class ErrorDetailsWidget extends Widget {
    $getDetailsElement() {
      return this.jq().children('.errorDetails');
    }

    $getMessageElement() {
      return this.jq().children('.errorMessage');
    }

    $getDetailsButtonElement() {
      return this.jq().children('.detailsButton');
    }

    $getDetailsButton() {
      return Widget.in(this.$getDetailsButtonElement().children('button'));
    }

    /**
     * Build HTML and add `ErrorDetailsWidget` class.
     * @param {JQuery} dom
     */
    doInitialize(dom) {
      dom.addClass('ErrorDetailsWidget ux-fg');
      dom.html(errorDetailsHtml);
    }

    /**
     * Load details about the error. If the error has additional detail to show,
     * create a "details" button that will show the detail when clicked.
     *
     * @param {Error} err
     * @returns {Promise}
     */
    doLoad(err) {
      const msg = err instanceof Error ? err.message : String(err);
      const stack = getErrorStack(err);

      this.$getMessageElement().text(msg);

      if (stack) {
        const buttonDom = this.$getDetailsButtonElement();

        this.$getDetailsElement().text(stack);

        return fe.buildFor({
          dom: buttonDom.children('button'),
          type: CommandButton,
          value: new Command({
            displayName: '%lexicon(webEditors:dialogs.details)%',
            func: () => {
              this.$getDetailsElement().show();
              buttonDom.hide();
            }
          })
        })
          .then(() => buttonDom.show());
      }
    }

    /**
     * Remove `ErrorDetailsWidget` class and destroy the details button.
     */
    doDestroy() {
      this.jq().removeClass('ErrorDetailsWidget ux-fg');
      const btn = this.$getDetailsButton();
      return btn && btn.destroy();
    }
  }


////////////////////////////////////////////////////////////////
// Exports
////////////////////////////////////////////////////////////////

  /**
   * Widget used by `error()` for showing error details.
   * @private
   * @type {module:bajaux/Widget}
   */
  feDialogs.ErrorDetailsWidget = ErrorDetailsWidget;

  /**
   * Shows a field editor in a dialog.
   *
   * When the user clicks OK, the editor will be saved, committing any changes.
   * The value that the user entered will be read from the editor and used to
   * resolve the promise.
   *
   * @param {Object} params params to be passed to `fe.buildFor()`.
   * @param {jQuery} [params.dom] if your widget type should be instantiated
   * into a specific kind of DOM element, it can be passed in as a parameter.
   * Note that the given element will be appended to the dialog element itself,
   * so do not pass in an element that is already parented. If omitted, a `div`
   * will be created and used.
   * @param {String} [params.title] title for the dialog
   * @param {Number} [params.delay=200] delay in ms to wait before showing a
   * loading spinner. The spinner will disappear when the field editor has
   * finished initializing and loading.
   * @param {boolean} [params.save] set to false to specify that the dialog
   * should *not* be saved on clicking OK - only the current value will be read
   * from the editor and used to resolve the promise.
   * @param {Function} [params.progressCallback] pass a progress callback to
   * receive notifications as the editor being shown goes through the stages
   * of its life cycle (`created`, `initialized`, `loaded`), as well as whenever
   * the editor is validated (`invalid`, `valid`).
   * @returns {Promise} promise to be resolved when the user has entered
   * a value into the field editor and clicked OK, or rejected if the field
   * could not be read. The promise will be resolved with the value that the
   * user entered (or `null` if Cancel was clicked).
   *
   * @example
   *   feDialogs.showFor({
   *     value: 'enter a string here (max 50 chars)',
   *     properties: { max: 50 },
   *     progressCallback: function (msg, arg) {
   *       switch(msg) {
   *       case 'created':     return console.log('editor created', arg);
   *       case 'initialized': return console.log('editor initialized', arg.jq());
   *       case 'loaded':      return console.log('editor loaded', arg.value());
   *       case 'invalid':     return console.log('validation error', arg);
   *       case 'valid':       return console.log('value is valid', arg);
   *       }
   *     }
   *   })
   *   .then(function (str) {
   *     if (str === null) {
   *       console.log('you clicked cancel');
   *     } else {
   *       console.log('you entered: ' + str);
   *     }
   *   });
   */
  feDialogs.showFor = function showFor(params) {
    params = baja.objectify(params, 'value');

    const shouldSave = params.save !== false,
      contentDiv = (params.dom || $('<div/>'));

    if (contentDiv.parent().length) {
      return Promise.reject(new Error('element already parented'));
    }

    // eslint-disable-next-line promise/avoid-new
    return new Promise(function (resolve, reject) {
      let editor;
      const progress = function (event, ed) {
        if (event === 'created') {
          editor = ed;
        }
        params.progressCallback && params.progressCallback(event, ed);
      };

      const firstShown = _.once(function () {
        /* wait until the content is visible then toggle its visibility
        off and on to work around iOS -webkit-touch-scrolling issue */
        contentDiv.toggle(0);
        contentDiv.toggle(0);
        return editor && editor.requestFocus && editor.requestFocus();
      });

      dialogs.showOkCancel({
        delay: params.delay || DEFAULT_DELAY,
        title: params.title,
        layout: () => {
          //layout the editor when the dialog lays out
          firstShown();
          return editor && editor.layout();
        },
        content: (dlg, content) => {
          contentDiv.appendTo(content);

          contentDiv.on(LOAD_EVENT + ' ' + MODIFY_EVENT, (e, ed) => {
            if (ed === editor) {
              editor.validate()
                .then((value) => {
                  dlg.buttonJq('ok').attr('title', '');
                  dlg.enableButton("ok");
                  progress('valid', value);
                }, (err) => {
                  dlg.buttonJq('ok').attr('title', String(err));
                  dlg.disableButton("ok");
                  progress('invalid', err);
                });
            }
          });

          contentDiv.on('keyup', function (e) {
            if (e.which === ENTER_KEY) {
              Widget.in(contentDiv).validate()
                .then(() => dlg.click('ok'))
                .catch(_.noop);
            }
          });

          return buildEditor(params, contentDiv, progress, dlg)
            .then((ed) => {
              contentDiv.on(VALUE_READY_EVENT, (e, value) => {
                emitValueReady(ed, value)
                  .then(() => ed.destroy().catch(logError))
                  .then(() => {
                    dlg.close();
                    resolve(value);
                  })
                  .catch(logError);
              });

              dlg
                .ok(() => readAndDestroy(ed, shouldSave).then(resolve))
                .cancel(() => ed.destroy().catch(logError).then(() => resolve(null)));
            })
            .catch((err) => {
              content.text(String(err));
              reject(err);
            });
        }
      });
    });
  };

  /**
   * Show an editor in a dialog, similar to `showFor`, but with the added
   * expectation that the editor represents a one-time interaction, like a
   * button click, after which the dialog can be immediately closed. In other
   * words, the "click ok to close" functionality is embedded in the editor
   * itself. Only a Cancel button will be shown in the dialog itself.
   *
   * In order for the dialog to close, the shown editor must trigger a
   * `feDialogs.VALUE_READY_EVENT`, optionally with a read value. When this
   * event is triggered, the dialog will be closed and the promise resolved
   * with the value passed to the event trigger.
   *
   * @param {Object} params params to be passed to `fe.buildFor`
   * @param {String} [params.title] title for the dialog
   * @param {Number} [params.delay=200] delay in ms to wait before showing a
   * loading spinner. The spinner will disappear when the field editor has
   * finished initializing and loading.
   * @param {Function} [params.progressCallback] pass a progress callback to
   * receive notifications as the editor being shown goes through the stages
   * of its life cycle (created, initialized, loaded).
   * @returns {Promise} promise to be resolved when the editor has
   * triggered its own value event. It will be resolved with any value passed
   * to the event trigger, or with `null` if Cancel was clicked.
   *
   * @example
   *   <caption>Trigger a VALUE_READY_EVENT to cause the dialog to be closed.
   *   </caption>
   *
   * // ...
   * MyEditor.prototype.doInitialize = function (dom) {
   *   dom.on('click', 'button', function () {
   *     dom.trigger(feDialogs.VALUE_READY_EVENT, [ 'my value' ]);
   *   });
   * };
   * //...
   *
   * feDialogs.selfClosing({
   *   type: MyEditor
   * }}
   *   .then(function (value) {
   *     if (value === 'my value') {
   *       //success!
   *     }
   *   });
   */
  feDialogs.selfClosing = function (params) {
    params = baja.objectify(params, 'value');

    const progress = params.progressCallback || $.noop;
    let delay = params.delay;

    if (typeof delay === 'undefined') { delay = DEFAULT_DELAY; }

    // eslint-disable-next-line promise/avoid-new
    return new Promise((resolve, reject) => {
      dialogs.showCancel({
        delay: delay,
        title: params.title,
        content: function (dlg, content) {
          const contentDiv = $('<div/>').appendTo(content);

          dlg.cancel(() => resolve(null));

          buildEditor(params, contentDiv, progress)
            .then((ed) => {
              contentDiv.on(VALUE_READY_EVENT, (e, value) => {
                ed.destroy()
                  .finally(() => {
                    dlg.close();
                    resolve(value);
                  });
              });
            })
            .catch(reject);
        }
      });
    });
  };

  /**
   * Invoke an action on a mounted component. If the action requires a
   * parameter, a field editor dialog will be shown to retrieve that argument
   * from the user.
   *
   * @param {Object} params
   * @param {baja.Component} params.component the component on which to invoke
   * the action. Must be mounted.
   * @param {String|baja.Slot} params.slot the action slot to invoke. Must be
   * a valid Action slot.
   * @param {baja.Value} [params.actionArgument] Starting in Niagara 4.10, this
   * action argument can be used instead of showing a dialog to obtain
   * the argument.
   * @returns {Promise} promise to be resolved with the action return
   * value if the action was successfully invoked, resolved with `null` if
   * the user clicked Cancel, or rejected if the parameters were invalid or the
   * action could not be invoked.
   */
  feDialogs.action = function action(params) {
    try {
      validateActionParams(params);
    } catch (e) {
      return Promise.reject(e);
    }

    function performInvocation(comp, slot, actionArgument) {
      const actionArgumentProvided = actionArgument !== undefined;
      let param;

      return Promise.resolve(actionArgumentProvided || comp.getActionParameterDefault({ slot: slot }))
        .then((p) => {
          if (actionArgumentProvided) {
            return actionArgument;
          }
          param = p;
          //if param is called for, read it from a field editor.
          if (param !== null) {
            return feDialogs.showFor({
              title: comp.getDisplayName(slot),
              value: param,
              formFactor: 'any',
              properties: _.extend(comp.getFacets(slot).toObject(), { ordBase: comp })
            });
          }
        })
        .then((readValue) => {
          //TODO: mobile does decodeFromString here. still necessary?
          if (readValue === null) {
            // user clicked cancel to parameter dialog
            return null;
          }

          return comp.invoke({
            //complexes are always edit by ref.
            value: isComplex(param) ? param : readValue,
            slot: slot
          });

          //TODO: mobile forces a sync here. still necessary?
        });
    }

    const comp = params.component;
    const slot = params.slot;
    const actionArgument = params.actionArgument;

    return confirmInvoke(comp, slot)
      .then(() => performInvocation(comp, slot, actionArgument), () => /* invocation canceled */ null)
      .catch((err) => feDialogs.error(err).then(() => { throw err; }));
  };

  /**
   * Show details about an error.
   *
   * @param {Error|*} err
   * @returns {Promise}
   */
  feDialogs.error = function (err) {
    logError(err);
    return feDialogs.showFor({
      title: getErrorTitle(err),
      type: feDialogs.ErrorDetailsWidget,
      value: err
    });
  };

  return (feDialogs);
});