api.controller = function($scope, $http, spModal, SPGlideAjax) {
/**
* The Angular controller for managing the Action Table
* @class module:WidgetComponents.ActionTableController
*/
/**
* Identitifies the localStorage key used for saving and recovering the current state
* of the table's rendering.
* @memberof ActionTableController
* @type {String}
* @alias stateKey
* @private
*/
var stateKey = "actiontable:state:" + $scope.options.id,
loading = localStorage.getItem(stateKey),
/**
* Time to wait to allow typing to continue before updating the corpus.
* @property {Number} filterInterval
* @memberof ActionTableController
* @default 200
*
* @private
*/
filterInterval = 200,
fieldTracking = {},
fieldList = [],
mapDataFields,
clearFault,
endReload,
filtering,
mapFields,
saveState,
sortData,
setIcons,
refresh,
visible,
timing,
fillin,
fault,
x;
/**
* Save an encoded value of the scope's state value to give persistence to the table's
* rendering.
* @method module:WidgetComponents.ActionTableController#saveState
* @private
*/
saveState = function() {
localStorage.setItem(stateKey, JSON.stringify($scope.state));
};
/**
* Updates the icons for various objects for cached rendering purposes
* @method module:WidgetComponents.ActionTableController#setIcons
*/
setIcons = function() {
if($scope.columns) {
var column;
for(x=0; x<$scope.columns.length; x++) {
column = $scope.columns[x];
if(column && !column.no_sort) {
if(column.field === $scope.state.order) {
if($scope.state.above === 1) {
column.sort_icon = "fa fa-sort-alpha-asc";
} else {
column.sort_icon = "fa fa-sort-alpha-desc";
}
} else {
column.sort_icon = "fa fa-sort";
}
}
}
}
};
/**
* Sorting function used to sort the data.rows array based on the current state values.
* @method module:WidgetComponents.ActionTableController#sortData
* @param a
* @param b
*/
sortData = function(a, b) {
if($scope.state.order) {
if(a && b) {
var cA = a[$scope.state.order],
cB = b[$scope.state.order];
if(cA > cB) {
return $scope.state.above;
} else if(cA < cB) {
return $scope.state.below;
}
} else if(a && !b) {
return $scope.state.above;
} else if(!a && b) {
return $scope.state.below;
}
return 0;
}
};
/**
*
* @method module:WidgetComponents.ActionTableController#visible
* @param {Object} row
*/
visible = function(row) {
return !$scope.state.searching || (row && typeof(row.$search) === "string" && row.$search.indexOf($scope.state.searching) !== -1);
};
/**
* Timed function used to balance user typing with when to fire updating the results.
* @method module:WidgetComponents.ActionTableController#filtering
* @private
*/
filtering = function() {
var now = Date.now();
if(timing && timing < now) {
// Maintain case insensitive search but don't mutate the user input
$scope.state.searching = $scope.state.search.toLowerCase();
$scope.state.page = 0;
$scope.filter_icon = "fa-filter";
$scope.loadCorpus();
$scope.update();
timing = null;
saveState();
} else {
setTimeout(filtering, filterInterval);
}
};
/**
* Used to keep the data up to date if a refresh interval has been specified.
* @method module:WidgetComponents.ActionTableController#refresh
* @private
*/
refresh = function() {
if(!isNaN($scope.options.refresh_interval) && 60000 <= $scope.options.refresh_interval) {
$scope.reloadData();
setTimeout(refresh, $scope.options.refresh_interval);
}
};
/**
* Waits 1 second to reset the reload icon to give the visual impact time.
* @method module:WidgetComponents.ActionTableController#endReload
* @private
*/
endReload = function() {
setTimeout(function() {
// $scope.reload_icon = "fa-refresh";
$scope.update();
}, 1000);
};
/**
* Use template filling to create a new object whose values mimic the fill object but
* with templates completed based on the row. Due to the regular expressions in volved
* and the number of search/replacements that can be triggered here, this method should
* be used sparingly.
* @method module:WidgetComponents.ActionTableController#fillin
* @param {Object} fill Object whose values are to be completed
* @param {Object} row Source for values
*/
fillin = function(fill, row) {
var keys = Object.keys(fill),
result = {},
i;
for(i=0; i<keys.length; i++) {
result[keys[i]] = $scope.completeTemplate(row, fill[keys[i]]);
}
return result;
};
/**
* Internal method for displaying an error that was encountered.
* @method module:WidgetComponents.ActionTableController#fault
* @private
* @param {Error} error That occurred and should be displayed
*/
fault = function(error) {
var message = "STSTable: Data Request from ";
if($scope.options.data_source == "ajax") {
message += "Client Script[" + $scope.options.script + "." + $scope.options.script_method + "]: ";
} else if($scope.options.data_source == "snapi") {
message += "Service-Now API Endpoint[" + $scope.options.endpoint + "]: ";
} else {
"Unknown Source: ";
}
console.error(message, $scope.error);
$scope.error = error;
$scope.update();
};
/**
* Internal method for clearing an error that had been encountered if one such error exists.
* @method module:WidgetComponents.ActionTableController#clearFault
* @private
*/
clearFault = function() {
if($scope.error) {
$scope.error = null;
$scope.update();
}
};
/**
* Updates the fieldTracking & fieldList properties.
* @method module:WidgetComponents.ActionTableController#mapDataFields
*/
mapDataFields = function() {
var i;
fieldList.splice(0);
if($scope.data.rows && $scope.data.rows.length) {
for(i=0; i<$scope.data.rows.length; i++) {
mapFields($scope.data.rows[i]);
}
}
fieldList.push.apply(fieldList, Object.keys(fieldTracking));
};
/**
* Checks the object keys to ensure that all fields have a RegExp mapping in the `fieldTracking`
* object.
* @method module:WidgetComponents.ActionTableController#mapFields
* @param {Object} row
*/
mapFields = function(row) {
var keys = Object.keys(row),
i;
for(i=0; i<keys.length; i++) {
if(!fieldTracking[keys[i]]) {
fieldTracking[keys[i]] = new RegExp("\\{\\{" + keys[i] + "\\}\\}", "ig");
}
}
};
// Initialize local data for table management
try {
$scope.columns = JSON.parse($scope.options.columns);
} catch(parseException) {
console.error("Action Table: Failed to parse column specifications:", parseException, $scope.options.columns);
$scope.error = parseException;
}
try {
$scope.actions = JSON.parse($scope.options.actions);
} catch(parseException) {
console.error("Action Table: Failed to parse action specifications:", parseException, $scope.options.actions);
$scope.error = parseException;
}
if($scope.data.error) {
/**
* Handles displaying an error to the widget.
*
* Generally set by calling the private function `fault`.
* @memberof module:WidgetComponents.ActionTableController
* @type {Object}
* @alias error
*
*/
$scope.error = new Error($scope.data.error);
}
/**
*
* The displayed icon in the filter to give feedback to the user.
* @memberof module:WidgetComponents.ActionTableController
* @type {String}
* @alias filter_icon
*/
$scope.filter_icon = "fa-filter";
/**
* The displayed icon for reloading data to give feedback to the user.
* @memberof module:WidgetComponents.ActionTableController
* @type {String}
* @alias reload_icon
*/
$scope.reload_icon = "fa-refresh";
/**
* Each element contained here is an element that is valid after the rows have been
* filtered by search and sort criteria.
* @memberof module:WidgetComponents.ActionTableController
* @type {Array}
* @alias corpus
*/
$scope.corpus = [];
/**
* Each element contained here is a row to render on the page. This is pared down to
* only the rows that should render based on the current page and sourced from the
* corpus array to follow search and sort criteria and drive a faster rendering.
* @memberof module:WidgetComponents.ActionTableController
* @type {Array}
* @alias render
*
*/
$scope.render = [];
/**
* Doubles as a page count and rendering array for ng-repeat.
* @memberof module:WidgetComponents.ActionTableController
* @type {Array}
* @alias pages
*/
$scope.pages = [];
// Attempt to recover the previous state of the table, if any
if(loading) {
try {
/**
* Holds the stateful data for the widget that should be tracked and
* reloaded on refresh. This is specifically accomplished by calls
* to `saveState` in combination with a `$watch` specification.
* @memberof module:WidgetComponents.ActionTableController
* @type {State}
* @link State
* @alias state
*/
$scope.state = JSON.parse(loading);
} catch(loadException) {
console.error("ActionTable: State Loading Error: ", loadException);
$scope.state = {};
}
} else {
$scope.state = {};
}
if($scope.state.page === undefined) {
$scope.state.page = 0;
}
if($scope.state.search === undefined) {
$scope.state.search = "";
}
if(!isNaN($scope.options.per_page) && $scope.options.per_page > 0) {
$scope.state.size = $scope.options.per_page;
} else if(isNaN($scope.state.size)) {
$scope.state.size = 20;
}
$scope.state.per_page = $scope.state.size.toString();
if($scope.state.above === undefined) {
$scope.state.above = -1;
}
if($scope.state.below === undefined) {
$scope.state.below = -1 * $scope.state.above;
}
// Cache regular expressions for quick template replacements based on the row's field values
mapDataFields();
// When the search string changes, trigger the filtering function to eventually update the rendered values
$scope.$watch("state.search", function() {
if(!timing) {
$scope.filter_icon = "fa-spinner fa-pulse";
$scope.update();
filtering();
}
timing = Date.now() + 2 * filterInterval;
});
// Watch for changes to the size string, likely by the corner select, to push the value into the state and update
$scope.$watch("state.per_page", function() {
$scope.state.size = parseInt($scope.state.per_page);
$scope.loadCorpus();
saveState();
});
/**
* Change the direction of sorting or the column to sort by.
*
* Calling on the currently sorted column toggles the sort direction.
*
* Changing to a new column does NOT change the sort direction.
* @method module:WidgetComponents.ActionTableController#reorder
* @param {Column} column
*/
$scope.reorder = function(column) {
if(column) {
if($scope.state.order === column.field) {
$scope.state.below *= -1;
$scope.state.above *= -1;
} else {
$scope.state.order = column.field;
}
$scope.loadCorpus();
saveState();
setIcons();
}
};
/**
* Retrieves data from the server if necessary.
*
* This is essesntially a stepping method for AJAX sourced data as the other 2 sources
* (server script, and table) would already be populated here by the server initialization.
*
* The SPGlideAjax is heavily favored for the ability to create new configurable data sources
* without modifying the widget or its supporting pieces while also keeping the creating of
* the data in a more traditionally understood form, Script Includes, instead of passing the
* data through a more web traditional method such as an API, where Scripted APIs may get
* heavier. Though support for such a process should be added and would also be handled here.
* @method module:WidgetComponents.ActionTableController#loadData
*/
$scope.loadData = function() {
var request;
clearFault();
switch($scope.options.data_source) {
case "ajax":
request = new SPGlideAjax($scope.options.script);
request.addParam("sysparm_name", $scope.options.script_method);
request.addParam("query", $scope.options.query);
request.getXMLAnswer($scope.receiveData);
break;
case "snapi":
if($scope.options.endpoint) {
if($scope.options.endpoint[0] !== "/") {
$scope.options.endpoint = "/" + $scope.options.endpoint;
}
$http.get($scope.options.endpoint + "?query=" + $scope.options.query)
.then(function(response) {
if(response.status === 200) {
$scope.receiveData(response.data.result);
} else {
fault(new Error("Malformed request for endpoint data - HTTP" + response.status + ": " + response.statusText));
}
}, fault);
} else {
fault(new Error("No 'endpoint' option is defined"));
}
break;
case "server":
case "table":
if($scope.data.error) {
if(typeof($scope.data.error) === "string") {
$scope.error = {
"message": $scope.data.error
};
} else {
$scope.error = $scope.data.error;
}
}
$scope.prepareData();
break;
default:
console.error("Unknown Data Source (data_source) option specified for STSTable widget[" + $scope.options.id + "]: ", $scope);
$scope.error = {
"message": "Unknown Data Source (data_source) option specified for widget.",
"options": $scope.options
};
}
};
/**
* Called for receiving the text portion of a request for data. All text is assumed to be a JSON
* object with the general format:
* ```
* {
* "rows": [{
* Object 1 Data...
* }, {
* Object 2 Data...
* }, {
* ...
* {,
* Object N Data...
* }]
* }
* ```
*
* An object is used to allow for other information to be present on the API call for use later.
* @method module:WidgetComponents.ActionTableController#receiveData
* @param {String} response
*/
$scope.receiveData = function(response) {
var loading;
if(response) {
if(typeof(response) === "string") {
try {
loading = JSON.parse(response);
} catch(parseException) {
fault(parseException);
}
} else {
loading = response;
}
if(loading && loading.rows instanceof Array) {
$scope.data.rows.splice(0);
$scope.data.rows.push.apply($scope.data.rows, loading.rows);
$scope.data.loaded = Date.now();
$scope.prepareData();
} else {
fault(new Error("Receive malformed data, must return a Object JSON with a \"rows\" property that contains the array to load"));
}
} else {
fault(new Error("Failed to receive any data"));
}
};
/**
* Essentially prepares the data received from the server.
*
* This primarily involves setting the `$search` property for easy lower cased
* string index checks for filtering based on the columns and `options.filterable`.
*
* Additionally, the data objectis emitted on the root scope for other widgets to
* consume if needed under the event "ststable:data:[ID]" where "[ID]" is the table's
* ID specified in options. This allows another widget on the same page as the table
* to implement something akin to `$scope.$on("ststable:data:[ID]", $scope.processAPIData)`
* to receive the data and perform any needed actions.
* @method module:WidgetComponents.ActionTableController#prepareData
*/
$scope.prepareData = function() {
var column,
load,
row,
c,
i;
if($scope.data.rows) {
for(i=0; i<$scope.data.rows.length; i++) {
row = $scope.data.rows[i];
row.$search = "";
// Ensure that the visible columns are searchable
if($scope.options.filterable && $scope.options.filterable.length) {
// Add additional columns that are flagged as filterable in the instance options
load = $scope.columns.concat($scope.options.filterable);
} else {
load = $scope.columns;
}
for(c=0; c<load.length; c++) {
column = load[c];
if(typeof(row[column.field]) === "string") {
// Map to lower case; Filter is meant to be case insensitive
row.$search += " :: " + row[column.field].toLowerCase();
} else
if(typeof(row[column.field]) === "object") {
// Handle Field Descriptor data
if(row[column.field].display) {
row.$search += " :: " + row[column.field].display.toLowerCase();
}
} else {
// Direct Value
row.$search += " :: " + row[column.field];
}
}
}
$scope.corpus.sort(sortData);
}
$scope.reload_icon = "fa-refresh";
mapDataFields();
$scope.$root.$emit("ststable:data:" + $scope.options.id, $scope.data);
$scope.loadCorpus();
};
/**
* Filter and sort the general data received from the server.
*
* This serves as our cache for paging through the data set.
* @method module:WidgetComponents.ActionTableController#loadCorpus
*/
$scope.loadCorpus = function() {
$scope.corpus.splice(0);
$scope.pages.splice(0);
var row,
i;
if($scope.data.rows) {
for(i=0; i<$scope.data.rows.length; i++) {
row = $scope.data.rows[i];
if(visible(row)) {
$scope.corpus.push(row);
}
}
$scope.corpus.sort(sortData);
}
$scope.pageCount = $scope.corpus.length/$scope.state.size;
for(i=0; i<$scope.pageCount; i++) {
$scope.pages.push(i + 1);
}
$scope.loadRender();
};
/**
* Load data from the `corpus` to the `render` array for the current page being viewed.
* @method module:WidgetComponents.ActionTableController#loadRender
*/
$scope.loadRender = function() {
$scope.render.splice(0);
var start = $scope.state.page * $scope.state.size,
end = start + $scope.state.size,
row,
i;
for(i=start; i<end && i<$scope.corpus.length; i++) {
row = $scope.corpus[i];
if(row) {
$scope.render.push(row);
}
}
$scope.update();
};
/**
* Sets the page and updates the `render` array for display via the `loadRender` method.
* @method module:WidgetComponents.ActionTableController#toPage
* @param {Number} page
*/
$scope.toPage = function(page) {
$scope.state.page = page - 1;
$scope.loadRender();
saveState();
};
/**
*
* @method module:WidgetComponents.ActionTableController#getPageClasses
* @param {Number} page
* @returns {String}
*/
$scope.getPageClasses = function(page) {
if(page -1 === $scope.state.page) {
return "btn-primary";
} else {
return "btn-default";
}
};
/**
* Check if an action is visible based on its `condition` object.
*
* No condition object indicates it is always visible.
*
* Condition checking is managed with the `checkConditions` function.
* @method module:WidgetComponents.ActionTableController#actionVisible
* @param {Object} row
* @param {Object} action
* @return {Boolean}
*/
$scope.actionVisible = function(row, action) {
if(action.condition) {
return $scope.checkCondition(row, action.condition);
}
return true;
};
/**
* Process an action object for a row.
* @method module:WidgetComponents.ActionTableController#processAction
* @param {Object} row
* @param {Object} action
*/
$scope.processAction = function(row, process) {
var buffer,
keys,
i;
switch(process.action) {
case "link":
buffer = $scope.completeTemplate(row, process.perform);
if(buffer[0] !== "/") {
buffer = "/" + buffer;
}
location = buffer;
break;
case "newlink":
buffer = $scope.completeTemplate(row, process.perform);
if(buffer[0] !== "/") {
buffer = "/" + buffer;
}
window.open(buffer, "_blank");
break;
case "form-modal":
process = fillin(process, row);
spModal.open({
"shared": $scope.state,
"value": process,
"title": process.title,
"widget": "widget-form",
"widgetInput": process
}).then(function (/* button */) {
// User clicked "OK" - example; button = {"label":"OK","primary":true,"focus":true}
}, function(/* error */) {
// User clicked "Cancel", The close button on the dialog, or clicked outside the box
});
break;
case "ajax-call":
process = fillin(process, row);
keys = Object.keys(process);
buffer = new SPGlideAjax(process.ajax_class);
buffer.addParam("sysparm_name", process.ajax_method);
for(i=0; i<keys.length; i++) {
buffer.addParam(keys[i], process[keys[i]]);
}
buffer.getXMLAnswer(function(/* response */) {
if(process.new_url && process.new_url[0] !== "/") {
process.new_url = "/" + process.new_url;
}
switch(process.ajax_complete) {
case "redirect":
location = process.new_url;
break;
case "newwindow":
window.open(process.new_url, "_blank");
break;
}
});
break;
}
};
/**
* Get the string to display for the value in row under the column's field value.
* @method module:WidgetComponents.ActionTableController#renderValue
* @param {Object} row Of data from which to get the value to render.
* @param {Object} column Describing what data should be rendered.
* @return {String} To place in the table
*/
$scope.renderValue = function(row, column) {
var point = row[column.field],
formatting,
buffer,
value;
buffer = typeof(point);
if(buffer === "object") {
formatting = point.type || column.formatting || buffer;
value = point.display;
} else {
formatting = column.formatting || buffer;
value = point;
}
switch(formatting) {
case "time":
buffer = new Date(value);
return buffer.toLocaleDateString() + " " + buffer.toLocaleTimeString();
case "date":
buffer = new Date(value);
return buffer.toLocaleDateString();
}
// Contain runaway decimal point values
if(typeof(value) === "number" && value%1) {
value = value.toFixed(3);
}
return value;
};
/**
* Using a Modal, show the status text for the row.
* @method module:WidgetComponents.ActionTableController#viewStatusWarning
* @param {Object} row
*/
$scope.viewStatusWarning = function(row) {
spModal.confirm(row.$status_text);
};
/**
* Performs basic token replacement in a string based on the values in the row object using "{{...}}" for replacement
* indicators.
*
* Due to service-now template processing, using "${...}" fails without oerly complicated syntax, for example
* an option value of "My name is ${name}" simply displays as "My name is name" and looking at the option value
* received to the widget, the value of that option will also be "My name is name" because Sevice-Now's templating
* has already altered the value.
*
* Additionally note that the replacement handling is managed with cached regular expressions generated at the start
* of this controller.
* @method module:WidgetComponents.ActionTableController#completeTemplate
* @param {Object} row
* @param {String} template
* @returns {String}
*/
$scope.completeTemplate = function(row, template) {
for(var i=0; i<fieldList.length; i++) {
template = template.replace(fieldTracking[fieldList[i]], row[fieldList[i]] || "");
}
return template;
};
/**
* Every field in the condition that is defined must match the corresponding field value in the row, or the
* check fails.
* @method module:WidgetComponents.ActionTableController#checkCondition
* @param {Object} row
* @param {Object} condition
* @returns {Boolean}
*/
$scope.checkCondition = function(row, condition) {
for(var i=0; i<fieldList.length; i++) {
if(condition[fieldList[i]] !== undefined && condition[fieldList[i]] !== row[fieldList[i]] && (!row[fieldList[i]] || row[fieldList[i]].value !== condition[fieldList[i]])) {
return false;
}
}
return true;
};
/**
* Get a new array of data from the server and apply it to the current state and render.
* @method module:WidgetComponents.ActionTableController#reloadData
*/
$scope.reloadData = function() {
var success,
failure;
$scope.reload_icon = "fa-refresh fa-spin";
if($scope.options.data_source == "ajax") {
$scope.loadData();
} else {
success = function(response) {
$scope.data.rows.splice(0);
$scope.data.rows.push.apply($scope.data.rows, response.data.rows);
$scope.data.loaded = response.data.loaded;
$scope.loadData();
endReload();
};
failure = function(error) {
$scope.error = error;
endReload();
};
$scope.server.get()
.then(success, failure);
}
};
/**
*
* @method module:WidgetComponents.ActionTableController#getLastUpdateDisplay
* @returns {String} 0
*/
$scope.getLastUpdateDisplay = function() {
var date = new Date($scope.data.loaded);
return date.toLocaleDateString() + " " + date.toLocaleTimeString();
};
/**
* Forces a re-rendering of AngularJS bindings
* @method module:WidgetComponents.ActionTableController#update
*/
$scope.update = function() {
try {
$scope.$digest();
} catch(updateException) {
// Generally just a digest exception from a current update cycle
}
};
// Initialize Corpus for rendering based on the loaded state
if(!isNaN($scope.options.refresh_interval) && $scope.options.refresh_interval) {
setTimeout(refresh, $scope.options.refresh_interval);
}
$scope.loadData();
setIcons();
};