My Delegates Widget

“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”
Martin Fowler

A while back I was tinkering with creating a universal Help infrastructure for my Service Portal widgets, and one of my example images used my My Delegates widget for the demonstration.

Widget Help example using the My Delegates widget

While the concept of delegation is an out-of-the-box feature, the widget is a custom component that I built to allow Service Portal users to manage their delegates. It’s really just a portalized version of the same functionality available inside the UI, but there was no way to do that within the Service Portal itself, so I threw together a simple widget to do so. Here is the HTML:

<snh-panel rect="rect" title="'${My Delegates}'">
  <div style="width: 100%; padding: 5px 50px;">
    <table class="table table-hover table-condensed">
      <thead>
        <tr>
          <th style="text-align: center;">Delegate</th>
          <th style="text-align: center;">Approvals</th>
          <th style="text-align: center;">Assignments</th>
          <th style="text-align: center;">CC notifications</th>
          <th style="text-align: center;">Meeting invitations</th>
          <th style="text-align: center;">Remove</th>
        </tr>
      </thead>
      <tbody>
        <tr ng-repeat="item in c.data.listItems track by item.id | orderBy: 'delegate'" ng-hide="item.removed">
          <td data-th="Delegate">
            <sn-avatar class="avatar-small-medium" primary="item.id" show-presence="true"/>
             
            {{item.delegate}}
          </td>
          <td data-th="Approvals" style="text-align: center;"><input type="checkbox" ng-model="item.approvals"/></td>
          <td data-th="Assignments" style="text-align: center;"><input type="checkbox" ng-model="item.assignments"/></td>
          <td data-th="CC notifications" style="text-align: center;"><input type="checkbox" ng-model="item.notifications"/></td>
          <td data-th="Meeting invitations" style="text-align: center;"><input type="checkbox" ng-model="item.invitations"/></td>
          <td data-th="Remove" style="text-align: center;"><img src="/images/delete_row.gif" ng-click="removePerson($index)" alt="Click here to remove this person as a delegate" title="Click here to remove this person from the list" style="cursor: pointer;"/></td>
        </tr>
      </tbody>
    </table>
    <p>To add a delegate to the list, select a person from below:</p>
    <sn-record-picker id="snrp" field="data.personToAdd" ng-change="addSelected()" table="'sys_user'" display-field="'name'" display-fields="'title,department,location,email'" value-field="'sys_id'" search-fields="'name'" page-size="20"></sn-record-picker>
    <br/>
    <p>To remove a delegate from the list, click on the Remove icon.</p>
  </div>

  <div style="width: 100%; padding: 5px 50px; text-align: center;">
    <button ng-click="saveDelegates()" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to save your changes">Save</button>
     
    <button ng-click="returnToProfile()" class="btn ng-binding ng-scope" role="button" title="Click here to cancel your changes">Cancel</button>
  </div>
</snh-panel>

Basically, it is just a table of delegates followed by an sn-record-picker from which you can choose additional people to add to the list. The source of the data is the same as that used by the internal delegate maintenance form, which you can see gathered up in server-side script here:

(function() {
	data.userID = gs.getUser().getID();
	if (input) {
		data.listItems = input.listItems || fetchList();
		if (input.personToAdd && input.personToAdd.value > '') {
			addPersonToList(input.personToAdd.value);
		}
		if (input.button == 'save') {
			saveList();
		}
	} else {
		if (!data.listItems) {
			data.listItems = fetchList();
		}
	}

    function fetchList() {
		var list = [];
		var gr = new GlideRecord('sys_user_delegate');
		gr.addQuery('user', data.userID);
		gr.orderBy('delegate.name');
		gr.query();
		while (gr.next()) {
			var thisDelegate = {};
			thisDelegate.sys_id = gr.getValue('sys_id');
			thisDelegate.id = gr.getValue('delegate');
			thisDelegate.delegate = gr.getDisplayValue('delegate');
			thisDelegate.approvals = (gr.getValue('approvals') == 1);
			thisDelegate.assignments = (gr.getValue('assignments') == 1);
			thisDelegate.notifications = (gr.getValue('notifications') == 1);
			thisDelegate.invitations = (gr.getValue('invitations') == 1);
			list.push(thisDelegate);
		}
		return list;
	}

    function saveList() {
		for (var i=0; i<data.listItems.length; i++) {
			var thisDelegate = data.listItems[i];
			if (thisDelegate.removed) {
				if (thisDelegate.sys_id != 'new') {
					var gr = new GlideRecord('sys_user_delegate');
					gr.get(thisDelegate.sys_id);
					gr.deleteRecord();
				}
			} else {
				var gr = new GlideRecord('sys_user_delegate');
				if (thisDelegate.sys_id != 'new') {
					gr.get(thisDelegate.sys_id);
				} else {
					gr.initialize();
					gr.user = data.userID;
					gr.delegate = thisDelegate.id;
					gr.starts = new Date();
				}
				gr.approvals = thisDelegate.approvals;
				gr.assignments = thisDelegate.assignments;
				gr.notifications = thisDelegate.notifications;
				gr.invitations = thisDelegate.invitations;
				gr.update();
			}
		}
		gs.addInfoMessage('Your Delegate information has been updated.');
	}

    function addPersonToList(selected) {
		var existing = -1;
		for (var i=0; i<data.listItems.length && existing == -1; i++) {
			if (data.listItems[i].id == selected) {
				existing = i;
			}
		}
		if (existing == -1) {
			var thisDelegate = {};
			thisDelegate.sys_id = 'new';
			thisDelegate.id = selected;
			var gr = new GlideRecord('sys_user');
			gr.get(selected);
			thisDelegate.delegate = gr.getDisplayValue('name');
			thisDelegate.approvals = true;
			thisDelegate.assignments = true;
			thisDelegate.notifications = true;
			thisDelegate.invitations = true;
			data.listItems.push(thisDelegate);
		} else {
			data.listItems[existing].removed = false;
		}
		input.personToAdd = {};
	}
})();

