logo

NJP

Custom Interactive Filter Templates (Multi-Table, Scoped, Choice Building)

Import · Mar 15, 2021 · article

Hello!

Introduction

image

Role required: content_admin

I want to share with you all a few dynamic content block templates I have put together for custom interactive filters.

Template Capabilities:

1. Multi-Table Compatible: Perhaps the biggest benefit is that these templates are capable of implementing their filters across multiple tables. OOTB choice filters require a widget per table, resulting in a less than ideal user experience. These templates, on the other hand, let you handle all your tables centrally from one widget.

2. Filter Choices Persist: Per Tab/Widget/User the selected filter saves to the canvas preferences and loads automatically each time the widget is loaded.

- Full Disclosure: I was never able to get this feature to function adequately due primarily to content blocks being in the same load order as reports - and semi-random, at that - which often allows reports to load before our custom filters. In such a scenario, the report reloads when the custom filter later renders and engages its default and I recall this reload only having roughly an 85% success at engaging said default, sometimes instead reloading with its full superset of data as if no filter was engaged. Sadly, I gave up on overcoming this, as I did not manage to find a way to dictate for our custom filters to always load before any report began to render.

3. Widget Destroy Cleanup: Persisted choices are cleaned up on both widget and tab destroy.

4. Manual Choice Capability: The Manual Choice template allows a user to build a manual choice for added flexibility and freedom to use custom logic.

5. Query Choice Builder: Via GlideAggregate the Choice Builder template can build a choice list rather unconventionally in a flexible and fast manner.

6. Fully Scoped Templates: These templates are scoped which means that more than one iteration of these templates can be placed on the same dashboard tab without the various iterations interfering with each other.

7. Consistent with OOTB Styling: These templates utilize the ootb jQuery select2 styling to help create a cohesive end-user experience when using a mixture of custom and ootb interactive filters.

Without further ado, here are the templates. These, with a little modification, should be able to fit a wide variety of use cases.

Choice Builder (Template 1)

Description: A template that builds a choice list for you from values found in your desired table's field. Any field.

Main Use: Since OOTB Interactive filters only work with reference fields, booleans, and fields with choices defined, this template opens up the use of the remainder of the field types plus the utility of building the choice list for you.

Advice: Use this for building a choice list from a field that has reasonable cardinality. Utilizing fields with high cardinality such as short description are not advised.

<?xml version="1.0" encoding="utf-8" ?>


var widget_title = "Choice Builder v6.2 Template";
var choice_table = "sys_user";
var choice_name = "title";
var choice_label = "Title";
var showEmpty = false;
var filterDebug = true;
var tables_fields = JSON.stringify([
{table:"incident",field:"caller_id.title",},
{table:"sc_req_item",field:"request.requested_for.title",}
]);
/g:evaluate


${gs.getMessage('No preview available')}
/j:if


var uid = 'UID_' + Math.round(Math.random() * 1000000000000000)
/g:evaluate


var cl = new GlideChoiceList();
var ga = new GlideAggregate(choice_table);
ga.addQuery(choice_name, '!=', '');
ga.groupBy(choice_name);
ga.groupBy(choice_label);
ga.orderBy(choice_label);
ga.query();
while (ga.next()) {
cl.add(ga.getValue(choice_name), ga[choice_name].getDisplayValue());
}
if (showEmpty) {
cl.add("NULL","(Empty)");
}
cl;
/g:evaluate

<!-- 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() { SNC.canvas.interactiveFilters.removeDefaultValue(customFilter${uid}.widgetId, true); dashboardMessageHandler${uid}.removeFilter(); }, customFilterUtil: { getDefaultValueByKey: function() {customFilter${uid}.customFilterUtil.Callback('getDefaultValueByKey')}, removeAllDefaultValues: function(event) {customFilter${uid}.customFilterUtil.Callback('removeAllDefaultValues',event)}, Callback: function(utility, event) { var ga = new GlideAjax('CustomFilterUtil'); ga.addParam('sysparm_name', utility); ga.addParam('sysparm_widget_id', customFilter${uid}.widgetId); ga.addParam('sysparm_canvas_id', customFilter${uid}.canvasId); ga.getXMLWait(); <!--not async to obtained default before load. Most noticeable side effect is slower widget refresh --> var response = ga.getAnswer() ? ga.getAnswer() : ''; if (response &amp;&amp; response.length) { if (utility == "getDefaultValueByKey") { customFilter${uid}.select.val(JSON.parse(response)[0].filter.split("=")[1]) customFilter${uid}.select.change() if (${filterDebug}) { $j('#${uid}_debug').after('<span id="${uid}_persist" style="background-color: LightGreen;">Persisted filter found.</span>') } } else { customFilter${uid}.removeFilter() if (${filterDebug}) { alert('CUSTOM FILTER DESTROY\nWidget Title: ${widget_title}\nWidget ID: '+customFilter${uid}.widgetId+'\nCanvas ID: '+customFilter${uid}.canvasId+'\nEvent: '+event+'\nDefaults Deleted: '+parseInt(response)) } } } } }, 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 --> <!-- Detect Event: EventHandlers return an element, eventbus.subscribe returns data.action --> 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}.removeFilter() 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 customFilter${uid}.customFilterUtil.removeAllDefaultValues(event) } }, }; <!-- Call Handler, Events Subscription, Default Filter, & jQuery's select2 --> dashboardMessageHandler${uid} = new DashboardMessageHandler(customFilter${uid}.widgetId) SNC.canvas.eventbus.subscribe(customFilter${uid}.eventsId,customFilter${uid}.eventCallback) customFilter${uid}.customFilterUtil.getDefaultValueByKey(); //load default value on widget load 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}(selection) { value${uid} = selection.value; 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}}, true); dashboardMessageHandler${uid}.publishMessage(finalFilter${uid}); } } <!-- 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>');}
All

