Friday, 4 October 2013

Quick Search By Map in a Portlet

Following my previous article about Quick Search By Location in a Portlet, I am now extending the filter by Location to be more visual, introducing the possibility to search the catalog for offerings by selecting a location on a map. Specifically, I am using Google Maps. A useful tutorial on how to use Google Maps can be found at http://www.w3schools.com/googleAPI/

More in general, I will look into integrating a map experience in Saba for:

  • Retrieving geographic coordinates (Latitude and Longitude) for a location; this information is necessary for displaying the Location on the map at the correct point.
  • Displaying a map with the available locations identified by markers; users will then be able to select a location and search for offerings.


Integrating Google Maps

Google Maps exposes a JavaScript API that allows third-party application to display a map to their users and provide services like directions, distance, geolocation, etc.
The first level of integration that I want to present in this article is about retrieving latitude and longitude for a location. This process is called geocoding.  Geocoding is the process of converting addresses (like "1600 Amphitheater Parkway, Mountain View, CA") into geographic coordinates (like latitude 37.423021 and longitude -122.083739), which you can use to place markers or position the map.
By retrieving the coordinates of a location, I'll be able to display location markers on a map later in my search portlet.
But where do we store this information? Best place is to use two custom attributes of the Location component and add Latitude and Longitude as type Real.

Latitude and Longitude as custom attributes of the Location component.

Then we need to introduce a customization in the Location page for allowing to geocode the coordinates from the location name. It is time to use the API of Google Maps then! The API is entirely JavaScript based, so all the interactions between Saba and Google Maps happen client-side, on the browser.
First of all, we need to reference the API in the Saba page by adding this <script> tag:

<script type="text/javascript" src="http://maps.googleapis.com/maps/api/js?v=3.exp&amp;key=<your-API-key>&amp;sensor=true"></script>

You need an API key to specify in the key parameter instead of the <your-API-key> placeholder; details on how to obtain a key are described on the Getting Started web site for Google Maps.
Before being able to use the Google Maps API we need to initialize it. Initialization should happen after the document is loaded. In the Saba view page we need a JavaScript instruction that executes on page load and then creates an instance of the google.maps.Geocoder object.

var geocoder; 
document.addEventListener("DOMContentLoaded",
   function() {
      google.maps.event.addDomListener(window, "load",
         function() {
            geocoder = new google.maps.Geocoder();
         });
   }, false);

To the page document, I'm adding a listener for the DOMContentLoaded event. This event is triggered when the content of the page is fully loaded. The function associated with this listener, in turn, adds another listener to the window's load event in the google.maps.event collection. We need to be sure that the Google Maps API is fully loaded before being able to use it, and due to the disconnected nature of the web, using delegate JavaScript functions is the only way to control asynchronous event handling. Once the API is fully loaded, we can create an instance of the Geocoder object.

The Google Maps API is now ready and we have a geocoder object that we can use for obtaining latitude and longitude coordinates of a location, from its name. Actually, a bit more than just the location name, for avoiding confusion and limit the number of potential matches. We will use city, state and country for identifying a location precisely.

Customized Location page for retrieving Latitude and Longitude.

The assumption here is that locations entered in Saba have the City, State and Country field specified.
By clicking on the Find Coords button, the findCoords() JavaScript function is called, which obtains a list of matches for the indicated location.

function findCoords(city, state, country)
{
   geocoder.geocode(
      {"address": city + ", " + state + ", " + country},
      function (results, status) {
         if (status == google.maps.GeocoderStatus.OK) {
            var location = results[0].geometry.location;
            setField("custom1", location.lat());
            setField("custom2", location.lng());
         }
      });
}

City, State and Country are concatenated in a single address line. The geocode method of the geocoder object accepts two parameters:

  1. address: The full detailed location to search for.
  2. function: JavaScript function called asynchronously at completion of the geocoding; this function has two parameters:
    • results: Collection of matches for the search string.
    • status: Status of the geocoding operation; it can assume one of the values in the GeocodeStatus object.

On successful completion of the geocoding, I take the first match contained in the results collection (index = 0) and specifically the geometry object, which is an instance of GeocoderGeometry. This object contains a location property of type LatLng, from which I can obtain the values of Latitude and Longitude to assign to the relative custom attributes of my location in Saba.

Repeat the geocoding for a few more locations and you will have a set of locations and their geographical coordinates ready to use in the portlet.

Australian locations in Saba with geographical coordinates.

A map in a portlet

Once the back-end information is ready, i.e. the list of locations to display on the map in the portlet, it is time to implement the portlet itself. This portlet is similar to the one presented in my post about a Quick Search by Location, with the obvious difference that the selection of the location happens with a map rather than with a drop-down list. Much better, isn't it?
To accommodate this enhancement, we need to extend our model too, to take into consideration the additional information needed for displaying locations on a map. Specifically, we need the Latitude and Longitude attributes of each location. We would also add the State and Country information, to be used for displaying additional information to the user on the map.

This is the final layout of the portlet that we want, with a map embedded within it, available locations displayed on the map and identified by markers, and the possibility for the user to select a location and  search for offerings available there.

Location "Melbourne" selected on the map in the portlet.

To meet this requirement, we need to extend the XML model document as follows:

<Locations>
   <Location>
      <Id>locat000000000001023</Id>
      <Name>Adelaide</Name>
      <State>SA</State>
      <Country>Australia</Country>
      <Lat>-34.867905</Lat>
      <Lng>138.611316</Lng>
   </Location>
   <Location>
      <Id>locat000000000001026</Id>
      <Name>Brisbane</Name>
      <State>QLD</State>
      <Country>Australia</Country>
      <Lat>-27.469973</Lat>
      <Lng>153.023579</Lng>
   </Location>
   ...
</Locations>

With this data structure, which we obtain from a portlet command that accesses the database, retrieves this data and returns it to the Saba model page in XML format, I can then build the map and display the locations as markers.
As seen before for the Geocoder, we need to invoke the Google Maps API via JavaScript, initialize it and, this time, create an instance of the google.maps.Map object. Then, we need to display the map on the screen, inside the portlet.

Let's start from the Saba view page: we need a place where to display the map inside the portlet. Simply, a <div> would fit.

<div id="googleMap"></div>

When initializing the map, a reference to this <div> is obtained by Id, and passed to the constructor of the google.maps.Map object.

var map = new google.maps.Map(
   document.getElementById("googleMap"),
   {
      center: new google.maps.LatLng(centerX, centerY),
      zoom: 3,
      mapTypeId: google.maps.MapTypeId.ROADMAP
   }
);

The Map object accepts also a second parameter of type MapOptions. This is a collection of attributes used to initialize the layout of the map on screen. Very important to set are:

  • center: The initial center point of the map; I'm passing this information in the centerX and centerY coordinates and point to the center of Australia, in my case.
  • zoom: The initial map zoom level; a value of 3 identifies a country level, approximately.
  • mapTypeId: The initial type of map to display (street map, satellite, terrain or hybrid); values are defined in the MapTypeId collection.

Next, we need to add the locations to the map. We do this in two steps:

  1. Define a JavaScript array of location objects, as defined in the XML model, with all the available information.
  2. Add a marker to the map for each location.
Let's see this in order. The JavaScript object is built dynamically for each <Location> node defined in the XML model. 


var locations = [
   <xsl:for-each select="Locations/Location">
   {
      id: '<xsl:value-of select="Id"/>',
      name: '<xsl:value-of select="Name"/>',
      state: '<xsl:value-of select="State"/>',
      country: '<xsl:value-of select="Country"/>',
      lat: <xsl:value-of select="Lat"/>,
      lng: <xsl:value-of select="Lng"/>
   },
   </xsl:for-each>
   {}];



The trick is in blending the use of server-side XSLT instructions, which are processed by the Saba rendering engine, and JavaScript syntax for building an array of objects. For each location object in the array, I add a marker to the map at the indicated coordinates, by calling the addLocation() function with the index number of each location in the array.

function addLocation(i)
{
   var marker = new google.maps.Marker(
   {
      position: new google.maps.LatLng(
         locations[i].lat, locations[i].lng),
      map: map,
      title: locations[i].name
   });
 
   var infoWindow = new google.maps.InfoWindow(
   {
      content:
         "<div class='info'>" + locations[i].name + ", " +
         locations[i].state + "<br/>" +
         "<a href=\"javascript:selectLocation('"
         locations[i].name + "');\">Select</a></div>"
   });
   
   google.maps.event.addListener(marker, 'click',
      function () {
         infoWindow.open(map, marker);
      });
}

