/**
* @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);
});