Saturday, 6 April 2013

Table Football score list with Backbone.js and localStorage


Once upon a time there was a small company filled with talented and hard working individuals who played table football all the time. They were so competitive they thought it'd be a great idea to record the scores on a web app. However, they never had a chance to finish completing the app. They loved Backbone.js and Twitter Bootstrap, so they thought it was a natural choice to use it. All there was left to do was to find someone to get the job done...

The app needed some way of adding and editing table football results and players with persisting these beyond the browser session.


They also wanted the winners name to be highlighted  in yellow so they could easily see who's won a match.


Another requirement was that the results had to be saved in browsers localStorage and displayed when players return to the page.

This apps had to use Underscore’s template function. The template that had to be passed into it was  required to have a bi-directional binding (both model and DOM updates) :

HTML Template:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Table Football Scores</title>
<link href="css/bootstrap.css" rel="stylesheet">
</head>
<body>
<div class="navbar">
  <div class="navbar-inner">
    <div class="container"> <a class="brand" href="#"> Table Football Scores </a>
      <ul class="nav">
        <li><a href="#players">Player List</a></li>
        <li><a href="#results">Results List</a></li>
        <li><a href="#addplayer">Add Player</a></li>
        <li><a href="#addresult">Add Result</a></li>
        <li><a href="#editplayer">Edit Players</a></li>
        <li><a href="#editresult">Edit Results</a></li>
      </ul>
    </div>
  </div>
</div>
<div id="content"></div>
<!-- Templates --> 
<script type="text/template" id="tpl-player-list-item">
            <td><a href='#playerDetail/<%= id %>'><%= firstName %> <%= lastName %></a></td>
        </script> 
<script type="text/template" id="tpl-result-list-item">
            <td><%= player1.name %></td>
            <td><%= player1.score %></td>
            <td><%= player2.name %></td>
            <td><%= player2.score %></td>
        </script> 
<script type="text/template" id="nameInput">
  <form action="#" id="playerName">
     <label>First Name:</label><input class="firstName" value=""/>
  <label>Last Name:</label> <input class="lastName" value=""/>
     <button id="addPlayerName">Save</button>
  </form>
  </script> 
<script type="text/template" id="resultInput">
  <form action="#">
  <div id="filter1"><label>Player 1:</label></div>
  <label>Result Player 1:</label><input class="result1" value=""/>
  <div id="filter2"><label>Player 2:</label></div>
  <label>Result Player 2:</label><input class="result2" value="" />
  <button id="addResults">Save</button>
  </form>
  </script> 
<script type="text/template" id="playerEdit">
  <form action="#">
  <div id="playerSelect"><label>Select a Player:</label></div>
  <label>First Name:</label><input class="firstName" value=""/>
  <label>Last Name:</label> <input class="lastName" value=""/>
  <button id="editPlayer">Save</button>
  </form>
  </script> 
<script type="text/template" id="resultEdit">
  <form action="#">
  <div id="gameSelect"><label>Select a Game:</label></div>
  <div id="filter3"><label>Player 1:</label></div>
  <label>Result Player 1:</label><input class="result1" value=""/>
  <div id="filter4"><label>Player 2:</label></div>
  <label>Result Player 2:</label><input class="result2" value="" />
  <button id="editResult">Save</button>
  </form>
  </script> 
<!-- JS Scripts --> 
<script type="text/javascript" src="libs/underscore.js"></script> 
<script type="text/javascript" src="libs/jquery-1.6.2.js"></script> 
<script type="text/javascript" src="libs/backbone.js"></script> 
<script type="text/javascript" src="js/backbone.localStorage.js"></script> 
<script type="text/javascript" src="js/app.js"></script>
</body>
</html>
Applying <%= and %> tags had to make the template function replace those areas with the object properties defined in separate JSON files (one for results and one for the players). Model binding links had to be established  between the data in the models and mark-up shown by the views.

JavaScript View:
var TFS = TFS || {};
var AppRouter = Backbone.Router.extend({
    routes: {
        "results": "listResults",
        "players": "listPlayers",
        "addresult": "addResult",
        "addplayer": "addPlayer",
        "editresult": "editResult",
        "editplayer": "editPlayer",
        "": "listPlayers"
    },
    initialize: function () {
        this.results = new TFS.ResultCollecton();
        this.players = new TFS.PlayerCollection();
        this.resultsView = new TFS.ResultsListView({
            model: this.results
        });
        this.playersView = new TFS.PlayerListView({
            model: this.players
        });
        this.players.fetch();
        var rs = "ResultsStore";
        var rc = this.results;
        if (localStorage.getItem(rs)) {
            rc.localStorage = new Backbone.LocalStorage(rs);
            rc.fetch();
        } else {
            rc.fetch().done(function () {
                rc.localStorage = new Backbone.LocalStorage(rs);
                _.each(rc.models, function (r) {
                    r.save()
                });
            });
        }
    },
    listPlayers: function () {
        $('#content').html(this.playersView.render().el);
    },
    listResults: function () {
        $('#content').html(this.resultsView.render().el);
    },
    addPlayer: function () {
        var addPlayerView = new TFS.AddPlayerView();
        $('#content').html(addPlayerView.render().el);
    },
    addResult: function () {
        var addResultsView = new TFS.AddResultsView({
            model: this.players
        });
        $('#content').html(addResultsView.render().el);
    },
    editPlayer: function () {
        var editPlayerView = new TFS.EditPlayerView({
            model: this.players
        });
        $('#content').html(editPlayerView.render().el);
    },
    editResult: function () {
        var editResultsView = new TFS.EditResultsView({
            model: this.results
        });
        $('#content').html(editResultsView.render().el);
    },
});
TFS.Player = Backbone.Model.extend({});
TFS.Result = Backbone.Model.extend({});
TFS.PlayerCollection = Backbone.Collection.extend({
    model: TFS.Player,
    url: "data/players.json"
});
TFS.ResultCollecton = Backbone.Collection.extend({
    model: TFS.Result,
    url: "data/results.json"
});
TFS.AddResultsView = Backbone.View.extend({
    template: _.template($('#resultInput').html()),
    initialize: function () {
        this.model.bind("reset", this.render, this);
    },
    events: {
        "click #addResults": "saveResults",
    },
    saveResults: function (e) {
        e.preventDefault();
        var saveObj = TFS.validateResults(e.target);
        if (saveObj.msg) {
            alert(saveObj.msg);
        } else {
            e.target.parentNode.reset();
            TFS.app.results.create(saveObj.resultEntry);
            alert("New result has been added.")
        }
    },
    render: function () {
        $(this.el).html(this.template());
        $(this.el).find("#filter1").append(TFS.createPlayerSelect());
        $(this.el).find("#filter2").append(TFS.createPlayerSelect());
        return this;
    }
})
TFS.validateResults = function (t) {
    var o = {
        msg: "",
        resultEntry: {}
    };
    var formData = [];
    var selData = [];
    var inputs = $(t).siblings("input");
    var selections = $(t).siblings("div").find("select");

    function isNormalInteger(str) {
        var n = ~~Number(str);
        return String(n) === str && n >= 0;
    }
    var l = inputs.length;
    while (l) {
        var el = inputs[l - 1];
        if (isNormalInteger($(el).val())) {
            formData.push($(el).val());
        } else {
            o.msg = "Note: Enter positive numeric value in the Result fields.";
            return o;
        }--l;
    }
    var l = selections.length;
    while (l) {
        var el = selections[l - 1];
        if ($(el).val() !== "" && !~selData.indexOf($(el).val())) {
            selData.push($(el).val());
        } else {
            o.msg = "Please select different players' names from the lists";
            return o;
        }--l;
    }
    o.resultEntry = {
        player1: {
            name: selData[0],
            score: formData[0]
        },
        player2: {
            name: selData[1],
            score: formData[1]
        }
    }
    return o;
}
TFS.createPlayerSelect = function () {
    var select = $("<select/>", {
        html: "<option value=''>Select a Player</option>"
    });
    _.each(TFS.app.players.models, function (item) {
        var option = $("<option/>", {
            value: item.attributes.firstName,
            text: item.attributes.firstName
        }).appendTo(select);
    });
    return select;
}
TFS.AddPlayerView = Backbone.View.extend({
    template: _.template($('#nameInput').html()),
    events: {
        "click #addPlayerName": "saveName",
    },
    saveName: function (e) {
        e.preventDefault();
        var formData = {
            id: TFS.app.players.length + 1,
        };
        var inputs = $(e.target).siblings("input");
        var l = inputs.length;
        while (l) {
            var el = inputs[l - 1];
            if ($(el).val() !== "") {
                formData[el.className] = $(el).val();
            } else {
                alert("Please enter first and last names");
                return false;
            }--l;
        }
        TFS.app.players.add(formData);
        e.target.parentNode.reset();
        alert("New player has been added to the list");
    },
    render: function () {
        $(this.el).html(this.template());
        return this;
    }
});
TFS.EditPlayerView = Backbone.View.extend({
    template: _.template($('#playerEdit').html()),
    initialize: function () {
        this.model.bind("reset", this.render, this);
    },
    events: {
        "click #editPlayer": "saveResults",
        "change #playerSelect select": "setFilter"
    },
    setFilter: function (e) {
        var selected = $(e.currentTarget).find('option:selected');
        var inputs = $(this.el).find("#playerSelect ~ input");
        $(inputs[0]).val($(selected).attr("data-firstName"));
        $(inputs[1]).val($(selected).attr("data-lastName"));
    },
    createGameSelect: function () {
        var select = $("<select/>", {
            html: "<option value=''>Please select</option>"
        });
        _.each(this.model.models, function (item) {
            var option = $("<option/>", {
                value: item.attributes.id,
                text: item.attributes.id,
                "data-firstName": item.attributes.firstName,
                "data-lastName": item.attributes.lastName
            }).appendTo(select);
        });
        return select;
    },
    saveResults: function (e) {
        e.preventDefault();
        var selection = $(e.target).siblings("div").find("select").val();
        if (!selection) {
            alert("Note: Select a player");
            return false;
        }
        var inputs = $(e.target).siblings("input");
        var l = inputs.length;
        while (l) {
            if ($(inputs[l - 1]).val() == "") {
                alert("Note: First or last name is blank");
                return false;
            }--l;
        }
        this.model.models[selection - 1].set({
            firstName: $(inputs[0]).val(),
            lastName: $(inputs[1]).val()
        })
        e.target.parentNode.reset();
        alert("Player details have been edited.")
    },
    render: function () {
        $(this.el).html(this.template());
        $(this.el).find("#playerSelect").append(this.createGameSelect());
        return this;
    }
});
TFS.EditResultsView = Backbone.View.extend({
    template: _.template($('#resultEdit').html()),
    events: {
        "click #editResult": "saveResults",
    },
    initialize: function () {
        this.model.bind("reset", this.render, this);
        TFS.app.players.bind("reset", this.render, this);
    },
    createSelect: function () {
        var select = $("<select/>", {
            html: "<option value=''>Select a game</option>"
        });
        var i = 0;
        _.each(this.model.models, function (item) {
            i++;
            var option = $("<option/>", {
                value: i,
                text: i,
                "data-firstPlayer": item.attributes.player1,
                "data-secondPlayer": item.attributes.player2
            }).appendTo(select);
        });
        return select;
    },
    saveResults: function (e) {
        e.preventDefault();
        var selection = $(e.target).siblings("div").find("select").val();
        if (!selection) {
            alert("Note: Select a Game");
            return false;
        }
        var saveObj = TFS.validateResults(e.target);
        if (saveObj.msg) {
            alert(saveObj.msg);
            return false;
        }
        selection--;
        var m = this.model.models[selection];
        m.id = this.model.localStorage.records[selection]
        m.set({
            player1: saveObj.resultEntry.player1,
            player2: saveObj.resultEntry.player2
        })
        this.model.localStorage.update(m);
        alert("Result entry has been modified");
        e.target.parentNode.reset();
    },
    render: function () {
        $(this.el).html(this.template());
        $(this.el).find("#gameSelect").append(this.createSelect());
        $(this.el).find("#filter3").append(TFS.createPlayerSelect());
        $(this.el).find("#filter4").append(TFS.createPlayerSelect());
        return this;
    }
});
TFS.PlayerListView = Backbone.View.extend({
    tagName: "table",
    className: "table table-bordered table-striped",
    initialize: function () {
        this.model.bind("reset", this.render, this);
    },
    render: function (eventName) {
        $(this.el).empty();
        _.each(this.model.models, function (player) {
            $(this.el).append(new TFS.SinglePlayerView({
                model: player
            }).render().el);
        }, this);
        return this;
    }
});
TFS.SinglePlayerView = Backbone.View.extend({
    tagName: "tr",
    template: _.template($('#tpl-player-list-item').html()),
    render: function (eventName) {
        $(this.el).html(this.template(this.model.toJSON()));
        return this;
    }
});
TFS.SingleResultView = Backbone.View.extend({
    tagName: "tr",
    template: _.template($('#tpl-result-list-item').html()),
    render: function (eventName) {
        var re = this.model.toJSON();
        $(this.el).html(this.template(re));
        var sc1 = re.player1.score,
            sc2 = re.player2.score;
        if (sc1 !== sc2) {
            $(this.el).find("td:contains(" + Math.max(sc1, sc2) + ")").prev().css("background-color", "yellow");
        }
        return this;
    }
});
TFS.ResultsListView = Backbone.View.extend({
    tagName: "table",
    className: "table table-bordered table-striped",
    initialize: function () {
        this.model.bind('change', this.render, this);
    },
    render: function (eventName) {
        $(this.el).empty();
        _.each(this.model.models, function (wine) {
            $(this.el).append(new TFS.SingleResultView({
                model: wine
            }).render().el);
        }, this);
        return this;
    }
});
TFS.app = new AppRouter();
Backbone.history.start();

They expected the task to take no longer than an hour to get working to a reasonable standard. They were not too judgemental on UI, it's the code they cared about.

So if you were looking for a table football application with high-score table here it is: 
https://github.com/dondido/table-football-score

3 comments: