Deepak Negi
Mega Sage

The Scenario is Classic:

 

You are building a new Service Catalog Item — maybe it’s a “Relocation Request” or a “Hardware Refresh” — and the requirements look innocent enough.

 

The Requirement: The user needs to search and select three distinct items from a massive dataset stored in a specific field of a table, e.g City field in Location(cmn_location) table.

It sounds simple. But then you look at the data structure, and you realize it’s a trap.

 

The Dataset is Massive: 50,000+ records (e.g., cmn_location or sys_user).

 
 
 

Dtaset.png

The Data is Text: You need to query a specific string column (like a city, category, or job_title), not the unique sys_id of the record itself.

The Constraint: The choices must be mutually exclusive. If the user picks “Item A” for Slot 1, they cannot pick “Item A” for Slot 2.

 

Why Standard Catalog Variables Fail:

  • Lookup Select Box: Tries to load all 50,000 text values on page load. Your Service Portal freezes. Users complain. You cry.
  • Reference Field: You cannot Reference a String column. If you reference the cmn_location record, you get duplicates (e.g., 50 results for "Bangalore" because you have 50 buildings there).
  • List Collector: Good for multi-select, but terrible for ranking (1st Choice vs. 2nd Choice) and offers zero control over duplicates in real-time.

 

So, the “Old School” workaround is born:

 

  1. Create a Custom Table (e.g., u_city) to gather all cities from the Location table.
  2. Write a Scheduled Job to sync distinct string values from the main table to the new table every night.

 

Stop. It’s 2026. We don’t need redundant tables. We don’t need nightly sync jobs.

Today, I’m going to show you a cleaner, faster way: The Dynamic Shadow Pattern.

 

The User Story: “As an employee requesting relocation, I want to search and select my Top 3 Preferred Cities from the global location list so that HR knows where to place me.”

 

The Technical Problem: The cmn_location table has a city column, but it's just text. We need to extract unique city names dynamically and ensure the user doesn't pick "New York" three times.

 

The Solution: Dynamic Typeahead + Shadow Variables

We are going to skip the custom table entirely. We will query the Source(cmn_location) directly and dynamically using GlideAggregate and handle the "Mutually Exclusive" logic instantly on the client side using a custom Service Portal widget.

We separate the User Interface from the Data Storage.

 

  • A custom AngularJS widget that handles the complex “No Duplicates” logic and live typeahead searching.
  • Standard, hidden Catalog Variables that store the final result so your Flows and Workflows stay simple.

 

Step 1: The “Shadow” Variables (The Backend)

First, we create the bucket where the data will live, but we want to hide them from the user so we can show our fancy widget instead.

Create 3 Variables on your Catalog Item:

 

  • Names: pref_city_1, pref_city_2, pref_city_3.
  • Hidden: True
  • Type: Single Line Text

 

Step 2: The Widget HTML (The Interface)

Create a new Custom widget, Service Portal > Widgets

Name: City Typeahead Variable

Body HTML Template:

<div>
  <div ng-if="c.error" class="alert alert-danger" style="margin-bottom: 15px;">
    <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
    {{c.error}}
  </div>

  <div class="form-group">
    <label class="field-label" title="Select your first preferred city">
      <span style="text-danger; margin-right:3px;" title="Mandatory"></span>
      Preferred City 1
    </label>
    <input type="text" 
           class="form-control" 
           ng-model="c.data.city1" 
           uib-typeahead="city as city for city in c.getCities($viewValue)" 
           typeahead-wait-ms="250"
           typeahead-min-length="2"
           typeahead-on-select="c.validateAndSet('city1')"
           placeholder="Search India Cities..." />
  </div>

  <div class="form-group">
    <label class="field-label" title="Select your second preferred city">
      Preferred City 2
    </label>
    <input type="text" 
           class="form-control" 
           ng-model="c.data.city2" 
           uib-typeahead="city as city for city in c.getCities($viewValue)" 
           typeahead-wait-ms="250"
           typeahead-min-length="2"
           typeahead-on-select="c.validateAndSet('city2')"
           placeholder="Search India Cities..." />
  </div>

  <div class="form-group">
    <label class="field-label" title="Select your third preferred city">
      Preferred City 3
    </label>
    <input type="text" 
           class="form-control" 
           ng-model="c.data.city3" 
           uib-typeahead="city as city for city in c.getCities($viewValue)" 
           typeahead-wait-ms="250"
           typeahead-min-length="2"
           typeahead-on-select="c.validateAndSet('city3')"
           placeholder="Search India Cities..." />
  </div>

 

Server Script

(function() {
  if (input && input.action === 'search_city') {
    data.cities = [];
    var ga = new GlideAggregate('cmn_location');
    ga.addQuery('city', 'CONTAINS', input.search_text);
    ga.addNotNullQuery('city');
    ga.addAggregate('COUNT', 'city');
    ga.orderBy('city');
    ga.setLimit(10); // Performance limit
    ga.query();

    while (ga.next()) {
      data.cities.push(ga.getValue('city'));
    }
  }
})();

 

Client Controller

api.controller = function($scope, $timeout) {
  var c = this;
  c.error = null;

  // Search Function
  c.getCities = function(query) {
    return c.server.get({
      action: 'search_city',
      search_text: query
    }).then(function(r) {
      return r.data.cities;
    });
  };

  // Vsalidation Function
  c.validateAndSet = function(changedField) {
    $timeout(function() {
      var v1 = c.data.city1 || '';
      var v2 = c.data.city2 || '';
      var v3 = c.data.city3 || '';
      if ((v1 && v2 && v1 === v2) || 
          (v1 && v3 && v1 === v3) || 
          (v2 && v3 && v2 === v3)) {
        
        c.error = "Preferred cities cannot be the same. Please select a different city.";
        
        // Force Clear the invalid field
        c.data[changedField] = '';
        // Clear the hidden variable
        updateHiddenField(changedField, '');
      } else {
        c.error = null;
        updateHiddenField(changedField, c.data[changedField]);
      }
    });
  };

  // 3. Helper to update hidden variables safely
  function updateHiddenField(field, value) {
    if (!$scope.page.g_form) return;

    var map = {
      'city1': 'pref_city_1', 
      'city2': 'pref_city_2', 
      'city3': 'pref_city_3'  
    };

    if (map[field]) {
      if ($scope.page.g_form.hasField(map[field])) {
        $scope.page.g_form.setValue(map[field], value);
      }
    }
  }
};

 

Step 3: Create custom variable to call widget

Open the catalog item just created and add a “Custom” variable and place the widget created in step 2

 

Image 2.png

 

This is how the final variables structure would look like

 

image 3.png

 

Important Considerations

Before you rush to deploy this, keep these four “Gotchas” in mind:

 

  1. This solution relies on Service Portal widgets (AngularJS). It will not work on the classic Native UI (backend) views. If you have fulfillers submitting requests from the backend, they will see the raw variables without the fancy typeahead.
  2. In the Server Script, I used ga.setLimit(10). You can increase this if you really need to, but remember: nobody likes scrolling through a dropdown of 500 cities.
  3. This widget is technically Scope Independent (it works in Global or Scoped apps). However, if your widget is in a Scoped App but you are querying a Global table (like cmn_location), ensure your Scope has Cross-Scope Privileges to read that table.
  4. The variables containing the actual selected values are hidden in catalog form but should be made available on Target records so please add UI policy to make them visible on the target records

Happy Coding!

 

Demonstration

Cities Selection.gif

 

 

 

 

 

Version history
Last update:
2 hours ago
Updated by:
Contributors