/j:if

/j:jelly

Manual Choice (Template 2)

Description: A template that lets you build a custom choice list.

Use Case: For building a choice list utilizing either a) field values (partial string match, custom date logic) or b) operators (contains, startswith, etc) not available to ootb interactive filters.

<?xml version="1.0" encoding="utf-8" ?>


var widget_title = "Manual Choice v6.2 Template";
var choice_table = "sys_user";
var choice_name = "city";
var choice_label = "City";
var showEmpty = false;
var filterDebug = true;
var tables_fields = JSON.stringify([
{table:"incident",field:"caller_id.city",},
{table:"sc_req_item",field:"request.requested_for.city",}
]);
var manual_choices = [
{label:"Nashville",value:"nashville"},
{label:"Detroit",value:"detroit"}
];
/g:evaluate


${gs.getMessage('No preview available')}
/j:if


var uid = 'UID_' + Math.round(Math.random() * 1000000000000000)
/g:evaluate


var cl = new GlideChoiceList();
for (var i = 0; i != manual_choices.length; i++) {
cl.add(manual_choices[i].value, manual_choices[i].label);
}
if (showEmpty) {
cl.add("NULL","(Empty)");
}
cl;
/g:evaluate

<!-- 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() { SNC.canvas.interactiveFilters.removeDefaultValue(customFilter${uid}.widgetId, true); dashboardMessageHandler${uid}.removeFilter(); }, customFilterUtil: { getDefaultValueByKey: function() {customFilter${uid}.customFilterUtil.Callback('getDefaultValueByKey')}, removeAllDefaultValues: function(event) {customFilter${uid}.customFilterUtil.Callback('removeAllDefaultValues',event)}, Callback: function(utility, event) { var ga = new GlideAjax('CustomFilterUtil'); ga.addParam('sysparm_name', utility); ga.addParam('sysparm_widget_id', customFilter${uid}.widgetId); ga.addParam('sysparm_canvas_id', customFilter${uid}.canvasId); ga.getXMLWait(); <!--not async to obtained default before load. Most noticeable side effect is slower widget refresh --> var response = ga.getAnswer() ? ga.getAnswer() : ''; if (response &amp;&amp; response.length) { if (utility == "getDefaultValueByKey") { customFilter${uid}.select.val(JSON.parse(response)[0].filter.split("=")[1]) customFilter${uid}.select.change() if (${filterDebug}) { $j('#${uid}_debug').after('<span id="${uid}_persist" style="background-color: LightGreen;">Persisted filter found.</span>') } } else { customFilter${uid}.removeFilter() if (${filterDebug}) { alert('CUSTOM FILTER DESTROY\nWidget Title: ${widget_title}\nWidget ID: '+customFilter${uid}.widgetId+'\nCanvas ID: '+customFilter${uid}.canvasId+'\nEvent: '+event+'\nDefaults Deleted: '+parseInt(response)) } } } } }, 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 --> <!-- Detect Event: EventHandlers return an element, eventbus.subscribe returns data.action --> 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}.removeFilter() 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 customFilter${uid}.customFilterUtil.removeAllDefaultValues(event) } }, }; <!-- Call Handler, Events Subscription, Default Filter, & jQuery's select2 --> dashboardMessageHandler${uid} = new DashboardMessageHandler(customFilter${uid}.widgetId) SNC.canvas.eventbus.subscribe(customFilter${uid}.eventsId,customFilter${uid}.eventCallback) customFilter${uid}.customFilterUtil.getDefaultValueByKey(); //load default value on widget load 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}(selection) { value${uid} = selection.value; 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}}, true); dashboardMessageHandler${uid}.publishMessage(finalFilter${uid}); } } <!-- 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>');}
All