All of the changes are held in the session until you decide to Save or Cancel, and if you elect to save, then things are updated on the database at that time. On the client side of things, we just have functions to add and remove people from the list, and to handle the two buttons:

function($scope, $window) {
	var c = this;

	$scope.addSelected = function() {
		$scope.server.update().then(function(response) {
			$('#snrp').select2("val","");
		});
	};

	$scope.removePerson = function(i) {
		c.data.listItems[i].removed = true;
	};

	$scope.saveDelegates = function() {
		c.data.button = 'save';
		$scope.server.update().then(function(response) {
			reloadPage();
		});
	};

	$scope.returnToProfile = function() {
		reloadPage();
	};

	function reloadPage() {
		$window.location.reload();
	}
}

That’s the whole thing in all of its glory. If you want a copy of your own, here’s a little Update Set.

Update: There is an even better version here.

Dynamic Service Portal Breadcrumbs

“Do not wait; the time will never be ‘just right.’ Start where you stand, and work with whatever tools you may have at your command, and better tools will be found as you go along.”
George Herbert

I’ve had this idea for a while to attempt a different approach to Service Portal breadcrumbs, and I finally quit tinkering with my Data Table clones and Configurable Content Selector long enough to actually throw something together. My issue with the out-of-the-box breadcrumb widget is that you have to tell it what the breadcrumbs are on every page rather than the system keeping track of where you are and how you got there. It seemed to me that it would not only be easier to set up, but it would also be more accurate, since there are often times more than one path to get to a specific page.

To keep track of the current page stack for the breadcrumbs, I decided to leverage the existing User Preferences infrastructure. User Preferences are accessible in the Service Portal via built-in GlideSystem functions, and provide a convenient means to keep track of a user’s path through the various screens in the portal. To fetch a User Preference, you use the gs.getPreference(key) method, and to update a User Preference, the script is gs.getUser().setPreference(key, value).

To begin, I pulled up the existing breadcrumb widget and created a clone that I called SNH Breadcrumbs. I did not want to change the way the breadcrumbs were displayed, so I left the HTML portion of the widget intact. I did not want to set the value of the breadcrumbs via widget option anymore, though, so I removed the option. Then I modified the server-side script to create a label for the current page and pull the current breadcrumbs out of the User Preferences. I also provided the means to update the breadcrumbs when an update was invoked on the client side. The complete server-side script now looks like this:

(function() {
	if (input) {
		if (input.breadcrumbs) {
			gs.getUser().setPreference('snhbc', JSON.stringify(input.breadcrumbs));
		}
	} else {
		data.table = $sp.getParameter('table');
		data.sys_id = $sp.getParameter('sys_id');
		if (data.table) {
			var rec = new GlideRecord(data.table);
			if (data.sys_id) {
				rec.get(data.sys_id);
				data.page = rec.getDisplayValue('number');
				if (!data.page) {
					data.page = rec.getDisplayValue('name');
				}
				if (!data.page) {
					data.page = rec.getDisplayValue('short_description');
				}
				if (!data.page) {
					data.page = rec.getLabel();
				}
			} else {
				data.page = rec.getPlural();
			}
		}
		data.breadcrumbs = [];
		var snhbc = gs.getPreference('snhbc');
		if (snhbc) {
			data.breadcrumbs = JSON.parse(snhbc);
		}
	}
})();

The page label is based on the URL parameters table and sys_id. If both are present, I go ahead and grab the record and attempt to obtain a label from the data. If only the table parameter is present, then I assume that we are talking about multiple records, so I grab the Plural label for the table itself. If there is no table parameter, then I let the client-side script handle the label for the page. On the client side, I build a breadcrumb entry for the current page, and then loop through the existing breadcrumbs to see if this page is already in the list. If it is, then that’s where we will stop; otherwise, we will just tack the new current page entry on to the end of the existing stack of pages. Here is the complete client-side script:

function($scope, $rootScope, $location, spUtil) {
	var c = this;
	c.expanded = !spUtil.isMobile();
	c.expand = function() {
		c.expanded = true;
	};
	c.breadcrumbs = [];
	var thisPage = {url: $location.url(), id: $location.search()['id'], label: c.data.page || document.title};
	
	if (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();
}

That’s really all there is to it. Here’s one example of how it looks in practice:

Dynamic breadcrumbs example

In the example above, the URL for the page contains a table parameter, but no sys_id parameter. This generates a page label from the table’s getPlural() method. If we select a different perspective, which uses a different table, we will still be on the same page, but the page label will reflect the current table in use for that perspective.

Breadcrumbs example using a different perspective/table

Now, if you click on one of the items in the table, you will see that the breadcrumb list grows, and this time the URL has both a table and a sys_id parameter, but the record in question (sysapproval_approver) has no number, name, or short_description fields, so the label is defaulted to the generic label for the record.

Approval record breadcrumb example

Clicking on the Approvals breadcrumb will take you back to the original screen, removing the single Approval record from the breadcrumb array.

Using the breadcrumb to return to the initial screen

Now, if you click on the Change record instead of the Approval record, the Change record actually does have a number field, so the label for that page is the actual number of the record.

Breadcrumbs example with numbered record

And finally, if you click on the Opened by column, which is configured to take you to the User Profile page, there is no number, but there is a name, so that becomes the label.

Breadcrumbs example from the Opened by column

The reason that you can find the record to fetch the name in the above example is because the Data Table widget arbitrarily passes both the table and sys_id parameters to the User Profile page, even though table is not needed (the table sys_user is assumed by the User Profile page — you don’t have to pass it). When you pull down the User menu and select Profile, no table name is passed in the URL, so the label defaults to the name of the page.

Breadcrumbs example from the User Profile selection

One thing to keep in mind is that the trail will only build as you pass through pages that contain the widget. Any pages that you pass through that do not contain the widget will not get added to the running list of pages, as there will be no code running that pushes the page onto the stack. Other than that, it seems to work in most other cases. If you want to try it out for yourself, here’s an Update Set that contains the custom widget.

Update: There is a better (working!) version here.

Configurable Data Table Widget Content Selector, Part IV

“It has long been an axiom of mine that the little things are infinitely the most important.”
Sir Arthur Conan Doyle

When we last left this particular widget, I was experiencing problems with the companion button-handling widget executing more than one time per click. While my little conditional work-arounds seem to have hidden the unwanted results of that unfortunate behavior, I am still no closer to understanding how or why that is happening. In my simple mind, one click on the button should result in one execution of the activated code. Given that I don’t really know what I am doing, I may never understand why that isn’t the case, but it annoys my sense of The Way Things Ought To Be. Still, it’s probably long past time to simply move on.

Before I do, though, there is one more little enhancement that I wanted to squeeze in. Whenever I lay out a page where the content selector is on the top, instead of on the left- or right-hand side of the actual Data Table, it takes up too much vertical space and leaves a lot of unused screen real estate on either side. To change that, I added a new option to the widget called display_inline that changes the way the elements of the widget are laid out. Below is a sample use case where display_inline has been set to true.

Content selector widget in “in-line” mode

The magic to pull that off was just the addition of conditional class attributes on each of the three primary DIV elements. There was already a class attribute on each, so I could have just thrown some logic in there, but in the end I decided to leverage the AngularJS ng-class attribute instead. By adding the Bootstrap class col-sm-4 to the DIV whenever data.inline is true, the DIVs end up side by side instead of their normal stacked configuration.

    <div class="panel panel-heading" ng-class="{'col-sm-4': c.data.inline}" ng-show="c.data.config.authorizedPerspective.length > 1">
      <div align='center' class="u-space-bottom u-align-center">
        <b>Perspective</b>
        <br/>
        <span ng-repeat="p in c.data.config.authorizedPerspective" style="margin: 5px;">
          <input style="display: inline;" type="radio" name="perspective" id="{{p.name}}Perspective" value="{{p.name}}" ng-model="c.data.perspective" ng-click="selectPerspective(p.name)">
          <label style="display: inline;" for="{{p.name}}Perspective">{{p.label}}</label>
        </span>
      </div>
    </div>

    <div class="panel panel-heading" ng-class="{'col-sm-4': c.data.inline}">
      <div align='center' class="u-space-bottom u-align-center">
        <button ng-repeat="s in c.data.config.state" ng-click="selectState(s.name)" role="button" ng-class="(c.data.state==s.name) ? 'btn btn-primary btn-pressed btn-md' : 'btn btn-default btn-sm'" style="margin: 2px;">{{s.label}}</button>
      </div>
    </div>
  
    <div class="panel" ng-class="{'col-sm-4': c.data.inline, 'panel-heading': c.data.inline, 'panel-default': !c.data.inline}">
      <ul class="list-group">
        <li class="list-group-item" ng-repeat="t in c.data.list" ng-class="(t.name==c.data.table)?'highlight':''" ng-click="selectTable(t.name)">{{t.label}}
          <span class="badge">{{t.value}}</span>
        </li>
      </ul>
    </div>

To process the option, I initially default the variable value to false, and the reset it to true if the option was set to the string ‘true’.

data.inline = false;
if (options && options.display_inline == 'true') {
	data.inline = true;
}

Of course, I had to define the Display Inline option as well, but that was just a matter of updating the widget’s Option schema will a little JSON object:

[{"hint":"If selected, will display the widget content in a single row rather than a stacked block",
"name":"display_inline",
"default_value":"false",
"section":"Behavior",
"label":"Display Inline",
"type":"boolean"}]

That’s about it for allowing this to stretch out across the page rather than be stacked up in the corner. Hopefully, this will be the last you see of this unless I happen to figure out my other issue. For those of you who are interested in seeing all of the parts and pieces in detail, I have assembled everything into yet another Update Set, which you can grab from here.

Configurable Data Table Widget Content Selector, Part III

“Everyone thinks of changing the world, but no one thinks of changing himself.”
Leo Tolstoy

After all of that work on customizing the Data Table widget(s), I realized that my Data Table Content Selector widget didn’t support all of the new features. If I wanted to have buttons or icons or customized reference links, I needed to tweak the code a little bit to provide that capability. Primarily, I needed to expand the schema for my configuration object to include options for buttons and reference pages for every table configuration. That would make the typical state value for a given table look something like this:

open: {
    filter: 'caller_idDYNAMIC90d1921e5f510100a9ad2572f2b477fe%5Eactive%3Dtrue',
    fields: 'number,opened_by,opened_at,short_description',
    btnarray: [],
    refmap: {sys_user: 'user_profile'}
},

To maintain backwards compatibility, both of the new options, btnarray and refmap, would need to be optional. Since the content selector widget relies on the URL-based version of the Data Table widget, implementing the new features was simply a matter of including them, if present, in the new URL at every page refresh:

function refreshPage(table, perspective, state) {
	var tableInfo = getTableInfo(table, perspective);
	var s = {};
	s.id = $location.search().id;
	s.table = tableInfo.name;
	s.filter = tableInfo[state].filter;
	s.fields = tableInfo[state].fields;
	s.buttons = '';
	if (tableInfo[state].btnarray && Array.isArray(tableInfo[state].btnarray) && tableInfo[state].btnarray.length > 0) {
		s.buttons = JSON.stringify(tableInfo[state].btnarray);
	}
	s.refpage = '';
	if (tableInfo[state].refmap) {
		s.refpage = JSON.stringify(tableInfo[state].refmap);
	}
	s.px = perspective;
	s.sx = state;
	var newURL = $location.search(s);
	spAriaFocusManager.navigateToLink(newURL.url());
}

That was basically all there was to it. Now I just need to create a new configuration object that takes advantage of these new features. My original example configuration contained two perspectives, Requester and Fulfiller. To show off the new buttons feature, I decided to add a third perspective, Approver, and then include three separate icons, one to Approve, one to Approve with comments, and another to Reject. The button configuration that I created to support this turned out like this:

btnarray: [
	{
		name: 'approve',
		label: 'Approve',
		heading: '-',
		icon: 'workflow-approved',
		color: 'success',
		hint: 'Click here to approve'
	},{
		name: 'approvecmt',
		label: 'Approve w/Comments',
		heading: '-',
		icon: 'comment-hollow',
		color: 'success',
		hint: 'Click here to approve with comments'
	},{
		name: 'reject',
		label: 'Reject',
		heading: '-',
		icon: 'workflow-rejected',
		color: 'danger',
		hint: 'Click here to reject'
	}
]

After entering some additional modifications to the configuration to add the new perspective, the resulting page ended up looking like this:

Approver perspective with Action Icons

That took care of the look and feel, but to make the buttons actually work, I needed to create another button handling widget to process the button clicks on the three icons that I had configured. For that, I just grabbed the example that I had created earlier and cloned it to create a new Approval Click Handler widget. Here is the client script:

function(spModal, $rootScope) {
	var c = this;
	$rootScope.$on('button.click', function(e, parms) {
		if (!c.data.inProgress) {
			c.data.inProgress = true;
			c.data.sys_id = parms.record.sys_id;
			c.data.action = parms.button.name;
			c.data.comments = '';
			if (c.data.action == 'reject' || c.data.action == 'approvecmt') {
				getComments(c.data.action);
			} else if (c.data.action == 'approve') {
				processDecision();
			}
			c.data.inProgress = false;
		}
	});

	function getComments(state) {
		var msg = 'Approval comments:';
		if (state == 'reject') {
			msg = 'Please enter the reason for rejection:';
		}
		spModal.prompt(msg, '').then(function(comments) {
			c.data.comments = comments;
			processDecision();
		});
	}

	function processDecision() {
		c.server.update().then(function(response) {
			window.location.reload(true);
		});
	}
}

… and here is the server side script:

(function() {
	if (input) {
		var current = new GlideRecord('sysapproval_approver');
		current.get(input.sys_id);
		if (current.state == 'requested') {
			current.state = 'approved';
			if (input.action == 'reject') {
				current.state = 'rejected';
			}
			var comments = 'Approval response from ' + gs.getUserDisplayName() + ':';
			comments += '\n\nDecision: ' + current.getDisplayValue('state');
			if (input.comments) {
				comments += '\nReason: ' + input.comments;
			}
			current.comments = comments;
			current.update();
		}
	}
})();

When I first pulled this up and tested the various buttons, a single click appeared to launch multiple iterations of the code. After entering comments in the modal pup-up box, another comment entry box would pop-up as if I had clicked on the icon a second time. Looking at the resulting records, the entered comments would often appear multiple times. On one example, it was entered 10 times! I never did figure out why that was happening, but I added conditionals to both the client side and server side scripts in an effort to put a stop to that behavior. That seems to have stopped it, but that still doesn’t explain to me why that is happening.

Looks like I have a little more testing to do before I put together a final Update Set

Customizing the Data Table Widget, Part VII

“Never give up on something that you can’t go a day without thinking about.”
Winston Churchill

Those of us who develop software for a living always like to blame the customer for the inevitable scope creep that works its way into an assignment or project. The real truth of the matter, though, is that a lot of that comes right from the developers themselves. Often, just when you think you are about to wrap something up and call it complete, you get that nagging you-know-it-would-be-even-better-if-we-added-this feeling that just won’t go away.

It was my intention to make my previous installment on the Custom Data Table widget my last and final offering on the subject. I had this idea to create a custom Service Portal breadcrumbs widget that didn’t require you to specify the entire page hierarchy on every page where it was included, and I was ready to dive into that little adventure. But there were still a couple of things pulling at me on the Data Table widget(s), and I just felt compelled to wrap those up before I moved on. For one, I never really implemented the buttons and icons as URL parameters in the version of the wrapper widget that was based on the URL. On top of that, I never really liked reusing the same page for all reference links; I wanted to create the capability to have different pages for different references. None of that was really super critical, but I couldn’t really call it complete until I took care of that, so here we are.

For the reference links, I decided to create a simple JSON object that mapped reference tables to portal pages. Primarily, I wanted to send all user references to the user_profile page, but I also wanted to create possibility of sending any reference to any page. So I added yet another configuration option, much like the other two that are already there:

[
   {
      "hint":"If enabled, show the list filter in the breadcrumbs of the data table",
      "name":"enable_filter",
      "default_value":"false",
      "section":"Behavior",
      "label":"Enable Filter",
      "type":"boolean"
   },{
      "hint":"A JSON object containing the specification for row-level buttons and action icons",
      "name":"buttons",
      "default_value":"",
      "section":"Behavior",
      "label":"Buttons",
      "type":"String"
   },{
      "hint":"A JSON object containing the page id for any reference column links",
      "name":"refpage",
      "default_value":"",
      "section":"Behavior",
      "label":"Reference Pages",
      "type":"String"
   }
]

This creates yet another text input on the widget configuration page just under the one that we created for the button specifications:

Reference Page specification input field for the JSON object

The JSON object itself is just a simple mapping of table name to portal page name (id). To support my intent to bring up all users via the User Profile page, I used the following JSON object:

{
    "sys_user": "user_profile"
}

To add more options, you just add more properties to the object using the table name as the property name and the associated page name/id as the property value. To make all of that work, I had to pull in the JSON string and then parse it out to create the actual object to be used.

if (data.refpage) {
	try {
		var refinfo = JSON.parse(data.refpage);
		if (typeof refinfo == 'object') {
			data.refmap = refinfo;
		} else {
			gs.error('Invalid reference page option in SNH Data Table widget: ' + data.refpage);
			data.refmap = {};
		}
	} catch (e) {
		gs.error('Unparsable reference page option in SNH Data Table widget: ' + data.refpage);
		data.refmap = {};
	}
} else {
	data.refmap = {};
}

Once you have all of that tucked away for future reference, whenever a reference link is clicked, we simply refer to the map to see if there is a specific page associated to the table specified in the reference link. If there is, we simply pass that along with the rest of the parameters when we broadcast the reference click.

if (c.data.refmap[table]) {
	parms.page_id = c.data.refmap[table];
}

For those listening for the reference click, all we needed to do was to check for the presence of a page_id in the parms before we check for a page_id in the options, which we were already doing before we settled on the default page of ‘form’.

var p = parms.page_id || $scope.data.page_id || 'form';

That’s about it for supporting table-specific reference link pages. The other thing that I wanted to do was to make sure that my Data Table from URL Definition widget also supported buttons and icons as well as the new reference link page specifications. That turned out to be a simple matter of just adding those two extra options to the existing copyParameters function call to have those values pulled in from the URL and added to the data passed to the core Data Table widget.

copyParameters(data, ['p', 'o', 'd', 'filter','buttons','refpage']);

With that in place, you can add something like &refpage={“sys_user”:”user_profile”} to the URL for the page and have that picked up by the Data Table from URL Definition widget and processed just as if it were specified in the widget options. I’ve wrapped all of that up into yet another Update Set for those of you who are into that sort of thing. Hopefully, this will bring this little adventure to a close now, and I can move on to other things.

Customizing the Data Table Widget, Part VI

“A person who never made a mistake never tried anything new.”
Albert Einstein

Well, it turns out it didn’t take all that long to figure out why I was losing the buttons and icons on my customized Data Table widget whenever I sorted the data. It was definitely my fault, which was easily predictable based on the The First Rule of Programming. I actually had one of those this is not right feelings at the time that I did it, but I ignored that and did it, anyway. When I was parsing the JSON string for the button configuration to obtain the actual JSON array of button specs, I reused the same variable to hold the array as the original variable that held the string. Here is the offending code:

if (data.buttons) {
	try {
		var buttoninfo = JSON.parse(data.buttons);
		if (Array.isArray(buttoninfo)) {
			data.buttons= buttoninfo;
		} else if (typeof buttoninfo == 'object') {
			data.buttons = [];
			data.buttons[0] = buttoninfo;
		} else {
			gs.error('Invalid buttons option in SNH Data Table widget: ' + data.buttons);
			data.buttons= [];
		}
	} catch (e) {
		gs.error('Unparsable buttons option in SNH Data Table widget: ' + data.buttons);
		data.buttons = [];
	}
} else {
	data.buttons = [];
}

The problem with doing that, which is just a bad practice in general and I’ve been around long enough to know better, is that we pass through this same code again and again whenever the table is sorted. Reusing the same variable means that when you come through this logic the next time around, things are not the same as they were the first time through. That’s not good. I tried several failed attempts at detecting and avoiding the problem, but I finally broke down and just created a new variable to hold the button spec array and left the original JSON string intact. That solved the problem. Here is what it looks like now:

if (data.buttons) {
	try {
		var buttoninfo = JSON.parse(data.buttons);
		if (Array.isArray(buttoninfo)) {
			data.btnarray = buttoninfo;
		} else if (typeof buttoninfo == 'object') {
			data.btnarray = [];
			data.btnarray[0] = buttoninfo;
		} else {
			gs.error('Invalid buttons option in SNH Data Table widget: ' + data.buttons);
			data.btnarray = [];
		}
	} catch (e) {
		gs.error('Unparsable buttons option in SNH Data Table widget: ' + data.buttons);
		data.btnarray = [];
	}
} else {
	data.btnarray = [];
}

Of course, once I changed the name of the variable, then I had to hunt down and modify every reference to that variable, which was a task in itself. But I’ve done the work now, and retested everything, so here’s another Update Set with the corrected code. Lesson learned (yeah, right!).

Customizing the Data Table Widget, Part V

“Quality is not an act; it is a habit.”
Aristotle

In order to test my Data Table buttons and icons, I’m going to need a way to trigger both options, navigating to a new page and processing the global broadcast. Since my initial test page was configured to use the sys_user table, bouncing over to the User Profile page seems like easiest thing to do to demonstrate the first. But, to demonstrate the second, I’m going to have to create another widget, one that I will build just to prove that the other option works as well. Before we do that, though, let’s set up the button configuration JSON object to create one button and one icon, and have one implement the first option and the other implement the second. That will set things up for our testing.

[
   {
      "name":"button",
      "label":"Button",
      "heading":"Button",
      "color":"primary",
      "page_id":"user_profile",
      "hint":"Clicking this button should take you to the User Profile page"
   },{
      "name":"icon",
      "label":"Icon",
      "heading":"Icon",
      "icon":"user",
      "hint":"Clicking this icon should open a modal pop-up"
   }
]

We can enter that configuration using the Page Designer and clicking on the Edit icon (pencil) to bring up the options screen:

Entering the button configuration in the widget options panel

With that configured, we can actually test the button right away, as that one is configured to branch to another page (user_profile). We can’t test the icon, though, until we build a widget to listen for the broadcast.

So, I went over to the Service Portal Widgets list and clicked on the New button to create a net new widget to listen for the broadcast message. The only things this widget will do is listen and take action, so I didn’t need a Body HTML template and I didn’t need a Server script, so I just left those blank. For the Client controller, I just entered this:

function(spModal, $rootScope) {
	var c = this;
	$rootScope.$on('button.click', function(e, parms) {
		spModal.open({widget: 'user-profile', widgetInput: {sys_id: parms.record.sys_id}}).then(function() {
			//
		});
	});
}

This was about a simple as I could think of and still test out the process. Unfortunately, when I tested it, it didn’t work. It turns out that the stock user-profile widget cannot accept a sys_id from input. Well, that’s easily enough fixed, but I had to clone the user-profile widget to add in the code, and then I had to have my new listener widget launch the cloned snh-user-profile widget instead of the original. Now, that worked.

Modal pop-up opened by example listener widget

The listener widget has no HTML body, so you can pretty much drag it anywhere on the page. Once it shares the page with the customized Data Table widget, it will be able to pick up the broadcast and do whatever it is that you want to do. My example just checks for a ‘button-click’ event, but if you have more than one button on your page, you may also what to check to see which button was clicked before you take any action. I’ll leave that to those of you who want to try all this out on your own.

One unfortunate bit of bad news, though: in the process of testing all of this out, I clicked on one of the column headings to sort the list and my buttons disappeared. That was deflating! That’s why we pull on all of the levers and twist all of the dials, though. It’s important to check everything out thoroughly. Still, I hate to be reminded that I don’t really know what I am doing. It will probably take me a while to dig around, find the source of the problem, and come up with a viable solution. Still, I did promise to publish an Update Set soon, so I think I am going to go ahead and do that, even though this version violates Rule #1. If you don’t mind playing around with something that is obviously broken, you can get the version 1.0 Update Set here. Just be aware that there is a flaw that has yet to be corrected. Speaking of which, I better get busy diagnosing and correcting that problem.

Customizing the Data Table Widget, Part IV

“All life is an experiment. The more experiments you make, the better.”
Ralph Waldo Emerson

Now that we’ve tweaked the Data Table widget to support reference links and action buttons and icons, it’s time to add the code that will process the clicks on the buttons and icons. There are a number of things that we could do here, but since I like to start out small and build things up over time, today I think I will just handle a couple of the options: 1) navigating to another page, and 2) broadcasting the click and letting some other widget or function take it from there. The first is basically just a copy of what is already being done for the primary row link and the reference links and the second is just the weasel way out of building other options into the Data Table widget itself. By broadcasting the details of the click, users can pretty much do whatever it is that they would like to do by setting up listeners for the broadcast, and then I won’t have to add any additional logic or options on my end.

But before we get into all of that, I do have to admit that I got a little sidetracked the other day and started to tinker. Since I had already opened up the code and was messing with it anyway, I decided to go ahead and see if I could add a user avatar to any reference to the sys_user table. I had to dig around a little bit to see how that was done, but it is actually pretty simple with the sn-avatar tag.

Custom Data Table with User Avatars and Presence

I had to play around with the classes for various things to get everything to come out visually the way that I wanted, but the end result turned out to be a relatively simple addition to the HTML:

<td ng-repeat="field in ::data.fields_array" ng-if="!$first" aria-label="{{item[field].display_value}}" ng-class="{selected: item.selected}" data-field="{{::field}}" data-th="{{::data.column_labels[field]}}">
  <sn-avatar ng-if="item[field].value && item[field].type == 'reference' && item[field].table == 'sys_user'"  primary="item[field].value" class="avatar-small" show-presence="true" enable-context-menu="false"></sn-avatar>
  <a ng-if="item[field].value && item[field].type == 'reference'" href="javascript:void(0)" ng-click="go(item[field].table, {sys_id: item[field].value, name: item[field].display_value})" title="${Click for more on }{{::item[field].display_value}}">{{::item[field].display_value}}</a>
  <span ng-if="!item[field].value || item[field].type != 'reference'">{{::item[field].display_value}}</span>
