Built something you're proud of? Tell the story. A quick G2 review of App Engine or Build Agent helps other developers see what's possible on ServiceNow. Share your experience.

How To: Add a "Copy Row" Action to MRVS on Service Portal

David Cole1
Tera Expert

Background

ServiceNow's Service Portal supports Multi-Row Variable Sets (MRVS) on catalog item pages. Out of the box, each row provides two actions: an edit (pencil) button, and a remove (x) button.

DavidCole1_0-1777331124044.png

In a recent project, we had users who needed to add rows that were nearly identical to existing ones, with only minor differences between them. Re-entering the same data repeatedly was a pain point. We wanted a Copy Row button that would duplicate a row and insert it directly below the source.

 

This was not available out of the box. The customization to achieve it is relatively minimal, and does not require touching any widget server-side code.

DavidCole1_1-1777331175135.png

 

Important Context - UI Type and Scope

This customization targets the Service Portal (UI Type: Mobile / Service Portal). It has not been tested against Next Experience or Workspace views, and should be assumed non-applicable there without further investigation.

The approach here is also built around our internal portal's catalog item widget, which is a clone of the OOB catalog item widget. If your portal uses a different setup, the wiring step (described below) may differ, but the UI Script itself will remain the same.

 

Pre-Getting Started - What's Needed?

You will need access to create/update the following:

  • UI Script [sys_ui_script]
  • Your portal's catalog item Widget [sp_widget], specifically its Client Controller

You will also need SNUtils installed in your browser, or another way to identify which widget is rendering a given section of your portal page. More on this below.

 

Pre-Getting Started - The Backend of MRVS on Service Portal

If you just want the "how-to," and care less about the "why," you can skip this section and go to the next, however, this should offer some good insight into what we're about to do, and why, at each step.

 

Why You Can't Just Edit a Widget

Unlike most Service Portal content, the MRVS row UI is not rendered by a discrete, editable widget. It is driven by an Angular directive (sp-sc-multi-row-element), whose template is compiled and served as part of ServiceNow's minified portal JavaScript bundle (sp_min.jsx). The HTML for the edit/remove row actions is not accessible in any widget record you can clone and modify directly.

The key here is Angular's $templateCache. Even though the MRVS template is compiled into the bundle, Angular stores it in the template cache at runtime under a known key (sp_element_sc_multi_row.xml). We can retrieve and modify this cached template before Angular uses it to render rows, effectively injecting our copy button without touching any OOB file.

 

How the Approach Works - 2 Parts

  1. Template Cache Patch: A function that retrieves the MRVS template from $templateCache, finds the existing "Remove Row" anchor, and inserts a new "Copy Row" anchor immediately before it. Once patched, Angular will use this modified template for any MRVS it renders.
  2. Controller Patch: The new button calls ng-click="c.copyRow($index)", but copyRow does not exist on the OOB MRVS controller. We locate the rendered MRVS Angular elements, access their isolated scopes, and inject the copyRow function onto the controller at runtime.

Both patches are applied at runtime. A polling loop (with a timeout ceiling) waits for MRVS elements to appear on the page before patching their controllers. Route change events are also handled, so the patching survives SPA navigation within the portal.

 

Getting Started - Identifying Your Widget with SNUtils

Before writing any code, you need to know which widget is rendering your catalog item page, so you know where to add the UI Script call. The easiest way to do this is with SNUtils, a browser extension for ServiceNow developers. If you don't have it, you can find it at arnoudkooi.com

 

Do note: There are two extensions for SNUtils, one for on-prem domains, and one for service-now domains. My recommendation is have both if you work with both. 

 

Once installed, or if already active:

  1. Bavigate to a catalog item on your Service Portal that includes an MRVS
  2. Right click on anywhere within the MRVS
  3. It will show a widget name:
    DavidCole1_8-1777334181082.png


    Note this name. In our case, we were already using a custom clone of the OOB "HRM Catalog Item," but you may be using the older "Catalog Item V2" widget.

  4. Navigate to sp_widget.list in your filter nav
  5. Filter for the widget by name, and open the record

    If you are using an OOB widget, for best practices, you should clone a copy before performing this modification, to prevent conflicts for future upgrades. Keep in mind that after cloning the widget, you must also update the instances to use your cloned version instead.

  6. Keep this tab open for now

 

Customization - Creating the UI Script

Note: This should be done in the Global scope.

In a new tab, navigate to System UI > UI Scripts and create a new record:

