OverZealous Creations

Natural Sorting within Angular.js

I was reading Ben Nadel’s blog post, User-Friendly Sort Of Alpha-Numeric Data In JavaScript, and I was inspired to expand upon his ideas and turn it into a reusable module that fit better with the MVC pattern.

What I came up with is a full-featured naturalSort module that can be used with the built-in orderBy function.

What is Natural Sorting?

Normally, numerical values in strings are sorted using their ascii (or unicode) values in a non-inutitive manner. Natural sorting adjusts the sorting for these items to ensure that they are sorted in the order that the user would expect.

Example:

Before      After
----------- -----------
foo-1       foo-1
foo-11      foo-2
foo-2       foo-5
foo-25      foo-11
foo-5       foo-25

The algorithm here can also handle sorting dates in common formats (if included), and version strings.

The Idea

The original code from Ben Nadel handled converting basic integers and decimals into a sortable string perfectly well. But it required the sorting to occur on the controller, which I didn’t like. It also didn’t group it into any sort of reusable module, and it basically ignored version strings (like 1.0.3). As I was developing it, I realized I could handle another very common use case, out-of-sequence dates, as well.

I really wanted to integrate the function into the existing Angular filter orderBy. OrderBy, if you didn’t already know, handles sorting an array before it is processed, usually by ngRepeat. It accepts either a single predicate or an array of predicates, and those predicates can either be a property to sort on, or a function that returns the value to sort on. It’s the function we’re going to take advantage of.

To separate this module from the controllers completely, we’re going to add a function to the application’s $rootScope. It’s a little taboo, but I think this is a valid use case for it. This function allows you to choose which field to sort on just like the normal orderBy predicates.

The function’s general process is to look for instances of numbers, and pad them out with either leading zeros (for integegral parts, dates, and version numbers) or trailing zeros (for fractional parts).

Dates are a special case, where dates entered in the format of d/m/yyyy or m/d/yyyy (where / can also be - or .) are automatically converted into yyyy-m-d for consistent sorting. The module will actually look up the shortdate format code from $locale to determine whether to assume d/m or m/d in ambiguous cases.

General Usage

  • First, include one of the versions of the file in your HTML. (For the difference between the versions, check the Readme file — or just use naturalSortVersionDates.js, the recommended one for most applications).
  • Make sure to add it to your application module:

    var app = angular.module('myApp', ['naturalSort']);
    
  • Use it as part of the orderBy filter

    <li ng-repeat="item in items | orderBy:natural('title')">{{ title }}</li>
    

A Warning

Because the natural function is on $rootScope, it is only available from within controllers, or from directives that do not isolate their scope. If a directive is using an isolate scope, it wil need to reference the $rootScope function directly, like so:

<li ng-repeat="item in items | orderBy:$rootScope.natural('title')">{{ title }}</li>

The Code

Finally, here’s the source code, if you want to look it over:

/*!
 * Copyright 2013 Phil DeJarnett - http://www.overzealous.com
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

// Create a module for naturalSorting
angular.module("naturalSort", [])

// The core natural service
.factory("naturalService", ["$locale", function($locale) {
    "use strict";
        // the cache prevents re-creating the values every time, at the expense of
        // storing the results forever. Not recommended for highly changing data
        // on long-term applications.
    var natCache = {},
        // amount of extra zeros to padd for sorting
        padding = function(value) {
            return "00000000000000000000".slice(value.length);
        },

        // Converts a value to a string.  Null and undefined are converted to ''
        toString = function(value) {
            if(value === null || value === undefined) return '';
            return ''+value;
        },

        // Calculate the default out-of-order date format (dd/MM/yyyy vs MM/dd/yyyy)
        natDateMonthFirst = $locale.DATETIME_FORMATS.shortDate.charAt(0) === "M",
        // Replaces all suspected dates with a standardized yyyy-m-d, which is fixed below
        fixDates = function(value) {
            // first look for dd?-dd?-dddd, where "-" can be one of "-", "/", or "."
            return toString(value).replace(/(\d\d?)[-\/\.](\d\d?)[-\/\.](\d{4})/, function($0, $m, $d, $y) {
                // temporary holder for swapping below
                var t = $d;
                // if the month is not first, we'll swap month and day...
                if(!natDateMonthFirst) {
                    // ...but only if the day value is under 13.
                    if(Number($d) < 13) {
                        $d = $m;
                        $m = t;
                    }
                } else if(Number($m) > 12) {
                    // Otherwise, we might still swap the values if the month value is currently over 12.
                    $d = $m;
                    $m = t;
                }
                // return a standardized format.
                return $y+"-"+$m+"-"+$d;
            });
        },

        // Fix numbers to be correctly padded
        fixNumbers = function(value) {
            // First, look for anything in the form of d.d or d.d.d...
            return value.replace(/(\d+)((\.\d+)+)?/g, function ($0, integer, decimal, $3) {
                // If there's more than 2 sets of numbers...
                if (decimal !== $3) {
                    // treat as a series of integers, like versioning,
                    // rather than a decimal
                    return $0.replace(/(\d+)/g, function ($d) {
                        return padding($d) + $d;
                    });
                } else {
                    // add a decimal if necessary to ensure decimal sorting
                    decimal = decimal || ".0";
                    return padding(integer) + integer + decimal + padding(decimal);
                }
            });
        },

        // Finally, this function puts it all together.
        natValue = function (value) {
            if(natCache[value]) {
                return natCache[value];
            }
            natCache[value] = fixNumbers(fixDates(value));
            return natCache[value];
        };

    // The actual object used by this service
    return {
        naturalValue: natValue,
        naturalSort: function(a, b) {
            a = natValue(a);
            b = natValue(b);
            return (a < b) ? -1 : ((a > b) ? 1 : 0);
        }
    };
}])

// Attach a function to the rootScope so it can be accessed by "orderBy"
.run(["$rootScope", "naturalService", function($rootScope, naturalService) {
    "use strict";
    $rootScope.natural = function (field) {
        return function (item) {
            return naturalService.naturalValue(item[field]);
        };
    };
}]);