</td>

Now, back to our regularly scheduled programming …

In the main Data Table widget’s Client Controller, I virtually copied the existing go function and then just added in a quick loop to hunt through the configured buttons to find the one that was clicked so that we could pass it on. Other than that, it’s virtually the same as the original:

$scope.buttonclick = function(button, item) {
	spNavStateManager.onRecordChange(c.data.table).then(function() {
		var parms = {};
		parms.table = c.data.table;
		parms.sys_id = item.sys_id;
		parms.record = item;
		for (var b in c.data.buttons) {
			if (c.data.buttons[b].name == button) {
				parms.button = c.data.buttons[b];
			}
		}
		$scope.ignoreLocationChange = true;
		for (var x in c.data.list) {
			c.data.list[x].selected = false;
		}
		item.selected = true;
		$scope.$emit(eventNames.buttonClick, parms);
	}, function() {
		// do nothing in case of closing the modal by clicking on x
	});	
};

Then, on the wrapper widgets, I copied the original listener and hacked it up to look into the button spec to see if there was a page configured. If so, then I navigate to the page; otherwise, I simply broadcast the click on the root scope so that another, independent widget can pick things up from there:

$scope.$on('data_table.buttonClick', callButtonClick);
	
function callButtonClick(e, parms) {
	if (parms.button.page_id) {
		var oid = $location.search().id;
		var p = parms.button.page_id;
		var s = {id: p, table: parms.table, sys_id: parms.sys_id, view: $scope.data.view};
		var newURL = $location.search(s);
		spAriaFocusManager.navigateToLink(newURL.url());
	} else {
		$rootScope.$broadcast('button.click', parms);
	}
}

That was all there was to it. Now all that’s left is to write a sample independent widget to listen for the broadcast and test all of this out to make sure that it all works. That should wrap all of this up quite nicely and should turn out to be the final installment in this particular series.

Customizing the Data Table Widget, Part III

“Many of life’s failures are people who did not realize how close they were to success when they gave up.”
Thomas Alva Edison

In addition to the reference links that I wanted to add to my version of the Data Table widget, I also wanted to add the capability to add buttons or icons to each row for specific actions. This seemed like something that could be accomplished relatively easily, and configured just like the many other options associated with the various flavors the Data Table. My thought was to be able to configure things to look something like this:

Data Table with example button and action icon

For the icons, my plan was to just leverage the Retina Icons that I had stumbled across the other day, and then lift the associated HTML right out of the old snh-form-field tag. But first, I had to come up with a way to pass in all of the information needed to configure each button. There is a way to use a table as a source for complex widget options, but this was just an experiment at this point, so I decided to go the quick and easy way and just pass in a JSON array of button specification objects that would look something like this:

[
  {
    "name": "button",
    "label": "Button",
    "heading": "Button",
    "color": "primary",
    "hint": "Click this button to do something",
    "action": "doSomething"
  },{
    ... etc ...
  }
]

I haven’t worked out all of the details yet, but you get the idea. That’s the basic concept, anyway … all we have to do now is to code it up!