The google.maps.Marker object represents a marker (or pin) on the map. A marker is identified by these attributes:

  • position: Latitude and Longitude of the location.
  • map: The JavaScript object that represents the map, as created during the initialization of the API.
  • title: The tooltip to display when hovering with the mouse on the marker.
The tooltip, however, cannot contain formatted value and is limited in size. The google.maps.InfoWindow object, instead, allows to define larger pop-up windows that can be displayed when clicking on the marker, as represented in the last picture above. An InfoWindow has one attribute:
  • content: The HTML of the content of the pop-up window.
In our case, I have defined a <div> containing two lines: first line is the name of the location and its state separate by comma. In the second line I display a Select link that calls the JavaScript function selectLocation() passing the location name.
To associate the InfoWindow with the marker, I just added a new event listener on the marker object for the click event. The listener calls the open() method of the infoWindow object for the pertinent map and marker.

The last thing to do now is to submit the location information back to Saba and perform the search for offerings in that location. We have a name, we need the locationId.


function selectLocation(locName)
{
   wdkSHF("locationId", getLocationId(locName));
   wdkFrameworkSubmit("/platform/presentation/portal/portalDriver.rdf");
}

function getLocationId(locName)
{
   for (var i in locations) {
      if (locations[i].name == locName) {
         return locations[i].id;
      }
   }
   return "";
}


The selectLocation() function is called when clicking on the Select link in the InfoWindow displayed for each marker on the map. This function obtains the locationId given the location name, and assigns it to a hidden field in the form by using the wdkSHF() function. The wdkSHF() function is part of the Saba JavaScript API. If you are wondering what SHF is for, my best guess is that it stands for Set Hidden Field.
Using a hidden field is a way to pass parameters to a Saba page for values that are not displayed on screen. In our portlet we are displaying locations on a map, but Saba needs their internal ids to work out the search. Ids are in the locations array, and this is what the getLocationId() function does, it loops the locations array searching for the first entry matching the location name, and then returns the value of its id attribute.

The hidden field has been previously defined in the Saba model page using the <wdk:hiddenField> tag.

<wdk:hiddenField name="locationId"></wdk:hiddenField>

What is the closest location to where I am

The last piece of integration of Google Maps in a portlet that I want to show you today is a way to find the closest location, among those available, to where you are.
Let's start from this last point, actually... where am I? Geolocation is the answer, or better the technology to use for identifying your current location in a browser that supports it. This has nothing to do with Google Maps, actually. It is a functionality of modern internet browsers that expose the navigator.geolocation object.


if (navigator.geolocation) {
   navigator.geolocation.getCurrentPosition(
   function (position) {
      var from = new google.maps.LatLng(
         position.coords.latitude, position.coords.longitude);
 
      ...
   },
   function() {
      alert("Current location not found");
   });
}

First of all, let's check whether the geolocation object exists, as not all older browsers support it. The getCurrentPosition() method retrieves your current position, not surprisingly, asynchronously. There are two function parameters to this method. The first function is executed when the position is found successfully, and the second one in case of error. If found, the coordinates of your current position are in the position.coords object as latitude and longitude. I'll convert these into a LatLng object for Google Maps, to be used later a starting point for calculating the distance of the other locations from my current position.

var service = new google.maps.DistanceMatrixService();
service.getDistanceMatrix(
{
   origins: [from],
   destinations: [to],
   travelMode: google.maps.TravelMode.DRIVING,
   unitSystem: google.maps.UnitSystem.METRIC,
   avoidHighways: false,
   avoidTolls: false
}, 
function (response, status) {
   if (status == google.maps.DistanceMatrixStatus.OK) {
      var results = response.rows[0].elements;
      setClosestLocation(index, results[0].distance);
   }
else {
   // Distance not found
}});

