JosephW1
Tera Guru

INTRODUCTION

I want to share a pair of custom interactive filter templates that I've made for Incident Category & Subcategory.

The cool part is not that these filter category & subcategory. That's boring. What's cool is that these interlock with each other. The subcategory filter's list is rebuilt with the chosen category's dependent subcategories each time the category is changed. I've also thrown in visual cues to make both noticing and using all of this seamless for the end user.

 

PREVIEW

 

 

TEMPLATES

Without further ado, here are the templates. To use these, copy and paste them into a Dynamic Content Block and then add that content block to your desired dashboard tab(s). The content_admin role will be required.

Category Template

<?xml version="1.0" encoding="utf-8" ?>
<j:jelly trim="false" xmlns:j="jelly:core" xmlns:g="glide" xmlns:j2="null" xmlns:g2="null">

<!-- User Defined Settings -->
<g:evaluate>
	var widget_title = "Category Template";
	var showEmpty = false;
	var filterDebug = false;
	var tables_fields = JSON.stringify([
		{table:"incident",field:"category"},
		{table:"incident_metric",field:"inc_category"},
		{table:"task",field:"ref_incident.category"}
	]);
</g:evaluate>

<!-- Stop Here If Previewing (Crash Prevention) -->
<j:if test="${RP.isPreview()}">
	<center>${gs.getMessage('No preview available')}</center>
</j:if>

<!-- Proceed If Not Previewing -->
<j:if test="${!RP.isPreview()}">
	
<!-- Set Unique Identifier -->
<g:evaluate>
	var uid = 'UID_' + Math.round(Math.random() * 1000000000000000)
</g:evaluate>

<!-- Build Choice List -->
<g:evaluate var="jvar_cl" object="true" jelly="true">
	var cl = new GlideChoiceList();
	var ga = new GlideAggregate('sys_choice');
	ga.addQuery('name', 'incident');
	ga.addQuery('element', 'category');
	ga.addQuery('inactive', false);
	ga.groupBy('label');
	ga.groupBy('value');
	ga.orderBy('label');
	ga.query();
	while (ga.next()) {
		cl.add(ga.getValue('value'), ga.getValue('label'));
	}
	if (showEmpty) {
		cl.add('NULL', '(Empty)');
	}
	cl;
</g:evaluate>