The first order of business is to create the new widget option to hold the button specifications. One quick way to do that is to just edit the Option Schema directly, as it just happens to be a JSON object itself. In fact, there is already one option defined (enable_filter), so it is a simple matter to just use that one as an example and create a second one for our purposes:

[
   {
      "hint":"If enabled, show the list filter in the breadcrumbs of the data table",
      "name":"enable_filter",
      "default_value":"false",
      "section":"Behavior",
      "label":"Enable Filter",
      "type":"boolean"
   },{
      "hint":"A JSON object containing the specification for row-level buttons and action icons",
      "name":"buttons",
      "default_value":"",
      "section":"Behavior",
      "label":"Buttons",
      "type":"String"
   }
]

Once we have updated the Option Schema, editing an instance of the widget will include a place to enter the specifications for any desired buttons. Now we have to add code to the widget’s Server Script to pull in the value of the new option and turn it from a String to an Object. Since there is no guarantee that the instance author will provide a parsable JSON object, we will have to put in a little defensive code to check for a few possibilities.

if (data.buttons) {
	try {
		var buttoninfo = JSON.parse(data.buttons);
		if (Array.isArray(buttoninfo)) {
			data.buttons = buttoninfo;
		} else if (typeof buttoninfo == 'object') {
			data.buttons = [];
			data.buttons[0] = buttoninfo;
		} else {
			gs.error('Invalid buttons option in SNH Data Table widget: ' + data.buttons);
			data.buttons = [];
		}
	} catch (e) {
		gs.error('Unparsable buttons option in SNH Data Table widget: ' + data.buttons);
		data.buttons = [];
	}
} else {
	data.buttons = [];
}

And finally, we have to alter the HTML to include any configured buttons or icons. Modifications will need to be made in two places, one for the column headings and the other for the data columns. Here is what we will add for the column headings:

<th ng-repeat="button in data.buttons" class="text-nowrap center" tabindex="0">
  {{button.heading || button.label}}
</th>

And this is what we will toss in for each row of data:

