Leveraging Bootstrap and Angular

Saturday, April 13, 2013

The driving force behind my current Javascript experiment was a need to modernize the UI for the Pivot Table. Clearly the old good JPivot-like UI doesn't match modern users expectations: context menus, drag & drop items, etc. So, once my Javascript cellset fulfilled basic functionality (drill up/down and add/remove hierarchies) I've started my work to rejuvenate the UI into a more stylish one.


Bootstrap looked like the obvious choice for styling, and the AngularJS team has a project providing Angular directives for Bootstrap components. So, I made no further research (as I've said in a previous post, I'm pretty new to this Javascript thing, and have no strong opinions on it).

Table Styling: A Piece of Cake

Never underestimate a CSS style sheet, just:

  1. include Bootstrap's stylesheet
  2. remove previous lame styling CSSs
  3. add a handful of classes to the <table> element: table, table-striped, table-bordered
    table-hover, table-condensed

  4. include expand/collapse icons (courtesy of Glyphicons through
    Bootstrap)

and voilá, a modern looking CellSet with row hover highlight for free.


Context Menu



Wouldn't be nice if you could just right-click on a hierarchy element to remove it from the hierachy, remove the whole hierarchy, or add a new one to that axis? That's the first step to get rid of the hierarchy panel in the old JSF version, and there are a couple of things to do to make that happen.

Styling

Bootstrap's toolbox saves our day again: just use its dropdowns to get something like this:

Dynamic Behaviour

I'll start with the context for the menu. I modified the CellSet jQuery component to add a function  olapGetMetadataAt(element) to the DOM element it's binded. This function provides, for each DOM element within the table, metadata information for the represented olap
object. The returned object will have the following properties:

axisOrdinal {number}
for hierarchies and hierarchy members, the ordinal for the axis containing it (0 for columns, 1 for rows)
hierarchy {Object}
hierarchy metadata for hierarchies and hierarchy members
member {Object}
member metadata for hierarchy members
isLeaf {Boolean}
indicates if that member is a leaf in its hierarchy

With this function in place, we can add a ng-click directive to the HTML element containing the cell set, and use the event information to get the metadata for the clicked element. So we modify the div containing the cellset like this:

<div ng-click="showContextMenu($event)" olap-cellset="query"/>

And then the implementation for the click handler in the Angular controller sets a scope property (currentMember) with the clicked on metadata:

$scope.showContextMenu = function(event) {
  var cellSetElem = event.currentTarget
  var metadata = cellSetElem.olapGetMetadataAt(
      angular.element(event.target)
  );
  $scope.currentMember = metadata;
};

The HTML for the menu can now be rendered using standard ng-repeat directives based on this property:

<ul aria-labelledby="dropdownMenu" class="dropdown-menu" context-menu-on="currentMember" role="menu">
  <li>
    <a ng-click="...">Remove <b>{{context.hierarchy.caption}}</b> hierarchy</a></li>
    <li class="dropdown-submenu"><a href="#">Add hierarchy</a>
    <ul class="dropdown-menu">
      <li ng-repeat="hierarchy in $parent.hierarchies">
      <a ng-click="..." href="#">{{hierarchy.caption}}</a>
      </li>
    </ul>
  </li>
</ul>

ng-click directives are used to implement menu actions delegating to controller functions, the Remove menu option handler I've omitted in line 3 of the previous code snippet looks like this:

$parent.removeHierarchy(context.axisOrdinal,context.hierarchy.caption)

And, finally, the custom context-menu-on directive shows/hides the context menu based on the value of the watched context property (passed as the value for the directive attribute)

.directive('contextMenuOn', function () {
    return {
      scope: {context: '=contextMenuOn'},
      link: function (scope, iElement, iAttrs) {
        function hideMenu() {
          scope.$apply(function (scope) {
            scope.context = null
          });
          $('html').unbind('click', hideMenu);
        }

        scope.$watch('context', function (newValue, oldValue) {
          if (newValue && !oldValue) {
            // The menu is closed if the menu is alreadyOpen (oldValue truthy)
            scope.context = newValue;
            iElement.css('position', 'absolute');
            iElement.css('left', window.event.clientX - 5);
            iElement.css('top', window.event.clientY - 5);
            iElement.show();
            $('html').click(hideMenu);
            iElement.click(function (event) {
              event.stopPropagation();
            });
            if ( window.event.stopPropagation ) {
              window.event.stopPropagation();
            } else {
              window.event.cancelBubble = true;
            }
          } else {
            scope.context = null;
            iElement.hide();
          }
        });
      }
    };
  });