Record Navigation in ServiceNow Service Portal

How to Implement Previous & Next Record Navigation in ServiceNow Service Portal

Navigating between records in the ServiceNow Service Portal is not as straightforward as in the classic UI.

In Classic UI, when you open a record from a filtered list, the platform automatically remembers:
  • The filter
  • The sorting (ASC / DESC)
  • The record position
But in Service Portal, this behavior does not exist out of the box.
  • Previous / Next record navigation
  • Respecting list filters dynamically
  • Respecting sorting (ASC & DESC)
  • SPA-safe navigation (no page refresh required)
  • Embedded directly inside the Form Widget
  • Production-ready and reusable
This solution was implemented in a real-world GDPR module and works reliably across tables.

Context and preparation

This article was inspired by the widget "Service Portal Next and Previous feature on form'', which I found in this ServiceNow community article.

That widget allows the user to navigate to the next and previous records without navigating to the list view. This idea originates from the Classic UI form, allowing you to go to the next and previous record in the list.


Similarly, we want to add this feature to the Service Portal form view.

You can achieve that with the widget above easily. However, the functionality of that widget is simple. It doesn't take into account the list filter or the order of the number of records (ASC or DESC). Regarding enabling Edit Filters on the List for End Users in the Service Portal, this tutorial walks you through how to achieve that.

If you want to have a more advanced version of that widget's functionality that takes the above into account, then this article is for you.

First note
Instead of extending that widget, I have embedded the implementation of the code/script directly in the default ServiceNow widgets themselves - list widget & form widget. This is also a best practice to keep the code tight. Additionally, you'll avoid working with formatters, etc., that are described in the documentation of the feature.

Second note:
Add the Data Table from Instance Definition widget to the catalog home page in the Service Portal:

Best practice is to clone the widget.

Third note

Clone the form widget and add it to the form page:


Fourth note:
The implementation supports list filters. So, you need to enable it in the list. This tutorial walks you through how exactly you do that.

After enabling the list filter, you should have a list with a filter option:


Now, let me walk you through how to implement the Previous & Next Record Navigation in the Service Portal.

The Problem

When opening a record from a list in Service Portal:
  • The filter context is lost
  • The order direction is not preserved
  • Navigation buttons (if added) don’t respect the list
  • SPA navigation prevents server re-execution unless handled properly
Result:
Navigation becomes inconsistent, broken, or requires manual page refresh.

Architecture Overview

The solution consists of three core parts:
  1. List Widget Enhancement
    • Pass filter and order parameters to the form
  2. Form Widget Server Logic
    • Reconstruct the ordered result set
    • Determine previous and next records
  3. Form Widget Client Logic
    • Navigate SPA-safe
    • Force server re-execution correctly

List Widget – Passing Filter & Sorting Context

When clicking a record in the list, we pass:

  • sysparm_query
  • sysparm_orderby
  • sysparm_orderby_desc
  • spa=1
List Widget Client Controller

function($scope, spUtil, $location, spAriaFocusManager) {
    // $scope.$on('data_table.click', function(e, parms){
    // var p = $scope.data.page_id || 'form';
    // var s = {id: p, table: parms.table, sys_id: parms.sys_id, view: 'sp'};
    // var newURL = $location.search(s);
    // spAriaFocusManager.navigateToLink(newURL.url());
    // });

// START: Prev/Next Buttons

    function getDT() {
        return ($scope.data && $scope.data.dataTableWidget) ? $scope.data.dataTableWidget : null;
    }

    function pickFirst() {
        for (var i = 0; i < arguments.length; i++) {
            if (arguments[i] !== undefined && arguments[i] !== null && arguments[i] !== '')
                return arguments[i];
        }
        return '';
    }

        $scope.$on('data_table.click', function(e, parms) {

        var formPage = $scope.data.page_id || 'form';
        var urlParams = $location.search();
        var dt = getDT();

        // Try all likely places where Data Table stores the *current* query
        var dtQuery =
            (dt && dt.data && (dt.data.encodedQuery || dt.data.filter || dt.data.query)) ||
            (dt && dt.options && (dt.options.filter || dt.options.query)) ||
            '';

        // URL can also carry it (depends on instance/version/config)
        var urlQuery = urlParams.sysparm_query || urlParams.filter || '';

        var encodedQuery = pickFirst(dtQuery, urlQuery, $scope.data.filter);

        // Ordering: prefer URL (if present), otherwise widget options
        var orderByField = pickFirst(
            urlParams.sysparm_orderby,
            urlParams.o,
            (dt && dt.options && dt.options.o),
            $scope.data.o,
            'number'
        );

        var orderDirection = pickFirst(
            urlParams.d,
            (dt && dt.options && dt.options.d),
            $scope.data.d,
            'asc'
        );

        var navigationParams = {
            id: formPage,
            table: parms.table,
            sys_id: parms.sys_id,
            view: 'sp',
            sysparm_query: encodedQuery,
            sysparm_orderby: orderByField,
            spa: 1
        };

        if (orderDirection === 'desc')
            navigationParams.sysparm_orderby_desc = 'true';

        // var newURL = $location.search(navigationParams);
        // spAriaFocusManager.navigateToLink(newURL.url());

        $location.search(navigationParams);
        spAriaFocusManager.navigateToLink($location.url());


    });
    // END: Prev/Next Buttons
}

