logo

NJP

Interlocked Category/Subcategory Custom Interactive Filters

Import · May 12, 2021 · article

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

View original source

https://www.servicenow.com/community/platform-analytics-articles/interlocked-category-subcategory-custom-interactive-filters/ta-p/2302117