/**
* @copyright 2015 Tridium, Inc. All Rights Reserved.
* @author Gareth Johnson
*/
/**
* @module bajaux/commands/Command
*/
define([ 'lex!',
'jquery',
'Promise',
'underscore',
'bajaux/events',
'nmodule/js/rc/asyncUtils/asyncUtils' ], function (
lex,
$,
Promise,
_,
events,
asyncUtils) {
'use strict';
const { isFunction, isString } = _;
const { doRequire } = asyncUtils;
const {
CHANGE_EVENT: COMMAND_CHANGE_EVENT,
FAIL_EVENT: COMMAND_FAIL_EVENT,
INVOKE_EVENT: COMMAND_INVOKE_EVENT
} = events.command;
// avoid circular dependency
const requireErrorDetailsWidget = _.once(() => doRequire('bajaux/util/ErrorDetailsWidget'));
let undoManager;
let idCounter = 0;
function parseAccelerator(acc) {
var a = null,
codes, c, i, res;
if (acc) {
if (acc && typeof acc === "object") {
a = acc;
} else {
a = {
keyCode: acc.toLowerCase()
};
}
// If we have a String then convert the character code.
if (typeof a.keyCode === "string") {
codes = a.keyCode.split("+");
for (i = 0; i < codes.length; ++i) {
c = codes[i];
if (c.length === 1) {
// Process keycode
a.keyCode = c.charCodeAt(0);
} else {
// Process modifiers
if (c === "alt") {
a.alt = true;
} else if (c === "shift") {
a.shift = true;
} else if (c === "ctrl") {
a.ctrl = true;
} else if (c === "meta") {
a.meta = true;
} else if (c === "backspace") { // Process other key codes
a.keyCode = 8;
} else if (c === "tab") {
a.keyCode = 9;
} else if (c === "space") {
a.keyCode = 32;
} else if (c === "pageup") {
a.keyCode = 33;
} else if (c === "pagedown") {
a.keyCode = 34;
} else if (c === "left") {
a.keyCode = 37;
} else if (c === "up") {
a.keyCode = 38;
} else if (c === "right") {
a.keyCode = 39;
} else if (c === "down") {
a.keyCode = 40;
} else if (c === "delete") {
a.keyCode = 46; // 127 in BAccelerator
} else if (c === "end") {
a.keyCode = 35;
} else if (c === "esc") {
a.keyCode = 27;
} else if (c === "home") {
a.keyCode = 36;
} else if (c === "insert") {
a.keyCode = 45; // 155 in BAccelerator;
} else if (c.charAt(0) === "f") {
// Process function keys
res = /f([1-9]+)/.exec(c);
if (res) {
a.keyCode = 112 + (parseInt(res[1], 10) - 1);
}
}
}
}
}
}
return a;
}
function toUndoableGetter(undoableParam) {
return (...args) => {
return Promise.resolve()
.then(() => isFunction(undoableParam) ? undoableParam(...args) : undoableParam)
.then((undoable) => {
if (!undoable) { return; }
let { undo, redo, canUndo, canRedo, undoText, redoText } = undoable;
if (!isFunction(canUndo)) { canUndo = toGetter(true); }
if (!isFunction(canRedo)) { canRedo = toGetter(true); }
if (!isFunction(undo)) {
undo = () => { throw new Error('undo not implemented'); };
canUndo = () => false;
}
if (!isFunction(redo)) {
redo = () => { throw new Error('redo not implemented'); };
canRedo = () => false;
}
if (isString(undoText)) { undoText = toGetter(undoText); }
if (isString(redoText)) { redoText = toGetter(redoText); }
if (!isFunction(undoText)) { undoText = toGetter(''); }
if (!isFunction(redoText)) { redoText = toGetter(''); }
return {
undo: promiseTry(undo),
redo: promiseTry(redo),
canUndo: promiseTry(canUndo),
canRedo: promiseTry(canRedo),
undoText: promiseTry(undoText),
redoText: promiseTry(redoText)
};
});
};
}
function promiseTry(func) { return () => Promise.resolve().then(func); }
/**
* A Command is essentially an asynchronous function with a nicely formatted
* display name attached.
*
* @class
* @alias module:bajaux/commands/Command
*
* @param {String|Object} params An Object Literal or the display name for
* the Command.
*
* @param {String} params.displayName The display name format
* for the Command. This format will be used in
* `toDisplayName`. This can also be the first argument, in which
* case `func` must be the second.
*
* @param {Function} [params.func] The function this command will execute. The
* function can return a promise if it's going to be invoked asynchronously.
* As of Niagara 4.11 this can be omitted if creating an undoable function.
*
* @param {module:bajaux/commands/Command~Undoable|function} [params.undoable] As
* of Niagara 4.11, any necessary configuration to make this command undoable.
* This can be either an `undoable` directly, or a function that resolves to one.
* Please note that an `undoable()` function itself should not do the actual
* work - the `redo()` function of the returned undoable should do the work.
* Note that any asynchronous functions may be declared as synchronous when
* passed to the constructor, for simplicity.
*
* @param {String} [params.description] A description of the
* Command. This format will be used in `toDescription`.
*
* @param {Boolean} [params.enabled] The enabled state of the Command.
* Defaults to `true`.
*
* @param {Number} [params.flags] The Command flags. Defaults to
* `Command.flags.ALL`.
*
* @param {String} [params.icon] The Command Icon. This can also be a
* String encoding an icon will be created from.
*
* @param {Function} [invokeFunction] the function to invoke, if using the two-argument
* constructor
*
* @example
* new Command("baja.Format compatible display name", function () {
* alert("I'm a command!");
* });
*
* new Command("Format compatible display name", function () {
* return new Promise(function (resolve, reject) {
* setTimeout(function () {
* wobble.foo();
* resolve;
* }, 1000);
* });
* });
*
* new Command({
* displayName: "I'll be converted to a Format: %lexicon(baja:january)%",
* description: "I'll be converted to a Format too: %lexicon(baja:true)%",
* func: function () {
* alert("I'm a command!");
* }
* });
*
* new Command({
* module: "myModule", // Create a Command that gets its displayName, description
* lex: "myCommand", // and icon from a module's lexicon.
* func: function () {
* alert("I'm a command!");
* }
* });
*
* new Command({
* undoable: () => {
* return promptUser('Are you sure you want to make this change?')
* .then((userSaidYes) => {
* if (!userSaidYes) { return; }
*
* // redoText/undoText may be strings, getters, or async getters
* // canRedo/canUndo may be booleans, getters, or async getters
* return {
* redo: () => console.log('perform the work of the command'),
* undo: () => console.log('revert/back out the work of the command'),
* redoText: () => 'Text describing what the command will do',
* undoText: () => 'Text describing what undoing the command will do',
* canRedo: () => true, // true if doing the work of the command is allowed
* canUndo: () => true // true if reverting the work of the command is allowed
* }
* });
* });
*/
var Command = function Command(params, invokeFunction) {
var that = this,
loadingPromise;
params = params && params.constructor === Object ? params
: { displayName: params, func: invokeFunction };
let {
accelerator,
description,
displayName,
enabled = true,
flags = Command.flags.ALL,
func,
icon = "",
jq,
undoable
} = params;
if (undoable) {
that.undoable = toUndoableGetter(undoable);
}
if (!func) {
if (that.isToggleCommand()) {
func = () => that.toggle();
} else {
func = (...args) => {
if (that.isUndoable()) {
return Promise.resolve(that.undoable(...args))
.then((undoable) => undoable && undoable.redo());
}
};
}
}
that.$accelerator = parseAccelerator(accelerator);
that.$description = description || "";
that.$displayName = displayName || "";
that.$enabled = enabled;
that.$flags = flags;
that.$func = func;
that.setIcon(icon || "");
that.$id = idCounter++;
that.$jq = jq || null;
that.$undoable = undoable;
this.$blankDisplayName = false;
this.$blankDescription = false;
// If a module and lexicon is specified then resolve the lexicon
// and get the value. This asynchronously updates the Command. When
// the Command is updated, an change event will be fired to update
// any user interfaces.
var pending = true;
if (params.module && params.lex) {
if (!displayName) {
this.$displayName = '%lexicon(' + params.module + ':' + params.lex + '.displayName)%';
}
if (!description) {
this.$description = '%lexicon(' + params.module + ':' + params.lex + '.description)%';
}
// Always update asynchronously
loadingPromise = lex.module(params.module)
.then(function (moduleLex) {
var res;
try {
if (!displayName) {
that.$blankDisplayName = !moduleLex.get(params.lex + ".displayName");
}
if (!description) {
that.$blankDescription = !moduleLex.get(params.lex + ".description");
}
res = moduleLex.get(params.lex + ".icon");
if (res) {
that.setIcon(res);
}
res = moduleLex.get(params.lex + ".accelerator");
if (res) {
that.setAccelerator(res);
}
} catch (ignore) {
} finally {
pending = false;
}
});
} else {
pending = false;
loadingPromise = Promise.resolve();
}
loadingPromise.isPending = () => pending;
that.$loading = loadingPromise;
};
/**
* Namespace containing numbers for comparing flag bits. C&P'ed directly
* from MgrController in workbench module.
*/
Command.flags = {};
/**
* Match all flags.
* @type {number}
*/
Command.flags.ALL = 0xFFFF;
/**
* Makes the command be available in the main menu. Not typically used in web profiles, but will
* signal to Workbench (through bajaux interop) that the command should appear in the Workbench
* menu.
* @type {number}
*/
Command.flags.MENU_BAR = 0x0001;
/**
* Makes the command be available in the main toolbar.
* @type {number}
*/
Command.flags.TOOL_BAR = 0x0002;
/**
* Match no flags.
* @type {number}
*/
Command.flags.NONE = 0x0000;
/**
* Reserved for use by Command subclasses.
* @name module:bajaux/commands/Command.USER_DEFINED_1
* @type {number}
*/
Object.defineProperty(Command.flags, 'USER_DEFINED_1', { value: 0x0100, enumerable: false });
/**
* Reserved for use by Command subclasses.
* @name module:bajaux/commands/Command.USER_DEFINED_2
* @type {number}
*/
Object.defineProperty(Command.flags, 'USER_DEFINED_2', { value: 0x0200, enumerable: false });
/**
* Reserved for use by Command subclasses.
* @name module:bajaux/commands/Command.USER_DEFINED_3
* @type {number}
*/
Object.defineProperty(Command.flags, 'USER_DEFINED_3', { value: 0x0400, enumerable: false });
/**
* Reserved for use by Command subclasses.
* @name module:bajaux/commands/Command.USER_DEFINED_4
* @type {number}
*/
Object.defineProperty(Command.flags, 'USER_DEFINED_4', { value: 0x0800, enumerable: false });
/**
* Return true if the Command is still loading.
*
* @returns {Boolean} true if still loading.
*/
Command.prototype.isLoading = function isLoading() {
return this.$loading.isPending();
};
/**
* Return the loading promise for the Command.
*
* The returned promise will be resolved once the Command
* has finished loading.
*
* @returns {Promise} The promise used for loading a Command.
*/
Command.prototype.loading = function loading() {
return this.$loading;
};
/**
* Return the format display name of the command.
*
* @returns {String}
*/
Command.prototype.getDisplayNameFormat = function getDisplayNameFormat() {
return this.$displayName;
};
/**
* Set the display name format of the command. Triggers a
* `bajaux:changecommand` event.
*
* @param {String} displayName display name - supports baja Format syntax
*/
Command.prototype.setDisplayNameFormat = function setDisplayNameFormat(displayName) {
this.$displayName = displayName;
this.$blankDisplayName = false;
this.trigger(COMMAND_CHANGE_EVENT);
};
/**
* Access the Command's display name.
*
* In order to access the display name, a promise will be returned
* that will be resolved once the command has been loaded and
* the display name has been found.
*
* @returns {Promise} Promise to be resolved with the display name
*/
Command.prototype.toDisplayName = function toDisplayName() {
var that = this;
return that.$loading
.then(function () {
if (that.$blankDisplayName) {
return '';
}
return lex.format(that.$displayName);
});
};
/**
* Get the unformatted description of the command.
*
* @returns {String}
*/
Command.prototype.getDescriptionFormat = function getDescriptionFormat() {
return this.$description;
};
/**
* Set the description format of the command. Triggers a
* `bajaux:changecommand` event.
*
* @param {String} description the command description - supports baja Format
* syntax
*/
Command.prototype.setDescriptionFormat = function setDescriptionFormat(description) {
this.$description = description;
this.$blankDescription = false;
this.trigger(COMMAND_CHANGE_EVENT);
};
/**
* Access the Command's description.
*
* In order to access the description, a promise will be returned
* that will be resolved once the command has been loaded and
* the description has been found.
*
* @returns {Promise} Promise to be resolved with the description
*/
Command.prototype.toDescription = function toDescription() {
var that = this;
return that.$loading
.then(function () {
if (that.$blankDescription) {
return '';
}
return lex.format(that.$description);
});
};
/**
* Return the Command's icon URI
*
* @returns {String}
*/
Command.prototype.getIcon = function getIcon() {
return this.$icon;
};
/**
* Sets the icon for this Command. Triggers a `bajaux:changecommand` event.
*
* @param {String} icon The Command's icon (either a URI or a module:// ORD string)
*/
Command.prototype.setIcon = function setIcon(icon) {
this.$icon = icon.replace(/^module:\/\//, "/module/");
this.trigger(COMMAND_CHANGE_EVENT);
};
/**
* Gets this command's enabled status.
*
* @returns {Boolean}
*/
Command.prototype.isEnabled = function isEnabled() {
return this.$enabled;
};
/**
* Sets this command's enabled status. Triggers a
* `bajaux:changecommand` event.
*
* @param {Boolean} enabled
*/
Command.prototype.setEnabled = function setEnabled(enabled) {
this.$enabled = !!enabled;
this.trigger(COMMAND_CHANGE_EVENT);
};
/**
* Get this command's flags.
*
* @returns {Number}
*/
Command.prototype.getFlags = function getFlags() {
return this.$flags;
};
/**
* Set this command's flags.
*
* @param {Number} flags
*/
Command.prototype.setFlags = function setFlags(flags) {
this.$flags = flags;
this.trigger(COMMAND_CHANGE_EVENT);
};
/**
* Check to see if this command's flags match any of the bits of the
* input flags.
*
* @param {Number} flags The flags to check against
* @returns {Boolean}
*/
Command.prototype.hasFlags = function hasFlags(flags) {
return !!(this.$flags & flags);
};
/**
* Return the raw function associated with this command.
*
* @returns {Function}
*/
Command.prototype.getFunction = function getFunction() {
return this.$func;
};
/**
* Set the Command's function handler.
*
* @param {Function} func The new function handler for the command.
*/
Command.prototype.setFunction = function setFunction(func) {
this.$func = func;
this.trigger(COMMAND_CHANGE_EVENT);
};
/**
* Invoke the Command. Triggers a `bajaux:invokecommand` or
* `bajaux:failcommand` event, as appropriate.
*
* Arguments can be passed into `invoke()` that will be passed into the
* function's Command Handler.
*
* @returns {Promise} A promise object that will be resolved (or rejected)
* once the Command's function handler has finished invoking.
*/
Command.prototype.invoke = function invoke() {
var that = this,
args = arguments;
if (undoManager && this.isUndoable()) {
return undoManager.invoke(this, arguments);
}
// eslint-disable-next-line promise/avoid-new
return Promise.try(function () {
return that.$func.apply(that, args);
})
.then(function (result) {
var args = Array.prototype.slice.call(arguments);
that.trigger.apply(that, [ COMMAND_INVOKE_EVENT ].concat(args));
return result;
}, function (err) {
var args = Array.prototype.slice.call(arguments);
that.trigger.apply(that, [ COMMAND_FAIL_EVENT ].concat(args));
throw err;
});
};
/**
* If your Command optionally implements this function, then CommandButton
* will call it on click instead of simply calling `invoke`. Use this in case
* your Command needs to respond differently based on where on the screen the
* user is pointing.
*
* @function module:bajaux/commands/Command#invokeFromEvent
* @param {JQuery.Event} e the DOM event triggered by the user's request to
* invoke this function
* @returns {Promise|*}
* @since Niagara 4.11
*/
/**
* Always returns true.
*/
Command.prototype.isCommand = function isCommand() {
return true;
};
/**
* Always returns false.
*/
Command.prototype.isToggleCommand = function isToggleCommand() {
return false;
};
/**
* @returns {boolean} true if this command is undoable
* @since Niagara 4.11
*/
Command.prototype.isUndoable = function () {
return typeof this.undoable === 'function';
};
/**
* @param {module:bajaux/commands/Command|module:bajaux/commands/Command~Undoable} undoable
* @returns {boolean} true if the given parameter is an undoable Command, or
* is an Undoable object
* @since Niagara 4.11
*/
Command.isUndoable = function (undoable) {
if (undoable instanceof Command) {
return undoable.isUndoable();
}
return !!(undoable && typeof undoable === 'object' && isFunction(undoable.undo) && isFunction(undoable.redo));
};
/**
* Return a unique numerical id for the Command.
*
* This is id unique to every Command object created.
*/
Command.prototype.getId = function getId() {
return this.$id;
};
/**
* Return the accelerator for the Command or null if
* nothing is defined.
*
* @see module:bajaux/commands/Command#setAccelerator
*
* @returns {Object} The accelerator or null if nothing is defined.
*/
Command.prototype.getAccelerator = function getAccelerator() {
return this.$accelerator;
};
/**
* Set the accelerator information for the Command.
*
* @param {Object|String|Number|null|undefined} acc The accelerator keyboard information. This can
* be a keyCode number, a character (i.e. 'a') or an Object that contains the accelerator information.
* If no accelerator should be used the null/undefined should be specified.
* @param {String|Number} acc.keyCode The key code of the accelerator. This can be a character
* code number or a character (i.e. 'a').
* @param {Boolean} [acc.ctrl] `true` if the control key needs to be pressed.
* @param {Boolean} [acc.shift] `true` if the shift key needs to be pressed.
* @param {Boolean} [acc.alt] `true` if the alt key needs to be pressed.
* @param {Boolean} [acc.meta] `true` if a meta key needs to be pressed.
*
* @see module:bajaux/commands/Command#getAccelerator
*/
Command.prototype.setAccelerator = function setAccelerator(acc) {
this.$accelerator = parseAccelerator(acc);
// Trigger a change event
this.trigger(COMMAND_CHANGE_EVENT);
};
/**
* Visit this Command with the specified function.
*
* @param {Function} func Will be invoked with this
* Command passed in as an argument.
*/
Command.prototype.visit = function visit(func) {
return func(this);
};
/**
* Triggers an event from this Command.
*
* @param {String} name
*/
Command.prototype.trigger = function trigger(name) {
var that = this,
passedArgs = Array.prototype.slice.call(arguments, 1),
args = [ that ].concat(passedArgs);
if (that.$jq) {
that.$jq.trigger(name, args);
}
if (that.$eh) {
that.$eh.trigger(name, args);
}
};
/**
* If a jQuery DOM argument is specified, this will set the DOM.
* If not specified then no DOM will be set.
* This method will always return the jQuery DOM associated with this Command.
*
* @param {JQuery} [jqDom] If specified, this will set the jQuery DOM.
* @returns {JQuery} A jQuery DOM object for firing events on.
*/
Command.prototype.jq = function jq(jqDom) {
if (jqDom !== undefined) {
this.$jq = jqDom;
}
return this.$jq || null;
};
/**
* Register a function callback handler for the specified event.
*
* @param {String} event The event id to register the function for.
* @param {Function} handler The event handler to be called when the event is fired.
*/
Command.prototype.on = function (event, handler) {
var that = this;
that.$eh = that.$eh || $("<div></div>");
that.$eh.on.apply(that.$eh, arguments);
};
/**
* Unregister a function callback handler for the specified event.
*
* @param {String} [event] The name of the event to unregister.
* If name isn't specified, all events for the Command will be unregistered.
* @param {Function} [handler] The function to unregister. If
* not specified, all handlers for the event will be unregistered.
*/
Command.prototype.off = function (event, handler) {
var that = this;
if (that.$eh) {
that.$eh.off.apply(that.$eh, arguments);
}
};
/**
* Attempt to merge this command with another command, and return a new
* Command that does both tasks. If the two commands are mutually
* incompatible, return a falsy value.
*
* @param {module:bajaux/commands/Command} cmd
* @returns {module:bajaux/commands/Command}
*
* @example
* <caption>
* Here is an example to show the basic concept. Commands that simply
* add two numbers together can easily be merged together thanks to the
* associative property.
* </caption>
*
* var AddCommand = function AddCommand(inc) {
* this.$inc = inc;
* Command.call(this, {
* displayName: 'Add ' + inc + ' to the given number',
* func: function (num) { return num + inc; }
* });
* };
* AddCommand.prototype = Object.create(Command.prototype);
*
* AddCommand.prototype.merge = function (cmd) {
* if (cmd instanceof AddCommand) {
* return new AddCommand(this.$inc + cmd.$inc);
* }
* };
*
* var addOneCommand = new AddCommand(1),
* addFiveCommand = new AddCommand(5),
* addSixCommand = addOneCommand.merge(addFiveCommand);
* addSixCommand.invoke(10)
* .then(function (result) {
* console.log('is 16? ', result === 16);
* });
*/
Command.prototype.merge = function (cmd) {
return null;
};
function toGetter(o) { return () => o; }
/**
* Represents a unit of undoable/redoable work as provided by a Command.
*
* @interface module:bajaux/commands/Command~Undoable
*/
/**
* Perform the undo work.
*
* @function module:bajaux/commands/Command~Undoable#undo
* @returns {Promise}
*/
/**
* Perform the redo work.
*
* @function module:bajaux/commands/Command~Undoable#undo
* @returns {Promise}
*/
/**
* Resolves true if it is possible to do the undo work at this time.
*
* @function module:bajaux/commands/Command~Undoable#canUndo
* @returns {Promise.<boolean>}
*/
/**
* Resolves true if it is possible to do the redo work at this time.
*
* @function module:bajaux/commands/Command~Undoable#canRedo
* @returns {Promise.<boolean>}
*/
/**
* Resolve some text that describes the undo work about to be done.
*
* @function module:bajaux/commands/Command~Undoable#undoText
* @returns {Promise.<string>}
*/
/**
* Resolve some text that describes the redo work about to be done.
*
* @function module:bajaux/commands/Command~Undoable#redoText
* @returns {Promise.<string>}
*/
/**
* @private
*/
Command.$installGlobalUndoManager = (mgr) => { undoManager = mgr; };
/**
* @private
*/
Command.$getGlobalUndoManager = () => undoManager;
/**
* Provides a default way of notifying the user about a Command invocation
* failure. Shows a dialog with details about the error.
*
* You might override this at runtime with your own error dialog handler.
*
* @param {Error|*} err
* @param {object} [params]
* @param {string} [params.messageSummary] any additional information you
* would like to include in the error dialog
* @returns {Promise}
* @since Niagara 4.12
*/
Command.prototype.defaultNotifyUser = function (err, params) {
return requireErrorDetailsWidget()
.then((ErrorDetailsWidget) => {
return ErrorDetailsWidget.dialog(err, Object.assign({ command: this }, params));
});
};
return Command;
});