There is something to explain here. Distances between points are not exact and may vary according to different conditions. I have made some assumptions here: I'm using driving travel mode among the options available in the google.maps.TravelMode collection (at the time of writing, other options are Bicycling, Transit and Walking). I also want to use the Metric unit system, over the Imperial one. That is kilometers instead of miles, as defined in the google.map.UnitSystem collection. I also do not want to avoid highways and toll roads, get me there as quickly as possible! As you figured it out, the distance service works by calculating the direction to get from point A to B. Actually, the directions, more than one. Hence, results can be more than a possible value only, but for the sake of keeping this simple, I will take the first entry only.

The distance service is called for each location in the locations array, the distance saved in a global variable and updated every time a shortest location is found. At the end, the location with the shortest distance wins, representing the location closest to my current position.
The map is then centered and zoomed on the closest location, and the distance displayed in the portlet in a <div> with id = message.

The closest location found from my current position.

The JavaScript code is as follows.

var closestDistance;
var closestLocation = -1;

function setClosestLocation(index, distance)
{
   if (closestLocation == -1 || 
      distance.value < closestDistance.value) {
      closestLocation = index;
      closestDistance = distance;
   }
}

function displayClosestLocation()
{
   if (closestLocation != -1) {
      var messageDiv = document.getElementById("message");
      messageDiv.innerText = "Your closest location is "
         locations[closestLocation].name + " ("
         closestDistance.text + ")";
      map.setCenter(new google.maps.LatLng(
         locations[closestLocation].lat, 
         locations[closestLocation].lng));
      map.setZoom(10);
   }
}

All done! If you have any suggestion on how to improve the use of maps in this portlet on in general in Saba, please let me know and I'll be happy to explore your suggestion and write about it!

Monday, 30 September 2013

Transcript Portlet

Saba Learning 5.x has a portlet for In-Progress activities, which I also have enhanced as described in this article to show the certification structure. However, there is no Transcript portlet for a quick list of your completed courses, with the possibility to re-launch content with just one click. Quickly accessible online content, even for completed courses, is a major commodity for reference and for just a refresh, so why "burying" this functionality when we can make it available on the front page of each student?

The Transcript portlet in three steps

Building a brand new Transcript portlet is as easy as an ABC... or should I say as an MCM?

  • The Manager, responsible for defining the controller page that displays the information in the portlet.
  • The Command, responsible for retrieving information from the database and presenting it in XML format.
  • The Model (and View and Controller), which ultimately renders the XML information in a portlet on the screen.
If you have followed my past tutorials on how to create a custom portlet, you should be already familiar with these steps. In this post, I will spend more time describing the business logic for implementing the following requirements for the Transcript portlet:
  1. The portlet should display a list of completed courses, from the most recent to the oldest.
  2. For each entry, Title, Completion Date and an Action link should be displayed.
  3. The Action link should be:
    • Launch: If there are content modules defined for the offering; by clicking on this link, the user is able to open the first existing content.
    • View Details: If there are no content modules defined; the user will be redirected to the details page of the offering.
  4. The number of completed courses displayed by the portlet should be limited to a specific value entered as a parameter in the portlet definition.
The Transcript Portlet

The Portlet Manager

The portlet manager class is TranscriptPortletManager. The overridden init() method registers the  transcriptPortlet.rdf controller page and the NumberOfTranscripts parameter. That's it, job done!

public class TranscriptPortletManager
   extends AbstractPortletPageManager
   implements PortletPageManager
{
   @Override
   protected void init() throws SabaException
   {
      registerPage("showDefaultDisplay"
         "/custom/portlets/transcriptPortlet.rdf");
         
      ParameterDetail recordNo = 
         new ParameterDetail("NumberOfTranscripts"
            TypeInfo.createStringType());
      registerParameter(recordNo);
   }
}

The Command

The purpose of the portlet command TranscriptPortletCommand is to build an XML representation of the information needed by the Model page for displaying transcript entries within the portlet. The information we need is summarised in the following XML structure, with one or more <Transcript> nodes for as many completed courses exists for the given user. All those *Id values for registration, offering, course, subscription, context, order and order item are required (don't ask why...) by the content launch and the offering details display pages, so I am making sure that I bring with me these pieces of information in my model.

<Result>
   <Transcript>
      <Id></Id>
      <RegistrationId></RegistrationId>
      <OfferingId></OfferingId>
      <CourseId></CourseId
      <SubscriptionId></SubscriptionId>
      <ContextId></ContextId>
      <OrderId></OrderId>
      <OrderItemId></OrderItemId>
      <Title></Title>
      <CompletionDate></CompletionDate>
      <HasContentModules></HasContentModules>
   </Transcript>
</Result>

The Java code is a bit long here, but highly readable in its flow. Briefly, what it does is:
  1. Add the input parameter NumberOfTranscripts in the constructor.
  2. Read the value of the parameter in the doExecute() method and start visiting (aka building) the XML document.
  3. Retrieve a list of transcript using the Saba finder LearningFinder.kLearnerTranscriptFinder. This finder returns a collection of ArrayList objects, containing (most of) all the required information.
  4. The TranscriptEntry class represents the Java object of a transcript record, to be serialised in to the XML document. I have implemented attributes as public fields instead of having setters and getters for simplicity of use. Aah how much I miss C#-like properties in Java...
  5. The getTranscripts() method eventually sorts results by date using a custom comparator dateComparator and returns a subset of the result collection up to the indicated number of records to display.

public class TranscriptPortletCommand
   extends SabaWebCommand
{
   public TranscriptPortletCommand()
   {
      addInParam("NumberOfTranscripts", String.class, "...");
   }

   public void doExecute(HttpServletRequest request,
      IXMLVisitor visitor)
   {
      String recordStr = (String)getArg("NumberOfTranscripts");
      int recordNum = 10; // default to 10
      try {
         recordNum = Integer.parseInt(recordStr);
      } catch (NumberFormatException ex) {
         ...
      } finally {
         visitTranscript(visitor, recordNum);
      }
   }

   private void visitTranscript(IXMLVisitor visitor,
      int recordNum)
   {
      visitor.beginVisit(null, "Result"nullnullnull);
      List<TranscriptEntry> results = getTranscripts(recordNum);
      
      for (TranscriptEntry entry : results) {
         visitor.beginVisit(null"Transcript"nullnullnull);
         visitor.visit(null, "Id", entry.Id);
         ... // repeat for all the other attributes
         visitor.endVisit(null, "Transcript");
      }
 
      visitor.endVisit(null, "Result");
   }

   private List<TranscriptEntry> getTranscripts(int recordNum)
   {
      List<TranscriptEntry> results = 
         new ArrayList<TranscriptEntry>();
      addCompletedCourses(locator, results);
      Collections.sort(results, dateComparator);
      return results.size() > recordNum ? 
          results.subList(0, recordNum) :
          results;
   }

    private void addCompletedCourses(
        List<TranscriptEntry> results)
   {
      ServiceLocator locator = getServiceLocator();
      FinderManager finder = (FinderManager)
         locator.getManager(Delegates.kFinder);
      DriverData driverData = finder.getDefaultDriverData(
         LearningFinder.kLearnerTranscriptFinder);
      driverData.addCondition("learnerId",
         FinderOperator.kEqual, 
         locator.getSabaPrincipal().getID());  
      FinderCollection collection = finder.findAll(
         LearningFinder.kLearnerTranscriptFinder, driverData);
      Iterator iter = collection.iterator();
      while (iter.hasNext()) {
         ArrayList list = (ArrayList)iter.next();
         TranscriptEntry entry = new TranscriptEntry();
         entry.Id = list.get(0).toString();
         ... // repeat for all the other attributes
         results.add(entry);
      }
   }

   private class TranscriptEntry
   {
      public String Id;
      public String RegistrationId;
      public String OfferingId;
      public String CourseId;
      public String SubscriptionId;
      public String ContextId;
      public String OrderId;
      public String OrderItemId;
      public String Title;
      public Date CompletionDate;
      public boolean HasContentModules;
   }

   private Comparator<TranscriptEntry> dateComparator = 
      new Comparator<TranscriptEntry>()
   {
      public int compare(TranscriptEntry entry1, 
         TranscriptEntry entry2)
      {
         // sort from most recent first
         return entry2.CompletionDate 
            .compareTo(entry1.CompletionDate);
      }
   };
}

The Model

The transcriptPortlet.xml page represents the Model page. Along with the Controller (.rdf extension) and the View (.xsl extension), it is a key element of the Saba WDK for displaying information in a browser.
First of all, we need an instance of the TranscriptPortletCommand object, to use for executing the command via the <wdktags:execute> tag. It is important that this WDK tag is nested inside the <wdk:model> tag, as the output of the command object is an XML document representing the model.

<xsp:logic>
TranscriptPortletCommand command = new TranscriptPortletCommand();
</xsp:logic>

<wdk:model>
   <wdktags:execute commandObj="command">
      <param name="NumberOfTranscripts" 
          expr="NumberOfTranscripts" type="String" mode="in" /> 
   </wdktags:execute>
</wdk:model>

Among the widgets, a <wdk:table> displays data in a table format. The <wdktags:attachTo> indicates where, in the XML model, to go and find the data to display (i.e. which is the root node of the XML document generated by the command), and the <wdktags:nodeRef> tags retrieve the value for the indicated node.
We also need XML node values in the <xsp:logic> section, as we need to decide whether the portlet should display the Launch or the Details link. Some simple Java code can be embedded in the model page (do not abuse of it, business logic should be always implemented within Java classes). Based on the values of the HasContentModules node (true or false), MaxAttempts and LearnerAttempts, either link is displayed.
The WDK tag for defining a link widget is <wdk:frameworkLink>, which contains a reference to the model page to invoke when clicking on it, in the <mainPage> tag, and one or more parameters to pass to that page, as defined in the <field> tag.

<wdk:widgets>
   <wdk:table name="transcript">
      <wdktags:attachTo path="Result"/>
<row path="Transcript">
<column>
            <wdktags:nodeRef path="Title"/>
         </column>
<column>
            <wdktags:nodeRef path="CompletionDate"/></column>
<column>
<xsp:logic>
String hasContentModules = WDKDomUtils
            .getNodeTextValue(wdkwidgetNode, "HasContentModules",                wdkWidgetMaster.getXPathCache());
String maxAttemptsStr = WDKDomUtils
            .getNodeTextValue(wdkwidgetNode, "MaxAttempts",
               wdkWidgetMaster.getXPathCache());
String learnerAttemptsStr = WDKDomUtils
            .getNodeTextValue(wdkwidgetNode, "LearnerAttempts"
               wdkWidgetMaster.getXPathCache());
int maxAttempts = Integer.parseInt(maxAttemptsStr);
int learnerAttempts = 
            Integer.parseInt(learnerAttemptsStr);
boolean showLaunchLink = 
            hasContentModules.equals("true") &amp;&amp; 
            (maxAttempts == 0 || 
             maxAttempts &gt; learnerAttempts);
if (showLaunchLink)
{
</xsp:logic>
<wdk:frameworkLink name="wbtLaunch">
            <mainPage>learningOfferingDetails.xml</mainPage>
<field>
<name>offeringId</name>
<value>
<wdktags:nodeRef path="OfferingId"/>
</value>
</field>
            ...
</wdk:frameworkLink>
<xsp:logic>
}
else
{
</xsp:logic>
<wdk:frameworkLink name="offeringDetails">
         ...
</wdk:frameworkLink>
<xsp:logic>
}
</xsp:logic>
      </row>
   </wdk:table>
</wdk:widgets>

That's everything you need for creating a Transcript portlet. Hope you find it useful!


Tuesday, 24 September 2013

Quick Search By Location in a Portlet

Looking for a simple portlet for quick search in the catalog by location? Look no further, Saba Guru has created this portlet for you! The portlet is a simplified version of the Catalog Search, offering a list of locations and the next 5 offerings available for the selected location. In this first implementation of the portlet, I'll present the list of locations in a drop-down list. In the next article, locations will be displayed on a map. Because the search in the catalog is specific to offerings available by location, WBT (Web Based Training) and any other type of offerings without a location assigned are automatically excluded by the result.

A list of Locations

Locations will be displayed in a drop-down list, at least initially, and displayed in the portlet as in the screenshot below.

List of Locations in the portlet

By selecting a location in the list, the portlet refreshes and then displays the search result, that is a table of the next five offerings available in the selected location.


For each offering, it is possible to register to it by clicking on the Register link under the Actions column.

Step by step, this is the way I have implemented the Location drop-down list in Saba:


1) Any portlet always starts with a manager. I am assigning the name QuickSearchByLocationPortletPageManager to the manager class. The fully qualified name of the manager class, i.e. including also the package name, will be entered in Saba when configuring the portlet on the portal.


public class QuickSearchByLocationPortletPageManager
   extends AbstractPortletPageManager
   implements PortletPageManager
{
   @Override
   protected void init()
   {
      registerPage("showDefaultDisplay"
         "/custom/portlets/quickSearchByLocation.rdf");
   }
}

2) The manager refers to the quickSearchByLocation.rdf page for the layout of the portlet on screen. This is the controller page, we also have the model quickSearchByLocation.xml and the view quickSearchByLocation.xsl.
In the model we define the logic for retrieving the list of locations, which is encapsulated in a portlet command. A portlet command is simply an extension of a regular SabaWebCommand that generates the result (the list of locations) and returns it to the model page as an XML document.

public class QuickSearchByLocationPortletCommand
   extends SabaWebCommand
{
   public void doExecute(HttpServletRequest request, IXMLVisitor visitor)
   {
      visitor.beginVisit(null, "Result"nullnullnull);
      visitLocations(visitor);
      visitor.endVisit(null, "Result");
   }

QuickSearchByLocationPortletCommand is the portlet command class that builds a model document with the list of the available locations, in alphabetical order. For each location, ID and Name attributes are retrieved and passed to the model page using the following XML format:

<Result>
   <Locations>
      <Location>
         <Id></Id>
         <Name></Name>
      </Location>
   </Locations>
</Result>

The structure of the <Locations> tag is built by the visitLocations() method.

protected void visitLocations(IXMLVisitor visitor)
{
   visitor.beginVisit(null, "Locations"nullnullnull);

   List<ArrayList<String>> locations = getLocations();

   for (ArrayList<String> loc : locations) {
      visitor.beginVisit(null, "Location"nullnullnull);
      visitor.visit(null, "Id", loc.get(0));
      visitor.visit(null, "Name", loc.get(1));
      visitor.endVisit(null, "Location");
   }

   visitor.endVisit(null, "Locations");
}

The getLocations() method retrieves a list of ArrayList containing Id and Name at position 0 and 1 respectively for each location, and with this information builds the XML tree aforementioned.

3) With the XML model ready, it is now the turn of the model page to map it to a widget for display inside the portlet. A drop-down list control is rendered in Saba using the <wdk:list> widget with type = select. I call this widget locationId, as this will be the name of the parameter passed to the portlet command again when searching for offerings in the indicated location, as we will see later.

<wdk:list name="locationId">
   <type>select</type>
   <wdktags:attachTo path="Result/Locations"/>
   <wdktags:repeat name="options" path="Location">
      <option>
         <value>
            <wdktags:nodeRef source="options" path="Id"/>
                      </value>
         <text>
            <wdktags:nodeRef source="options" path="Name"/>
         </text>
      </option>
   </wdktags:repeat>
       <event>
      <type>action</type>
      <action>
         <type>submit</type>
         <href>
            /platform/presentation/portal/portalDriver.rdf
         </href>
      </action>
   </event>
</wdk:list>

The list is "attached to" the Result/Locations path using the <wdktags:attachTo> tag. This means that single locations are listed under this parent node in the XML model, and each location is identified by the Location tag, as defined in the <wdktags:repeat> tag. This tag, as the name implies, repeats each occurrence of the defined path and displays multiple options for the drop-down list. The value of the option is assigned as the content of the Id tag in the XML model, and the text as the Name tag.
When an option is selected, the event defined in the <event> tag is triggered, which basically is a refresh of the portal driver page that contains the portlet.

4) When the portal page is refreshed, the locationId parameter identifying the selected location is passed to the portlet command for performing the search in the catalog.

<xsp:logic>
   QuickSearchByLocationPortletCommand command =
      new QuickSearchByLocationPortletCommand(); 
</xsp:logic>
<wdk:model>
   <wdktags:execute commandObj="command">
      <param name="locationId" expr="locationId" type="String" 
         mode="in" /> 
   </wdktags:execute>
