Add sort functionality to table Component

Added optional sort function to the shared table component.
Table sort is not implemented on any existing table, but will
be used in the redesigned Event Log table.

- Changed table model attribute to two separate properties
  data and header to take advantage of $onChanges lifecycle
  hook
- Update local user table and user role table to account for
  these updates

Tested on Chrome, Safari, Firefox, Edge, IE

Signed-off-by: Yoshie Muranaka <yoshiemuranaka@gmail.com>
Change-Id: I4fe68e78ae9d1228d7d9350538f61076036b1089
diff --git a/app/assets/icons/icon-arrow--down.svg b/app/assets/icons/icon-arrow--down.svg
new file mode 100644
index 0000000..64e2e49
--- /dev/null
+++ b/app/assets/icons/icon-arrow--down.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 30"><g><path d="M7.5 30l7.5-7.5-1.76-1.76-4.49 4.47V0h-2.5v25.21l-4.49-4.47L0 22.5 7.5 30z"/></g></svg>
\ No newline at end of file
diff --git a/app/assets/icons/icon-arrow--up.svg b/app/assets/icons/icon-arrow--up.svg
new file mode 100644
index 0000000..fce3343
--- /dev/null
+++ b/app/assets/icons/icon-arrow--up.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 30"><g><path d="M7.5 0L0 7.5l1.76 1.76 4.49-4.47V30h2.5V4.79l4.49 4.47L15 7.5 7.5 0z"/></g></svg>
\ No newline at end of file
diff --git a/app/common/components/table/table.html b/app/common/components/table/table.html
index ceedd91..6b2420d 100644
--- a/app/common/components/table/table.html
+++ b/app/common/components/table/table.html
@@ -1,17 +1,49 @@
 <table class="bmc-table {{$ctrl.size}}">
-  <thead>
-    <!-- Header row -->
-    <tr>
-      <th ng-repeat="header in $ctrl.model.header"
+  <thead class="bmc-table__head">
+    <!-- Header row (non-sortable) -->
+    <tr ng-if="!$ctrl.sortable">
+      <th ng-repeat="headerItem in $ctrl.header"
           class="bmc-table__column-header">
-        {{header}}
+        {{headerItem.label}}
+      </th>
+    </tr>
+    <!-- Header row (sortable) -->
+    <tr ng-if="$ctrl.sortable">
+      <th ng-repeat="headerItem in $ctrl.header track by $index"
+          class="bmc-table__column-header">
+        <span ng-if="!headerItem.sortable">
+          {{headerItem.label}}
+        </span>
+        <span ng-if="headerItem.sortable"
+              ng-click="$ctrl.onClickSort($index)"
+              class="bmc-table__column-header--sortable">
+          {{headerItem.label}}
+          <!-- Sort icons -->
+          <button class="sort-icon"
+                  type="button"
+                  aria-label="sort {{headerItem.label}}">
+            <icon file="icon-arrow--up.svg"
+                  ng-if="$index === $ctrl.activeSort"
+                  ng-class="{
+                    'sort-icon--descending': !$ctrl.sortAscending,
+                    'sort-icon--ascending' : $ctrl.sortAscending }"
+                  class="sort-icon--active"
+                  aria-hidden="true"></icon>
+            <span ng-if="$index !== $ctrl.activeSort"
+                  class="sort-icon--inactive"
+                  aria-hidden="true">
+                <icon file="icon-arrow--up.svg"></icon>
+                <icon file="icon-arrow--down.svg"></icon>
+            </span>
+          </button>
+        </span>
       </th>
     </tr>
   </thead>
-  <tbody>
+  <tbody class="bmc-table__body">
     <!-- Data rows -->
-    <tr ng-if="$ctrl.model.data.length > 0"
-        ng-repeat="row in $ctrl.model.data"
+    <tr ng-if="$ctrl.data.length > 0"
+        ng-repeat="row in $ctrl.data"
         class="bmc-table__row">
       <!-- Row item -->
       <td ng-repeat="item in row.uiData track by $index"