/j:if

/j:jelly

Script Include (CustomFilterUtil)

Description: A script include that enables a dashboard's viewer to access sys_canvas_preferences with the dashboard's custom filters.

Use Case: For loading persisted filters on page load and for deleting persisted filters of a destroyed widget on widget destroy.

Client callable: true

var CustomFilterUtil = Class.create();
CustomFilterUtil.prototype = Object.extendsObject(AbstractAjaxProcessor, {
// Dynamic Content Block GlideRecord access constraints to sys_canvas_preferences and the incompatibility of
// SNC.canvas.interactiveFilters.getDefaultValueByKey with custom filters necessitate the use of this utility.

getDefaultValueByKey: function(){
    var grCanvasPreferences = new GlideRecord('sys_canvas_preferences');
    grCanvasPreferences.addQuery('user', gs.getUserID());
    grCanvasPreferences.addQuery('widget_id', this.getParameter("sysparm_widget_id"));
    grCanvasPreferences.addQuery('canvas_page', this.getParameter("sysparm_canvas_id"));
    grCanvasPreferences.query();
    grCanvasPreferences.next();
    return grCanvasPreferences.getValue('value');
},

removeAllDefaultValues: function(){
    var delCount = 0;
    var grCanvasPreferences = new GlideRecord('sys_canvas_preferences');
    grCanvasPreferences.addQuery('widget_id', this.getParameter("sysparm_widget_id"));
    grCanvasPreferences.addQuery('canvas_page', this.getParameter("sysparm_canvas_id"));
    grCanvasPreferences.query();
    while (grCanvasPreferences.next()) {
        grCanvasPreferences.deleteRecord();
        delCount++;
    }
    return delCount;
},

type: 'CustomFilterUtil'

});

Miscellaneous Details

Widget Titles: I've added a function that will set the widget's header. Just set the 'widget_title' variable in the User Defined Settings portion at the top of each filter and you'll be good to go.

Script Include: I threw in a script include! Mainly becauseI couldn't get the OOTB getDefaultValuesByKey() to work on page load - only on widget refresh. This has the added benefit of letting one know how many preferences were deleted, too.

Event Handling: I added a handful of event listeners to ensure that the relevant actions are handled, to promote a culture of cleanup regarding the saved preferences. The eventbus's data.actions weren't granular enough to provide this functionality alone - especially since deleting a widget, deleting a tab, and changing tabs are all considered a "destroy" event - but we don't want to delete defaults, for example, on tab changes, haha! No problem, we've got it covered in this template.

Lazy Loading : Dashboards have a feature called "Lazy loading" that causes only visible widgets to load. However, the order that said widgets are loaded in is using an algorithm I have not been able to identify. It is not top-to-bottom, left-to-right. This seemingly random load order means that these custom filters cannot be guaranteed to engage prior to the first report that renders. If a report renders first then it will reload when our filter is rendered and engages its default, though sometimes the report reloads without the default properly engaged. P lease let me know if you know how I can make these dynamic content blocks pre-empt lazy loading.

Debug: I've added a debug mode which displays the key IDs of each widget onscreen from the dashboard and alerts regarding the canvas preferences deletion context for convenience. To turn it on, just set the filterDebug variable of said dynamic content block to true. You can add the debug homepage filters widget to your tab as well for easy debugging.

imageimage

End

Please, if anyone has any improvements, feel free to share.

Fun Additional Reading:

1.Building custom visualizations and interactive filters (CreatorCon 2019) (INCREDIBLY USEFUL!) *This lab has a ton of great reusable concepts in its code, which I used heavily here in a dumbed down and altered fashion, especially in scoping the filter in order to make it cooperate with other iterations of itself. This lab's filters are far superior to my templates and I would have absolutely just used this labs content - and never made these templates - if it wasn't for a glitch in the lab's filters that was killing their implementation - which @Adam Stout just recently mentioned how to fix. The fix's details can be found in this community question.

2. Interactive Filter - Display Field (Community Question) * @Ararana Thank you! I built upon your excellent work here! image
3. Date Range Filter of Dashboard

* @Christy Anusha Thank you! I used your method to get SNC.canvas.eventbus.subscribe() to work on widget destroy events!

4. Custom interactive filter example - Task filter (Product Doc)

5. Custom interactive filter example - Multiple reports (Product Doc)

6. jQuery's Select2 Official Documentation

7. Interlocked Category/Subcategory Custom Interactive Filters

I hope someone finds this useful! I hope it empowers you to provide better service, just like this community has empowered me. Please mark this post as helpful or bookmark it if you find it helpful. Thanks!

Kind Regards,

Joseph

View original source

https://www.servicenow.com/community/developer-articles/custom-interactive-filter-templates-multi-table-scoped-choice/ta-p/2330039