</wdk:model>

An instance of the QuickSearchByLocationPortletCommand object is created and then used to execute the command as specified in the <wdktags:execute> tag, by passing the locationId parameter.

Performing the search in the catalog by location

Continuing along the step by step implementation, it is now the turn of performing the search in the catalog for offerings in the selected location.

5) We are now back in the portlet command. First of all we need to register the input parameter, and we do so in the constructor of the command class.

public QuickSearchByLocationPortletCommand()
{
   addInParam("locationId", String.class, "Id of the Location");
}

6) We then retrieve the value of this parameter in the doExecute() method and then pass it to the Saba API that performs the search in the catalog.

protected void visitOfferings(IXMLVisitor visitor,
   String locationId)
{
   visitor.beginVisit(null, "Offerings"nullnullnull);
   Iterator<OfferingResult> offerings = getOfferings(locationId);
   while (offerings.hasNext()) {
      OfferingResult result = offerings.next();
 
      visitor.beginVisit(null, "Offering"nullnullnull);
 
      visitor.visit(null, "Id",
         result.getPrimaryKey().toString());
      visitor.visit(null, "OfferingId",
         result.getPartNo());
      visitor.visit(null, "Title",
         result.getOffTemp().getDisplayName());
      visitor.visit(null, "Delivery"
         result.getDelivery().getDisplayName());
      visitor.visit(null, "StartDate",       
         result.getStartDate());
 
      visitor.endVisit(null, "Offering");
   }

   visitor.endVisit(null, "Offerings");
}

The visitOfferings() method is basically building the XML model with the list of offerings to display in the portlet, for the selected location. The XML model has the following format, with the <Offering> tag repeated for each occurrence. The <Offerings> node is contained within the <Result> root node, as we have seen previously for the <Locations> node.

<Offerings>
   <Offering>
      <Id></Id>
      <OfferingId></OfferingId>
      <Title></Title>
      <Delivery></Delivery>
      <StartDate></StartDate>
   </Offering>
</Offerings>

7) The final touch is with displaying the offering list in the portlet. I decided to use the <wdk:table> widget for displaying items in a table format. The full description of the syntax of this widget can be found in the Saba Application Developer Guide, so for now I will describe only the significant parts that concern this portlet.

<wdk:table name="finderResults">
   <wdktags:attachTo path="Result/Offerings"/>
   <head>
      <column>Title</column>
      <column>Offering ID</column>
      <column>Delivery Type</column>
      <column>Start Date</column>
      <column>Actions</column>
   </head>
   <row path="Offering">
      <column><wdktags:nodeRef path="Title"/></column>
      <column><wdktags:nodeRef path="OfferingId"/></column>
      <column><wdktags:nodeRef path="Delivery"/></column>
      <column><wdktags:nodeRef path="StartDate"/></column>
      <column>
         <wdk:link name="register"></wdk:link>
      </column>
   </row>
</wdk:table>

The widget is attached to the Result/Offerings path in the XML model, where all the offerings can be found. For each row identified by the Offering node, the following columns are displayed:
  1. Title
  2. Offering ID
  3. Delivery Type
  4. Start Date
The Actions column contains a link to the registration page for the offering (link not detailed in the code above). For simplicity of visualization of the code snipped above, I hard-coded the name of the columns in the <column> tag within <head>. In a real scenario, no hard-coded labels should be used, but a reference to the label bundle should be used instead. This will facilitate internationalization of the portlet as well.
The value of the each column, as in the model, is retrieved from the XML node using the <wdktags:nodeRef> tag for each <column> within <row>.

Wrap up

Lot of Java and XML code in this post, but hope it's been useful to figure out the necessary steps for creating a fully functional portlet that interacts with the database. I saved you the use of the Saba API for actually retrieving information (locations and offerings) from the database, as this is not specific to a portlet implementation. Feel free to contact me if you want to discuss these details further.

In the next article, I will expand the functionality of this portlet by introducing a visualization of locations based on Google Maps rather than in a drop-down list. The opportunity is good to show how to integrate Saba resources (locations in this case) with external web sites and display everything within a portlet!

Happy search!