Add the above script to the client controller list widget:




Now the form receives the full list context.

Form Widget Server Script – Building Prev / Next Logic

This is the core logic.

/// ===== START PREV / NEXT LOGIC =====

    (function() {

        data.listQuery = '';
        data.orderBy = 'number';
        data.orderDesc = false;

        // Prefer SPA input first
        // --- Read filter context (support both filter and sysparm_query) --

        // data.listQuery = (input && input.sysparm_query) || '';
        // data.orderBy = (input && input.sysparm_orderby) || 'number';
        // data.orderDesc = (input && input.sysparm_orderby_desc) == 'true';

        data.listQuery =
            (input && input.sysparm_query) ||
            $sp.getParameter('sysparm_query') ||
            '';

        data.orderBy =
            (input && input.sysparm_orderby) ||
            $sp.getParameter('sysparm_orderby') ||
            'number';

        data.orderDesc =
            ((input && input.sysparm_orderby_desc) ||
                $sp.getParameter('sysparm_orderby_desc')) == 'true';


        // data.debugURLQuery = $sp.getParameter('sysparm_query');

        buildPrevNext();

    })();

    function buildPrevNext() {
        if (!data.table || !data.sys_id)
            return;

        var gr = new GlideRecordSecure(data.table);

        // Apply the same filter as the list
        if (data.listQuery)
            gr.addEncodedQuery(data.listQuery);

        // Apply the same ordering as the list
        if (data.orderDesc) {
            gr.orderByDesc(data.orderBy);
            gr.orderByDesc('sys_id'); // IMPORTANT: tie-breaker desc too
        } else {
            gr.orderBy(data.orderBy);
            gr.orderBy('sys_id'); // tie-breaker asc
        }

        gr.query();

        var ids = [];
        while (gr.next()) {
            ids.push(String(gr.getUniqueValue()));
        }

        var current = String(data.sys_id);
        var idx = ids.indexOf(current);

        if (idx === -1) {
            data.prevSysId = null;
            data.nextSysId = null;
        } else {
            data.prevSysId = (idx > 0) ? ids[idx - 1] : null;
            data.nextSysId = (idx < ids.length - 1) ? ids[idx + 1] : null;
        }

        // Send debug to the browser (since you said gs.info isn't showing)
        data.prevNextDebug = {
            table: data.table,
            sys_id: data.sys_id,
            listQuery: data.listQuery,
            orderBy: data.orderBy,
            orderDesc: data.orderDesc,
            total: ids.length,
            index: idx,
            prev: data.prevSysId,
            next: data.nextSysId
        };
    }

    /// ===== END PREV / NEXT LOGIC =====

Between the snippet code: 

data.f = $sp.getForm(data.table, data.sys_id, data.query, data.view, isPopup); 

And:

    if (data.f && data.f._fields) {
        for (var key in data.f._fields) {
            if ((data.f._fields[key].type === 'script' || data.f._fields[key].type === 'script_plain') && !data.f._fields[key].attributes.client_script) {
                if (typeof data.esLatestVersion === 'undefined')
                    data.esLatestVersion = data.sys_id == "-1" || $sp.shouldUseESLatest(data.table, data.sys_id);
                data.f._fields[key].useEsLatest = data.esLatestVersion;
            }
        }
    }

