Personalizing the Portal Experience With Favorites

featured

What you’ll learn in this post:

  • How to use an angular directive to make a reusable link to add/remove an item to their favorites list.
  • How to use an angular service to provide reusable client scripting to your widgets.
  • How to use a scripted REST API when a widget server script isn’t available.
  • How to include drag’n’drop functionality in your widgets.

The topic of personalizing the portal experience comes up often in my portal design workshops. The best personalized experience is one that features suggestions based on assets assigned to a user via the CMDB. However, many organizations aren’t ready to do that. Therefore, there’s not much we know about the user that allows us to make intelligent personalized suggestions.

Perhaps the next best thing is to allow users to create their own list of favorite content. Using some advanced techniques in AngularJS and Service Portal, we can provide the following features to users.

Save content as favorites from a list.

Users can add and remove favorites from a list of content, such as search results.

Save an item as a favorite from a document.

Users can mark a catalog item as a favorite when viewing the item.

Access favorites from the header menu.

Easy access to favorite content from the header menu.

Manage their favorites via a My Favorites page.

Users can change the order of their favorites by dragging these tiles around.

I demonstrated the full experience and how I built it in a recent Ask the Expert session. I’m not going to explain everything step by step in this post, since you can watch the video and see for yourself.

But as promised, I will share the code and general architecture. Here is everything I used in the video. Enjoy!

 

Favorites Table

The first thing you’ll need is a table to store your user’s favorites.

The fields I created on this table are:

  1. User (type = reference: sys_user)
  2. Table (type = Table Name)
  3. Document (type = Document ID, dependent on the table field)
  4. Order

 

Script Include

This script include is used to store server side functions that will be used by many of the other components.

var portalFavorites = Class.create();
portalFavorites.prototype = {
  initialize: function() {
  },
  
  getFavorites: function() {
    var favorites = [];
    
    var fav = new GlideRecord('u_portal_favorite');
    fav.addQuery('u_user', gs.getUserID());
    fav.orderBy('u_order');
    fav.query();
    while(fav.next()) {
      favorites.push(this.formatFavorite(fav));
    }
    
    return favorites;
  },

  isFavorite: function(table, document) {
    var isFavorite = false;

    var fav = new GlideRecord('u_portal_favorite');
    fav.addQuery('u_user', gs.getUserID());
    fav.addQuery('u_table', table);
    fav.addQuery('u_document', document);
    fav.query();
    if(fav.hasNext()) {
      isFavorite = true;
    }
    return isFavorite;
  },

  reorderFavorites: function(newIndex, oldIndex, id) {
    var i;

    var fav = new GlideRecord('u_portal_favorite');
    fav.addQuery('u_user',gs.getUserID());

    if(!oldIndex) {
      //This is a new item, so move everything down in order.
      fav.addQuery('sys_id','!=',id);
      fav.query();
      while(fav.next()) {
        i = Math.floor(fav.getValue('u_order'));
        i++;
        fav.u_order = i;
        fav.update();
      }
      return;
    }

    if(!id) {
      //An item was removed, so move everything that was behind it up in order.
      fav.addQuery('u_order','>',oldIndex);
      fav.query();
      while(fav.next()) {
        i = Math.floor(fav.getValue('u_order'));
        i--;
        fav.u_order = i;
        fav.update();
      }
      return;
    }

    if(oldIndex < newIndex) {
      //The item was moved down in order.
      //example, if item moved from 3 to 7, then get the items with indexes 4, 5, 6, & 7 and move them up in order
      fav.addQuery('sys_id','!=',id);
      fav.addQuery('u_order','>',oldIndex);
      fav.addQuery('u_order','<=',newIndex);
      fav.query();
      while(fav.next()) {
        i = Math.floor(fav.getValue('u_order'));
        i--;
        fav.u_order = i;
        fav.update();
      }
    }

    if(oldIndex > newIndex) {
      //The item was moved up in order.
      //example, if item moved from 7 to 3, then get all items with indexes 3, 4, 5, & 6 and move them down in order
      fav.addQuery('sys_id','!=',id);
      fav.addQuery('u_order','<',oldIndex);
      fav.addQuery('u_order','>=',newIndex);
      fav.query();
      while(fav.next()) {
        i = Math.floor(fav.getValue('u_order'));
        i++;
        fav.u_order = i;
        fav.update();
      }
    }

    //If a favorite was moved (not added or removed), then the last step is to update the new position of the moved item
    var item = new GlideRecord('u_portal_favorite');
    item.get(id);
    item.u_order = newIndex;
    item.update();

  },
  
  removeFavorite: function(id) {
    var fav = new GlideRecord('u_portal_favorite');
    fav.get(id);
    var oldIndex = fav.getValue('u_order');

    if(fav.deleteRecord()) {
      this.reorderFavorites(null, oldIndex);
    }
  },
  
  formatFavorite: function(fav) {
    var item = {};

    item.type = 'favorite';
    item.table = fav.getValue('u_table');
    item.document_id = fav.getValue('u_document');
    item.order = fav.getValue('u_order');
    item.sys_id = fav.getValue('sys_id');
    item.url_target = '_parent';
    
    if(item.table == 'kb_knowledge') {
      item.href = '?id=kb_article&sys_id=' + item.document_id;
      item.label = fav.u_document.short_description.getDisplayValue();
      item.image = fav.u_document.image.getDisplayValue();
      item.icon = 'fa-info';

    } else if(item.table.substring(0, 3) == 'sc_') {
      item.href = '?id=sc_cat_item&sys_id=' + item.document_id;
      
      var catItem = new GlideRecord(fav.getValue("u_table"));
      catItem.get(fav.u_document.sys_id);
      item.label = catItem.getDisplayValue("name");
      item.image = catItem.picture.getDisplayValue();
      item.icon = 'fa-shopping-cart';
        
    }

    return item;
  },

  type: 'portalFavorites'
};

Scripted REST API

I used a scripted REST API to make server side functions available to my directive and service, since Angular Providers don’t have a server script field like widgets do. Note the two resources attached to the API. That’s where this API’s scripts live.

deleteFavorite REST Resource

(function process(/*RESTAPIRequest*/ request, /*RESTAPIResponse*/ response) {

  var data = request.body.data;
  var result;

  var fav = new GlideRecord('u_portal_favorite');
  fav.addQuery('u_user', gs.getUserID());
  fav.addQuery('u_table', data.table);
  fav.addQuery('u_document', data.document);
  fav.query();
  if(fav.next()) {
    var oldIndex = fav.getValue('u_order');
    if(fav.deleteRecord()) {
      result = 'success';
      new portalFavorites().reorderFavorites(null, oldIndex);

    } else {
      result = 'fail';

    }
  } else {
    result = 'fail';

  }

  var body = {data: result};

  response.setBody(body);

})(request, response);
insertFavorite REST Resource

(function process(/*RESTAPIRequest*/ request, /*RESTAPIResponse*/ response) {

  var data = request.body.data;
  var result;

  var fav = new GlideRecord('u_portal_favorite');
  fav.initialize();
  fav.u_user = gs.getUserID();
  fav.u_table = data.table;
  fav.u_document = data.document;
  fav.u_order = 0;
  if(fav.insert()) {
    result = fav.getUniqueValue();
    new portalFavorites().reorderFavorites(0, null, result);

  } else {
    result = 'fail';

  }

  var body = {data: result};

  response.setBody(body);

})(request, response);

Angular Service

This service is used to provide reusable functions for adding and removing favorites. Since Angular Providers don’t have a server script, like a widget does, I used a scripted REST API to handle the server side functions.