<script>	
	<!-- Initialize Scoped Filter -->
	var container${uid} = document.getElementById('${uid}_display').closest('[data-uuid]');
	var customFilter${uid} = {
		select: $j('#${uid}_select'),
		widgetId: container${uid}.getAttribute("data-original-widget-sysid"),
		canvasId: SNC.canvas.layoutJson.canvasSysId,
		eventsId: container${uid}.getAttribute("data-uuid"),
		setTitle: $j('#${uid}_display').closest('[data-uuid]').find('.grid-widget-header-title:first').html('<span>${widget_title}</span>'),
		removeFilter: function() { <!-- Remove filter on "All" -->
			SNC.canvas.interactiveFilters.removeDefaultValue(customFilter${uid}.widgetId, false);
			dashboardMessageHandler${uid}.removeFilter();
		},
		resetFilter: function() { <!-- Reset filter (& subcategory) on events -->
			if (customFilter${uid}.select.val() != "All") {
				customFilter${uid}.select.val("All")
				customFilter${uid}.select.change()
			}
		},
		wgtDelBtn: container${uid}.querySelector('button[data-original-title="Remove"]'),
		wgtRelBtn: container${uid}.querySelector('button[data-original-title="Refresh"]'),
		tabCfgBtn: document.getElementById("navbar").querySelector('button[data-original-title="Configuration"]'),
		tabDelBtn: document.getElementById("modalBtnPrimary"),
		destroyed: false,
		eventCallback: function(data) { <!-- EVENTS: Refresh, Remove, Configuration, DeleteDashboardTab, TabChange, resize, destroy -->
			var event = this instanceof Element ? (this.getAttribute("data-original-title") ? this.getAttribute("data-original-title") : 'DeleteDashboardTab') : (customFilter${uid}.destroyed ? data.action : (data.action == 'resize' ? 'resize' : 'TabChange'))
			<!-- Unbind Event Listeners -->
			if (event == 'Refresh' || event == 'Remove' || event == 'DeleteDashboardTab' || event == 'TabChange') {
				customFilter${uid}.resetFilter();
				customFilter${uid}.wgtDelBtn.removeEventListener("click",customFilter${uid}.eventCallback)
				customFilter${uid}.tabDelBtn.removeEventListener("click",customFilter${uid}.eventCallback)
				customFilter${uid}.tabCfgBtn.removeEventListener("click",customFilter${uid}.eventCallback)
			}
			<!-- Rebind Events After Configuration Resets Them -->
			if (event == 'Configuration') {setTimeout(bindEvents${uid}, 1000)}
			<!-- Handle Widget Destroy -->
			if (event == "Remove" || event == "DeleteDashboardTab") {
				customFilter${uid}.destroyed = true
			}
		},
	};
	
	<!-- Call Handle, Events Subscription, and jQuery's select2() -->
	dashboardMessageHandler${uid} = new DashboardMessageHandler(customFilter${uid}.widgetId)
	SNC.canvas.eventbus.subscribe(customFilter${uid}.eventsId,customFilter${uid}.eventCallback)
	customFilter${uid}.select.select2(); //transforms select into combobox
	
	<!-- Bind Widget Reload/Delete & Tab Delete Listeners -->
	function bindEvents${uid}() {
		customFilter${uid}.wgtRelBtn.addEventListener("click",customFilter${uid}.eventCallback,{once:true})
		customFilter${uid}.wgtDelBtn.addEventListener("click",customFilter${uid}.eventCallback,{once:true})
		customFilter${uid}.tabCfgBtn.addEventListener("click",customFilter${uid}.eventCallback,{once:true})
		customFilter${uid}.tabDelBtn.addEventListener("click",customFilter${uid}.eventCallback,{once:true})
	}
	bindEvents${uid}()
	
	<!-- Function for Handling Changes -->
	function selectChange${uid}() {
		<!-- Handle Self -->
		value${uid} = customFilter${uid}.select.val();
		tablesFields${uid} = JSON.parse("${tables_fields}");
		if (value${uid} == "All") {
			customFilter${uid}.removeFilter();
		} else {
			var finalFilter${uid} = [];
			for (var i = 0; i != tablesFields${uid}.length; i++) {
				finalFilter${uid}.push({table: tablesFields${uid}[i].table, filter: tablesFields${uid}[i].field + '=' + value${uid},
				});
			}
			SNC.canvas.interactiveFilters.setDefaultValue({id: customFilter${uid}.widgetId, filters: finalFilter${uid}}, false);
			dashboardMessageHandler${uid}.publishMessage(finalFilter${uid});
		}
	
		<!-- Handle Subcategory Filter -->
		setTimeout(function() { <!-- slight delay allows subcategory filter to render in time -->
		var subcatID = document.querySelector('select[data-id="Incident Subcategory Custom Filter"]').getAttribute("data-uid");
		window['buildChoicesCallback'+subcatID]();},50)
	}
	
	<!-- Add Debug Messages If Debug On -->
	if (${filterDebug}) {$j('#${uid}_debug').append('<p style="line-height: 8px; padding-top: 8px;"><b>UID</b>: ${uid}<br/><b>Widget ID</b>: '+customFilter${uid}.widgetId+'<br/><b>Canvas ID</b>: '+customFilter${uid}.canvasId+'<br/><b>Events ID</b>: '+customFilter${uid}.eventsId+'</p>');}
</script>

<!-- Place Filter On Screen -->
<div id="${uid}_display" class="select2-container form-control">
	<select id="${uid}_select" data-id="Incident Category Custom Filter" data-uid="${uid}" class="form-control" value="All" onchange="selectChange${uid}()">
		<option value="All">All</option>
		<g:options choiceList="${jvar_cl}" />
	</select>
</div>
<div id="${uid}_debug"></div>

<!-- End of Preview Prevention -->
</j:if>

</j:jelly>

 

Subcategory Template

<?xml version="1.0" encoding="utf-8" ?>
<j:jelly trim="false" xmlns:j="jelly:core" xmlns:g="glide" xmlns:j2="null" xmlns:g2="null">
	
<!-- User Defined Settings -->
<g:evaluate>
	var widget_title = "Subcategory Template";
	var showEmpty = false;
	var filterDebug = false;
	var tables_fields = JSON.stringify([
		{table:"incident",field:"subcategory"},
		{table:"incident_metric",field:"inc_subcategory"},
		{table:"task",field:"ref_incident.subcategory"}
	]);
</g:evaluate>

<!-- Stop Here If Previewing (Crash Prevention) -->
<j:if test="${RP.isPreview()}">
	<center>${gs.getMessage('No preview available')}</center>
</j:if> 

