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