function($http, $q) {

  function _insert(data) {
    var def = $q.defer();
    var putter = '/api/116638/portalfavorites/insertFavorite';
    $http.put(putter, data)
      .success(function(response){
        console.log('insert success');
        def.resolve(response.result.data);
      })
      .error(function() {
        console.log('insert fail');
        def.reject("Failed to get message");
      });

    return def.promise;
  }

  function _delete(data) {
    var def = $q.defer();
    var putter = '/api/116638/portalfavorites/deleteFavorite';
    $http.post(putter, data)
      .success(function(response){
        console.log('delete success');
        def.resolve(response.result.data);
      })
      .error(function() {
        console.log('delete fail');
        def.reject("Failed to get message");
      });

    return def.promise;
  }

  return {

    toggleFavorite: function(item) {
      var data = {
        table: item.type == 'kb' ? 'kb_knowledge' : 'sc_cat_item',
        document: item.sys_id
      };

      if(item.isFavorite) {
        _delete(data).then(function(r){
          item.isFavorite = !item.isFavorite;
        });

      } else {
        _insert(data).then(function(r){
          item.isFavorite = !item.isFavorite;
        });

      }
    }

  };
}

Angular Directive

This directive is used to provide a common HTML template to be used as the toggle link. Notice that we reference the above service in two ways:

  • Attach it to the directive file via the Required Providers related list.
  • Inject the service into the directive’s function by name.

function favoriteToggle(portalFavorites) {
  return {
    restrict: 'E',
    scope: {
      item: "="
    },
    replace: true,
    link: function(scope) {
      scope.favorites = portalFavorites;
    },
    template: '<div class="ht-portal-favorite-toggle">' +
      '<a href="javascript:void(0)" ng-click="favorites.toggleFavorite(item)">' +
      '<i class="fa" ng-class="item.isFavorite ? \'fa-heart\' : \'fa-heart-o\'"></i>' +
      '</a>' +
      '</div>'
  };
}

 

My Search Page Widget

The components documented above are what we need to add the favorite toggle icon link to any widget. You will need to create custom copies of any out-of-box widget with which you plan to enable favorites. Don’t forget to attach the “favoriteToggle” directive to your widget via the Angular Providers related list.

The first step in enabling your widget is to add the directive to the HTML template. I added it to line 30, inside the existing ng-repeat.

<div role="listitem" ng-repeat="item in data.results | orderBy:'score':true | limitTo:data.limit" class="panel-body b-b ">
  <favorite-toggle item="item"></favorite-toggle>
  <div ng-include="item.templateID"></div>
</div>

Notice how we are passing the “item” object to the directive via the “item” attribute? We need to update the item object so that it stores information our supporting scripts will find useful for updating the item in the favorites table. In the case of the Search Page widget, we need to go to the portal’s Search Sources to do that.

Search Sources

The out-of-box portal uses two search sources that we’re interested in for this example: Knowledge Base and Service Catalog.

These search sources already provide most of the information we need to know about the item, namely “sys_id” and “type”. We just want to know one more thing about the item. Is it already a favorite? To find out, I add the following property definition to the “Data Fetch Script” field for each search source:

KB search source – line 30:

article.isFavorite = new portalFavorites().isFavorite('kb_knowledge', kb.getUniqueValue());

SC search source – line 39:

 item.isFavorite = new portalFavorites().isFavorite('sc_cat_item', item.sys_id);

Now the item objects that come from each search source will have a property, isFavorite, that we can use to show the right icon depending on the existing favorite status of each item.

 

My KB Article Page & My SC Catalog Item Widgets

We need to do similar work for the document detail widgets as we did for the search page widget. First clone the out-of-box widgets KB Article Page and SC Catalog Item and put them on their respective pages. The processing scripts we wrote are expecting to receive an object containing (at least) three properties: sys_id, type, and isFavorite. The server scripts of these widgets only define one of those properties, sys_id. Therefore, we need to define an item object that we can give to the directive.

KB Article Page server script, line 41:
data.item = {
  sys_id: data.sys_id,
  type: 'kb',
  isFavorite: new portalFavorites().isFavorite('kb_knowledge', t.sys_id)
};
KB Article Page HTML template, line 8:
 <favorite-toggle item="data.item"></favorite-toggle>