<!-- Proceed If Not Previewing -->
<j:if test="${!RP.isPreview()}">

<!-- Set Unique Identifier -->
<g:evaluate>
	var uid = 'UID_' + Math.round(Math.random() * 1000000000000000)
</g:evaluate>

<!-- Build Choice List -->
<g:evaluate>
	var subcategories = [];
	var i = 0;
	var ga = new GlideAggregate('sys_choice');
	ga.addQuery('name', 'incident');
	ga.addQuery('element', 'subcategory');
	ga.addQuery('inactive', false);
	ga.groupBy('label');
	ga.groupBy('value');
	ga.groupBy('dependent_value');
	ga.orderBy('label');
	ga.query();
	while (ga.next()) {
		subcategories.push({ value:ga.getValue('value'), label:ga.getValue('label'), dependency:ga.getValue('dependent_value') });
	}
	subcategories = JSON.stringify(subcategories);
</g:evaluate>	

<script>	
	<!-- Initialize Scoped Filter -->
	var container${uid} = document.getElementById('${uid}_display').closest('[data-uuid]');
	var customFilter${uid} = {
		select: $j('#${uid}_select'),
		widgetId: container${uid}.getAttribute("data-original-widget-sysid"),
		canvasId: SNC.canvas.layoutJson.canvasSysId,
		eventsId: container${uid}.getAttribute("data-uuid"),
		setTitle: $j('#${uid}_display').closest('[data-uuid]').find('.grid-widget-header-title:first').html('<span>${widget_title}</span>'),
		removeFilter: function() {
			if ($j('#${uid}_display').find('span.select2-chosen').html()) {
				SNC.canvas.interactiveFilters.removeDefaultValue(customFilter${uid}.widgetId, false);
				dashboardMessageHandler${uid}.removeFilter();
			}
		},
		resetFilter: function() { <!-- Reset filter (& subcategory) on events -->
			if ($j('#${uid}_display').find('span.select2-chosen').html() != "All") {
				customFilter${uid}.select.val("All")
				customFilter${uid}.select.change()
			}
		},
		wgtDelBtn: container${uid}.querySelector('button[data-original-title="Remove"]'),
		wgtRelBtn: container${uid}.querySelector('button[data-original-title="Refresh"]'),
		tabCfgBtn: document.getElementById("navbar").querySelector('button[data-original-title="Configuration"]'),
		tabDelBtn: document.getElementById("modalBtnPrimary"),
		destroyed: false,
		eventCallback: function(data) { <!-- EVENTS: Refresh, Remove, Configuration, DeleteDashboardTab, TabChange, resize, destroy -->
			var event = this instanceof Element ? (this.getAttribute("data-original-title") ? this.getAttribute("data-original-title") : 'DeleteDashboardTab') : (customFilter${uid}.destroyed ? data.action : (data.action == 'resize' ? 'resize' : 'TabChange'))
			<!-- Unbind Event Listeners -->
			if (event == 'Refresh' || event == 'Remove' || event == 'DeleteDashboardTab' || event == 'TabChange') {
				customFilter${uid}.resetFilter();
				customFilter${uid}.wgtDelBtn.removeEventListener("click",customFilter${uid}.eventCallback)
				customFilter${uid}.tabDelBtn.removeEventListener("click",customFilter${uid}.eventCallback)
				customFilter${uid}.tabCfgBtn.removeEventListener("click",customFilter${uid}.eventCallback)
			}
			<!-- Rebind Events After Configuration Resets Them -->
			if (event == 'Configuration') {setTimeout(bindEvents${uid}, 1000)}
			<!-- Handle Widget Destroy -->
			if (event == "Remove" || event == "DeleteDashboardTab") {
				customFilter${uid}.destroyed = true
			}
		},
		buildChoices: function() {
			<!-- Detect Category Filter -->
			var catSelect = document.querySelector('select[data-id="Incident Category Custom Filter"]')
			if (typeof catSelect !== 'undefined' &amp;&amp; catSelect !== null) {
				<!-- Find Category Select -->
				var catWidgetId = catSelect.getAttribute('data-uid');
				var catValue = catSelect.value;
				var catLabel = catSelect.options[catSelect.selectedIndex].innerHTML;
	
				<!-- Display Label and Debug -->
				$j('#${uid}_category').html('')
				if (catValue &amp;&amp; catValue != "All") {$j('#${uid}_category').html('<b>Category: '+catLabel+'</b>');}
				if (${filterDebug}) {
					var msg = '<p id="${uid}_debug1" style="line-height: 8px; padding-top: 8px;"><b>Category Widget</b>: '+catWidgetId+'<br/><b>Category Selection</b>: '+catLabel+' ('+catValue+')</p>';
					if ($j('#${uid}_debug1').length) {$j('#${uid}_debug1').html(msg)} else {$j('#${uid}_debug').after(msg)}
				}
			}
	
			<!-- Display Category Bulge Helper Visual (Except on load) -->
			if (customFilter${uid}.select.val()) { <!-- val is NULL on load -->
				$j('#${uid}_display').stop().animate({width: '97%'},250);
				$j('#${uid}_display').animate({width: '100%'},250);
			}

			<!-- Empty Select List & Repopulate w/Category-Dependent Subcategories -->
			customFilter${uid}.select.empty();
			customFilter${uid}.select.append('<option value="All">All</option>');
			var subcategories = JSON.parse("${subcategories}")
			for (var i = 0; i != subcategories.length; i++) {
				if (catValue == "All" || typeof catValue == 'undefined') { <!-- Prevent Duplicates (Same subcat. in multiple cats.) -->
					if (subcategories.map(function(element){return element.value;}).indexOf(subcategories[i].value, i+1) != -1) {continue;}
				} else {  <!-- Enforce Cat. Dependency -->
					<!-- Decode URL Encoding Artifacts Left Behind From JSON.stringify (Ex: change &amp; back to &) -->
					var elem = document.createElement('textarea');
					elem.innerHTML = subcategories[i].dependency;
					var dependency = elem.value;
					if (dependency != catValue) {continue;}
				}
				customFilter${uid}.select.append('<option value="'+subcategories[i].value+'">'+subcategories[i].label+'</option>');
			}
			if (${showEmpty}) {customFilter${uid}.select.append('<option value="NULL">(Empty)</option>');}
			
			
			<!-- Reset the Filter --> <!-- Don't change on refresh if "All", but change on category change regardless -->
			if ($j('#${uid}_display').find('span.select2-chosen').html() != "All") {
				customFilter${uid}.select.val("All")
				customFilter${uid}.select.change()
			}
		},
	};
	
	<!-- Call Handler, Events Subscription, & Default Filter -->
	dashboardMessageHandler${uid} = new DashboardMessageHandler(customFilter${uid}.widgetId)
	SNC.canvas.eventbus.subscribe(customFilter${uid}.eventsId,customFilter${uid}.eventCallback)
	
	<!-- Build Choices, Define Callback, & Implement jQuery select2 -->
	customFilter${uid}.buildChoices();
	var buildChoicesCallback${uid} = function(){customFilter${uid}.buildChoices();}
	customFilter${uid}.select.select2(); //transforms select into combobox
	
	<!-- Bind Widget Reload/Delete & Tab Delete Listeners -->
	function bindEvents${uid}() {
		customFilter${uid}.wgtRelBtn.addEventListener("click",customFilter${uid}.eventCallback,{once:true})
		customFilter${uid}.wgtDelBtn.addEventListener("click",customFilter${uid}.eventCallback,{once:true})
		customFilter${uid}.tabCfgBtn.addEventListener("click",customFilter${uid}.eventCallback,{once:true})
		customFilter${uid}.tabDelBtn.addEventListener("click",customFilter${uid}.eventCallback,{once:true})
	}
	bindEvents${uid}()
	
	<!-- Function for Handling Changes -->
	function selectChange${uid}() {
		value${uid} = customFilter${uid}.select.val();
		tablesFields${uid} = JSON.parse("${tables_fields}");
		if (value${uid} == "All") {
			customFilter${uid}.removeFilter();
		} else {
			var finalFilter${uid} = [];
			for (var i = 0; i != tablesFields${uid}.length; i++) {
				finalFilter${uid}.push({table: tablesFields${uid}[i].table, filter: tablesFields${uid}[i].field + '=' + value${uid},
				});
			}
			SNC.canvas.interactiveFilters.setDefaultValue({id: customFilter${uid}.widgetId, filters: finalFilter${uid}}, false);
			dashboardMessageHandler${uid}.publishMessage(finalFilter${uid});
			<!-- customFilter${uid}.select.val(value${uid}) //This creates an infinite loop. Why did I add this? 5/12/21 11:28am
			customFilter${uid}.select.change() I think was a bandaid for select2() conflicts that are now resolved. 5/12/21 12:24pm -->
		}
	}
	
	<!-- Add Debug Messages If Debug On -->
	if (${filterDebug}) {$j('#${uid}_debug').append('<p style="line-height: 8px; padding-top: 8px;"><b>UID</b>: ${uid}<br/><b>Widget ID</b>: '+customFilter${uid}.widgetId+'<br/><b>Canvas ID</b>: '+customFilter${uid}.canvasId+'<br/><b>Events ID</b>: '+customFilter${uid}.eventsId+'</p>');}
	
