“Good ideas are common – what’s uncommon are people who’ll work hard enough to bring them about.” — Ashleigh Brilliant
There is quite a bit of Webhook stuff in various IntegrationHubspokes, but it all seems to be oriented towards consuming incoming events from different external event publishers. I want to actually be the publisher, and send out information based on some preferences selected by the consumer. That may be hidden somewhere in the Now Platform already, but I can’t seem to find it, so I have decided that I would try to develop a Scoped Application to do just that. This may very well be recreating something that already exists in the platform today, but it sounds like a fun exercise, so I am going to give it the old college try.
As always, I will attempt to start out with the most basic of offerings, and then incrementally expand to add more and better features. My approach is to treat this feature as somewhat analogous to a Watch List, in that you sign up to follow certain events, but instead of sending a notification to a User when the event occurs, the result will be that the information is posted to a specified URL. This can apply to any number of things, but to start off, I am going to focus on some very specific changes to one particular table (Incident), and then later expand from there.
To make this work, there will need to be some kind of Webhook Registry where a consumer would sign up to receive these posts. When registering your webhook, you would enter the URL to which you want the data posted along with the specifics of what type or types of events you would like to have included. I’m thinking about linking them directly to an owner, and having some kind of My WebhooksPortal Page where you could manage your existing registrations and add new ones. When adding a new one, you should be able to enter and test your URL, and for our first iteration, that may be the only choice that you get. Later on, we will want to add the ability to choose what you want to follow, which specific updates should trigger a new post, and even what you would like to have included in the payload. But we will also want to start out as simple as possible, so the initial registry may turn out to be quite barren as far as input fields go.
Once registered, there will need to be some process to actually send out the posts as requested in the registration. This could be a Business Rule on the source table, or maybe something created in the Flow Designer. Either way, the process should scan the registry for any condition matches and then send out a post for each match. Each post and response should be logged in some kind of Webhook Activity Log, and any bad HTTP Response Codes should be reported to Event Management. A robust service would attempt to repost any failures up to a certain limit before giving up completely, but all of that can be delegated to some Alert Management Rule at some later time. Again, we will want to start out simple, so our initial focus will just be on making that initial post attempt. Everything else can be pushed off until later on in the process.
Those would seem to be the two major functions: registering the webhook and sending out the posts. We may want some other things at some point, such as the ability to review the logs or to manually repost or to clone an existing registration, but for now, just those two things should get the ball rolling. We may also want to set up a sample receiver for testing purposes, but in practice, the receivers would be other products and outside the scope of this development exercise. There is actually an existing service out on the Internet called Webhook.site that might turn out to be just what I need in order to do a little testing. We should check that out when we get to that point.
For our parts list, then, I can see the need for the following artifacts:
A table to hold the webhook registrations,
A my_webhooks portal widget to list all webhooks owned by the user,
A webhook portal widget for editing a single webhook registration,
A Business Rule or Flow to send out the posts,
A log table to record the posts and response, and possibly
Of course, before we create any of that, we will have to create the Scoped Application itself, so that should be where we start next time when we initiate the actual construction phase of this effort.
“Continuous improvement is better than delayed perfection.” — Mark Twain
When I hacked up the Ticket Conversations Widget to add support for @mentions, I knew that a number of people had already asked ServiceNow to provide the same capability out of the box. I also knew, though, that these things take time, and not wanting to wait, I just charged ahead as I am often prone to do. However, I was happy to hear recently that the wait may not be all that much longer:
Note: This is a system-generated email, do not reply to this email id. If you would like to stop receiving these emails you can change your preferences here.
Sincerely, Community Team
I am assuming that the “next Family version” is a reference to Quebec, although I have nothing on which to base that interpretation. It’s either that or Rome, so one way or another, it appears to be on the way. If and when it does arrive, I will gladly toss my version into the trash and use the real deal instead. I don’t mind building out things that I want that the product currently doesn’t provide, but as soon the product does provide it, my theory is that you fully embrace the out-of-the-box version and throw that steaming pile of home-grown customized nonsense right out the window. I actually look forward to that day.
But then again, that day is not today! Not yet, anyway.
“Learning from mistakes and constantly improving products is a key in all successful companies.” — Bill Gates
When I first conceived of my Configurable Data Table Widget Content Selector, my main focus was on creating a process that would read the JSON configuration file and turn those configuration rules into a functioning widget in accordance with the specifications. That was an interesting challenge that I had a quite a bit of fun with, but I started out by hard-coding the configuration object at the beginning of the widget and I never went back and set things up so that you could reuse the widget with a different configuration. Obviously, that’s not very friendly; now I need to go back in and set things right. The configuration object that you want to use should be an external parameter that gets passed into the widget via some external source such as a URL parameter or widget option. Let’s see if we can’t fix that right now.
I think that the first thing that I want to do is to create a base class for the configuration object script. That will do two things: 1) provide a common foundation of code for all of the configuration objects that you would like to build, and 2) provide a way to identify all of the qualifying scripts as all scripts that extend this particular base class. We’ll call it the ContentSelectorConfig:
For now, I am just going to reuse the list of perspectives, states, and tables that I was using before and focus on the mechanics of making this configuration an external parameter rather than a hard-coded reference. Rather than make the changes to my original My Data widget, though, I decided to clone the widget, give it a new name, and leave the original widget as is. I called my new widget Content Selector, and it started out life as an exact copy of the My Data widget before I started to hack it up.
The first thing that I did was to add a new option to the Option Schema so that we could pass in the name of the ContentSelectorConfig that we want to use. We already had an existing option to display the widget content in a single row rather than a stacked block, so this was just a matter of adding a second option to the existing array of options.
[{
"hint":"Mandatory configuration script that is an extension of ContentSelectorConfig",
"name":"configuration_script",
"section":"Behavior",
"label":"Configuration Script",
"type":"string"
},{
"hint":"If selected, will display the widget content in a single row rather than a stacked block",
"name":"display_inline",
"default_value":"false",
"section":"Presentation",
"label":"Display Inline",
"type":"boolean"
}]
Now that our new option has been defined, it’s time to rewrite the code that pulled in the hard-coded configuration object. Here is the original version of the first few lines of code in the server side script:
We are still going to want to establish the data.config object, but we are going to want to do it using an instance of the class named in our new Option. Last year, when I was working on my Static Monthly Calendar, I needed to turn a class name into an object of that class, and I built a little tool for that, which I called the Instantiator. We can use that same tool here to turn the name of our configuration script into an instance of that class that we can use to pull in the configuration. Here is the restructured code to start out our updated widget:
data.config = {};
data.inline = false;
data.user = data.user || {sys_id: gs.getUserID(), name: gs.getUserName()};
if (options) {
if (options.configuration_script) {
var instantiator = new Instantiator();
instantiator.setRoot(this);
var configurator = instantiator.getInstance(options.configuration_script);
data.config = configurator.getConfig($sp);
data.config.authorizedPerspective = getAuthorizedPerspectives();
establsihDefaults();
}
if (options.display_inline == 'true') {
data.inline = true;
}
}
Now we just need to throw it on a page with a Data Table widget, configure the options, and give it a spin. After dragging all of the widgets onto the page in the Page Designer, clicking on the pencil icon in the upper right-hand corner of our update widget will bring up the Options dialog where we can specify our configuration script.
Once the widget Options have been specified, all we need to do is to pull up the page and see if things are still functioning as they should, which appears to be the case.
Well, that’s about all there is to that. Basically, it does exactly what it did before, but you can now specify the configuration script using the Widget Options instead of having it hard-coded in the script as it was in the original version. Here’s an Update Set with the modifications if you’d like to play around with it on your own.
“What we see depends mainly on what we look for.” — Sir John Lubbock
One of the nice things about sharing your code with others is they end up testing it for you, and they do it from a different perspective than your own. The other day I received a comment from Ken out in California regarding my Dynamic Service Portal Breadcrumbs widget. He wanted to let me know that whenever he dragged the widget onto a page in the Portal Page Designer, the widget would disappear. I’ve had that little widget for quite a while now, and I have corrected it, perfected it, enhanced it, and I was actually aware of that behavior, but I had always just chalked that up as an irritating annoyance. From Ken’s perspective, though, it was a problem that needed to be addressed, and of course, he is correct. So, I decided to see if I could figure out why it was doing that, and if I could actually fix the problem.
I started out by doing a little research, just to see if anyone else had experienced this phenomenon. Sure enough, other people had reported the same behavior with other widgets, and the common thread always seemed to be that there was some coding error in the Client Script of the widget. Armed with that little tidbit of information, I set out to do a little experimenting.
The first thing that I did was comment out all of the code inside of the Client Script function and then run over to the Page Designer to see if that had any effect. Sure enough, the widget now appeared on the screen. That told me that I was on the right track, and that the issue actually was some problem in the Client Script. After that, it was just a matter of running through a sort of binary search, revealing and commenting out various chunks of code until I finally narrowed it down to the one line that was causing the problem:
var portalSuffix = ' - ' + $rootScope.portal.title.trim();
The reason that this worked on all of the pages in all of the portals where I have used it, but not in the Page Designer itself, was that the Page Designer has no title defined. When you try to run the trim() function on a String that doesn’t exist, you are going to get a null pointer exception. That’s obviously not good. So now what?
To step back just a bit, the whole point in grabbing the title was to look for it in the page title and remove it. Page titles in the form of <page title> – <portal title> are extra wide and contain the redundant portal title value, so I wanted to strip off that portion, if it was present. If there is no portal title, then that entire segment of code has no value, so I ended up checking for the presence of the title first, and then tucking all of that code underneath that conditional.
if ($rootScope.portal && $rootScope.portal.title) {
var portalSuffix = ' - ' + $rootScope.portal.title.trim();
var cutoff = thisTitle.indexOf(portalSuffix);
if (cutoff != -1) {
thisTitle = thisTitle.substring(0, cutoff);
}
}
That was it. Problem solved. Thank you, Ken, for prodding me to take a look at that. It turned out to be a relatively easy fix, and something that I should have addressed a long time ago. Here’s an Update Set with the corrected code.
“We are what we repeatedly do. Excellence, then, is not an act, but habit.” — Aristotle
The other day I was using my sn-record-picker Helper to create a picker that allowed multiple selections and I discovered that there were a couple of undetected errors in there that needed to be cleaned up. I rarely have an occasion to use the multiple=”true” option, so I never noticed the issues before. The first one was relatively simple: there was an extra trailing quote in the generated code for the multiple attribute. That was easy enough to fix. The other one was a little more complicated. The live example of the configured picker was never set up to handle multiple selections. That seemed like it would be a relatively easy fix, but it turned out to be a little more complicated than I realized.
My first thought was to just add the multiple attribute to the tag, and set the value to the value of the checkbox on the form, thinking that it would resolve to true or false and take care of the problem.
Unfortunately, that didn’t work. It didn’t really do anything bad, it just did not render out as a multiple selection picker, even when I checked the box. I thought maybe that it needed to be interpreted/resolved, so I surrounded the variable with double curly braces.
multiple="{{c.data.multiple}}">
Things really went South at that point. The whole thing crashed with the following error:
invalid key at column 2 of the expression [{{c.data.multiple}}] starting at [{c.data.multiple}}].
So, I tried a number of other, different things, none of which seemed to do the trick. Apparently, you have to hard-code the value of that attribute to true or it just won’t work. So much for making it dynamic. So, in the end, I had to create two versions of the element, one for single and another nearly identical one for multiple, and then show or hide them based on the value of the c.data.multiple variable.
Not the most elegant solution, but it does work, so there’s that. One thing that did not work on the multiple version was the modal pop-up on change. That works pretty slick on the single selection version, but on the multiple, the change event never fires. I played around with that for a while looking for a solution, but I finally gave up and just removed that attribute from the multiple version, since it didn’t actually do anything. On the multiple version, everything that you have selected is already displayed right there in front of you, so I figured that we weren’t losing all that much by my not finding a ready solution to the problem.
So that’s it: two little fixes. It’s not all that much, but it does correct a couple of annoying little problems, so here’s a fresh Update Set with the corrections in place.
Update: There is a better (corrected further) version here.
“There are three kinds of men. The one that learns by reading. The few who learn by observation. The rest of them have to pee on the electric fence for themselves.” — Will Rogers
I meant to do this earlier, but I got a little sidetracked on a completely different issue. Now that that little adventure is behind us, we can circle back to my recent changes to the Dynamic Service Portal Breadcrumbs and test everything out to see if it actually works. For that, I need to build a simple Portal Page, drop the breadcrumbs widget up at the top of the page, and then add a new widget that can listen for the messages coming out of the breadcrumbs widget.
So, let’s start with the new widget. This is just for testing, so we don’t need to get too fancy. Just a couple of test buttons, one to call for the return path and the other a back button that will use the return path. Here is some HTML that should work:
The first one just broadcasts the message that we missed the return path, so please send it over again. The second one, which is an actual back button, looks to see if we have already obtained the return path, and if not, makes the same call to request it, and the drops into a loop until it shows up. I threw a counter in there just for something to do inside of the loop, but if I display that out, I can also see how long it takes to hear back from the request to rebroadcast the return path. Speaking of receiving the return path, we will need to add a little code to listen for that as well:
Wrapping all of that together with a little initialization code give us a complete Client Script for our new widget:
function TestWidget($scope, $rootScope) {
var c = this;
var pointlessCounter = 0;
$scope.testFunction = function() {
$rootScope.$broadcast('snhShareReturnPath');
};
$scope.goBack = function() {
if (!c.data.returnPath) {
$rootScope.$broadcast('snhShareReturnPath');
}
while (!c.data.returnPath) {
pointlessCounter++;
}
window.location.href = c.data.returnPath;
};
$rootScope.$on('snhBreadcrumbs', function(evt, info) {
c.data.returnPath = info.returnPath;
alert(pointlessCounter + '; ' + c.data.returnPath);
});
}
There is no server side script needed, so that completes our tester. Now we just have to throw it on a page and see what happens.
Well, there is our test page containing the two widgets. There is no alert, though, which means that no one was listening when the breadcrumbs widget announced the return path, or something is broken. Let’s try the Test Button to see if we can get the breadcrumbs widget to announce it again.
OK, that worked. Now, let’s try the back button.
.. and that takes us back to the Incident that we were just looking at before we brought up the test page. Nice!
So far, so good. I tried a few more things, like clicking on the back button without first clicking on the test button and clicking on the test button multiple times. Everything seems to work the way in which I had envisioned it. I like it. As far as I can tell, it is good enough for me to put out another Update Set. Although I did get a little sidetracked on this one, it was a quick diversion and I did manage to circle back around and finish it up.
Update: There is a better (corrected) version here.
“Build your own dreams, or someone else will hire you to build theirs.” — Farrah Gray
While I was playing around with my Configuration Item Icon Assignment widget, it occurred to me that it would be beneficial for my Dynamic Service Portal Breadcrumbs widget to somehow announce its presence to other widgets on the same page, and to provide them with the URL of previous page in case they wanted to set up some kind of Cancel or Done button that returned the user to the page from which they came. I even thought about putting such a button on the breadcrumbs widget itself, just under the row of breadcrumbs, but that seemed a little out of place, the more that I thought about it. So right now, I just want to let the other widgets know that they are sharing the page with the breadcrumbs widget and provide them with the return path.
The easiest way to do that is through some kind of root scope broadcast message, but that does present a little bit of a wrinkle, as you don’t really know which widgets have loaded first, and you could be broadcasting to no one if you are first to arrive on the scene. To solve that problem, you can listen for a different root scope message from other widgets that basically asks, “Please say that again now that I am ready to hear it.” From the listener’s perspective then, when you are ready to utilize the return path, if you haven’t yet received it, you can request it, and then go from there.
But first things first. Before we can announce the return path, we need to know what it is. Using the length of the breadcrumbs array as an index takes you past the end of the array, but subtracting 1 from that value gets you to the last entry, which happens to be the current page. We want the page before that, so we have to subtract 2 from the length. The danger there, of course, is that you might have an empty array or an array with a single element, and subtracting 2 would again put your index outside of the range of the array. So we have to start out with a default value, and then check the length of the array before we proceed any further. My code to do that looks like this:
That establishes the return path. Now we have to announce it. I created a function for that, since we will be doing this from multiple places (on load, and then again on request). Here is my function:
function shareReturnPath() {
$rootScope.$broadcast('snhBreadcrumbs', {
returnPath: c.returnPath
});
}
Now that we have created the function, we need to call it as soon as we establish the return path, and then we need to set up a listener for later requests that will call it again on demand. For that, we just need a little more code wedged in between the two blocks above:
Well, that wasn’t so bad. Combined with what we had before, the entire Client Controller now looks like this:
function snhBreadcrumbs($scope, $rootScope, $location, spUtil) {
var c = this;
c.expanded = !spUtil.isMobile();
c.expand = function() {
c.expanded = true;
};
c.breadcrumbs = [];
var thisTitle = c.data.page || document.title;
var portalSuffix = ' - ' + $rootScope.portal.title.trim();
var cutoff = thisTitle.indexOf(portalSuffix);
if (cutoff != -1) {
thisTitle = thisTitle.substring(0, cutoff);
}
var thisPage = {url: $location.url(), id: $location.search()['id'], label: thisTitle};
if (thisPage.id && thisPage.id != $rootScope.portal.homepage_dv) {
var pageFound = false;
for (var i=0;i<c.data.breadcrumbs.length && !pageFound; i++) {
if (c.data.breadcrumbs[i].id == thisPage.id) {
c.breadcrumbs.push(thisPage);
pageFound = true;
} else {
c.breadcrumbs.push(c.data.breadcrumbs[i]);
}
}
if (!pageFound) {
c.breadcrumbs.push(thisPage);
}
}
c.data.breadcrumbs = c.breadcrumbs;
c.server.update();
c.returnPath = '?';
if (c.breadcrumbs.length > 1) {
c.returnPath = c.breadcrumbs[c.breadcrumbs.length - 2].url;
}
shareReturnPath();
$rootScope.$on('snhShareReturnPath', function() {
shareReturnPath();
});
function shareReturnPath() {
$rootScope.$broadcast('snhBreadcrumbs', {
returnPath: c.returnPath
});
}
}
Yes, we still have to test it, just to make sure that it all works in the way in which we intended, but it should work and it didn’t take all that much to provide this new functionality. In fact, it will probably be more work to test it than it was to create it. I’ll have to find or build another widget to share the page with this one, then create a page to put them both on, and then bring up the page and try things out. Now that I think about it, that seems like a decent amount of effort, so I think I will just save all of that until next time!
“When to use iterative development? You should use iterative development only on projects that you want to succeed.” — Martin Fowler
I probably could have wrapped all of this up last time, but I had not really given a whole lot of thought to a couple of critical decisions related to the last two items that needed to be coded out, the Save button and the Cancel button. For the Cancel button, the main question is where are you going to land once you leave the page. For the Save button, which is the bigger question, I need to figure out where I am going to store the information now that I have collected it on the screen. Since that’s a more complicated deliberation, let’s focus on the Cancel button first.
One thing that I wanted to do with the Cancel button was to pop up a confirmation that you really did want to bail, but only if you had made any changes that you would be losing by leaving the page. I wasn’t keeping track of whether or not you had made any changes, though, so I needed to fix that first. I created a variable called dataAltered and initialized it to false, and then I set it to true if you added or removed a row or selected an icon. With that in place, I built the function to handle the Cancel button clicks.
$scope.cancel = function() {
if (dataAltered) {
spModal.confirm('Discard your recent changes?').then(function(confirmed) {
if (confirmed) {
goBack();
}
});
} else {
goBack();
}
};
Those of you paying close attention will notice that I still did not address the question of where to go after clicking the Cancel button; I just called a nonexistent function called goBack. That’s basically my way of putting things off until some future time when I am forced to deal with it, and usually I will create the function with a single line of code that pops an alert or write a log record to the let me know that I’ve made it that far. That let’s me test what I have done without having to deal with what I haven’t done. In this particular case, though, that was a short delay, because now it’s time to put real code in the goBack function, which means that it’s time to decide where people are going to land when they hit the Cancel button.
One of my first thoughts was that if this widget was sharing a screen with my Dynamic Service Portal Breadcrumbs widget, I could just go back one step in the chain to the previous screen. In order for that to work, I would need to know if I was sharing the page with that widget, which is not really possible at the moment. Ideally, that widget should announce its presence with some kind rootScope broadcast message that says something like, “Hey, I am here, and oh by the way, here is the URL for the previous page if you want to go back to it.” That actually sounds like a good idea, but it also sounds like a project for another day. I was planning on adding this widget to my existing Tools menu in the main UI, so I didn’t really see a lot of value in investing a lot of effort right now on getting it to work on the portal with breadcrumbs.
Ultimately, I decided to just go to the home page of the main UI, unless of course, we really were running in the portal, and in that case, I would just return to the home page of the portal. Since the main UI pages run inside of an iFrame, it seemed relatively simple enough to detect the difference by looking at the parent window. The final code turned out to be just this:
function goBack() {
var destination = '?';
if (window.parent.location.href != window.location.href) {
destination = '/home.do';
}
window.location.href = destination;
}
That takes care of the easy part. Now it is finally time to make a decision on where to store this information. The simplest thing to do seemed to be to just add another column to the sys_db_object table for the name of the icon. That ‘s a fairly foundational table to the whole ServiceNow platform, though, and I really do not like to mess with those if I can avoid it. A preferable alternative would be to create a simple m2m table that had a column for the CI class and another column for the icon name. That’s much better, but there is still a certain amount of work involved in creating the table, saving the data to the table, and then reading the data back in from the table whenever you wanted to use it. What I finally decided to do was to just keep the icon map hard-coded in my Script Include, and then just update the script column of the Script Include whenever the Save button is clicked. Look, Ma — no tables!
My plan was to grab the script, substring off the parts of the script before and after the property list, rebuild the property list from the data on the screen, and then rebuild the script from the before and after pieces and the new property list. There is probably some regex wizardry that would do all of that with a single line of code, but most of that is all voodoo magic as far as I can tell, and I like to build things that can be comprehended by a little bit larger audience. In the end, my approach seemed to have an awful lot of code and variables involved, but it does work, so there you go.
function save() {
var scriptGR = new GlideRecord('sys_script_include');
if (scriptGR.get('7b6ebf052f8e18104425fcecf699b6f6')) {
var original = scriptGR.getValue('script');
var i = original.indexOf('map: {');
if (i != -1) {
var preamble = original.substring(0, i);
var theRest = original.substring(i);
i = theRest.indexOf('\n\t},');
if (i != -1) {
var postamble = theRest.substring(i);
var separator = '';
var newScript = preamble + 'map: {';
for (i=0; i<data.itemArray.length; i++) {
newScript += separator + '\n\t\t' + data.itemArray[i].id + ": '" + data.itemArray[i].icon + "'";
separator = ',';
}
newScript += postamble;
scriptGR.setValue('script', newScript);
scriptGR.update();
gs.addInfoMessage('The Configuration Item icon assignments have been saved');
} else {
gs.addErrorMessage('The Script Include to update has been corrupted.');
}
} else {
gs.addErrorMessage('The Script Include to update has been corrupted.');
}
} else {
gs.addErrorMessage('The Script Include to update was not found.');
}
}
That’s a server side script, by the way, so we still need a client side script to send things over to the server. That one is pretty simple, though, and looks like this:
In fact, the whole client side script, including the code that we just added to detect changes and handle the two buttons now looks like this in its final form:
function CIIconAssignment($scope, spModal) {
var c = this;
var dataAltered = false;
$scope.addSelected = function() {
c.server.update();
$('#snrp').select2("val","");
dataAltered = true;
};
$scope.removeItem = function(inx) {
spModal.confirm('Remove ' + c.data.itemArray[inx].id + ' from the list?').then(function(confirmed) {
if (confirmed) {
c.data.itemArray.splice(inx, 1);
dataAltered = true;
}
});
};
$scope.selectIcon = function(inx) {
spModal.open({
title: 'Select Icon',
widget: 'icon-picker',
buttons: [
{label: '${Cancel}', cancel: true}
],
size: 'sm',
}).then(function(response) {
c.data.itemArray[inx].icon = response.selected;
dataAltered = true;
});
};
$scope.save = function() {
c.data.action = 'save';
c.server.update();
};
$scope.cancel = function() {
if (dataAltered) {
spModal.confirm('Discard your recent changes?').then(function(confirmed) {
if (confirmed) {
goBack();
}
});
} else {
goBack();
}
};
function goBack() {
var destination = '?';
if (window.parent.location.href != window.location.href) {
destination = '/home.do';
}
window.location.href = destination;
}
}
The last thing that I did was grab one of the existing sidebar menu options under my Tools section and use it as model for a new menu option to launch this page. After that, I used the new option to bring up the page and test everything out. Everything seems to work, and it came out looking pretty good, all things considered.
One thing that I did realize during my testing is that there is nothing to stop you from deleting the root cmdb_ci entry, which serves as my default. I don’t really want you doing that, so I added an ng-hide to the delete icon that will remove that option when the item.id is cmdb_ci. You can see in the image above that there is no delete option for that row. That’s much better.
After playing around and testing, I ended up saving my Script Include several times over. Right now, it looks like this:
Of course, that will change the next time that I use the tool, but I like the fact that I did not end up creating any extra columns or tables to pull this off. For those of you who like to play along at home, here is an Update Set containing all of the parts and pieces.
“Walking on water and developing software from a specification are easy if both are frozen.” — Edward V. Berard
It wasn’t entirely true that my empty shell CI Icon Assignment widget was completely devoid of all code underneath. I did have to toss in the function that launches the Retina Icon Picker in order to show that in action, but I was able to steal most of that code from the little test page that I had built to try that guy out.
In my original test page, I had a second response function to handle the Cancel button, but for our purposes here, that’s not really necessary. If you hit the Cancel button on the pop-up on this widget, it will just close and nothing else will happen, which is perfectly fine. The other alteration that I had to make was to include an index argument so that when you did make a selection, it was applied to the appropriate line. Other than that, it’s pretty much what we were working with earlier.
But the icon picker is not the only element on the screen that needs a little code behind it. We’ll need to do something with the Configuration Item picker as well. Selecting a CI from the picker should result in a new row added to the table for the selected CI, and then the picker should be cleared in preparation for another selection to be made. Since we have to go look up the label for our second column, that sounds like a server side operation, so our client side code will be pretty simple:
The first line just kicks the process over to the server side, and then then second line clears out the value of the sn-record-picker so that it is ready for another selection. Over on the server side, we do the work to add the new CI type to the list, as long as it isn’t already there.
if (input) {
data.itemArray = input.itemArray;
if (input.classToAdd && input.classToAdd.value) {
addClassToList(input.classToAdd.value);
}
}
function addClassToList(key) {
var foundIt = false;
for (var i=0; !foundIt && i<data.itemArray.length; i++) {
if (data.itemArray[i].id == key) {
foundIt = true;
}
}
if (!foundIt) {
var thisItem = {};
thisItem.id = key;
thisItem.name = getItemName(key);
data.itemArray.push(thisItem);
}
input.classToAdd = {};
}
function getItemName(key) {
var ciGR = new GlideRecord(key);
return ciGR.getLabel();
}
That takes care of adding an item to the list. Now we have to add some code to handle removing an item from the list. We put a Delete icon at the end of each line, so we just need to build the code that will run when someone clicks on that icon. Since it is a delete, we will want to pop up a quick confirm dialog before we actually remove the item from the list, just to make sure that they did that on purpose. All of that can be handled on the client side, and fairly simply.
$scope.removeItem = function(inx) {
spModal.confirm('Remove ' + c.data.itemArray[inx].id + ' from the list?').then(function(confirmed) {
if (confirmed) {
c.data.itemArray.splice(inx, 1);
}
});
};
To test all of that out, I added a few items to the list, selected an icon for each, and then tried to remove a few. During my testing, I realized that adding enough items to the list scrolls the icon selector off of the page and out of sight, which I didn’t really like, so I did a little rearranging and put the selector on top of the list instead of underneath. With that change, the modal delete confirmation now looks like this:
So now we have all of the code in place to select a CI, to select an icon for the CI, and to remove a CI from the list. Now all we have left to do is to put some code under the two buttons at the bottom of the screen. Of course, coding the Save button would be much easier if I knew where I was going store this information once I am through building the list, but I haven’t really given much thought to that just yet, since we hadn’t quite gotten to that part until just this moment. Now that writing the code for the buttons is the only thing left to do, it’s time to figure that out. But that could potentially be an entire installment in and of itself, so I think we’ll stop here and save that exercise for another day.
“You don’t have to see the whole staircase, just take the first step.” — Martin Luther King, Jr.
The main reason that I wanted to find a way to include an Icon in a pick list is because I wanted to assign different icons to various classes of Configuration Items to help visually distinguish the items based on their type. Now that I know that I can’t just add an icon to a single SELECT statement, I’m not exactly sure how I am going to that, but that’s not today’s issue. When all is said and done, I may not even be able to do what I am hoping to do, but I’m not working on that part right now. I try not to get too distracted by the things that I don’t understand or don’t know how I’m going to accomplish before it’s time. Since I can’t code everything all at once, I don’t need to solve all of the mysteries all at once, either. My theory is that I should be able to figure it out when the time comes, so there is no need to worry about it right now. To keep productive, and to maintain focus, I like to deal with things One Piece at a Time.
Today, I want to build a function that will return the appropriate icon name based on a passed configuration item class, which will give me something to call when it is time to go get the icon associated with a particular item. I want to return an icon name in all circumstances, so if the specific CI class is not mapped to an icon, then I want the function to check the parent class, and basically keep doing that until an icon name is found. If it makes it all the way to the top and there is still no icon, then there needs to be a default, because one way or another, I want to return an icon name no matter what. I will eventually stuff this function into a Script Include of some kind, but for now, I just want to code out the function. Here is what I came up with.
getIcon: function(ciClass) {
var icon = this.map[ciClass];
if (!icon) {
ciClass = this.getParentClass(ciClass);
if (ciClass) {
icon = this.getIcon(ciClass);
}
}
return icon;
},
getParentClass: function(ciClass) {
var parentClass = '';
var tableGR = new GlideRecord('sys_db_object');
if (tableGR.get('name', ciClass)) {
parentClass = tableGR.super_class.name;
}
return parentClass;
},
OK, it turns out that it is actually two functions, but you get the idea. This code assumes that there is a map included that associates icon names to CI classes, and that the map is keyed by CI class and returns the icon name. We don’t have to have the fully populated map right now, but to test the code, we will at least need to stub it out with a minimum of one item. That should be fairly simple to do.
map: {
cmdb_ci: 'configuration'
},
Since the cmdb_ci class is basically the root class of all Configuration Items, defining a value for that class essentially establishes the default. As you crawl up the parentage of any specific CI class, you will eventually find your way to the cmdb_ci class, so that should satisfy my requirement that there should always be a default response from the function call.
The stubbed-out map is a good start, but I want to build up my map using some kind of tool that will allow me to select a CI class and then use my new icon picker to select an appropriate icon for the class. Something that would look like this:
Once you added your CI class to the list, then you could click on the little magnifying glass icon to launch our icon picker to select your icon.
What you are seeing is just a screen mock up at this point, but putting the screen together is always a good place to start. Here is the HTML that I used to produce the screen image.
<div class="panel">
<div style="width: 100%; padding: 5px 50px;">
<h2 style="width: 100%; text-align: center;">${Configuration Item Icon Assignment}</h2>
<table class="table table-hover table-condensed">
<thead>
<tr>
<th style="text-align: center;">Item</th>
<th style="text-align: center;">Label</th>
<th style="text-align: center;">Icon</th>
<th style="text-align: center;">Icon Name</th>
<th style="text-align: center;">Delete</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="item in c.data.itemArray track by item.id | orderBy: 'id'" ng-hide="item.removed">
<td data-th="Item"><input class="form-control" ng-model="item.id" readonly="readonly"/></td>
<td data-th="Label"><input class="form-control" ng-model="item.name" readonly="readonly"/></td>
<td data-th="Icon" style="text-align: center;"><span style="font-size: 25px;" class="icon icon-{{item.icon}}"></span></td>
<td data-th="Icon Name">
<span class="input-group" style="width: 100%;">
<input class="form-control" ng-model="item.icon" readonly="readonly"/>
<span class="input-group-btn" ng-click="selectIcon($index)" aria-hidden="false">
<button class="btn-ref btn btn-default">
<span class="icon icon-search" aria-hidden="true"></span>
<span class="sr-only ng-binding">${Select an icon}</span>
</button>
</span>
</span>
</td>
<td data-th="Delete" style="text-align: center;"><img src="/images/delete_row.gif" ng-click="removeItem($index)" alt="Click here to remove this item from the list" title="Click here to remove this item from the list" style="cursor: pointer;"/></td>
</tr>
</tbody>
</table>
<p>To add another Configuration Item to the list, select an item from below:</p>
<sn-record-picker
id="snrp"
field="data.classToAdd"
ng-change="addSelected()"
table="'sys_db_object'"
default-query="'super_class=72e7251abc002300aadb875973a34b54^ORsuper_class.super_class=72e7251abc002300aadb875973a34b54^ORsuper_class.super_class.super_class=72e7251abc002300aadb875973a34b54^ORsuper_class.super_class.super_class.super_class=72e7251abc002300aadb875973a34b54^ORsuper_class.super_class.super_class.super_class.super_class=72e7251abc002300aadb875973a34b54^ORsuper_class.super_class.super_class.super_class.super_class.super_class=72e7251abc002300aadb875973a34b54^ORsuper_class.super_class.super_class.super_class.super_class.super_class.super_class=72e7251abc002300aadb875973a34b54'"
display-field="'label'"
display-fields="'name'"
search-fields="'label'"
value-field="'name'">
</sn-record-picker>
<br/>
<p>To remove an item from the list, click on the Delete icon.</p>
</div>
<div style="width: 100%; padding: 5px 50px; text-align: center;">
<button ng-click="save()" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to save your changes">Save</button>
<button ng-click="cancel()" class="btn ng-binding ng-scope" role="button" title="Click here to cancel your changes">Cancel</button>
</div>
</div>
For the sn-record-picker, I used my old friend, the sn-record-picker Helper, but before I got that far, I had to first work out the query. I wanted any CI table (the table name is also the class name), so I was looking for any table that was based on the cmdb_ci table. There may be an easier way to do this, but this works, so I just went with it.
That’s an ugly, brute force way of doing that, but it gets the job done, which is really all that we are after at this point. The real fun will be putting all of the code behind the screen to both build up the map and then store it somewhere. That’s pretty complicated stuff, and I’m not really sure exactly how I am going to do all of that, so we’ll save that as an exercise for another day.