Add the Prev / Next Logic.


Form Widget Client Controller – SPA-Safe Navigation

The critical mistake most developers make:

They only update $location.search()
But do not force server re-execution.

That breaks Prev/Next until page refresh.

First Implementation

    // --- START: Previous / Next navigation for embedded buttons ---

    $scope.$watchGroup(
        ['data.prevSysId', 'data.nextSysId'],
        function() {
            // UP arrow = previous record in list
            $scope.upTarget = $scope.data.prevSysId || null;

            // DOWN arrow = next record in list
            $scope.downTarget = $scope.data.nextSysId || null;
        }
    );

    // --- FIX: normalize order direction for Prev / Next ---
    (function normalizeOrderDirection() {
        var params = $location.search();

        // If list says desc (d=desc) but sysparm_orderby_desc is missing
        if (params.d === 'desc' && !params.sysparm_orderby_desc) {
            params.sysparm_orderby_desc = 'true';
            $location.search(params).replace();
        }

        // Sync into widget data so server logic is correct
        if (params.sysparm_orderby_desc === 'true') {
            $scope.data.orderDesc = true;
        }
    })();

    // --- END: Previous / Next navigation for embedded buttons ---

Add the above code snippet between line:

window.location.replace(downloadLink);

And:

spUtil.recordWatch($scope, "sys_attachment", "table_sys_id=" + tableId, function(response, data)

Second Implementation

// --- START: Previous / Next navigation for embedded buttons ---
    var c = this;

    c.goToRecord = function(sysId) {
        if (!sysId || sysId === $scope.data.sys_id)
            return;

        var params = angular.copy($location.search());
        params.table = $scope.data.table;
        params.sys_id = sysId;
        params.spa = 1;

        // update URL (keeps list context)
        $location.search(params).replace();

        // update widget model and ask server to re-render form + prev/next
        $scope.data.sys_id = sysId;

        return $scope.server.update({
            table: params.table,
            sys_id: sysId,
            sysparm_query: params.sysparm_query,
            sysparm_orderby: params.sysparm_orderby,
            sysparm_orderby_desc: params.sysparm_orderby_desc
        });
    };

    // --- END: Previous / Next navigation for embedded buttons ---

Add the above snippet between:

// var ctrl = this;

And:

// switch forms
var unregister = $scope.$on('$sp.list.click', onListClick);

But make sure that you comment the var ctrl = this;

Now:
  • URL updates
  • Server script re-runs
  • Prev/Next recalculates instantly
  • No page refresh required

HTML Template

<div class="pull-right m-l-sm">

  <button class="btn btn-default"
          ng-disabled="!upTarget"
          ng-click="c.goToRecord(upTarget)"
          title="Previous record"
          aria-label="Previous record">
    <span class="glyphicon glyphicon-arrow-up"></span>
  </button>

  <button class="btn btn-default"
          ng-disabled="!downTarget"
          ng-click="c.goToRecord(downTarget)"
          title="Next record"
          aria-label="Next record">
    <span class="glyphicon glyphicon-arrow-down"></span>
  </button>

</div>

Add the HTML template before the attachment feature:



The HTML template should be mapped with the First Implementation of the Form Widget Client Controller (see above).

Final Result

Your navigation now:
  • Respects dynamic list filters
  • Respects ASC / DESC sorting
  • Works without page refresh
  • Works in SPA mode
  • Stable ordering with tie-breaker
  • Easy to replicate on any table

Why This Approach Is Superior

Instead of comparing field values (number > currentNumber), like it is implemented in the widget above, we reconstruct the actual ordered result set.

That guarantees:
  • Identical behavior to the list view
  • No edge-case bugs
  • No direction confusion
  • No disabled button anomalies

Reuse Strategy

To implement this on another instance:
  1. Copy list click enhancement
  2. Copy Prev/Next server block
  3. Copy SPA-safe navigation method
  4. Add HTML buttons
  5. Ensure sysparm parameters are passed
That’s it.

Closing Thoughts

Service Portal is powerful — but SPA behavior changes how navigation works.

By correctly passing context, rebuilding the order on the server, and forcing server-side re-execution on the client, you can build enterprise-grade navigation within any custom form.

If you're building complex portals — especially GDPR, case management, or record-heavy apps — this pattern is essential.













Comments

Popular Posts