Thursday, 16 February 2017

MDB 3 DataTable Style

It's taken ages and ages but I've finally gotten around to creating a theme for DataTables which uses the lovely inputs from Material Design for Bootstrap (MDB). I've been playing with MDB for a while now and love its look and feel. I've loved DataTables for as long as I can remember so getting them to play nicely with each other was on my radar for a while. I finished that work last night and published it to GitHub this morning and I'm really, quite pleased with the results. This is the Readme:

DataTables comes with few themes, including those for Bootstrap 3 and 4. These themes work really well for Material Design for Bootstrap (MDB) as is, but...

This work enhances the Bootstrap 3 theme and takes advantage of the nice pagination and inputs available in MDB to make it look even better!

Initially only the pagination was implemented but, with some help and advice from Allan I got the filter input as well as the length select to work, using Input Fields and Material Select respectively.

There is presently no support for custom renderers for these elements so the "initComplete" function has been hijacked. Once custom renderers are implemented I'll move the work over to a that mechanism.

Once I have more time I'll also adapt it so that it will work with BootStrap 4 and MDB4.

And this is the code:

/*! DataTables MDBootstrap 3 integration
 * Based upon DataTables Bootstrap 3 integration
 * Dominic Myers <annoyingmouse@gmail.com>
 */

/**
 * DataTables integration for MDBootstrap 3. This requires MDBootstrap 3 and
 * DataTables 1.10 or newer.
 *
 * This file sets the defaults and adds options to DataTables to style its
 * controls using Bootstrap.
 */