SC Catalog Item server script, line 124:
data.item = {
  sys_id: data.sys_id,
  type: 'sc',
  isFavorite: new portalFavorites().isFavorite('sc_cat_item', data.sys_id)
};
SC Catalog Item HTML template, line 15:
<favorite-toggle item="data.item"></favorite-toggle>

 

Manage Favorites Widget

This widget allows users to reorder their favorites, remove items, and access the articles and items. It can be used on any page, and is an ideal candidate for the homepage. However, I made a new page with the ID “favorites”.

To support the drag’n’drop feature of this widget, you’ll need to include the Sortable javascript library.

URL:  https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js

HTML Template
<div class="ht-manage-favorites-container">

  <h3>
    ${Manage Favorites}
  </h3>

  <div>
    <p>
      ${Use this page to manage your favorite items.} <br />${Reorder them by dragging them around. Remove them with the 'remove' button.}
    </p>
    <h5 ng-if="c.Favorites.items.length < 1">
      ${There are no favorites yet. Save items as favorites by clicking on the heart in lists and form/article headers.}
    </h5>
  </div>

  <div id="favorites" class="ht-favorites">
    <div class="ht-favorite ht-card" document_id="{{item.document_id}}" id="{{item.sys_id}}" ng-repeat="item in data.items">
      <div class="ht-card-image">
        <img ng-if="item.image" class="ht-card-img" ng-src="{{item.image}}" alt="Card image cap">
        <div ng-if="!item.image" class="ht-card-icon">
          <i class="fa {{item.icon}}"></i>
        </div>
      </div>
      <div class="ht-card-body">
        <h4 class="ht-card-title">{{item.label}}</h4>
        <div class="ht-card-buttons">
          <a ng-href="{{item.href}}" class="btn btn-primary"><i class="fa fa-search"></i> ${View}</a>
          <a href="javascript:void(0)" class="ht-remove btn btn-danger"><i class="fa fa-close"></i> ${Remove}</a>
        </div>
      </div>
      <div class="ht-favorite-loading-icon" ng-show="item.saving">
        <i class="fa fa-spinner fa-spin" ></i> 
      </div>

    </div> 
  </div>

</div>
Client Script
function($scope, $rootScope, spUtil) {
  var c = this;

  var el = document.getElementById('favorites');
  var sortable = Sortable.create(el, {

    onSort: function(evt) {

      var payload = {
        action: 'sort', 
        item: evt.item.id, 
        newIndex: evt.newIndex, 
        oldIndex: evt.oldIndex
      };

      c.server.get(payload);

    },

    filter: '.ht-remove',
    onFilter: function (evt) {

      //Expose the spinny icon for the item being removed
      c.data.items.some(function(e){
        if(e.sys_id == evt.item.id) {
          e.saving = true; 
        }
      });

      var payload = {
        action: 'remove', 
        item: evt.item.id
      };

      c.server.get(payload).then(function(){
        for(var f=0; f<c.data.items.length; f++) {
          if(c.data.items[f].sys_id == payload.item) {
            c.data.items.splice(f,1);
            break;
          } 
        }
      });

    },

    handle: '.ht-favorite',
    draggable: '.ht-favorite',
    animation: 150

  });

}

 

Server Script
(function() {

  var favorites = new portalFavorites(); //A reference to the script include

  data.items = favorites.getFavorites();

  if(input.action == 'sort') {
    favorites.reorderFavorites(input.newIndex.toString(), input.oldIndex.toString(), input.item); 
  }

  if(input.action == 'remove') {
    favorites.removeFavorite(input.item);
  }

})();

 

CSS

A little CSS to pull it all together. Remember to add this style sheet to your theme, and give your browser a hard refresh to see the results. Note, everything in this style sheet could go in the CSS field of the Manage Favorites widget, EXCEPT FOR the first selector object, .ht-porta-favorite-toggle. That one definitely needs to be in a portal wide style sheet since that class name comes from a directive that is used by multiple widgets.