<td ng-repeat="button in data.buttons" class="text-nowrap center" tabindex="0">
  <a ng-if="!button.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{button.color || 'default'}}" ng-click="buttonclick(button.name, item)" title="{{button.hint}}" data-original-title="{{button.hint}}">{{button.label}}</a>
  <a ng-if="button.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{button.color || 'default'}}" ng-click="buttonclick(button.name, item)" title="{{button.hint}}" data-original-title="{{button.hint}}">
    <span class="icon icon-{{button.icon}}" aria-hidden="true"></span>
    <span class="sr-only">{{button.hint}}</span>
  </a>
</td>

That puts everything on the screen and clickable, but we still some code to perform whatever action each button is intended to perform. That’s a tad bit more complicated, so I think we’ll tackle that the next time out ….

Customizing the Data Table Widget, Part II

“It always seems impossible until it’s done.”
Nelson Mandela

Today I am going continue on with my little Data Table widget customization by tackling the HTML portion of project, and then set things up so that we can do a little testing. Currently, the HTML that displays a single row in the data table does so in a way that the entire row is the clickable link to the details for that row. My intent is to change that so that the first column is the link to the details for that row, and then any other column that contains a reference field can be a link to the details of that specific reference. So, let’s take a look at the HTML structure that is there now:

<tbody>
  <tr ng-repeat="item in data.list track by item.sys_id">
    <td class="sr-only" tabindex="0" role="link" ng-click="go(item.targetTable, item)" aria-label="${Open record}"></td>
    <td aria-label="{{item[field].display_value}}" class="pointer sp-list-cell" ng-class="{selected: item.selected}" ng-click="go(item.targetTable, item)" ng-repeat="field in ::data.fields_array" data-field="{{::field}}" data-th="{{::data.column_labels[field]}}">{{::item[field].display_value}}</td>
  </tr>
</tbody>

I’m not quite sure what purpose is served by that first <TD> that doesn’t repeat, but there is a column heading to match, although neither appear to have any value or content. Maybe it’s a spot for a row-level checkbox or a glyph or something, but I’m not really smart enough to figure it out. The first thing that I did on my version was to delete it, along with the associated column heading — if I don’t understand why it needs to be there, then it just needs to go. Of course, that has nothing to do with what I am trying to accomplish, but if you happen to notice it gone in my version, that’s because I made it gone. I may end up having to go out and look for it one day when I finally figure out its value, but for now at least, it sleeps with the fishes.

With that out of the way, we can focus on the actual table columns, which are all treated the same in this version. The entire cell, not just the content, is the link to further information on the row, and with every column the same, no matter where you click on the row, the result is the same. We don’t want to do that in our version, though, so we can keep the ng-repeat on the <TD>, but the rest will be dependent on the column. Since we want the links to be on the content and not on the entire cell, we don’t really need anything else at the <TD> level anyway.

The first column will always be a link, and it will perform the same function as clicking anywhere on the row in the original version. For all other columns, they will also be links if the data type is reference, and there is a value. We can use the $first property to detect the first column, so the code for that link can basically be lifted from the original, with the addition of an ng-if to target the first column.

<a ng-if="$first" href="javascript:void(0)" ng-click="go(item.targetTable, item)" title="${Click for more on }{{::item[field].display_value}}">{{::item[field].display_value}}</a>

The remaining columns will require a few more lines. Not only do we need to determine that this not the first column, we also need to check to see of the type is reference, and if there is a value (if there is no value, then there is no need to code the link). Wrapping the whole thing in a <SPAN> will allow us to separate all of this from the first column, and then we can use an anchor tag for the links and a simple <SPAN> for the rest.

<span ng-if="!$first">
  <a ng-if="item[field].value && item[field].type == 'reference'" href="javascript:void(0)" ng-click="go(item[field].table, item[field].record)" title="${Click for more on }{{::item[field].display_value}}">{{::item[field].display_value}}</a>
  <span ng-if="!item[field].value || item[field].type != 'reference'">{{::item[field].display_value}}</span>
</span>

At this point, I am leveraging the existing go function to handle the clicks. That function takes a table and a record as an argument, so I figure that I can just pass in the name of the referenced table and a mocked-up record (more on that later) and simply reuse the existing function. I may want to rethink that later and create a function specifically for reference field clicks, but for now this works, so it’s good enough. My earlier server-side additions did not originally create mocked-up record for each reference field, so I had to go back and that in to bring all of this together. I added that to the same block of code where I was adding the name of related table.

if (record[fld].type == 'reference') {
	record[fld].table = gr.getElement(fld).getED().getReference();
	record[fld].record = {sys_id: {value: record[fld].value, display_value: record[fld].value}, name: {value: record[fld].display_value, display_value: record[fld].display_value}};
}

Well, that should do it — at least, for this initial version. Now we just need to take it out for a spin and make sure that everything works. For that, we’ll need to create a test page and then put the widget on the page and configure it. Then we can hit the Try It! button.

First test of the customized Data Table widget

Well, that wasn’t too bad. Everything seems to work as I had intended. Nothing happens right now when you click on anything, but that’s because the go function simply broadcasts the click events, and I don’t have anything listening for that in this version. This was a clone of the core Data Table widget, and the listeners are in the wrapper widgets that implement the various ways to configure the display and contents. I’ll have to clone those wrapper widgets as well, or maybe modify them to choose between the stock Data Table widget and my customized version. Either one seems like a lot more work than I want to dive into right now, so I’m going to leave that for a later installment.