Perhaps you've got an exceptionally long query you'd like to run.
Perhaps you need to gobble up large amounts of data via scripted REST API.
Or, perhaps you're building a neat little UI to display an arbitrary number of records.
There are a dozen reasons why you might want to "page" through a list of records in a script, but unfortunately there hasn't been a good way to accomplish this. There are nigh-undocumented APIs like "saveLocation()", but they don't tend to work consistently, or quite accomplish our goal of pagination.
Instead, we've decided to write our own GlideRecord page-turning utility, and make it freely available here along with our other free tools. Read on for more info!
Pro Tip: If you haven't checked out our other tools yet, hover your mouse over the Tools option at the top of this page. You'll find highly useful and FREE tools, such as the Update Set Collision Avoidance Tool, the Better One-Click Approvals Tool, the Update Relocator Tool, and the Temporary Permissions Utility.
This tool functions a lot like the GlideRecord class (in fact, it uses GlideRecord), except that it exposes an additional method for "turning the page", and accepts some other arguments as to how large each page should be and so on.
The API is called PagedGlideRecord, and is represented as a Class inside a script include.
First, I'll walk you through the code piece-by-piece, and explore how it works; then, I'll show you how to use it. Finally, I'll give you a link to an update set that'll allow you to deploy it into your own instance, in case you're lazy and don't want to copy paste my code segment-by-segment. 😉
Constructor
The first method in our PagedGlideRecord class is, of course, the initialize method. As with any class constructor, this method is automatically invoked when a new instance of our class is instantiated using the new keyword like so:
var pgr = new PagedGlideRecord('incident');
First I'll paste the whole initialize function (the constructor), then we'll go over it piece by piece.
/** * Iterates over a large table by processing one chunk at a time. Passes each GlideRecord object into a callbackFunction function. Call the ".nextPage()" method to turn the page. * Note: You must call .nextPage() at least once after initializing PagedGlideRecord, to get any data. * @param tableName {string} the system name (such as incident or sc_request) of the table we're working with. * @param [queryString=''] {string} The query string used to filter the returned GlideRecord * @param [pageSize=500] {number} the size of the chunks to process at a time. Recommend 200-500. NOTE: This number MUST BE LESS THAN the glide.db.max_view_records property. If this property isn't set, then this number must be less than 10,000. * @returns {*} self */ initialize: function (tableName, queryString, pageSize) { //Enforce mandatory argument if (!tableName) { gs.logError('PagedGlideRecord was initialized without a valid table passed to the constructor.', 'PagedGlideRecord script include - initialize method'); throw 'PagedGlideRecord was initialized without a valid table passed to the constructor. tableName parameter[0]: ' + tableName; } //initialize optional values with defaults this.pageSize = 500; this.queryString = ''; //Set additional default values that are tracked at the class-scope level. this.currentLocation = -1; this.tableName = tableName.toString(); this.page = 0; //validate & setup optional arguments if (queryString) { this.queryString = queryString.toString(); } if (pageSize && pageSize > 0) { //Using parseInt to validate pageSize because "isInteger()" was introduced in ECMA2015, and so won't work in Geneva or earlier. pageSize = parseInt(pageSize); //Using ternary operators, set maxViewRecords to either the max_view_records property if it exists, or the default maximum (10k) if it does not. var maxViewRecords = gs.getProperty('glide.db.max_view_records') ? parseInt(gs.getProperty('glide.db.max_view_records')) : 10000; if (maxViewRecords < pageSize) { gs.logError('Page size specified is greater than maximum records returnable from GlideRecord query (as determined by system property glide.db.max_view_records). Thus, setting pageSize to the system maximum: ' + maxViewRecords + '.', 'PagedGlideRecord script include.'); pageSize = maxViewRecords; //If the page size is larger than the maximum records we can get from a GlideRecord query, set pageSize to the maximum query size. } this.pageSize = pageSize; } return this; }
As you can see from the usage example above, the constructor (initialize) method requires at least one argument: tableName. This is the system name (not the "label") of the table to run the PagedGlideRecord on.
However, as you can see from the arguments that the initialize method accepts (and from the JSDoc notes above it), this is not the only argument that the constructor accepts. You can also pass in an encoded query string (queryString), and an integer representing the number of records that should appear per-page (pageSize).
So let's break this function down, and go over it piece by piece.
First, we want to enforce the mandatory argument (tableName), so we check that the tableName argument exists and is a valid value. If it doesn't/isn't, then we both log and throw an error.
//Enforce mandatory argument if (!tableName) { gs.logError('PagedGlideRecord was initialized without a valid table passed to the constructor.', 'PagedGlideRecord script include - initialize method'); throw 'PagedGlideRecord was initialized without a valid table passed to the constructor. tableName parameter[0]: ' + tableName; }
Next, we declare and initialize some additional values using the "this" keyword. "this" refers to the current PagedGlideRecord class object. By declaring them this way, we make their values accessible by any other code inside our PagedGlideRecord class. This is scoping and I promise that when used correctly, it isn't always an annoyance. Sometimes, it's even useful!
Pro Tip: When declaring variables in the parent scope using the this keyword, you don't need to use "var"! In fact, technically, you never need to use var. Declaring a variable without using the var keyword (while I don't recommend it generally) creates that variable in the Global scope. Which is usually not what you want, but hey, sometimes it is!
//initialize optional values with defaults this.pageSize = 500; this.queryString = ''; //Set additional default values that are tracked at the class-scope level. this.currentLocation = -1; this.tableName = tableName.toString(); this.page = 0;
Rather than declaring these class-level values using the arguments that were passed in, I've just initialized them using default values. That way, I can validate the passed-in arguments, and only re-assign these values if the arguments are valid. Like so:
//validate & setup optional arguments if (queryString) { this.queryString = queryString.toString(); }
The first thing we do in the code above, is check that queryString contains a valid value. Any truthy value, actually. If its' truthiness is beyond reproach, then we overwrite the default value in the class-level variable this.queryString with the value in the variable of the same name, but in the current function scope, queryString (which we expect to have been passed into the constructor as an argument).
We also take care to explicitly cast queryString to a string (using .toString()), because of the fact that Helsinki sometimes has difficulty - due to the new version of Rhino it's running behind the scenes - interpreting a JavaScript String from a ConsString.
Pro Tip: If you're getting the below error after upgrading to Helsinki, it means that Mozilla Rhino is failing (just as it is designed to do, now) to implicitly coerce a value of one type (such as a ConsString) to a JS String. To fix this, just find the script that's throwing the error, and cast every string explicitly, using .toString().
org.mozilla.javascript.ConsString cannot be cast to java.lang.String
Next, we want to check the final optional constructor parameter, pageSize. Here's the code, followed by an explanation:
if (pageSize && pageSize > 0) { //Using parseInt to validate pageSize because "isInteger()" was introduced in ECMA2015, and so won't work in Geneva or earlier. pageSize = parseInt(pageSize); //Using ternary operators, set maxViewRecords to either the max_view_records property if it exists, or the default maximum (10k) if it does not. var maxViewRecords = gs.getProperty('glide.db.max_view_records') ? parseInt(gs.getProperty('glide.db.max_view_records')) : 10000; if (maxViewRecords < pageSize) { gs.logError('Page size specified is greater than maximum records returnable from GlideRecord query (as determined by system property glide.db.max_view_records). Thus, setting pageSize to the system maximum: ' + maxViewRecords + '.'); pageSize = maxViewRecords; //If the page size is larger than the maximum records we can get from a GlideRecord query, set pageSize to the maximum query size. } this.pageSize = pageSize; }
As you can see, we first check if "pageSize && pageSize > 0". This first checks that pageSize exists and is a truthy value, and then validates that it is a number - and a positive one at that - by checking that it is greater than zero. Non-or-negative-numbers will return a falsy value from this comparison, but any value we can coerce to a positive number - even one inside a string - will return true.
Once we determine that pageSize is indeed a valid value, we explicitly parse an integer from it, just in case the user uncleverly entered a float value or a number inside of a string. As you can see from my notes on that line, I had intended to use the Number.isInteger() method to validate that pageSize was a number rather than going this route, but since the isInteger method was only added in ECMA2015, it isn't available in Geneva or earlier versions of ServiceNow running a much older version of Rhino.
Next, in the following line that I'll re-post here, we use a ternary operator to check whether the property glide.db.max_view_records exists. If it does, we explicitly parse an int from its' value, and set the maxViewRecords variable to that value. However, if it is not set, then we set maxViewRecords to the system default maximum number of records that can be returned from a GlideRecord query in a script, which is 10,000.
var maxViewRecords = gs.getProperty('glide.db.max_view_records') ? parseInt(gs.getProperty('glide.db.max_view_records')) : 10000;
Once we've got the maxViewRecords variable set to the correct value, we compare it to the pageSize argument. If pageSize is larger than the maximum number of records returnable from a GlideRecord query in your instance, then we set pageSize to whatever the maximum is. We log an error when this happens, just so the user can check if they notice something wrong, but we continue execution without halting.
Then, once we've got a good value in pageSize, which isn't larger than the maximum allowable records in a query, we set the Class-scope level variable this.pageSize, to the value in the local function-level variable of the same name, pageSize.
Pro Tip: The maximum number of records that can be returned from a GlideRecord query, is determined by the system property "glide.db.max_view_records". If this property does not exist, then the default is 10,000 records.
Finally, we return the current Class so that the user can assign it to some object which they can use to call the nextPage method (discussed presently).
return this;
nextPage
The method our PagedGlideRecord Class exposes, is called nextPage. It works similarly to how the GlideRecord ".next()" method works, except that it iterates over n GlideRecords (where n is the pageSize passed into the constructor), passing each of them to the callback function in turn.
Speaking of callback functions, let's have a look at how this function works. As before, I'll paste the whole function in and then we can go over it piece by piece.
/**Turns the page, getting the next n rows (as defined by the pageSize parameter when initializing the class) and passes them into the callback function. * @param [callbackFunction] {callback} the SEMI-optional function to be called to do work on each GlideRecord object. * The specified callback function must accept one argument: A GlideRecord object containing ONE record from the table specified in the first argument. * Note that you must specify the callback function at least once either while calling nextPage(), or by calling setCallback(); subsequent calls to the nextPage() method don't necessarily need it specified; they'll continue using the previously-specified callback function. * @returns {boolean} Returns true if more records are found AFTER all records within the "page" are processed (That is, returns true if there is a "next page"). */ nextPage: function (callbackFunction) { var counter = 0; //validate callbackFunction exists or is specified. If not, throw an error. if (!this.setCallback(callbackFunction)) { return false; //setCallback logs an error if it fails. Otherwise, continue. } //do work var gr = new GlideRecord(this.tableName); //declare gr gr.addEncodedQuery(this.queryString); //query using whatever encoded query the user passed in, or a blank string (default value) which should return everything. this.currentLocation++; //Iterate currentLocation to the next record, since we don't want to get the last record we got in the last page, we want the first record of the NEW page! //Note: chooseWindow (below) INCLUDES the first index, but DOES NOT INCLUDE the last; hence the above iteration. //So "chooseWindow(0,3)" will run through records at index 0, 1, and 2 - but not the record at index 3. gr.chooseWindow((this.currentLocation), (this.currentLocation + this.pageSize), true); gr.query(); try { if (!gr.hasNext()) { return false; } this.page++; //increment the page counter, so we can keep track of where we are. while (gr.next()) { this.callbackFunction(gr); //For each record found, pass it into the callback function. counter++; } this.currentLocation = gr.getLocation(); //Once we've hit the limit of our window declared above by setLimit and below by chooseWindow, get our location so we can begin again from the same point. gs.log('PagedGlideRecord Processed PAGE ' + this.page + '. Last record processed: row ' + (this.currentLocation + 1) + '.'); if (counter < this.pageSize) { return false; } else { gr.chooseWindow((this.currentLocation + 1), (this.currentLocation + 2), true); //set the window so we run through the NEXT 500 records, if there are that many. gr.query(); return gr.hasNext(); } } catch (ex) { var errMsg = 'Error in attempting to iterate over a large table, using function PagedGlideRecord. Records processed: ' + this.currentLocation + ' Error details: ' + ex.message; gs.logError(errMsg, 'PagedGlideRecord'); throw errMsg; } }
You may have noticed that in addition to the arguments specified in the constructor when instantiating a new instance of the PagedGlideRecord Class, the nextPage method also accepts one semi-optional argument: a callback function.
//validate callbackFunction exists or is specified. If not, throw an error. if (!this.setCallback(callbackFunction)) { return false; //setCallback logs an error if it fails. Otherwise, continue. }
This little code block actually just calls another method of our PagedGlideRecord class (which the user can also call to specify the callback function if they like) called setCallback().
setCallback: function (callbackFunction) { //validate callbackFunction exists or is specified. If not, throw an error. if (callbackFunction) { this.callbackFunction = callbackFunction; return true; //return true if callback has a good value. } else if (this.callbackFunction) { return false; //return false and continue execution if callback was falsey but one is already specified. } else { //throw errors if neither the argument or existing callback are valid. gs.logError('Invalid or no callback function specified', 'PagedGlideRecord script include'); throw 'Invalid or no callback function specified when calling setCallback in PagedGlideRecord'; } },
This method does the work of validating the callbackFunction, returns true if the argument is valid, returns false if the argument is falsey but the callback function has already been specified, and throws an error if both are invalid/non-existent.
I referred to the callbackFunction argument of the nextPage method as semi-optional, because it must be specified the first time nextPage() is called (unless the setCallback() method is called, but can actually be omitted on subsequent calls. Basically, all we're looking for in the code above, is that either the callbackFunction argument is specified, or the parent-scope this.callbackFunction property exists. Whether it was specified by passing that argument into setCallback() or nextPage() doesn't matter all that much, and I don't (in this script) use the returned boolean value.
Next up, we just set up the GlideRecord and add the encoded query, if one was specified.
var gr = new GlideRecord(this.tableName); //declare gr gr.addEncodedQuery(this.queryString); //query using whatever encoded query the user passed in, or a blank string (default value) which should return everything. this.currentLocation++; //Iterate currentLocation to the next record, since we don't want to get the last record we got in the last page, we want the first record of the NEW page! //Note: chooseWindow (below) INCLUDES the first index, but DOES NOT INCLUDE the last; hence the above iteration. //So "chooseWindow(0,3)" will run through records at index 0, 1, and 2 - but not the record at index 3. gr.chooseWindow((this.currentLocation), (this.currentLocation + this.pageSize), true); gr.query();
Pretty standard stuff using the GlideRecord API, except for this new (and relatively undocumented) method: .chooseWindow(). This is actually specific to the new-ish scoped API, and is the key to avoiding building this whole thing hackily around .getLocation, .setLocation, .saveLocation, and .restoreLocation. As the code-comments state, .chooseWindow() allows us to specify the exact span of the "page". This should also give you a hint as to why we used class-level properties, rather than function-level variables to contain the currentLocation and pageSize values - because we want to be able to call .nextPage() over and over, without re-specifying (or indeed, re-acquiring) these values. This allows us to avoid several major performance-degrading loops.
After we query, inside of a try block, we then take several careful steps... First, we check if the query returned any actual records. If not, we go ahead and return false.
if (!gr.hasNext()) { return false; }
We return false in this case, because we want to be able to call .nextPage() inside of a loop just like .next() from a GlideRecord. In other words, I want to be able to call it like this:
var pgr = new PagedGlideRecord(args); pgr.setCallback(callbackFunction); while (pgr.nextPage()) { //do stuff, like logging or pre-rendering a page, or whatever. }
Next, we increment the counter this.page, mainly just for logging and record keeping, but also so the user can access that property using the .getPage() method.
this.page++; //increment the page counter, so we can keep track of where we are.
And then we iterate over each returned GlideRecord object -- calling the callback function once for each record, and passing in the GlideRecord. We then iterate a counter for reasons that will become clear momentarily.
while (gr.next()) { this.callbackFunction(gr); //For each record found, pass it into the callback function. counter++; }
I wanted this to pass each record, one at a time, into the callback function for my purposes. So for example, if I specified my pageSize as 200 (and the table had at least that many records matching my query), then my callback function would be called 200 times - once for each record - and the records would each be passed in, one at a time. It would however, be trivially easy to modify this so that it passed in the entire un-iterated 'gr' object, then called gr.getRowCount() and added that number to this.currentLocation, and you'd be able to call the callback function only once for each page. You could then do your iterating (while (gr.next()) {}) inside the callback function. The performance however, would be pretty much identical.
Next, we re-set this.currentLocation to the new current location (the last record iterated over), and add a message to the system logs to track our progress like so:
this.currentLocation = gr.getLocation(); //Once we've hit the limit of our window declared above by setLimit and below by chooseWindow, get our location so we can begin again from the same point. gs.log('PagedGlideRecord Processed PAGE ' + this.page + '. Last record processed: row ' + (this.currentLocation + 1) + '.');
Now we just need to check whether there are any records left to be iterated over, return true if so, and return false if not. Here's the code, followed by an explanation of how we did it:
if (counter < this.pageSize) { return false; } else { gr.chooseWindow((this.currentLocation + 1), (this.currentLocation + 2), true); //set the window so we run through the NEXT 500 records, if there are that many. gr.query(); return gr.hasNext(); }
The if block checks whether the counter (which you'll remember, was counting the number of records we looped over) is less than the value in pageSize. If that's the case, then that means we didn't find enough records to finish even the current page, so it's a fair bet that there isn't a next page.
Now in 999 cases out of a thousand, if the counter is the same as the pageSize, we can assume that there are more records left after the end of this page. However, we can't be 100% certain unless we check... but, we don't want to impact performance.
The else block does this by setting a new window that will contain only one record (to minimize the size of the query).
In fact, it occurs to me that we could further reduce the performance hit by a tiny amount if we instead set the first window to one more than the pageSize limit, stopped iterating over the GlideRecord once the counter hit the same value as pageSize, and returned the value of gr.hasNext() at that point, but I've already written most of this article so I'm not going to go back and change it! I will however, include the improved-performance logic in the tool you can download from the link at the end of this article. 😉
So that's all there is for the core logic. Let's have a look at the whole thing, all put together!
var PagedGlideRecord = Class.create(); PagedGlideRecord.prototype = { /** * Iterates over a large table by processing one chunk at a time. Passes each GlideRecord object into a callbackFunction function. Call the ".nextPage()" method to turn the page. * Note: You must call .nextPage() at least once after initializing PagedGlideRecord, to get any data. * @param tableName {string} the system name (such as incident or sc_request) of the table we're working with. * @param [queryString=''] {string} The query string used to filter the returned GlideRecord * @param [pageSize=500] {number} the size of the chunks to process at a time. Recommend 200-500. NOTE: This number MUST BE LESS THAN the glide.db.max_view_records property. If this property isn't set, then this number must be less than 10,000. * @returns {*} self */ initialize: function (tableName, queryString, pageSize) { //Enforce mandatory argument if (!tableName) { gs.logError('PagedGlideRecord was initialized without a valid table passed to the constructor.', 'PagedGlideRecord script include - initialize method'); throw 'PagedGlideRecord was initialized without a valid table passed to the constructor. tableName parameter[0]: ' + tableName; } //initialize optional values with defaults this.pageSize = 500; this.queryString = ''; //Set additional default values that are tracked at the class-scope level. this.currentLocation = -1; this.tableName = tableName.toString(); this.page = 0; //validate & setup optional arguments if (queryString) { this.queryString = queryString.toString(); } if (pageSize && pageSize > 0) { //Using parseInt to validate pageSize because "isInteger()" was introduced in ECMA2015, and so won't work in Geneva or earlier. pageSize = parseInt(pageSize); //Using ternary operators, set maxViewRecords to either the max_view_records property if it exists, or the default maximum (10k) if it does not. var maxViewRecords = gs.getProperty('glide.db.max_view_records') ? parseInt(gs.getProperty('glide.db.max_view_records')) : 10000; if (maxViewRecords < pageSize) { gs.logError('Page size specified is greater than maximum records returnable from GlideRecord query (as determined by system property glide.db.max_view_records). Thus, setting pageSize to the system maximum: ' + maxViewRecords + '.', 'PagedGlideRecord script include.'); pageSize = maxViewRecords; //If the page size is larger than the maximum records we can get from a GlideRecord query, set pageSize to the maximum query size. } this.pageSize = pageSize; } return this; }, /**Turns the page, getting the next n rows (as defined by the pageSize parameter when initializing the class) and passes them into the callback function. * @param [callbackFunction] {callback} the SEMI-optional function to be called to do work on each GlideRecord object. * The specified callback function must accept one argument: A GlideRecord object containing ONE record from the table specified in the first argument. * Note that you must specify the callback function at least once either while calling nextPage(), or by calling setCallback(); subsequent calls to the nextPage() method don't necessarily need it specified; they'll continue using the previously-specified callback function. * @returns {boolean} Returns true if more records are found AFTER all records within the "page" are processed (That is, returns true if there is a "next page"). */ nextPage: function (callbackFunction) { var counter = 0; //validate callbackFunction exists or is specified. If not, throw an error. if (!this.setCallback(callbackFunction)) { return false; //setCallback logs an error if it fails. Otherwise, continue. } //do work var gr = new GlideRecord(this.tableName); //declare gr gr.addEncodedQuery(this.queryString); //query using whatever encoded query the user passed in, or a blank string (default value) which should return everything. this.currentLocation++; //Iterate currentLocation to the next record, since we don't want to get the last record we got in the last page, we want the first record of the NEW page! //Note: chooseWindow (below) INCLUDES the first index, but DOES NOT INCLUDE the last; hence the above iteration. //So "chooseWindow(0,3)" will run through records at index 0, 1, and 2 - but not the record at index 3. gr.chooseWindow((this.currentLocation), (this.currentLocation + this.pageSize), true); gr.query(); try { if (!gr.hasNext()) { return false; } this.page++; //increment the page counter, so we can keep track of where we are. while (gr.next()) { this.callbackFunction(gr); //For each record found, pass it into the callback function. counter++; } this.currentLocation = gr.getLocation(); //Once we've hit the limit of our window declared above by setLimit and below by chooseWindow, get our location so we can begin again from the same point. gs.log('PagedGlideRecord Processed PAGE ' + this.page + '. Last record processed: row ' + (this.currentLocation + 1) + '.'); if (counter < this.pageSize) { return false; } else { gr.chooseWindow((this.currentLocation + 1), (this.currentLocation + 2), true); //set the window so we run through the NEXT 500 records, if there are that many. gr.query(); return gr.hasNext(); } } catch (ex) { var errMsg = 'Error in attempting to iterate over a large table, using function PagedGlideRecord. Records processed: ' + this.currentLocation + ' Error details: ' + ex.message; gs.logError(errMsg, 'PagedGlideRecord'); throw errMsg; } }, setCallback: function (callbackFunction) { //validate callbackFunction exists or is specified. If not, throw an error. if (callbackFunction) { this.callbackFunction = callbackFunction; return true; //return true if callback has a good value. } else if (this.callbackFunction) { return false; //return false and continue execution if callback was falsey but one is already specified. } else { //throw errors if neither the argument or existing callback are valid. gs.logError('Invalid or no callback function specified', 'PagedGlideRecord script include'); throw 'Invalid or no callback function specified when calling setCallback in PagedGlideRecord'; } }, /** * Get the page number for the page that was last turned to. The next page will be one greater than this number. * @returns {number} the current page number. */ getPage: function() { return this.page; }, /** * Get the current location - a zero-based index. The current "row number" will be this value, plus one. * @returns {number|*} the zero-based index of the last record returned on the last page that was turned to. */ getCurrentLocation: function() { return this.currentLocation; }, type: 'PagedGlideRecord' };
Beautiful, isn't she? Man, I love coding.
Want to deploy this into your instance, so that you too can run paginated GlideRecord queries? No problem! Just head on over to this page, or hover your mouse over Tools in the navigation bar at the top of this page, and click on Paginated Glide Record Utility!
Pro Tip: Pairing this callback with a javascript debounce function (as in underscore.js) would be a fantastic way to page through a huge number of records, SAFELY!
Have you got questions or comments? Want us to work with you, or just get our advice? We are here to help! Click on Contact Us to schedule some time to chat with us any time that's convenient for you. We'd love to hear from you!
If you'd love to hear from us too, use the form below or click here to subscribe!
-
August 2015
- Aug 27, 2015 Easily Clone One User's Access to Another User
- October 2015
-
December 2015
- Dec 2, 2015 Understanding Dynamic Filters & Checking a Record Against a Filter Using GlideFilter
- Dec 11, 2015 Array.indexOf() not working in ServiceNow - Solution!
- Dec 16, 2015 Detecting Duplicate Records with GlideAggregate
- Dec 17, 2015 Locate any record in any table, by sys_id in ServiceNow
- Dec 28, 2015 SN101: Boolean logic and ServiceNow's Condition Builder
-
January 2016
- Jan 4, 2016 Detect/Prevent Update Set Conflicts Before They Happen
- Jan 7, 2016 ServiceNow: Geneva & UI16 - What's new
- Jan 20, 2016 Customize the Reference Icon Pop-up
- Jan 25, 2016 Quickly Move Changes Between Update Sets
- Jan 29, 2016 A Better, One-Click Approval
-
February 2016
- Feb 1, 2016 Make Your Log Entries Easier to Find
- Feb 6, 2016 GlideRecord & GlideAjax: Client-Side Vs. Server-Side
- Feb 22, 2016 Reference Field Auto-Complete Attributes
-
March 2016
- Mar 18, 2016 ServiceNow: What's New in Geneva & UI16 (Pt. 2)
- Mar 28, 2016 Update Set Collision Avoidance Tool: V2
-
April 2016
- Apr 5, 2016 ServiceNow Versions: Express Vs. Enterprise
- Apr 27, 2016 Customizing UI16 Through CSS and System Properties
-
May 2016
- May 17, 2016 What's New in Helsinki?
-
July 2016
- Jul 15, 2016 Scripted REST APIs & Retrieving RITM Variables via SRAPI
- Jul 17, 2016 Granting Temporary Roles/Groups in ServiceNow
- September 2016
-
November 2016
- Nov 10, 2016 Chrome Extension: Load in ServiceNow Frame
-
December 2016
- Dec 2, 2016 We're Writing a Book!
- Dec 20, 2016 Pro Tip: Use updateMultiple() for Maximum Efficiency!
-
March 2017
- Mar 12, 2017 reCAPTCHA in ServiceNow CMS/Service Portal
- April 2017
- May 2017
-
June 2017
- Jun 4, 2017 Powerful Scripted Text Search in ServiceNow
- Jun 25, 2017 What's New in ServiceNow: Jakarta (Pt. 1)
- July 2017
-
September 2017
- Sep 12, 2017 Handling TimeZones in ServiceNow (TimeZoneUtil)
- November 2017
-
February 2018
- Feb 11, 2018 We have a new book!
- March 2018
- April 2018
-
May 2018
- May 29, 2018 Learning ServiceNow: Second Edition!
-
June 2018
- Jun 4, 2018 New Free Tool: Login Link Generator
- Jun 19, 2018 Improving Performance on Older Instances with Table Rotation
-
July 2018
- Jul 23, 2018 Admin Duty Separation with a Single Account
- September 2018
- October 2018
-
November 2018
- Nov 6, 2018 ServiceNow & ITSM as a Career?
- Nov 29, 2018 How to Learn ServiceNow
-
February 2019
- Feb 27, 2019 Making Update Sets Smarter - Free Tool
- March 2019
-
April 2019
- Apr 1, 2019 Outlook for Android Breaks Email Approvals (+Solution)
- Apr 4, 2019 Set Catalog Variables from URL Params (Free tool)
- Apr 10, 2019 Using Custom Search Engines in Chrome to Quickly Navigate ServiceNow
- Apr 21, 2019 Understanding Attachments in ServiceNow
- November 2019
- December 2019
-
January 2020
- Jan 20, 2020 Getting Help from the ServiceNow Community
- July 2020
- September 2020
-
November 2020
- Nov 17, 2020 SN Guys is now part of Jahnel Group!
- February 2021
- April 2021
- May 2021
- February 2022
- March 2022
-
August 2022
- Aug 14, 2022 New tool: Get Latest Version of ServiceNow Docs Page
- Aug 16, 2022 How to Get and Parse ServiceNow Journal Entries as Strings/HTML
- Aug 18, 2022 Free, Simple URL Shortener for ServiceNow Nerds (snc.guru)
- Aug 23, 2022 Using .addJoinQuery() & How to Query Records with Attachments in ServiceNow
- October 2022
-
December 2022
- Dec 13, 2022 ServiceNow Developers: BE THE GUIDE!
- April 2023
- May 2023
- July 2023
-
February 2024
- Feb 12, 2024 5 Lessons About Programming From Richard Feynman
- March 2024