axsh/wakame-vdc

View on GitHub
frontend/admin/app/assets/javascripts/backbone.paginator.js

Summary

Maintainability
F
1 wk
Test Coverage
/*globals Backbone:true, _:true, jQuery:true*/
Backbone.Paginator = (function ( Backbone, _, $ ) {
    "use strict";

    var Paginator = {};
    Paginator.version = "0.15";

    // @name: clientPager
    //
    // @tagline: Paginator for client-side data
    //
    // @description:
    // This paginator is responsible for providing pagination
    // and sort capabilities for a single payload of data
    // we wish to paginate by the UI for easier browsering.
    //
    Paginator.clientPager = Backbone.Collection.extend({

        // Default values used when sorting and/or filtering.
        initialize: function(){
            this.useDiacriticsPlugin = true; // use diacritics plugin if available
            this.useLevenshteinPlugin = true; // use levenshtein plugin if available

            this.sortColumn = "";
            this.sortDirection = "desc";
            this.lastSortColumn = "";

            this.fieldFilterRules = [];
            this.lastFieldFilterRules = [];

            this.filterFields = "";
            this.filterExpression = "";
            this.lastFilterExpression = "";
        },

        sync: function ( method, model, options ) {

            var self = this;

            // Create default values if no others are specified
            _.defaults(self.paginator_ui, {
                firstPage: 0,
                currentPage: 1,
                perPage: 5,
                totalPages: 10
            });

            // Change scope of 'paginator_ui' object values
            _.each(self.paginator_ui, function(value, key) {
                if( _.isUndefined(self[key]) ) {
                    self[key] = self.paginator_ui[key];
                }
            });

            // Some values could be functions, let's make sure
            // to change their scope too and run them
            var queryAttributes = {};
            _.each(self.server_api, function(value, key){
                if( _.isFunction(value) ) {
                    value = _.bind(value, self);
                    value = value();
                }
                queryAttributes[key] = value;
            });

            var queryOptions = _.clone(self.paginator_core);
            _.each(queryOptions, function(value, key){
                if( _.isFunction(value) ) {
                    value = _.bind(value, self);
                    value = value();
                }
                queryOptions[key] = value;
            });

            // Create default values if no others are specified
            queryOptions = _.defaults(queryOptions, {
                timeout: 25000,
                cache: false,
                type: 'GET',
                dataType: 'jsonp'
            });

            queryOptions = _.extend(queryOptions, {
                jsonpCallback: 'callback',
                data: decodeURIComponent($.param(queryAttributes)),
                processData: false,
                url: _.result(queryOptions, 'url')
            }, options);

            return $.ajax( queryOptions );

        },

        nextPage: function () {
            this.currentPage = ++this.currentPage;
            this.pager();
        },

        previousPage: function () {
            this.currentPage = --this.currentPage || 1;
            this.pager();
        },

        goTo: function ( page ) {
            if(page !== undefined){
                this.currentPage = parseInt(page, 10);
                this.pager();
            }
        },

        howManyPer: function ( perPage ) {
            if(perPage !== undefined){
                var lastPerPage = this.perPage;
                this.perPage = parseInt(perPage, 10);
                this.currentPage = Math.ceil( ( lastPerPage * ( this.currentPage - 1 ) + 1 ) / perPage);
                this.pager();
            }
        },


        // setSort is used to sort the current model. After
        // passing 'column', which is the model's field you want
        // to filter and 'direction', which is the direction
        // desired for the ordering ('asc' or 'desc'), pager()
        // and info() will be called automatically.
        setSort: function ( column, direction ) {
            if(column !== undefined && direction !== undefined){
                this.lastSortColumn = this.sortColumn;
                this.sortColumn = column;
                this.sortDirection = direction;
                this.pager();
                this.info();
            }
        },

        // setFieldFilter is used to filter each value of each model
        // according to `rules` that you pass as argument.
        // Example: You have a collection of books with 'release year' and 'author'.
        // You can filter only the books that were released between 1999 and 2003
        // And then you can add another `rule` that will filter those books only to
        // authors who's name start with 'A'.
        setFieldFilter: function ( fieldFilterRules ) {
            if( !_.isEmpty( fieldFilterRules ) ) {
                this.lastFieldFilterRules = this.fieldFilterRules;
                this.fieldFilterRules = fieldFilterRules;
                this.pager();
                this.info();
            }
        },

        // doFakeFieldFilter can be used to get the number of models that will remain
        // after calling setFieldFilter with a filter rule(s)
        doFakeFieldFilter: function ( fieldFilterRules ) {
            if( !_.isEmpty( fieldFilterRules ) ) {
                var bkp_lastFieldFilterRules = this.lastFieldFilterRules;
                var bkp_fieldFilterRules = this.fieldFilterRules;

                this.lastFieldFilterRules = this.fieldFilterRules;
                this.fieldFilterRules = fieldFilterRules;
                this.pager();
                this.info();

                var cmodels = this.models.length;

                this.lastFieldFilterRules = bkp_lastFieldFilterRules;
                this.fieldFilterRules = bkp_fieldFilterRules;
                this.pager();
                this.info();

                // Return size
                return cmodels;
            }
        },

        // setFilter is used to filter the current model. After
        // passing 'fields', which can be a string referring to
        // the model's field, an array of strings representing
        // each of the model's fields or an object with the name
        // of the model's field(s) and comparing options (see docs)
        // you wish to filter by and
        // 'filter', which is the word or words you wish to
        // filter by, pager() and info() will be called automatically.
        setFilter: function ( fields, filter ) {
            if( fields !== undefined && filter !== undefined ){
                this.filterFields = fields;
                this.lastFilterExpression = this.filterExpression;
                this.filterExpression = filter;
                this.pager();
                this.info();
            }
        },

        // doFakeFilter can be used to get the number of models that will
        // remain after calling setFilter with a `fields` and `filter` args.
        doFakeFilter: function ( fields, filter ) {
            if( fields !== undefined && filter !== undefined ){
                var bkp_filterFields = this.filterFields;
                var bkp_lastFilterExpression = this.lastFilterExpression;
                var bkp_filterExpression = this.filterExpression;

                this.filterFields = fields;
                this.lastFilterExpression = this.filterExpression;
                this.filterExpression = filter;
                this.pager();
                this.info();

                var cmodels = this.models.length;

                this.filterFields = bkp_filterFields;
                this.lastFilterExpression = bkp_lastFilterExpression;
                this.filterExpression = bkp_filterExpression;
                this.pager();
                this.info();

                // Return size
                return cmodels;
            }
        },

        // pager is used to sort, filter and show the data
        // you expect the library to display.
        pager: function () {
            var self = this,
                disp = this.perPage,
                start = (self.currentPage - 1) * disp,
                stop = start + disp;

            // Saving the original models collection is important
            // as we could need to sort or filter, and we don't want
            // to loose the data we fetched from the server.
            if (self.origModels === undefined) {
                self.origModels = self.models;
            }

            self.models = self.origModels;

            // Check if sorting was set using setSort.
            if ( this.sortColumn !== "" ) {
                self.models = self._sort(self.models, this.sortColumn, this.sortDirection);
            }

            // Check if field-filtering was set using setFieldFilter
            if ( !_.isEmpty( this.fieldFilterRules ) ) {
                self.models = self._fieldFilter(self.models, this.fieldFilterRules);
            }

            // Check if filtering was set using setFilter.
            if ( this.filterExpression !== "" ) {
                self.models = self._filter(self.models, this.filterFields, this.filterExpression);
            }

            // If the sorting or the filtering was changed go to the first page
            if ( this.lastSortColumn !== this.sortColumn || this.lastFilterExpression !== this.filterExpression || !_.isEqual(this.fieldFilterRules, this.lastFieldFilterRules) ) {
                start = 0;
                stop = start + disp;
                self.currentPage = 1;

                this.lastSortColumn = this.sortColumn;
                this.lastFieldFilterRules = this.fieldFilterRules;
                this.lastFilterExpression = this.filterExpression;
            }

            // We need to save the sorted and filtered models collection
            // because we'll use that sorted and filtered collection in info().
            self.sortedAndFilteredModels = self.models;

            self.reset(self.models.slice(start, stop));
        },

        // The actual place where the collection is sorted.
        // Check setSort for arguments explicacion.
        _sort: function ( models, sort, direction ) {
            models = models.sort(function (a, b) {
                var ac = a.get(sort),
                    bc = b.get(sort);

                if ( !ac || !bc ) {
                    return 0;
                } else {
                    /* Make sure that both ac and bc are lowercase strings.
                    * .toString() first so we don't have to worry if ac or bc
                    * have other String-only methods.
                    */
                    ac = ac.toString().toLowerCase();
                    bc = bc.toString().toLowerCase();
                }

                if (direction === 'desc') {

                    // We need to know if there aren't any non-number characters
                    // and that there are numbers-only characters and maybe a dot
                    // if we have a float.
                    if((!ac.match(/[^\d\.]/) && ac.match(/[\d\.]*/)) &&
                        (!bc.match(/[^\d\.]/) && bc.match(/[\d\.]*/))
                    ){

                        if( (ac - 0) < (bc - 0) ) {
                            return 1;
                        }
                        if( (ac - 0) > (bc - 0) ) {
                            return -1;
                        }
                    } else {
                        if (ac < bc) {
                            return 1;
                        }
                        if (ac > bc) {
                            return -1;
                        }
                    }

                } else {

                    //Same as the regexp check in the 'if' part.
                    if((!ac.match(/[^\d\.]/) && ac.match(/[\d\.]*/)) &&
                        (!bc.match(/[^\d\.]/) && bc.match(/[\d\.]*/))
                    ){
                        if( (ac - 0) < (bc - 0) ) {
                            return -1;
                        }
                        if( (ac - 0) > (bc - 0) ) {
                            return 1;
                        }
                    } else {
                        if (ac < bc) {
                            return -1;
                        }
                        if (ac > bc) {
                            return 1;
                        }
                    }

                }

                return 0;
            });

            return models;
        },

        // The actual place where the collection is field-filtered.
        // Check setFieldFilter for arguments explicacion.
        _fieldFilter: function( models, rules ) {

            // Check if there are any rules
            if ( _.isEmpty(rules) ) {
                return models;
            }

            var filteredModels = [];

            // Iterate over each rule
            _.each(models, function(model){

                var should_push = true;

                // Apply each rule to each model in the collection
                _.each(rules, function(rule){

                    // Don't go inside the switch if we're already sure that the model won't be included in the results
                    if( !should_push ){
                        return false;
                    }

                    should_push = false;

                    // The field's value will be passed to a custom function, which should
                    // return true (if model should be included) or false (model should be ignored)
                    if(rule.type === "function"){
                        var f = _.wrap(rule.value, function(func){
                            return func( model.get(rule.field) );
                        });
                        if( f() ){
                            should_push = true;
                        }

                    // The field's value is required to be non-empty
                    }else if(rule.type === "required"){
                        if( !_.isEmpty( model.get(rule.field).toString() ) ) {
                            should_push = true;
                        }

                    // The field's value is required to be greater tan N (numbers only)
                    }else if(rule.type === "min"){
                        if( !_.isNaN( Number( model.get(rule.field) ) ) &&
                            !_.isNaN( Number( rule.value ) ) &&
                            Number( model.get(rule.field) ) >= Number( rule.value ) ) {
                            should_push = true;
                        }

                    // The field's value is required to be smaller tan N (numbers only)
                    }else if(rule.type === "max"){
                        if( !_.isNaN( Number( model.get(rule.field) ) ) &&
                            !_.isNaN( Number( rule.value ) ) &&
                            Number( model.get(rule.field) ) <= Number( rule.value ) ) {
                            should_push = true;
                        }

                    // The field's value is required to be between N and M (numbers only)
                    }else if(rule.type === "range"){
                        if( !_.isNaN( Number( model.get(rule.field) ) ) &&
                            _.isObject( rule.value ) &&
                            !_.isNaN( Number( rule.value.min ) ) &&
                            !_.isNaN( Number( rule.value.max ) ) &&
                            Number( model.get(rule.field) ) >= Number( rule.value.min ) &&
                            Number( model.get(rule.field) ) <= Number( rule.value.max ) ) {
                            should_push = true;
                        }

                    // The field's value is required to be more than N chars long
                    }else if(rule.type === "minLength"){
                        if( model.get(rule.field).toString().length >= rule.value ) {
                            should_push = true;
                        }

                    // The field's value is required to be no more than N chars long
                    }else if(rule.type === "maxLength"){
                        if( model.get(rule.field).toString().length <= rule.value ) {
                            should_push = true;
                        }

                    // The field's value is required to be more than N chars long and no more than M chars long
                    }else if(rule.type === "rangeLength"){
                        if( _.isObject( rule.value ) &&
                            !_.isNaN( Number( rule.value.min ) ) &&
                            !_.isNaN( Number( rule.value.max ) ) &&
                            model.get(rule.field).toString().length >= rule.value.min &&
                            model.get(rule.field).toString().length <= rule.value.max ) {
                            should_push = true;
                        }

                    // The field's value is required to be equal to one of the values in rules.value
                    }else if(rule.type === "oneOf"){
                        if( _.isArray( rule.value ) &&
                            _.include( rule.value, model.get(rule.field) ) ) {
                            should_push = true;
                        }

                    // The field's value is required to be equal to the value in rules.value
                    }else if(rule.type === "equalTo"){
                        if( rule.value === model.get(rule.field) ) {
                            should_push = true;
                        }

                    // The field's value is required to match the regular expression
                    }else if(rule.type === "pattern"){
                        if( model.get(rule.field).toString().match(rule.value) ) {
                            should_push = true;
                        }

                    //Unknown type
                    }else{
                        should_push = false;
                    }

                });

                if( should_push ){
                    filteredModels.push(model);
                }

            });

            return filteredModels;
        },

        // The actual place where the collection is filtered.
        // Check setFilter for arguments explicacion.
        _filter: function ( models, fields, filter ) {

            //  For example, if you had a data model containing cars like { color: '', description: '', hp: '' },
            //  your fields was set to ['color', 'description', 'hp'] and your filter was set
            //  to "Black Mustang 300", the word "Black" will match all the cars that have black color, then
            //  "Mustang" in the description and then the HP in the 'hp' field.
            //  NOTE: "Black Musta 300" will return the same as "Black Mustang 300"

            // We accept fields to be a string, an array or an object
            // but if string or array is passed we need to convert it
            // to an object.

            var self = this;

            var obj_fields = {};

            if( _.isString( fields ) ) {
                obj_fields[fields] = {cmp_method: 'regexp'};
            }else if( _.isArray( fields ) ) {
                _.each(fields, function(field){
                    obj_fields[field] = {cmp_method: 'regexp'};
                });
            }else{
                _.each(fields, function( cmp_opts, field ) {
                    obj_fields[field] = _.defaults(cmp_opts, { cmp_method: 'regexp' });
                });
            }

            fields = obj_fields;

            //Remove diacritic characters if diacritic plugin is loaded
            if( _.has(Backbone.Paginator, 'removeDiacritics') && self.useDiacriticsPlugin ){
                filter = Backbone.Paginator.removeDiacritics(filter);
            }

            // 'filter' can be only a string.
            // If 'filter' is string we need to convert it to
            // a regular expression.
            // For example, if 'filter' is 'black dog' we need
            // to find every single word, remove duplicated ones (if any)
            // and transform the result to '(black|dog)'
            if( filter === '' || !_.isString(filter) ) {
                return models;
            } else {
                var words = filter.match(/\w+/ig);
                var pattern = "(" + _.uniq(words).join("|") + ")";
                var regexp = new RegExp(pattern, "igm");
            }

            var filteredModels = [];

            // We need to iterate over each model
            _.each( models, function( model ) {

                var matchesPerModel = [];

                // and over each field of each model
                _.each( fields, function( cmp_opts, field ) {

                    var value = model.get( field );

                    if( value ) {

                        // The regular expression we created earlier let's us detect if a
                        // given string contains each and all of the words in the regular expression
                        // or not, but in both cases match() will return an array containing all
                        // the words it matched.
                        var matchesPerField = [];

                        if( _.has(Backbone.Paginator, 'removeDiacritics') && self.useDiacriticsPlugin ){
                            value = Backbone.Paginator.removeDiacritics(value.toString());
                        }else{
                            value = value.toString();
                        }

                        // Levenshtein cmp
                        if( cmp_opts.cmp_method === 'levenshtein' && _.has(Backbone.Paginator, 'levenshtein') && self.useLevenshteinPlugin ) {
                            var distance = Backbone.Paginator.levenshtein(value, filter);

                            _.defaults(cmp_opts, { max_distance: 0 });

                            if( distance <= cmp_opts.max_distance ) {
                                matchesPerField = _.uniq(words);
                            }

                        // Default (RegExp) cmp
                        }else{
                            matchesPerField = value.match( regexp );
                        }

                        matchesPerField = _.map(matchesPerField, function(match) {
                            return match.toString().toLowerCase();
                        });

                        _.each(matchesPerField, function(match){
                            matchesPerModel.push(match);
                        });

                    }

                });

                // We just need to check if the returned array contains all the words in our
                // regex, and if it does, it means that we have a match, so we should save it.
                matchesPerModel = _.uniq( _.without(matchesPerModel, "") );

                if(  _.isEmpty( _.difference(words, matchesPerModel) ) ) {
                    filteredModels.push(model);
                }

            });

            return filteredModels;
        },

        // You shouldn't need to call info() as this method is used to
        // calculate internal data as first/prev/next/last page...
        info: function () {
            var self = this,
                info = {},
                totalRecords = (self.sortedAndFilteredModels) ? self.sortedAndFilteredModels.length : self.length,
                totalPages = Math.ceil(totalRecords / self.perPage);

            info = {
                totalUnfilteredRecords: self.origModels.length,
                totalRecords: totalRecords,
                currentPage: self.currentPage,
                perPage: this.perPage,
                totalPages: totalPages,
                lastPage: totalPages,
                previous: false,
                next: false,
                startRecord: totalRecords === 0 ? 0 : (self.currentPage - 1) * this.perPage + 1,
                endRecord: Math.min(totalRecords, self.currentPage * this.perPage)
            };

            if (self.currentPage > 1) {
                info.previous = self.currentPage - 1;
            }

            if (self.currentPage < info.totalPages) {
                info.next = self.currentPage + 1;
            }

            info.pageSet = self.setPagination(info);

            self.information = info;
            return info;
        },


        // setPagination also is an internal function that shouldn't be called directly.
        // It will create an array containing the pages right before and right after the
        // actual page.
        setPagination: function ( info ) {
            var pages = [], i = 0, l = 0;


            // How many adjacent pages should be shown on each side?
            var ADJACENT = 3,
                ADJACENTx2 = ADJACENT * 2,
                LASTPAGE = Math.ceil(info.totalRecords / info.perPage),
                LPM1 = -1;

            if (LASTPAGE > 1) {
                // not enough pages to bother breaking it up
                if (LASTPAGE < (7 + ADJACENTx2)) {
                    for (i = 1, l = LASTPAGE; i <= l; i++) {
                        pages.push(i);
                    }
                }
                // enough pages to hide some
                else if (LASTPAGE > (5 + ADJACENTx2)) {

                    //close to beginning; only hide later pages
                    if (info.currentPage < (1 + ADJACENTx2)) {
                        for (i = 1, l = 4 + ADJACENTx2; i < l; i++) {
                            pages.push(i);
                        }
                    }

                    // in middle; hide some front and some back
                    else if (LASTPAGE - ADJACENTx2 > info.currentPage && info.currentPage > ADJACENTx2) {
                        for (i = info.currentPage - ADJACENT; i <= info.currentPage + ADJACENT; i++) {
                            pages.push(i);
                        }
                    }
                    // close to end; only hide early pages
                    else {
                        for (i = LASTPAGE - (2 + ADJACENTx2); i <= LASTPAGE; i++) {
                            pages.push(i);
                        }
                    }
                }
            }

            return pages;
        }

    });


    // @name: requestPager
    //
    // Paginator for server-side data being requested from a backend/API
    //
    // @description:
    // This paginator is responsible for providing pagination
    // and sort capabilities for requests to a server-side
    // data service (e.g an API)
    //
    Paginator.requestPager = Backbone.Collection.extend({

        sync: function ( method, model, options ) {

            var self = this;

            // Create default values if no others are specified
            _.defaults(self.paginator_ui, {
                firstPage: 0,
                currentPage: 1,
                perPage: 5,
                totalPages: 10
            });

            // Change scope of 'paginator_ui' object values
            _.each(self.paginator_ui, function(value, key) {
                if( _.isUndefined(self[key]) ) {
                    self[key] = self.paginator_ui[key];
                }
            });

            // Some values could be functions, let's make sure
            // to change their scope too and run them
            var queryAttributes = {};
            _.each(self.server_api, function(value, key){
                if( _.isFunction(value) ) {
                    value = _.bind(value, self);
                    value = value();
                }
                queryAttributes[key] = value;
            });

            var queryOptions = _.clone(self.paginator_core);
            _.each(queryOptions, function(value, key){
                if( _.isFunction(value) ) {
                    value = _.bind(value, self);
                    value = value();
                }
                queryOptions[key] = value;
            });

            // Create default values if no others are specified
            queryOptions = _.defaults(queryOptions, {
                timeout: 25000,
                cache: false,
                type: 'GET',
                dataType: 'jsonp'
            });

            // Allows the passing in of {data: {foo: 'bar'}} at request time to overwrite server_api defaults
            if( options.data ){
                options.data = decodeURIComponent($.param(_.extend(queryAttributes,options.data)));
            }else{
                options.data = decodeURIComponent($.param(queryAttributes));
            }

            queryOptions = _.extend(queryOptions, {
                jsonpCallback: 'callback',
                processData: false,
                url: _.result(queryOptions, 'url')
            }, options);

            return $.ajax( queryOptions );

        },

        requestNextPage: function ( options ) {
            if ( this.currentPage !== undefined ) {
                this.currentPage += 1;
                return this.pager( options );
            } else {
                var response = new $.Deferred();
                response.reject();
                return response.promise();
            }
        },

        requestPreviousPage: function ( options ) {
            if ( this.currentPage !== undefined ) {
                this.currentPage -= 1;
                return this.pager( options );
            } else {
                var response = new $.Deferred();
                response.reject();
                return response.promise();
            }
        },

        updateOrder: function ( column ) {
            if (column !== undefined) {
                this.sortField = column;
                this.pager();
            }

        },

        goTo: function ( page, options ) {
            if ( page !== undefined ) {
                this.currentPage = parseInt(page, 10);
                return this.pager( options );
            } else {
                var response = new $.Deferred();
                response.reject();
                return response.promise();
            }
        },

        howManyPer: function ( count ) {
            if( count !== undefined ){
                this.currentPage = this.firstPage;
                this.perPage = count;
                this.pager();
            }
        },

        sort: function () {
            //assign to as needed.
        },

        info: function () {

            var info = {
                // If parse() method is implemented and totalRecords is set to the length
                // of the records returned, make it available. Else, default it to 0
                totalRecords: this.totalRecords || 0,

                currentPage: this.currentPage,
                firstPage: this.firstPage,
                totalPages: this.totalPages,
                lastPage: this.totalPages,
                perPage: this.perPage
            };

            this.information = info;
            return info;
        },

        // fetches the latest results from the server
        pager: function ( options ) {
            if ( !_.isObject(options) ) {
                options = {};
            }
            return this.fetch( options );
        }


    });

    return Paginator;

}( Backbone, _, jQuery ));