fe/feDialogs.js

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

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

  'use strict';

  const { extend, find, noop, omit, once, pick } = _;
  const { doRequire } = asyncUtils;
  const { LOAD_EVENT, MODIFY_EVENT } = events;
  const { VALUE_READY_EVENT } = BaseWidget;
  const [ jsLex, webEditorsLex ] = lexs;
  const { isAssignableFrom, isComplex } = typeUtils;

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

  // avoid circular dependency
  const requirePropertySheet = once(() => doRequire('nmodule/webEditors/rc/wb/PropertySheet'));

  /**
   * 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),
        text: webEditorsLex.get('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();
    const { summary } = params;

    contentDiv.detach();

    if (summary) {
      params = omit(params, 'summary');
    }

    let value;
    return fe.params(params)
      .then((feParams) => {
        value = feParams.getValueToLoad();
        let makeForParams = feParams;

        if (summary) {
          const { value } = feParams;
          makeForParams = {
            type: ValueWithSummaryWidget,
            value,
            properties: {
              getConfig: () => feParams,
              summary
            }
          };
        }
        return fe.makeFor(makeForParams);
      })
      .then((ed) => {
        progress('created', ed);

        return ed.initialize(contentDiv)
          .then(() => {
            ed.$dlg = dlg;
            progress('initialized', ed);
            return ed.load(value);
          })
          .then(() => {
            progress('loaded', ed);
            contentDiv.prependTo(parent);
            return ed;
          }, function (err) {
            throw err;
          });
      });
  }

  function readAndDestroy(ed, shouldSave, onSaveError) {
    const modified = ed.isModified();
    return Promise.resolve(shouldSave && ed.save())
      .catch((err) => {
        const errorProm = onSaveError ? Promise.resolve(onSaveError(err)) : feDialogs.error(err);
        return errorProm.catch(logError).then(() => {
          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 ed.emitAndWait(VALUE_READY_EVENT, value);
  }

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

  /**
   * 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 {String} [params.summary] (since Niagara 4.14) provide this to display a more detailed
   * textual description about this prompt, e.g.: a description of its purpose, instructions on how
   * to use the editor shown, any additional information the user may want.
   * @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.onSaveError] when this function is set and save
   * is set to true, the error will be handed off to this method. Otherwise,
   * when save is true feDialogs will show whatever error caused by the save in
   * a different dialog. This may optionally return a 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`).
   * @param {Array.<module:dialogs~Button|string>} [params.buttons] as of Niagara 4.12,
   * custom buttons can be specified. See examples for details.
   * @param {Object.<string, Function>} [params.on] as of Niagara 4.12, custom handlers for
   * `bajaux` events (and only `bajaux` events) can be specified. This is an object literal
   * where the keys are `bajaux` event names and the values are event handler functions.
   * See examples for details.
   * @param {Function} [params.validate] as of Niagara 4.14, specify a custom validate function
   * to ensure that the entered value is valid. If the user enters an invalid value, the OK button
   * will be disabled. This function should throw an Error, return a rejected Promise, or return or
   * resolve `false` to fail validation.
   * @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);
   *     }
   *   });
   *
   * @example
   * <caption>Specify custom button handlers. If the user clicks one of these custom buttons,
   * the showFor promise will be resolved with the value resolved by its handler.</caption>
   *
   * feDialogs.showFor({
   *   value: 'enter a string',
   *   buttons: [ {
   *     name: 'uppercase',
   *     displayName: 'Uppercase It',
   *     handler: (dialog, event, editor) {
   *       // the arguments to the button handler are: the Dialog instance, the click event,
   *       // and the editor being shown in the dialog.
   *       // call `dialog.keepOpen` if you are not ready for the dialog to close. the dialog will
   *       // stay open and the promise will not be resolved yet.
   *
   *       dialog.keepOpen();
   *       return editor.read().then((string) => editor.load(string.toUpperCase());
   *     }
   *   }, {
   *     name: 'lowercase',
   *     displayName: 'Lowercase It',
   *     handler: (dialog, event, editor) {
   *       dialog.keepOpen();
   *       return editor.read().then((string) => editor.load(string.toLowerCase()));
   *     }
   *   }, {
   *     // default 'ok' behavior is to read the value and resolve the promise. you don't have to
   *     // specify a handler to do this.
   *     name: 'ok'
   *   } ]
   * });
   *
   * @example
   * <caption>The strings 'ok', 'cancel', 'yes', and 'no' are special - you can include them in the buttons
   * parameter to get their default behavior.</caption>
   *
   * // only show the OK button, and resolve the promise with the entered value. 'yes' works the same.
   * feDialogs.showFor({ value: 'enter a string', buttons: [ 'ok' ] });
   *
   * // only show the Cancel button, and resolve the promise with null. 'no' works the same.
   * feDialogs.showFor({ value: 'your changes will not be used', buttons: [ 'cancel' ] });
   *
   * @example
   * <caption>The buttons parameter can be an object literal, where the values are button
   * definitions or handler functions.</caption>
   *
   * feDialogs.showFor({
   *   value: 'Value to Edit',
   *   buttons: {
   *     ok: () => 'my custom ok result', // the value can be just a handler function. the default display name will be used.
   *     cancel: {
   *       displayName: "Never Mind"
   *       // omit the handler, and default handler for "cancel" will resolve null.
   *     },
   *     yes: {}, // just an empty object will use the default display name and default handler.
   *     no: {
   *       handler: () => 'user clicked "no"' // include a handler to override the default handler.
   *     },
   *     delete: {
   *       // for anything other than 'ok', 'cancel', 'yes', or 'no', you'll need to provide a
   *       // display name - or else just the button name will be used.
   *       displayName: 'Delete Everything',
   *       handler: () => deleteEverything()
   *     },
   *     retry: shouldShowRetryButton() && { // falsy values will cause the button _not_ to be shown.
   *       displayName: 'Try Again',
   *       handler: () => letUserTryAgain()
   *     }
   *   }
   * });
   *
   * @example
   * <caption>Use the 'on' parameter to respond to any bajaux events that are triggered by the
   * editor.</caption>
   *
   * const { MODIFY_EVENT } = events;
   * feDialogs.showFor({
   *   value: 'edit me',
   *   properties: { max: 10 },
   *   on: {
   *     [MODIFY_EVENT]: (dialog, event, editor) {
   *       return editor.validate()
   *         .catch(() => alert('no more than 10 characters pls'));
   *     }
   *   }
   * });
   */
  feDialogs.showFor = function showFor(params) {
    params = baja.objectify(params, 'value');

    const {
      delay,
      dom,
      on = {},
      progressCallback,
      summary,
      title,
      validate
    } = params;

    const contentDiv = (dom || $('<div/>'));

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


    return normalizeButtons(params.buttons || [ 'ok', 'cancel' ])
    // eslint-disable-next-line promise/avoid-new
    .then((buttons) => new Promise(function (resolve, reject) {
      let editor;

      // when using a summary, the value editor is a child of a ValueWithSummaryWidget so will not
      // appear until the loaded event. we have to rapid-fire them after the editor becomes available.
      let innerEdAppeared = summary && once(() => {
        const inner = getEditorForCaller(editor);
        if (progressCallback) {
          progressCallback('created', inner);
          progressCallback('initialized', inner);
          progressCallback('loaded', inner);
        }
        if (validate) {
          applyValidator(inner, validate);
        }
      });

      const progress = function (event, ed) {
        if (event === 'created') {
          editor = ed;
          if (validate && !summary) {
            applyValidator(ed, validate);
          }
        }

        if (innerEdAppeared) {
          if (event === 'loaded') {
            innerEdAppeared();
            innerEdAppeared = null; // allow later progress messages to propagate as normal
          }
        } else {
          progressCallback && progressCallback(event, getEditorForCaller(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.show({
        buttons: withoutHandlers(buttons),
        delay: delay || DEFAULT_DELAY,
        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) => {
                  const okJq = dlg.buttonJq('ok');
                  if (okJq) {
                    okJq.attr('title', '');
                    dlg.enableButton("ok");
                  }
                  progress('valid', value);
                }, (err) => {
                  const okJq = dlg.buttonJq('ok');
                  if (okJq) {
                    okJq.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);
            }
          });

          Object.keys(on).forEach((eventName) => {
            const handler = on[eventName];
            contentDiv.on(eventName, function (e, ed, ...rest) {
              if (ed !== editor) { return; }

              Promise.try(() => handler.apply(this, [ dlg, e, getEditorForCaller(ed), ...rest ]))
                .catch(logError);
            });
          });

          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);
              });

              buttons.forEach((btn) => {
                const handler = getButtonHandler(btn, ed, params);
                dlg.on(btn.name, function (dlg) {
                  let keepOpen;
                  dlg.keepOpen = () => { keepOpen = true; };

                  return Promise.try(() => handler.apply(this, [ ...arguments, getEditorForCaller(ed) ]))
                    .then((result) => {
                      if (keepOpen) {
                        throw new Error();
                      }
                      delete dlg.keepOpen;
                      return ed.destroy().catch(logError)
                        .then(() => resolve(result));
                    }, (err) => {
                      logError(err);
                      throw err;
                    });
                });
              });
            })
            .catch((err) => {
              content.text(String(err));
              reject(err);
            });
        }
      });
    }));
  };

  function getButtonHandler(btn, ed, params) {
    let { handler } = btn;
    switch (btn.name) {
      case 'ok':
      case 'yes':
        return handler || (() => {
          const { save, onSaveError } = params;
          const shouldSave = save !== false;
          return readAndDestroy(ed, shouldSave, onSaveError);
        });
      case 'cancel':
      case 'no':
        return handler || (() => null);
      default:
        return handler || (() => {});
    }
  }

  /**
   * @param {Array.<module:dialogs~Button|string|bajaux/commands/Command>|object} buttons
   * @returns {Promise.<Array.<module:dialogs~Button>>}
   */
  function normalizeButtons(buttons) {
    if (!Array.isArray(buttons)) {
      buttons = Object.keys(buttons).map((name) => {
        let button = buttons[name];
        if (!button) {
          return;
        }

        if (typeof button === 'function') {
          button = { handler: button };
        }
        button.name = name;
        return button;
      });
    }

    let cmdCount = 0;
    return Promise.all(buttons.filter((b) => !!b).map((btn) => {
      if (btn instanceof Command) {
        return btn.toDisplayName()
          .then((displayName) => {
            return {
              name: 'cmd' + (cmdCount++),
              displayName,
              handler: () => btn.invoke()
            };
          });
      }

      if (typeof btn === 'string') {
        btn = { name: btn };
      }

      let { name, displayName } = btn;
      if (!displayName) {
        switch (name) {
          case 'ok':
          case 'cancel':
          case 'yes':
          case 'no':
            btn.displayName = jsLex.get('dialogs.' + name);
            break;
          default:
            btn.displayName = name;
        }
      }
      return btn;
    }));
  }

  function withoutHandlers(buttons) {
    return buttons.map((btn) => {
      if (typeof btn === 'string') {
        btn = { name: btn };
      }
      return omit(btn, 'handler');
    });
  }

  /**
   * 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) {
            const slotProps = extend(comp.getFacets(slot).toObject(), { ordBase: comp });
            const title = comp.getDisplayName(slot);
            return resolveActionParams(param.getType(), slotProps)
              .then(({ type, properties }) => feDialogs.showFor({
                value: param, title, type, properties
              }));
          }
        })
        .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; }));
  };

  /**
   * A simple mechanism for editing multiple properties at once.
   *
   * @param {Object.<string, baja.Value|object>|baja.Complex} props a JSON object representing the
   * properties to edit. May be nested. You can also simply pass a Complex to edit the slots of
   * that Complex. See examples.
   * @param {object} [params]
   * @param {baja.Component} [params.ordBase] if field editors may need to resolve ORDs themselves,
   * pass `ordBase` to allow them to successfully resolve ORDs even when offline.
   * @param {string} [params.title] optional dialog title
   * @param {string} [params.summary] optional summary details for dialog
   * @param {Function} [params.validate] optional validation function to ensure that the entered
   * value is valid. If the user enters an invalid value, the OK button will be disabled. This
   * function should throw an Error, return a rejected Promise, or return or resolve `false` to fail
   * validation. It will receive a JSON object or a Complex, depending on what was passed as the
   * `props` argument.
   * @returns {Promise.<object|null>} the newly entered values, or null if user clicked Cancel
   * @since Niagara 4.14
   *
   * @example
   * <caption>Edit a simple key->value map.</caption>
   * return feDialogs.props({ foo: 'bar' }); // resolves an object with user-edited "foo" property
   *
   * @example
   * <caption>Edit a nested key->value map.</caption>
   * return feDialogs.props({
   *   user: { value: { firstName: 'Moe', lastName: 'Howard' } }
   * }); // resolves an object with a "user" property containing user-editor "firstName" and "lastName"
   *
   * @example
   * <caption>Specify display name</caption>
   * return feDialogs.props({
   *   foo: { displayName: 'Your foo value', value: 'bar' }
   * }); // resolves an object with user-entered "foo" property, but user was shown customized display name
   *
   * @example
   * <caption>Use display name from lexicon</caption>
   * return feDialogs.props({
   *   overrideValue: { displayName: '%lexicon(control:override.value)%', value: 0 }
   * }); // resolves an object with user-entered "overrideValue" property but user was shown "Override Value" from lexicon
   *
   * @example
   * <caption>Specify flags</caption>
   * return feDialogs.props({
   *   hidden: { value: 'hiddenValue', hidden: true },
   *   readonly: { value: 'readonlyValue', readonly: true }
   * }); // resolves an object with "hidden" and "readonly" values. User did not see "hidden" and was not able to edit "readonly".
   *
   * @example
   * <caption>Specify properties</caption>
   * return feDialogs.props({
   *   percent: { value: 0, properties: { min: 0, max: 100 } }
   * }); // resolves an object with "percent" value. Editor was validated to be between 0 and 100.
   *
   * @example
   * <caption>Edit a Complex directly</caption>
   * const comp = baja.$('control:NumericWritable');
   * return feDialogs.props(comp); // please note that the input Complex will be *saved* when the user clicks OK
   *
   * @example
   * <caption>Specify title and summary</caption>
   * return feDialogs.props({ foo: 'bar' }, {
   *   title: 'Foo Dialog',
   *   summary: 'Please enter your foo value'
   * });
   */
  feDialogs.props = function (props, params = {}) {
    const { title, summary, validate } = params;
    return feDialogs.showFor({
      type: 'nmodule/webEditors/rc/fe/JSONPropertySheet', // no circular dependency
      value: props,
      formFactor: 'max',
      readonly: params.readonly,
      title,
      summary,
      validate,
      properties: pick(params, 'ordBase'),
      dom: $('<div class="-t-feDialogs-props-dialog"></div>')
    });
  };

  /**
   * Find a mini field editor to edit the action argument. If none is present,
   * find one that's a PropertySheet.
   * @param {Type} type
   * @param {object} properties
   * @returns {Promise.<{ type: Function, properties: object }>} the type of the
   * widget to use to edit the action argument, and the widget properties to set
   * on it
   */
  function resolveActionParams(type, properties) {
    return fe.getDefaultConstructor(type, { properties, formFactor: 'mini' })
      .then((ctor) => {
        if (ctor) { return { type: ctor, properties }; }

        return Promise.all([
          requirePropertySheet(),
          fe.getConstructors(type, { properties, formFactors: [ 'compact', 'max' ] })
        ])
          .then(([ PropertySheet, ctors ]) => {
            const sheetCtor = find(ctors, (ctor) => isAssignableFrom(PropertySheet, ctor));
            if (sheetCtor) {
              return {
                type: sheetCtor,
                properties: Object.assign({ showControls: false, showHeader: false }, properties)
              };
            } else {
              return { properties };
            }
          });
      });
  }

  /**
   * When the user passes a `summary` parameter the actual editor shown in the dialog will be a
   * ValueSummaryWidget. But the user's own handlers for bajaux events and button clicks don't want
   * to receive a ValueSummaryWidget - that's internal details - if the caller showFor()s a String,
   * they want to interact with a StringEditor. Give them that StringEditor.
   *
   * @param {module:bajaux/Widget} ed the editor shown in the dialog
   * @returns {module:bajaux/Widget} the editor the caller expects to interact with
   */
  function getEditorForCaller(ed) {
    return ed instanceof ValueWithSummaryWidget ? ed.getInnerValueEditor() : ed;
  }

  function applyValidator(ed, validate) {
    ed.validators().add((v) => {
      return Promise.try(() => validate(v))
        .then((result) => {
          if (result === false) {
            throw new Error();
          }
        });
    });
  }

  /**
   * Show details about an error.
   *
   * @param {Error|*} err
   * @param {Object} [params]
   * @param {String} [params.title]
   * @param {module:bajaux/commands/Command} [params.command] An optional Command to help display information on which Command failed
   * @param {String} [params.messageSummary] An optional messageSummary to prepend to the Error.
   * @returns {Promise}
   */
  feDialogs.error = function (err, params = {}) {
    logError(err);
    return ErrorDetailsWidget.dialog(err, params);
  };

  return (feDialogs);
});