Name: foo-mrvs-copy-row (You'll want a name you can identify later).

UI Type: Mobile / Service Portal

Active: true

Description:

Adds a "Copy Row" button to MRVS editor on catalog items.

Load via the widget client script:
   $script('/scripts/foo-mrvs-copy-row.jsdbx', function() { FOOMRVSCopyRow.init(); });

Or add it to the portal theme's JS includes, and call:
   FOOMRVSCopyRow.init();

Script:

Spoiler
var FOOMRVSCopyRow = (function() {
    'use strict';

    var LOG_PREFIX = '[foo-mrvs-copy] ';
    var POLL_MS = 300;
    var POLL_MAX = 30;
    var PATCH_ATTR = 'data-copy-row-patched';
    var timer;

    function patchTemplateCache() {
        try {
            var inj = angular.element(document.body).injector();
            if (!inj) return false;

            var $tc = inj.get('$templateCache');
            var key = 'sp_element_sc_multi_row.xml';
            var tmpl = $tc.get(key);

            if (!tmpl) {
                console.warn(LOG_PREFIX + 'Template "' + key + '" not found in $templateCache.');
                return false;
            }
            if (tmpl.indexOf('data-copy-row-patched') !== -1) {
                return true;
            }

            var DELETE_ANCHOR =
                '<a href="javascript&colon;void(0);" class="wrapper-xs fa fa-close"' +
                ' role="button" data-original-title="Remove Row"';

            if (tmpl.indexOf(DELETE_ANCHOR) === -1) {
                console.warn(LOG_PREFIX +
                    'Delete anchor not found in MRVS template — ' +
                    'sp_min.jsx may have changed. Copy Row button will not appear. ' +
                    'Update DELETE_ANCHOR in rit-mrvs-copy-row UI Script.');
                return false;
            }

            var COPY_BTN =
                '<a href="javascript&colon;void(0);"' +
                ' class="wrapper-xs fa fa-copy"' +
                ' role="button"' +
                ' data-original-title="Copy Row"' +
                ' aria-label="Copy Row {{$index + 1}}"' +
                ' data-toggle="tooltip"' +
                ' data-placement="top"' +
                ' ng-click="c.copyRow($index)"' +
                ' ng-class="{\'btn disabled no-border\' : c.disableMRVSActions}"' +
                ' data-copy-row-patched="true"></a>';

            $tc.put(key, tmpl.replace(DELETE_ANCHOR, COPY_BTN + DELETE_ANCHOR));
            console.info(LOG_PREFIX + 'Template cache patched successfully.');
            return true;

        } catch (err) {
            console.error(LOG_PREFIX + 'Template patch error:', err);
            return false;
        }
    }

	function buildCopyRow(scope, c, field) {
		return function copyRow(index) {
			if (c.disableMRVSActions) return;

			if (!Array.isArray(field._value) || !Array.isArray(field._displayValue)) {
				console.error(LOG_PREFIX + 'copyRow: field._value or _displayValue is not an array. Aborting.');
				return;
			}
			if (index < 0 || index >= field._value.length) {
				console.error(LOG_PREFIX + 'copyRow: index ' + index + ' out of bounds.');
				return;
			}

			var prevValue = angular.copy(field._value);
			var prevDisplay = angular.copy(field._displayValue);

			try {
				var values = angular.copy(field._value);
				var display = angular.copy(field._displayValue);

				values.splice(index + 1, 0, angular.copy(values[index]));
				display.splice(index + 1, 0, angular.copy(display[index]));

				field._value = values;
				field._displayValue = display;

				// Use $evalAsync instead of $apply — safely queues after the
				// current ng-click digest completes, avoiding $rootScope:inprog
				scope.$evalAsync(function() {
					scope.getGlideForm().setValue(
						field.name,
						JSON.stringify(values),
						JSON.stringify(display)
					);
				});

				console.info(LOG_PREFIX + 'Row ' + index + ' copied successfully.');

			} catch (err) {
				field._value = prevValue;
				field._displayValue = prevDisplay;
				console.error(LOG_PREFIX + 'copyRow failed, state rolled back:', err);
			}
		};
	}

    function patchControllers() {
		var els = document.querySelectorAll('sp-sc-multi-row-element, [ng-switch-when="sc_multi_row"]');
		if (!els.length) return 0;

		var patched = 0;
		angular.forEach(els, function(el) {
			if (el.getAttribute(PATCH_ATTR)) { patched++; return; }

			try {
				var scope = angular.element(el).isolateScope() || angular.element(el).scope();
				if (!scope) return;

				var c = scope.c;
				var field = scope.field;

				if (typeof c !== 'object' ||
					typeof c.updateRow !== 'function' ||
					typeof field !== 'object' ||
					!('_value' in field) ||
					!('_displayValue' in field) ||
					typeof scope.getGlideForm !== 'function') {

					console.warn(LOG_PREFIX +
						'Unexpected MRVS scope shape — skipping injection. ' +
						'Check for ServiceNow upgrade changes.',
						{ c: c, field: field });
					return;
				}

				c.copyRow = buildCopyRow(scope, c, field);
				el.setAttribute(PATCH_ATTR, 'true');
				patched++;
				console.info(LOG_PREFIX + 'copyRow injected for field: ' + field.name);

			} catch (err) {
				console.error(LOG_PREFIX + 'Controller patch error on element:', el, err);
			}
		});

		return patched;
	}

	var routeChangeBound = false;

	function bindRouteChange() {
		if (routeChangeBound) return;
		try {
			var inj = angular.element(document.body).injector();
			if (!inj) return;

			inj.get('$rootScope').$on('$routeChangeSuccess', function() {
				init();
			});
			routeChangeBound = true;
		} catch (e) {
			console.warn(LOG_PREFIX + 'Could not bind $routeChangeSuccess:', e);
		}
	}

    function init() {
        clearInterval(timer);

        var templatePatched = patchTemplateCache();
        var attempts = 0;

        timer = setInterval(function() {
            attempts++;
            if (!templatePatched) templatePatched = patchTemplateCache();

            var count = patchControllers();

            if (count > 0 || attempts >= POLL_MAX) {
                clearInterval(timer);
                if (attempts >= POLL_MAX && count === 0) {
                    console.warn(LOG_PREFIX +
                        'Polling timed out — no MRVS elements found after ' +
                        (POLL_MAX * POLL_MS / 1000) + 's. ' +
                        'Copy Row feature inactive for this load.');
                }
            }
        }, POLL_MS);

        bindRouteChange();
    }

    return { init: init };

})();

Note: The DELETE_ANCHOR string in patchTemplateCache is matched against the compiled OOB template in sp_min.jsx. If ServiceNow changes that HTML in a future release, the string will no longer match and the copy button will not appear. The console.warn will fire in that case.

 

If the button stops appearing after an upgrade, that is the first place to check.

 

Customization - Wiring the UI Script to Your Widget

Open back up your cloned catalog item widget, and in the Client Controller, add the following line of code at the very bottom, right before the last closing curly brace, or before the closure of the inline function:

$script('/scripts/foo-mrvs-copy-row.jsdbx', function() { FOOMRVSCopyRow.init(); });

Save, and you're done!

 

The Extra Mile - Hooking to Service Portal Theme (Optional)

This is by no means necessary, but is a cool way to see what portal themes let you do. This would allow you to enable this feature optionally, based on the portal it was being accessed from.

Navigate to sp_js_include.do in your filter navigator, and fill out the following record:

Display Name: FOO "Copy Row" MRVS Button (You'll want a display name you can identify later)

Source: UI Script

UI Script: <search for the script that was designed earlier>


Then Submit. Open the theme of your portal by navigating to "sp_portal.list" in your filter nav, finding your portal, and opening its linked Theme:

DavidCole1_0-1777335338511.png

Note: Make sure you are in the same scope as your theme is!

Scroll to the bottom, to the JS Includes related list, and choose Edit:

DavidCole1_7-1777333771787.png

Search by Display Name, find and add your newly create JS Includes:

DavidCole1_9-1777334315258.png

Save, and you're done with the theme.

 

Now back in the Client Controller of the Widget, where we previously had:

$script('/scripts/foo-mrvs-copy-row.jsdbx', function() { FOOMRVSCopyRow.init(); });

We are instead going to replace it with:

	try {
		FOOMRVSCopyRow.init();
	} catch(e) {}

If your intention is to apply this to all portals, make sure to add it to all applicable themes, but as stated earlier, this is also a method to control if it "served" to a portal or not. By not adding the JS Include to the theme for a portal, the init call will not happen, thus, no copy button.

 

The Result

When a catalog item with an MRVS is loaded on the portal, the Copy Row button will appear alongside the existing edit and remove buttons. Clicking it inserts a duplicate of the selected row immediately below it, with all field values and display values preserved.

Before:

DavidCole1_4-1777333100049.png

After:

DavidCole1_5-1777333117802.png

0 REPLIES 0