@@ -28,7 +60,7 @@
       </td>
     </tr>
     <!-- Empty table -->
-    <tr ng-if="$ctrl.model.data.length === 0">
+    <tr ng-if="$ctrl.data.length === 0">
       <td>No data</td>
     </tr>
   </tbody>
diff --git a/app/common/components/table/table.js b/app/common/components/table/table.js
index cf2b797..24aa0c9 100644
--- a/app/common/components/table/table.js
+++ b/app/common/components/table/table.js
@@ -6,57 +6,66 @@
    * bmcTable Component
    *
    * To use:
-   * The <bmc-table> component expects a 'model' attribute
-   * that will contain all the data needed to render the table.
    *
-   * The component accepts a 'row-actions-enabled' attribute,
-   * to optionally render table row actions. Defaults to false.
-   * Pass true to render actions. Row actions are defined in
-   * model.data.actions.
-   *
-   * The component accepts a 'size' attribute which can be
-   * set to 'small' which will render a smaller font size in the
-   * table.
-   *
-   * The model object should contain 'header' and 'data'
-   * properties.
-   *
-   * model: {
-   *    header: <string>[],  // Array of header labels
-   *    data: <any>[],       // Array of each row object
-   * }
-   *
-   * The header property will render each label as a <th> in the table.
-   *
-   * The data property will render each item as a <tr> in the table.
-   * Each row object in the model.data array should also have a 'uiData'
+   * The 'data' attribute should be an array of all row objects in the table.
+   * It will render each item as a <tr> in the table.
+   * Each row object in the data array should also have a 'uiData'
    * property that should be an array of the properties that will render
    * as each table cell <td>.
-   * Each row object in the model.data array can optionally have an
+   * Each row object in the data array can optionally have an
    * 'actions' property that should be an array of actions to provide the
    * <bmc-table-actions> component.
    *
