headerTable.jpg

Angular Directive: Sortable Table Pt. I

UPDATE (May 2016): This is written for Angular 1.x. While Angular 1.x is not close to being deprecated, we have an updated series written specifically for Angular 2. Check out part 1 of the new series to learn how to migrate Angular 1.x filters to Angular 2 pipes, and part 2 for how to migrate your Angular 1.x directives to Angular 2 components.

What you’re about to read is a step by step guide to building a sortable table through an Angular directive. We here at Fuel applied these same tactics and were able to reduce our HTML file size by over 50%. This cleaned up our HTML views, and kept all of our code separated, allowing for better and more maintainable code. You will be able to use this directive for sort any data set you can think of.

Angular Directive

We really wanted to be able to pass through any sort of data to an aesthetically pleasing Bootstrap table, and be able to sort it on any column. There are plenty of other table directives across the web, but none that can also sort. This is how we came up with our solution, step by step. First and foremost, we needed to start off with some HTML of what we wanted everything to look like:


Column 2
100
200

Column 1
Data 1
Data 2

Luckily, with Angular there is no extra work to get this HTML displaying correctly. Move this HTML into it’s own file, outside of your page’s view so that it will be a separate template we can manipulate. The next bridge we need to cross is being able to create a directive to print out this table using a custom tag.

angular.module('app.directives', [])
.directive('tableSortable', [function () {
    return {
        restrict: 'E', //E represents that the directive will be called using a tag
        templateUrl: 'path/to/your.html'
    }
}])

Now, all that needs to be done in our view’s HTML is place


anywhere in our HTML to print out the HTML we created! Now that’s great and all, but we want this table to print out any kind of data that we want. Directives offer a great way to pass through data that we need to our directive’s template.

Directive Parameters

Angular directives offer their own scope, or environment of variables separate from the rest of your application. Defining variables in the scope of your directive allows for your tag to take in extra attributes. Simply add a scope object to your directive with a name of the variable as the key and @, =, or & as the value. Each one of these symbols tells Angular how to compile the variable into your directive.

We’ll make an example. Let’s say we are calling this directive in a scope that has the following variables:

$scope.localString = "The local scope's string";
$scope.localFunction = function(){
    return $scope.localString; //"The local scope's string"
};

The @ tells Angular that the attribute does not need to compiled. Whatever is inside the quotes of this attribute will be that string.

scope: {
    param: '@'
}
 


 

The = tells Angular that the attribute needs to be compiled from the scope where the directive is placed.

scope: {
    param: '='
}

The & tells Angular that the attribute needs to compile a function from the scope where the directive is placed.

scope: {
    param: '&'
}

What Parameters Do We Need?

Let’s think about everything that we need:

  • An array of objects for rows
  • An array of objects for the column headers
  • Any sorting information (what column is sorted and ascending or descending)

Now how do each of these parameters need to be built?

Data

We want this to be any arbitrary array of objects. With any key/value pairs. So here’s the example we will work with:

$scope.rows = [
    {
        Name: 'Data 1',
        Amount: 100
    },
    {
        Name: 'Data 2',
        Amount: 200
    }
];

Columns

Now that we have our data array, we know that we have 2 keys per object in the array Name and Amount. This means we will have 2 columns to create for our columns array.

$scope.columns = [
    {
        display: 'Column 1', //The text to display
        variable: 'Name', //The name of the key that's apart of the data array
        filter: 'text' //The type data type of the column (number : digits, text, date, etc.)
    },
    {
        display: 'Column 2', //The text to display
        variable: 'Amount', //The name of the key that's apart of the data array
        filter: 'number : 0' //The type data type of the column (number : digits, text, date, etc.)
    }
];

Sorting

For the table to know what to sort on and which way to sort, we will need to pass through an object to tell the table this on load.

$scope.sorting = {
        column: 'Name', //to match the variable of one of the columns
        descending: false
};

Since none of these parameters will be strings, we know that we can’t use @ as our parameter type. It would be a lot cleaner to just use = rather than creating a function. Let’s add our scope to the directive:

angular.module('app.directives', [])
.directive('tableSortable', [function () {
    return {
        restrict: 'E', //E represents that the directive will be called using a tag
        templateUrl: 'path/to/your.html',
        transclude: true,
        scope: {
            columns: '=',
            data: '=',
            sort: '='
        }
    }
}])

You’ll see that we also added transclude: true. This is added because we are creating a directive that wraps arbitrary content.

Now that our directive is updated to take in our additional parameters, here is how we would place our custom tag in the view:


Directive’s Template

We’ve updated our directive to take in our needed parameters. Let’s modify our directive’s HTML to display our parameters rather than our hard coded table.

Template Column Headers

We need to loop over our columns and our rows in the directive’s template. We can do so by using ng-repeat in the DOM element we want to repeat. ng-repeat is one of Angular’s many great built-in attribute directives for manipulating the DOM from the view. For columns this will be the th tag. We need to loop over the columns array and print out that object’s display at the point in the array.


 

{{column.display}}

Easy, huh?

Template Rows

The rows are going to be treated a bit differently. We need to loop over each object of the data array similarly to how we looped over each object of the column array. This time we need to make a second loop. Our second loop is going to happen for each object of the array, and is going to loop over every key of that object. In our template’s tr tag inside of tbody, we are going to make an ng-repeat over the data parameter. This will allow us to loop over each data object in the array. Then we can call each of the properties of that object.


{{object.Amount}}

{{object.Name}}

This works correctly, but we want to be able to print out any kind of object with any properties. What if we didn’t know that Name and Amount were properties of each object in our data array? That’s where our columns parameter comes into play.

Remember we made a property of each column object called variable. We’ll be able to pull everything by that property and the current index of the ng-repeat. We’ll need to loop over each key of the object we currently are on, then pull the index of that key. But how do we get the index?

Angular makes this really easy by giving access to the index of the ng-repeat loop by simply using $index. Now with the index, we are able to pull the name of the variable by using the current index of the key that we are looping over.


 

{{object[columns[$index].variable]}}

Let’s break this down a little further. We are doing the following:

  • Looping over each object in the array of data parameter (our rows). ng-repeat="object in data"
  • Then inside of that loop we are looping over each key of that object. ng-repeat="key in object"
  • Then we are looking at the array of our columns parameter at the current index of the key loop. Then using the variable property to get the string. columns[$index].variable
  • And lastly, we are using that string to pull out the property of the row by name object[columns[$index].variable] or for example: object["Name"]

Awesome! You can see our template really shrinking now!

Sorting Functionality

Doing this, we can look up information about the variable key for the column. We’ll need to add some functions to the scope of our directive to do so. The way we can manipulate the scope of our directive without parameters is by using the link property. From the link property we can access a number of things about the directive: the javascript element, the attributes of the element, and most importantly, the scope of the directive. Here’s how we add it to the directive:

angular.module('app.directives', [])
.directive('tableSortable', [function () {
    return {
        restrict: 'E',
        transclude: true,
        templateUrl: 'tableSortable.html',
        scope: {
            columns: '=',
            data: '=',
            sort: '='
        },
        link: function (scope, element, attrs) {
          //Edit scope, element, and attributes here
        }
    }
}]);

We are going to need a few different functions in our directive’s scope for the template to use:

  • Tell if the column is selected and what kind of sorting it is
  • Change sorting to a column and toggle the sorting direction
  • Get the filter property from the corresponding column object

Each of these functions will need to take in the column’s name as a parameter, so that we can look up the correct column object in the columns parameter of our directive. Here’s how they look:

link: function (scope, element, attrs) {
  // This will tell us if the column is sorted and which way
  scope.selectedClass = function (columnName) {
      return columnName == scope.sort.column 
                 ? 'sort-' + scope.sort.descending  //CSS class name 'sort-false' or 'sort-true'
                 : false; //Not sorted on this column
  };

  // Will be used on click, and will toggle change our sorting parameter
  scope.changeSorting = function (columnName) {
      var sort = scope.sort;
      if (sort.column == columnName) {
          sort.descending = !sort.descending;
      } else {
          sort.column = columnName;
          sort.descending = false;
      }
  };

  // Pull the filter property by the column name
  scope.getFilterOfColumn = function (columnName) {
      for (var i = 0; i < scope.columns.length; i++) {
          if (columnName == scope.columns[i].variable)
              return scope.columns[i].filter;
      }
      return ''; //no filter found
  };
}

Let’s add these functions to be used in our template:


 

{{column.display}}
{{object[columns[$index].variable]}}

You’ll notice a few changes in the template now. Notably, in our header column loop, you’ll see two new attributes ng-class and ng-click.

ng-class allows for our selectedClass() function to run, taking in the column name of the current column in the loop. This then uses the returned CSS class string. If false is returned, the class is not changed.

ng-click is saying run our function changeSorting() when this th tag is clicked. This function is also taking in the column name of the current column in the loop.