</script>
	
<!-- Place Filter On Screen -->
<div id="${uid}_display" class="select2-container form-control" style="position: relative; left: 50%; transform: translateX(-50%)">
	<select id="${uid}_select" data-id="Incident Subcategory Custom Filter" data-uid="${uid}" class="form-control" value="All" onchange="selectChange${uid}(this)" style="position: relative; left: 50%; transform: translateX(-50%)"/>
</div>
	<p id="${uid}_category" class="noselect" style="color: #3399ff; text-align: right; font-size: 11px; line-height: 12px; margin: 0;"/>
<div id="${uid}_debug"></div>


<!-- End of Preview Prevention -->
</j:if>

</j:jelly>

 

CONCLUSION

These have some interesting features that are exclusive compared to the OOB filters. I hope you find them useful and that they make category/subcategory interactive filtering more enjoyable for you and your customers.

Please let me know if you have any improvements or find any issues. Thanks!

For more custom interactive filters, you can also check out my Custom Interactive Filter Templates (Multi-Table, Scoped, Persisting, Choice Building) article.

 

Kind Regards,
Joseph

Comments
Steve Chapman
Tera Contributor

Hi Joseph,
I found this post and it is exactly what i am looking for to make some dashboard reporting more 'end user' friendly.

However, i have found that the Subcategory block is not working for me. I checked against my PDI too and it worked fine so i have no doubts it is something specific to our instances.