-   * The 'rowActionsEnabled' property will render <bmc-table-actions> if set
-   * to true.
+   * data = [
+   *  { uiData: ['root', 'Admin', 'enabled' ] },
+   *  { uiData: ['user1', 'User', 'disabled' ] }
+   * ]
+   *
+   * The 'header' attribute should be an array of all header objects in the
+   * table. Each object in the header array should have a 'label' property
+   * that will render as a <th> in the table.
+   * If the table is sortable, can optionally add 'sortable' property to header
+   * row object. If a particular column is not sortable, set to false.
+   *
+   * header = [
+   *  { label: 'Username' },
+   *  { label: 'Privilege' }
+   *  { label: 'Account Status', sortable: false }
+   * ]
+   *
+   * The 'sortable' attribute should be a boolean value. Defaults to false.
+   * The 'default-sort' attribute should be the index value of the header
+   * obejct that should be sorted on inital load.
+   *
+   * The 'row-actions-enabled' attribute, should be a boolean value
+   * Can be set to true to render table row actions. Defaults to false.
+   *  Row actions are defined in data.actions.
+   *
+   * The 'size' attribute which can be set to 'small' which will
+   * render a smaller font size in the table.
    *
    */
 
   const TableController = function() {
+    this.sortAscending = true;
+    this.activeSort;
+
     /**
-     * Init model data
-     * @param {any} model : table model object
-     * @returns : table model object with defaults
+     * Sorts table data
      */
-    const setModel = (model) => {
-      model.header = model.header === undefined ? [] : model.header;
-      model.data = model.data === undefined ? [] : model.data;
-      model.data = model.data.map((row) => {
-        if (row.uiData === undefined) {
-          row.uiData = [];
+    const sortData = () => {
+      this.data.sort((a, b) => {
+        const aProp = a.uiData[this.activeSort];
+        const bProp = b.uiData[this.activeSort];
+        if (aProp === bProp) {
+          return 0;
+        } else {
+          if (this.sortAscending) {
+            return aProp < bProp ? -1 : 1;
+          }
+          return aProp > bProp ? -1 : 1;
         }
-        return row;
       })
-      return model;
     };
 
     /**
@@ -74,23 +83,73 @@
     };
 
     /**
-     * onInit Component lifecycle hooked
+     * Callback when sortable table header clicked
+     * @param {number} index : index of header item
+     */
+    this.onClickSort = (index) => {
+      if (index === this.activeSort) {
+        // If clicked header is already sorted, reverse
+        // the sort direction
+        this.sortAscending = !this.sortAscending;
+        this.data.reverse();
+      } else {
+        this.sortAscending = true;
+        this.activeSort = index;
+        sortData();
+      }
+    };
+
+    /**
+     * onInit Component lifecycle hook
+     * Checking for undefined values
      */
     this.$onInit = () => {
-      if (this.model === undefined) {
-        console.log('<bmc-table> Component is missing "model" attribute.');
-        return;
-      }
-      this.model = setModel(this.model);
+      this.header = this.header === undefined ? [] : this.header;
+      this.data = this.data == undefined ? [] : this.data;
+      this.sortable = this.sortable === undefined ? false : this.sortable;
       this.rowActionsEnabled =
-          this.rowActionsEnabled === undefined ? false : true;
+          this.rowActionsEnabled === undefined ? false : this.rowActionsEnabled;
+      this.size = this.size === undefined ? '' : this.size;
+
+      // Check for undefined 'uiData' property for each item in data array
+      this.data = this.data.map((row) => {
+        if (row.uiData === undefined) {
+          row.uiData = [];
+        }
+        return row;
+      })
+      if (this.sortable) {
+        // If sort is enabled, check for undefined 'sortable'
+        // property for each item in header array
+        this.header = this.header.map((column) => {
+          column.sortable =
+              column.sortable === undefined ? true : column.sortable;
+          return column;
+        })
+      }
       if (this.rowActionsEnabled) {
         // If table actions are enabled push an empty
         // string to the header array to account for additional
         // table actions cell
-        this.model.header.push('');
+        this.header.push({label: '', sortable: false});
       }
     };
+
+    /**
+     * onChanges Component lifecycle hook
+     * Check for changes in the data array and apply
+     * default or active sort if one is defined
+     */
+    this.$onChanges = (onChangesObj) => {
+      const dataChange = onChangesObj.data;
+      if (dataChange) {
+        if (this.activeSort !== undefined || this.defaultSort !== undefined) {
+          this.activeSort = this.defaultSort !== undefined ? this.defaultSort :
+                                                             this.activeSort;
+          sortData();
+        }
+      }
+    }
   };
 
   /**
@@ -99,6 +158,14 @@
   angular.module('app.common.components').component('bmcTable', {
     template: require('./table.html'),
     controller: TableController,
-    bindings: {model: '<', rowActionsEnabled: '<', size: '<', emitAction: '&'}
+    bindings: {
+      data: '<',               // Array
+      header: '<',             // Array
+      rowActionsEnabled: '<',  // boolean
+      size: '<',               // string
+      sortable: '<',           // boolean
+      defaultSort: '<',        // number (index of sort)
+      emitAction: '&'
+    }
   })
 })(window.angular);
diff --git a/app/common/styles/components/table.scss b/app/common/styles/components/table.scss
index 0cdb414..613d88a 100644
--- a/app/common/styles/components/table.scss
+++ b/app/common/styles/components/table.scss
@@ -154,6 +154,28 @@
   }
 }
 
+.bmc-table__head {
+  .sort-icon {
+    padding: 0;
+    position: absolute;
+    .icon {
+      margin: 0;
+      & + .icon {
+        margin-left: -18px;
+      }
+      svg {
+        height: 1em;
+      }
+    }
+  }
+  .sort-icon--descending {
+    transform: rotate(180deg);
+  }
+  .sort-icon--inactive {
+    opacity: 0.5;
+  }
+}
+
 .bmc-table__row {
   border-bottom: 1px solid $border-color-01;
 }
@@ -161,6 +183,12 @@
 .bmc-table__column-header {
   padding: 10px 16px;
   background-color: $background-03;
+  position: relative;
+}
+
+.bmc-table__column-header--sortable {
+  cursor: pointer;
+  user-select: none;
 }
 
 .bmc-table__cell {
diff --git a/app/users/controllers/user-accounts-controller.html b/app/users/controllers/user-accounts-controller.html
index 76df616..696a984 100644
--- a/app/users/controllers/user-accounts-controller.html
+++ b/app/users/controllers/user-accounts-controller.html
@@ -23,7 +23,8 @@
       </div>
       <!-- Local user table -->
       <bmc-table
-        model="tableModel"
+        data="tableData"
+        header="tableHeader"
         row-actions-enabled="true"
         emit-action="onEmitAction(value)"
         class="local-users__table">
@@ -36,4 +37,4 @@
       <role-table></role-table>
     </div>
   </div>
-</div>
+</div>
\ No newline at end of file
diff --git a/app/users/controllers/user-accounts-controller.js b/app/users/controllers/user-accounts-controller.js
index b5ed1a5..7591a53 100644
--- a/app/users/controllers/user-accounts-controller.js
+++ b/app/users/controllers/user-accounts-controller.js
@@ -17,9 +17,10 @@
       $scope.userRoles;
       $scope.localUsers;
 
-      $scope.tableModel = {};
-      $scope.tableModel.data = [];
-      $scope.tableModel.header = ['Username', 'Privilege', 'Account status'];
+      $scope.tableData = [];
+      $scope.tableHeader = [
+        {label: 'Username'}, {label: 'Privilege'}, {label: 'Account status'}
+      ];
 
       /**
        * Data table mapper
@@ -61,7 +62,7 @@
         APIUtils.getAllUserAccounts()
             .then((users) => {
               $scope.localUsers = users;
-              $scope.tableModel.data = users.map(mapTableData);
+              $scope.tableData = users.map(mapTableData);
             })
             .catch((error) => {
               console.log(JSON.stringify(error));
diff --git a/app/users/directives/role-table.html b/app/users/directives/role-table.html
index 55e8108..95b4c31 100644
--- a/app/users/directives/role-table.html
+++ b/app/users/directives/role-table.html
@@ -7,7 +7,8 @@
     <span ng-if="!roleTableCtrl.isCollapsed">Hide privilege role descriptions</span>
   </button>
   <div uib-collapse="roleTableCtrl.isCollapsed">
-    <bmc-table  model="roleTableCtrl.tableModel"
+    <bmc-table  data="roleTableCtrl.tableData"
+                header="roleTableCtrl.tableHeader"
                 size="'small'">
     </bmc-table>
   </div>
diff --git a/app/users/directives/role-table.js b/app/users/directives/role-table.js
index ee99a41..c23fed8 100644
--- a/app/users/directives/role-table.js
+++ b/app/users/directives/role-table.js
@@ -20,9 +20,10 @@
           const check =
               $sce.trustAsHtml(`<span class="icon__check-mark">${svg}<span>`);
 
-          this.tableModel = {};
-          this.tableModel.header =
-              ['', 'Admin', 'Operator', 'User', 'Callback'];
+          this.tableHeader = [
+            {label: ''}, {label: 'Admin'}, {label: 'Operator'}, {label: 'User'},
+            {label: 'Callback'}
+          ];
 
           // TODO: When API changed from D-Bus to Redfish, 'Operator' role
           // should have 'Configure components managed by this service'
@@ -30,7 +31,7 @@
           // TODO: When 'Operator' and 'User' roles have ability to change
           // own account's passwords, should have 'Update password for
           // current user account' privilege checked
-          this.tableModel.data = [
+          this.tableData = [
             {
               uiData: [
                 'Configure components managed by this service', check, '', '',