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!