Looking farther down in our template at the tr within tbody, you’ll notice we added | orderBy:sort.column:sort.descending in our ng-repeat. This is called a filter, which formats the value of an expression for display to the user. In this case, since we are using a filter on a loop, it manipulates the loop to loop in a certain direction. You’ll notice we are using the directive’s sort parameter to pass into the filter.

The last change we made is ng-if="key != '$$hashKey'", this is just an edge case we have to create to accommodate for the $$hashkey that Angular creates per object that is looped. Since we are looping over each key of the data row object, the $$hashkey is looped over, but we want to just skip it and not worry about it.

It’s Working! Well, Not Exactly…

It may appear that everything is sorting properly now, but unfortunately it isn’t. This is currently sorting everything as strings. This works for our Name property, but not for Amount. Don’t believe me? Change the Amount from 100 to 1000. 1000 is greater than 200 right? Well numerically yes, but not alphabetically. 1 becomes before 2 in the alphabet, so you could make any number at all that starts with 1, and it will always come alphabetically before 2. So how do we fix this?

Custom Filter

We need to create a filter that takes in a string to output the value in a certain way.

angular.module('app.filters', [])
.filter('customFilter', ['$filter', function($filter) {
  return function (input, filter) {
    var digits = 2; //default if no ':' exists
    
    if (filter.indexOf(':') > -1) {
      digits = parseInt(filter.split(':')[1].trim());
      filter = filter.split(':')[0].trim();
    }
    
    switch(filter) {
      case 'text':
        return input;
      case 'number':
        return $filter(filter)(input, digits);
      case 'percentage':
        return $filter('number')(input, digits) + '%';
      case 'dateTime':
        return $filter('date')(input, 'MMM d, y h:mm:ss a');
      default:
        return input;
    }
  }
}])

We added a couple examples that can be used. Text will just return the string as it is passed through the function. Number takes in the string and then uses Angular ‘number’ filter that adds necessary commas, and if we specify a certain number of digits. So using the filter ‘number : 2’ turns ‘100’ into 100.00. Percentage does the same as number, but adds a ‘%’ after it. DateTime takes in a timestamp, and then outputs it into a more user friendly representation.

Now to add this, we simply use our getFilterOfColumn() directive function from earlier, then we pass through the return of that function into our new customFilter filter. Here’s our completed template:


 

{{column.display}}
{{object[columns[$index].variable] | customFilter : getFilterOfColumn(columns[$index].variable)}}

To really see this in action. Let’s change our controller’s column and data arrays to add a dateTime row.

$scope.rows = [
  {
      Name: 'Data 1',
      Amount: 100,
      Date: '1441588216000'
  },
  {
      Name: 'Data 2',
      Amount: 200,
      Date: '1442387616000'
  },
  {
      Name: 'Data 3',
      Amount: 1000,
      Date: '1442187616000'
  }
];
$scope.columns = [
    {
        display: 'Column 1', //The text to display
        variable: 'Name', //The name of the key that's apart of the data array
        filter: 'text' //The type data type of the column (number, text, date, etc.)
    },
    {
        display: 'Column 2', //The text to display
        variable: 'Amount', //The name of the key that's apart of the data array
        filter: 'number : 0' //The type data type of the column (number, text, date, etc.)
    },
    {
        display: 'Column 3',
        variable: 'Date',
        filter: 'dateTime'
    }
];

Custom Styles

We know that we can click the header columns, but let’s add some styles to make it more apparent to the user. Using FontAwesome’s font library, we can add some nice icons to represent the sorting.

.table-sortable > thead > tr > th {
    cursor: pointer;
    position: relative;
    background-image: none !important;
}
 
.table-sortable > thead > tr > th:after,
.table-sortable > thead > tr > th.sort-false:after,
.table-sortable > thead > tr > th.sort-true:after {
    font-family: FontAwesome;
    padding-left: 5px;
}

.table-sortable > thead > tr > th:after {
    content: "\f0dc";
    color: #ddd;
}
.table-sortable > thead > tr > th.sort-false:after {
    content: "\f0de";
    color: #767676;
}
.table-sortable > thead > tr > th.sort-true:after {
    content: "\f0dd";
    color: #767676;
} 

Finished Product

Error: Embedded data could not be displayed.

This is a super useful, highly customizable directive that can be used in nearly all needed grids and tables. It makes for cleaner views and more maintainable code. We have used this throughout our Fuel Gauge Dashboard, and we here at Fuel hope you can make use of this as much as we have!

Continue on with part two to see how to add pagination for large amounts of data!

Leave a comment below!

comments

Cory ShawAngular Directive: Sortable Table Pt. I
Share this post