.ht-portal-favorite-toggle {
  float: right;
  font-size: 1.5em;
}
.ht-manage-favorites-container {
  min-height: 500px; 
}
.ht-favorites {
  display: grid;
  justify-content: center;
  grid-auto-rows: auto;
  grid-template-columns: repeat(auto-fit, minmax(25rem, 1fr));
  grid-gap: 2rem 2rem;
}
.ht-favorite {
  min-height: 275px;
  margin-bottom: 2rem;
  background: #fff;
  position: relative;
}
.ht-card {
  box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2);
  transition: 0.3s;
  text-align: center;
}
.ht-card:hover {
  box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2);
}
.ht-card-body {
  padding: 2px 16px 12px;
}
.ht-card-image {
  height: 150px;
}
.ht-card-image img{
  height: 100%;
}
.ht-card-icon i {
  font-size: 72px;
  margin-top: 36px;
  background: #666;
  padding: 12px;
  width: 100px;
  border-radius: 50px;
  color: #ededed;
}
.ht-card-body .btn {
  font-size: 1.2rem;
  border-width: 3px;
}
.ht-card-buttons {
  position: absolute;
  left: 0;
  right: 0;
  text-align: center;
  bottom: 1rem;
}
.ht-card-buttons a:last-child {
  margin-left: 1rem;
}
.ht-favorite-loading-icon {
  position: absolute;
  height: 100%;
  width: 100%;
  top: 0;
  left: 0;
}
.ht-favorite-loading-icon i {
  font-size: 72px;
  color: #f0f0f0;
  margin-top: 48px;
  opacity: .5;
}

 

Scripted Menu Item

By using native service portal menu functionality, we have a lot of control over what items appear in our menus. By following the example set forth by the My Requests menu item, we can easily create a My Favorites menu item. But as you can see, the script for favorites is much simpler.

// maximum number of entries in this Menu
var max = 30;

var t = data; // shortcut
t.items = new portalFavorites().getFavorites();

t.items = t.items.slice(0, max); // only want first 30
t.count = t.items.length;

var link = {title: gs.getMessage('Manage My Favorites'), type: 'link', href: '?id=favorites', items: []};
t.items.unshift(link); // put 'Manage My Favorites' first

t.record_watchers = [];
t.record_watchers.push({'table':'u_portal_favorite','filter':'u_user='+gs.getUserID()});

 

In Conclusion

As you can see, there are a lot of moving parts to this. Here’s a quick review of the components we built:

  • Table: u_portal_favorite
  • Script Include: portalFavorites
  • Scripted REST API: portalFavorites
    • Resource: deleteFavorite
    • Resource: insertFavorite
  • Angular Provider Service: portalFavorites
  • Angular Provider Directive: favoriteToggle
  • Widgets:
    • My Search Page
    • My KB Article Page
    • My SC Catalog Item
  • Dependency: sortable
  • CSS: favorites.css
  • Scripted Menu Item: My Favorites

I limited my example to three widgets and a menu. But you can go wild and include this functionality wherever you like, for any other kind of content too.

Note:

One thing I’d like to point out is that the record watch in our scripted menu item doesn’t get along too well with our Manage Favorites widget. The problem occurs when a user has a significant number of favorites (more than 5 for example), and then they reorder the items. This results in a several GlideRecord updates, since we need to update the order field on all those favorites. Subsequently, there is a noticeable ‘loading’ lag while the the record watch in the menu item processes each update.

There are a few solutions for this:

  • Don’t use the record watch in the favorites menu.
  • Create a separate favorites menu widget which allows you more control over how the record watch is processed. You can watch for selective table activity, and respond to it in ways that don’t result in a noticable quirk.
  • Use a different table approach. Instead of having a record for each favorite item, maintain only one record for each user. Then use a single text field that stores the favorites data in JSON format. You’ll obviously need to modify the way you read/write the favorites in processing scripts we detailed above.

However you choose to approach this concept of favorite content, hopefully you know have some new ideas and skills to implement your own version.

 

Aloha!

Jeff