(function (factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD
        define(['jquery', 'datatables.net'], function ($) {
            return factory($, window, document);
        });
    } else if (typeof exports === 'object') {
        // CommonJS
        module.exports = function (root, $) {
            if (!root) {
                root = window;
            }
            if (!$ || !$.fn.dataTable) {
                // Require DataTables, which attaches to jQuery, including
                // jQuery if needed and have a $ property so we can access the
                // jQuery object that is used
                $ = require('datatables.net')(root, $).$;
            }
            return factory($, root, root.document);
        };
    } else {
        // Browser
        factory(jQuery, window, document);
    }
}(($, window, document, undefined) => {
    'use strict';
    let DataTable = $.fn.dataTable;
    /* Set the defaults for DataTables initialisation */
    $.extend(true, DataTable.defaults, {
        "dom": `
            <'row'
                <'col-sm-6 input-field'
                    l
                >
                <'col-sm-6 input-field'
                    f
                >
            >
            <'row'
                <'col-sm-12'
                    tr
                >
            >
            <'row'
                <'col-sm-5'
                    i
                >
                <'col-sm-7'
                    p
                >
            >`,
        "renderer": 'mdbootstrap',
        "language": {
            "lengthMenu": "_MENU_",
            "paginate": {
                "first": `
                    <i class="material-icons">
                        first_page
                    </i>`,
                "last": `
                    <i class="material-icons">
                        last_page
                    </i>`,
                "next": `
                    <i class="material-icons">
                        chevron_right
                    </i>`,
                "previous": `
                    <i class="material-icons">
                        chevron_left
                    </i>`
            }
        },
        "initComplete": () => {
            /*
             * This makes the length dropdown into a material select
             */
            let lengthDiv = $(".dataTables_length"),
                lengthSelect = lengthDiv
                    .find("select[name$='_length']"),
                lengthSelectClone = lengthSelect
                    .clone(true);
            lengthDiv
                .replaceWith(lengthSelectClone);
            lengthSelectClone
                .material_select();
            let lengthSelectCloneCaret = lengthSelectClone
                .parent()
                .find(".caret");
            lengthSelectCloneCaret
                .css("display", "block")
                .css("width", "initial")
                .css("border-top", "initial");

            /*
             * This makes the filter input work as a material input
             */
            let filterDiv = $(".dataTables_filter"),
                filterDivParent = filterDiv
                    .parent(".input-field"),
                filterInput = filterDiv
                    .find("input[type='search']"),
                filterInputClone = filterInput
                    .clone(true),
                tableId = filterDiv
                    .closest(".dataTables_wrapper")
                    .attr("id")
                    .split("_")[0],
                filterLabel = filterInput
                    .parent("label")
                    .attr("for", tableId + "_cloned_input")
                    .empty()
                    .text("Search")
                    .clone(true);
            filterInputClone.attr({
                "id": tableId + "_cloned_input",
                "type": "text"
            }).on("blur", () => {
                /*
                 * We need to ensure that the active class is removed from the input if there
                 * isn't a value to make it look pretty.
                 */
                if (!filterInputClone.val().length) {
                    filterLabel.removeClass("active");
                }
            });
            filterDivParent
                .empty()
                .append(`
                    <i class="material-icons prefix">
                        search
                    </i>`)
                .append(filterInputClone)
                .append(filterLabel)
        }
    });
    /* Default class modification */
    $.extend(DataTable.ext.classes, {
        sWrapper: "dataTables_wrapper form-inline dt-bootstrap",
        sProcessing: "dataTables_processing panel panel-default"
    });
    /* MDBootstrap paging button renderer */
    DataTable.ext.renderer.pageButton.mdbootstrap = (settings, host, idx, buttons, page, pages) => {
        let api = new DataTable.Api(settings);
        let classes = settings.oClasses;
        let lang = settings.oLanguage.oPaginate;
        let aria = settings.oLanguage.oAria.paginate || {};
        let counter = 0;
        let getDisplayClass = button => {
            let tempBtns = {
                "tempBtnDisplay": "",
                "tempBtnClass": ""
            };
            let tempBtnDisplays = {
                "ellipsis": () => {
                    tempBtns.btnDisplay = "&#x2026;";
                    tempBtns.btnClass = "disabled";
                },
                "first": () => {
                    tempBtns.btnDisplay = lang.sFirst;
                    tempBtns.btnClass = button + (page > 0 ? '' : ' disabled');
                },
                "previous": () => {
                    tempBtns.btnDisplay = lang.sPrevious;
                    tempBtns.btnClass = button + (page > 0 ? '' : ' disabled');
                },
                "next": () => {
                    tempBtns.btnDisplay = lang.sNext;
                    tempBtns.btnClass = button + (page < pages - 1 ? '' : ' disabled');
                },
                "last": () => {
                    tempBtns.btnDisplay = lang.sLast;
                    tempBtns.btnClass = button + (page < pages - 1 ? '' : ' disabled');
                },
                "default": () => {
                    tempBtns.btnDisplay = button + 1;
                    tempBtns.btnClass = page === button ? 'active' : '';
                }
            };
            (tempBtnDisplays[button] || tempBtnDisplays["default"])();
            return tempBtns;
        };
        let attach = (container, buttons) => {
            let i, ien, node, button;
            let clickHandler = e => {
                e.preventDefault();
                if (!$(e.currentTarget).hasClass('disabled') && api.page() != e.data.action) {
                    api.page(e.data.action).draw('page');
                }
            };
            for (i = 0, ien = buttons.length; i < ien; i++) {
                button = buttons[i];

                if ($.isArray(button)) {
                    attach(container, button);
                }
                else {
                    let btnDisplayClass = getDisplayClass(button);
                    if (btnDisplayClass.btnDisplay) {
                        node = $('<li>', {
                            'class': classes.sPageButton + ' ' + btnDisplayClass.btnClass,
                            'id': idx === 0 && typeof button === 'string' ?
                                settings.sTableId + '_' + button :
                                null
                        }).append($('<a>', {
                                'href': '#',
                                'aria-controls': settings.sTableId,
                                'aria-label': aria[button],
                                'data-dt-idx': counter,
                                'tabindex': settings.iTabIndex
                            }).html(btnDisplayClass.btnDisplay)
                        ).appendTo(container);
                        settings.oApi._fnBindAction(
                            node, {action: button}, clickHandler
                        );
                        counter++;
                    }
                }
            }
        };
        // IE9 throws an 'unknown error' if document.activeElement is used
        // inside an iframe or frame.
        let activeEl;
        try {
            // Because this approach is destroying and recreating the paging
            // elements, focus is lost on the select button which is bad for
            // accessibility. So we want to restore focus once the draw has
            // completed
            activeEl = $(host).find(document.activeElement).data('dt-idx');
        } catch (e) {
        }
        attach(
            $(host).empty().html('<ul class="pagination pag-circle"/>').children('ul'), buttons
        );
        if (activeEl) {
            $(host).find('[data-dt-idx=' + activeEl + ']').focus();
        }
    };
    return DataTable;
}));

Wednesday, 4 January 2017

Javascript: Finding the index of an Object in an Array

Having done this more times than is wise it's documented here so that, when it's forgotten, this will act as an aide memoire.

Objects can be a bugger as they aren't equivalent unless they are actually the same thing:

console.log({"name": "Fred"} !== {"name": "Fred"});
var a = {"name": "Fred"};
var b = a;
console.log(a === b);

Meaning that the best way to check equivalence is to convert them to strings and compare them that way:

console.log(JSON.stringify({"name": "Fred"}) === JSON.stringify({"name": "Fred"}));

This string comparison is what happens in the following script. It iterates over the array of objects using the map array method and returns the index or false if the object does not exist in that position in the array. False values are filtered, and the array is counted. If the length is greater than zero then the index is again calculated and returned, otherwise -1 is returned.

var list = [
    {
        "name": "Jane"
    }, {
        "name": "John"
    }, {
        "name": "William"
    }],
    target = {
        "name": "Fred"
    },
    s = JSON.stringify,
    p = JSON.parse,
    c = a => p(s(a).replace(/(,false)/g, "").replace(/\[false\,/, "[").replace(/\,false\]/, "]").replace(/\[false\]/, "[]")),
    index = (c(list.map((o, i) => (s(o) === s(target)) && i)).length > 0) ? c(list.map((o, i) => (s(o) === s(target)) && i))[0] : -1;

console.log("index:", index);

It must be pointed out, though, that if the object is present more than once the index of the first appearance is returned.

The code is slightly tongue-in-cheek as it's stupidly minimal, please do take some time to check that you know what's going on before using it.

Thursday, 22 December 2016

Cambridge Evening News Classified Section Friday, May 3, 1974 p.8

How things have changed!
The advert reads:


NATIONAL BLOOD
TRANSFUSION SERVICE 

Long Road. 
Cambridge CB2 2PT 

BLOOD DONOR ATTENDANT

Ladies who are willing to 
travel throughout East Anglia
and work irregular hours are
required to staff mobile blood
collecting teams. Training will
be given. Adequate allowances
are payable for day and over-
night absences from Cam-
bridge. Uniform provided free.
Commencing salary £843 per
annum at age 18, rising to
£1,221 per annum commenc-
ing at £1,041 at age 21 and
over. 

PART-TIMERS 

to work 40 hour fortnight also 
required. 

Please write or telephone
Cambridge 45921

Tuesday, 13 December 2016

Extending jQuery Validation to cope with whitespace

I like formatting HTML; I go through all sorts of weird and wonderful ways of formatting HTML. One of my favourite things is to format forms so I can see things properly as I'm coding without too much horizontal scrolling, as such, I end up with forms a little like this:

<form 
  class="formTwo" 
  id="formTwo" 
  method="get" 
  action="">
    <fieldset>
        <legend>
            Test Two
        </legend>
        <p>
            <label 
              for="word2">
                Word 2 (required, at least 2 characters)
            </label>
            <input 
              id="word2" 
              name="word2" 
              type="text"/>
        </p>
        <p>
            <label 
              for="comment2">
                Comment 2 (required)
            </label>
            <textarea 
              id="comment2" 
              name="comment2">
              </textarea>
        </p>
        <p>
            <input 
              class="submit" 
              type="submit" 
              value="Submit"/>
        </p>
    </fieldset>
</form>

The thing is the Textarea ends up having a shed load of white space within it. This means that when I get around to validating that the Textarea has content using jQuery Validation I end up getting false positives as it counts whitespace as a valid input. All I'd need to do to pass the validation for the form above, with the following validation rules in place:

$("#formTwo").validate({
    "rules":{
        "word2": {
            "required": true,
            "minlength": 2
        },
        "comment2": "required"
    },
    "messages": {
        "word2": {
            "required": "Please enter a word",
            "minlength": "Your word must consist of at least 2 characters, with no spaces"
        },
        "comment2": "Don't be a prat!"
    }
});

Is to enter 2 spaces!

I've boon looking around at ways of solving this for a while, as it isn't only an issue for people who have odd indentation habits and I've come across two main ways of sorting it. One is to copy the required method, replace it and then call it again after running the replacement; the other is to extend it. I think that I prefer extending it but here are the two methods I've come across:

Copy, repalce and implement

$.validator.methods.oldRequired = $.validator.methods.required;
$.validator.addMethod("required", function(value, element, param) {
    if (value.trim().length === 0) {
        return false;
    }
    return $.validator.methods.oldRequired.call(this, value, element, param);
}, $.validator.messages.required);

Extend

$.extend($.validator.methods, {
    required: function(b, c, d) {
        if (!this.depend(d, c)) return "dependency-mismatch";
        if ("select" === c.nodeName.toLowerCase()) {
            var e = a(c).val();
            return e && e.length > 0
        }
        return this.checkable(c) ? this.getLength(b, c) > 0 : b.trim().length > 0
    }
});

Both methods add a check on the trimmed value passed to the method; ensuring that whitespace is removed before the value is tested.

I think I prefer the extension method, it was inspired by this answer, whereas the first method was stolen from Craig Stuntz.

It might be less robust, though, especially if the plugin undergoes a radical change and the required method alters a great deal, but I think that that's a small price to pay for elegance.

Wednesday, 30 November 2016

Transforming an object with arrays into an array of objects with Javascript

This question was a lot of fun and I spent a happy 10 minutes figuring out how to transform the data. This seems to do the job:

var obj = {
    "pharmacy": [
        "Walmart",
        "Safeway",
        "Kroger Pharmacy"
    ],
    "price": [
        58.14,
        65.45,
        66.76
    ]
}, data = [];

for(var i = 0; i < obj[Object.keys(obj)[0]].length; i++){
    var tempObject = {};
    for(var key in obj){
        tempObject[key] = obj[key][i];
    }
    data.push(tempObject)
}

console.log(data);

I'm only putting it up here so I don't forget how to do it again if I need to do so. It's also worthwhile noting that the initial arrays need to be the same length or things could go really quite wrong.

Saturday, 5 November 2016

DataTable Row Re-order

I got a little stumped by a Question on stackoverflow. The poster asked about swapping rows within a DataTable and I've never had a need to do that so I got to thinking about how it might be done. The poster also got into a wee bit of tizzy over the use of this so that was the first thing I corrected. Afterwards, it kept playing on my mind as an interesting challenge so this morning I decided to scratch that itch. I clocked that I needed an artificial index so added a hidden column to the table upon which to sort the rows:

<div class="container">
    <table cellpadding="0" cellspacing="0" border="0" class="table" id="example">
        <thead>
            <tr>
                <th>Index</th>
                <th>Rendering engine</th>
                <th>Browser</th>
                <th>Platform(s)</th>
                <th>Swap Down</th>
                <th>Swap Up</th>
            </tr>
        </thead>
        <tbody>
            <tr>
                <th>0</th>
                <td>Trident</td>
                <td>Internet Explorer 4.0</td>
                <td>Win 95+</td>
                <td></td>
                <td></td>
            </tr>
            <tr>
                <th>1</th>
                <td>Trident</td>
                <td>Internet Explorer 5.0</td>
                <td>Win 95+</td>
                <td></td>
                <td></td>
            </tr>
            <tr>
                <th>2</th>
                <td>Trident</td>
                <td>Internet Explorer 5.5</td>
                <td>Win 95+</td>
                <td></td>
                <td></td>
            </tr>
            <tr>
                <th>3</th>
                <td>Trident</td>
                <td>Internet Explorer 6</td>
                <td>Win 98+</td>
                <td></td>
                <td></td>
            </tr>
            <tr>
                <th>4</th>
                <td>Trident</td>
                <td>Internet Explorer 7</td>
                <td>Win XP SP2+</td>
                <td></td>
                <td></td>
            </tr>
            <tr>
                <th>5</th>
                <td>Trident</td>
                <td>AOL browser (AOL desktop)</td>
                <td>Win XP</td>
                <td></td>
                <td></td>
            </tr>
            <tr>
                <th>6</th>
                <td>Gecko</td>
                <td>Firefox 1.0</td>
                <td>Win 98+ / OSX.2+</td>
                <td></td>
                <td></td>
            </tr>
            <tr>
                <th>7</th>
                <td>Gecko</td>
                <td>Firefox 1.5</td>
                <td>Win 98+ / OSX.2+</td>
                <td></td>
                <td></td>
            </tr>
            <tr>
                <th>8</th>
                <td>Gecko</td>
                <td>Firefox 2.0</td>
                <td>Win 98+ / OSX.2+</td>
                <td></td>
                <td></td>
            </tr>
            <tr>
                <th>9</th>
                <td>Gecko</td>
                <td>Firefox 3.0</td>
                <td>Win 2k+ / OSX.3+</td>
                <td></td>
                <td></td>
            </tr>
            <tr>
                <th>10</th>
                <td>Gecko</td>
                <td>Camino 1.0</td>
                <td>OSX.2+</td>
                <td></td>
                <td></td>
            </tr>
            <tr>
                <th>11</th>
                <td>Gecko</td>
                <td>Camino 1.5</td>
                <td>OSX.3+</td>
                <td></td>
                <td></td>
            </tr>
            <tr>
                <th>12</th>
                <td>Gecko</td>
                <td>Netscape 7.2</td>
                <td>Win 95+ / Mac OS 8.6-9.2</td>
                <td></td>
                <td></td>
            </tr>
            <tr>
                <th>13</th>
                <td>Gecko</td>
                <td>Netscape Browser 8</td>
                <td>Win 98SE+</td>
                <td></td>
                <td></td>
            </tr>
            <tr>
                <th>14</th>
                <td>Misc</td>
                <td>Lynx</td>
                <td>Text only</td>
                <td></td>
                <td></td>
            </tr>
        </tbody>
    </table>
</div>

This JS seems to do the trick in terms of swapping rows:

$(function() {
    var table = $("#example").DataTable({
        "order": [
            [0, 'asc']
        ],
        "paging": true,
        "columns": [{
            "visible": false
        }, {
            "orderable": false
        }, {
            "orderable": false
        }, {
            "orderable": false
        }, {
            "render": function(d) {
                return $("<i/>", {
                    "class": "fa fa-caret-down swapable swapDown"
                }).prop("outerHTML");
            },
            "orderable": false
        }, {
            "render": function(d) {
                return $("<i/>", {
                    "class": "fa fa-caret-up swapable swapUp"
                }).prop("outerHTML");
            },
            "orderable": false
        }]
    });
    $('#example tbody').on('click', 'td', function(event) {
        // We're only interested in cells with a class of swapable
        if ($(this).find(".swapable")) {
            // Helper variable
            var _this = $(this).find(".swapable");
            // Total number of rows (including hidden rows (zero based index))
            var tableRows = table.data().length - 1;
            // Index of current row
            var rowIndex = table.row(this).index();
            // Data of current row
            var rowData = table.row(this).data();
            // Index value of row (artifical because it's ours) - also tempval
            var artificalIndex = ~~rowData[0];
            /*
             * If we're on the bottom row of the table and the direction of travel is downwards  or if we're
             * on the top row and the direction of travel is upwards then we need to just swap the top and
             * bottom rows
             */
            if(
                    (_this.hasClass("swapDown") && artificalIndex === tableRows)
                    ||
                    (_this.hasClass("swapUp") && artificalIndex === 0)
            ){
                var topIndex, bottomIndex;
                table.rows().every(function(rowIdx, tableLoop, rowLoop) {
                    var data = this.data();
                    if(~~data[0] === 0){
                        topIndex = rowIdx;
                    }
                    if(~~data[0] === tableRows){
                        bottomIndex = rowIdx;
                    }
                });
                table.cell(topIndex, 0).data(tableRows);
                table.cell(bottomIndex, 0).data(0);
            }else{
                var movingIndex, tempData;
                table.rows().every(function(rowIdx, tableLoop, rowLoop) {
                    var data = this.data();
                    // Moving down
                    if (_this.hasClass("swapDown") && ~~data[0] === artificalIndex + 1) {
                        movingIndex = rowIdx;
                        tempData = data[0];
                    }
                    // Moving up
                    if (_this.hasClass("swapUp") && ~~data[0] === artificalIndex - 1) {
                        movingIndex = rowIdx;
                        tempData = data[0];
                    }
                });
                table.cell(rowIndex, 0).data(tempData);
                table.cell(movingIndex, 0).data(artificalIndex);
            }
            table.draw(false);
        }
    });
});

I'm happy that it works but it does seem a little inefficient and I'm interested in how it might be improved if anyone can come up with any ideas? The working JSFiddle is here so please use that as a base upon which to work.

One of my main issues was figuring out how to cope with people clicking the up icon on the top row or the down icon on the bottom row, then I clocked that it merely meant that the top and bottom rows needed to be swapped. I also ran into difficulty figuring out the index of rows as I'm pretty sure it isn't updated after a draw as I kept running into issues... this is the reason I introduced my own, artificial, index in the hidden first column.