My java scripting isn't bad but even reviewing the code a few times i haven't been able to pinpoint where the issue would be.

As it stands the choice box just displays as empty (was expecting 'All' to be displayed) and was hoping you could give me an idea of how best to trouble shoot.

Any assistance greatly appreciated.

Kind regards

Steve

Mickey2
Giga Explorer

This is exactly what I have been looking for as well.  Any update on the Subcategory not working?

JosephW1
Tera Guru

@Steve Chapman & @Mickey 

I apologize for your troubles but I don't think I can pinpoint your issue.

Since it's still working in my PDI and in Steve's, the code seems to operate without any instance-specific curveballs to its execution.
Since I don't have access to your instances, I can't troubleshoot it for your instance-specific issues.

 

I feel that the best I can advise is for you to take the same steps I would while troubleshooting mine:

1. If the options are populated yet the box renders with no option selected - rather than rendering with "All" selected - then check the portion of code that selects the All value. I'm sure you can find it, but it's in the buildChoices function and behind a piece of logic that evaluates the .html() contents of the select's label. If it fails to evaluate the html that would explain why it's not displaying all. Try overriding the logic with a hardcoded "true" and see if it gets set. etc. etc.

2. If the options aren't populated then output various arrays/objects related to the non-working feature to the console to inspect their contents, to see if they were even populated correctly.

3. This code is heavily dependent upon jQuery and also jQuery's select2() function. Those two are very standard across instances, though, so I doubt they are the cause but it's worth a mention.

4. Add a series of simple console.[log/warn/error] throughout the code and then load the content block on a dashboard while analyzing the console output to see which, if any, console messages registered and to identify where within the code a potential error could be.
a. No console messages would indicate a syntax error that is causing the whole block not to render. (this isn't your case since you see the select box - indicating the block rendered, no syntax error - but it's empty - indicating that just a portion of the code failed to produce the desired result.)
b. A message that logs before but not after a line of code would identify a line of code that is producing an error.

5. If you have a error, starting at the top-most layer (polish), eliminate blocks of code one at a time, slowly whittling the code down to the base code until the error stops occurring. That will identify which section of code is producing the error.

6. etc. Just keep scrutinizing the code.

I hope that is helpful.

Kind Regards,
Joseph

Anthony DUF_TRE
Tera Contributor

Hi,

Your solution is quite good and useful.

I deployed it and the result I have is almost perfect.

 

However, in the category part, the list of categories I can see is each category twice as my system has translations of categories in french language too.

French categories report the same subcategories anyway.

How can I filter the category list to have only english version ?

 

(By the way, I have the same thing with subcategories)

Version history
Last update:
‎05-12-2021 02:22 PM
Updated by: