Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Hosted-services / Azure

Food Tracker - a SPA built with jQuery & Azure Mobile Services

4.33/5 (4 votes)
29 Dec 2014CPOL6 min read 25.3K  
SPA to track food expiry dates shows how to implement CRUD functionality through Azure Mobile Services HTTP OData/REST calls, without writing any server-side code

Introduction

In 2010, 21% of food at the consumer level went uneaten in the US. Presumably, a big portion of wastage across the world happens inadvertently as we don't pay attention to the food expiry dates. It bugged me that a large amount of packaged food that I was throwing way could be salvaged through a little tracking. It is with this intent that I wrote this little tool that tracks best before dates on the packaging.

Here is how the Single Page app looks on the desktop browser and on a mobile browser.

Image 1

Image 2

The entire code is available at this Github Gist. To run this application as is, you'll need an Azure subscription so that you can let Azure Mobile Services take care of your back-end.

Walkthrough

This HTML5 application makes use of the following: 

  • Azure Mobile Services (alternatively, you can build your own REST API for the CRUD functionality)
  • Bootstrap 3.0.0
  • jQuery 1.10 
  • jQuery UI 1.9.2 for the DatePicker 
  • FullCalendar 2.1.1 jQuery plugin to display summary in montly view of a calendar 
  • Toastr jQuery plugin for notifications 

I was inspired to use Azure Mobile Services after watching the Microsoft Virtual Academy video tutorial Single Page Applications with jQuery or AngularJS. Azure Mobile Services allows us to outsource the back-end work to it by providing CRUD functionality through HTTP/REST calls so that we don't have to write any server-side code. Setting up the the back-end takes just a little configuration. As Azure Mobile Services supports OData, we can filter the results directly with the HTTP request, without having to get the whole content of the table first.

In my code, I get the list of items for just the current month by using the OData $filter query option -

http://example.azure-mobile.net/tables/food?$filter=bestbefore gt '" + start.toISOString() + "' and bestbefore lt '" + end.toISOString() + "'&$orderby=bestbefore"

I adapted the code from the sample (shared on Github) in the video tutorial to add Search, Update and Delete functionality and extended it for my requirement. 

At a high-level, the two main steps to run this application are:
1) Set up the back-end with Azure Mobile Services 
2) Call the ready-made CRUD methods that Azure Mobile Services provides via HTTP/REST calls through jQuery

Setting up the back-end

To run this application for yourself, you will need to set up the back-end within Azure Mobile Services and replace the placeholders in the code sample (replace your own subdomain name in place of example in the API URL http://example.azure-mobile.net/tables/food & also the value for Application key) with the values relevant to your setup. The back-end configuration step happens in the Data tab of Mobile Services in the Azure Management Portal. For a step by step guide to create the back-end, refer to this article by Stacey Mulcahy  

Ideally, the task of finding out the food item name and the best before date can be best done by a barcode reader. To just focus on the task of alerting about the best-by date and reduce data entry, the schema is kept simple. The table that I call food has the columns item & bestbefore

Image 3

We can choose to allow only those HTTP requests to our REST-based web service that have an Application key in their HTTP headers. 

Image 4

Browser security stops  pages making AJAX requests to hosts other than to its originating host. To avoid the Cross-Origin Resource Sharing issue caused due to the web service and application being hosted on different servers, we have to white-list the domain hosting the app in the Azure portal. The host name localhost is added by default in the CORS section of the Configure tab in Azure Mobile Services. If you're hosting this application on any other host, that domain name will be have to be specified in the Allow requests from host names panel.

Image 5

Fiddler or Postman Chrome extension can be used to test the REST calls 

Front-end code to tie everything together

When the page loads it will provide a snapshot of all food items that will go unusable in the current month. A tabbed panel compartmentalizes the View, Add, Edit, Search features. The View panel is shown by default and it will list the items shown in the calendar. Each item has Edit and Delete buttons next to each record.  

The FullCalendar jQuery plugin can consume a JSON feed (in our case the JSON returned by the web service) & render a data-rich, responsive Calendar control that allows us to navigate easily across months, list items pertaining to a day within each box & gets the date for programmatic use when the cell related to a day is clicked. FullCalendar has a way for us to adapt the field names to match FullCalendar's Event object format

While testing the app, the month navigation buttons of FullCalendar jQuery plugin didn't work on mobile IE & Chrome in Boostrap 3.2.0 page. Changing the code to reference Boostrap version 3.0.0 fixed the issue in the mobile browsers

Toastr is used for notifications to inform the users if each CRUD operation has succeeded or failed. I found the Toastr jQuery notification plugin simpler to setup & deploy than iGrowl & a few other plugins as it doesn't have too many dependencies.

The CSS & JS files for toastr and FullCalendar are available on CDN JS. The CSS & JS files for BootStrap are fetched from MaxCDN while the jQuery & jQuery UI files are referenced from the Google CDN. Wherever available the minified version of the files are used. Surprisingly, Google CDN doesn't provide a minified jQuery UI CSS file though jquery-ui.min.js is available. Though not deliberately planned, domain sharding thus by having external JS files served by different servers provides a small performance boost. The performance best practise of keeping stylesheets at the top of the page and scripts at the bottom, is also followed.

Though the HTML & JavaScript are all in the same file for reading convenience, moving the script to an external JavaScript file will allow it to be cached in the user's browser after it loads the first time.

JavaScript
<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="content-type" content="text/html; charset=UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Food Tracker</title>

    <link rel="stylesheet" href="http://maxcdn.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.min.css">
    <link href='http://cdnjs.cloudflare.com/ajax/libs/fullcalendar/2.1.1/fullcalendar.min.css' rel='stylesheet' />
    <link href='http://cdnjs.cloudflare.com/ajax/libs/fullcalendar/2.1.1/fullcalendar.print.css' media='print' rel='stylesheet' />
    <link rel="stylesheet" type="text/css" href="http://ajax.googleapis.com/ajax/libs/jqueryui/1.9.2/themes/smoothness/jquery-ui.css">
    <link href="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/2.0.1/css/toastr.min.css" media="screen" rel="stylesheet" type="text/css" />

    <style>
        body {      }

        #calendar {     }

        .clickable {
            cursor: pointer;
        }

        .btn {
            background-color: #e0eaf1;
            border-bottom: 1px solid #b3cee1;
            border-right: 1px solid #b3cee1;
            color: #3e6d8e;
            display: inline-block;
            font-size: 90%;
            line-height: 1.4;
            margin: 2px 2px 2px 0;
            padding: 3px 4px 3px 4px;
            text-decoration: none;
            white-space: nowrap;
        }
    </style>
</head>
<body>
    <nav class="navbar navbar-default" role="navigation">
        <div class="container-fluid">
            <div class="navbar-header">
                <a class="navbar-brand" href="#">Food Tracker</a>
            </div>
        </div>
    </nav>

    <div class="container-fluid">
        <div class="row">
            <div class="col-md-6 col-sm-12 col-xs-12">
                <div id="alert"></div>
                <div id='loading'>
                    loading...
                </div>
                <div id='calendar'></div>
            </div>
            <div class="col-md-6">
                <ul id="myTab" class="nav nav-tabs" role="tablist">
                    <li class="active"><a href="#viewTab" data-id="viewTab" role="tab" data-toggle="tab">View</a></li>
                    <li><a href="#addTab" data-id="addTab" role="tab" data-toggle="tab">Add</a></li>
                    <li><a href="#editTab" data-id="editTab" role="tab" data-toggle="tab">Edit</a></li>
                    <li><a href="#searchTab" data-id="searchTab" role="tab" data-toggle="tab">Search</a></li>
                </ul>

                <div class="tab-content">
                    <div class="tab-pane active" id="viewTab">
                        <p><div id="tracker"></div></p>
                    </div>
                    <div class="tab-pane" id="addTab">
                        <div id="addForm">
                            <form>
                                <div><br />
                                    <label for="item">Item:</label>
                                    <input type="text" name="item" id="item" autofocus="autofocus"><br />
                                    <label for="bestBefore">Best before:</label>
                                    <input name="bestBefore" id="bestBefore" type="text" class="date-picker"><br />
                                    <button id="submit" class="fc-button fc-state-default fc-corner-left fc-corner-right">Save</button>
                                </div>
                            </form>
                            <div id="status"></div>
                        </div>
                    </div>

                    <div class="tab-pane" id="editTab">
                        <br /><h6>Click on a specific item in the list on the View tab or an item in the calendar, to edit</h6>
                        <div id="editForm">
                            <form>
                                <div>
                                    <label for="item">Item:</label>
                                    <input type="text" id="eitem"><br />
                                    <label for="bestBefore">Best before:</label>
                                    <input name="ebestBefore" id="ebestBefore" type="text" class="date-picker"><br />
                                    <input type="hidden" id="itemId" value="">
                                    <button id="update" class="fc-button fc-state-default fc-corner-left fc-corner-right">Save</button>
                                </div>
                            </form>
                        </div>
                    </div>
                    <div class="tab-pane" id="searchTab">
                        <div id="searchForm">
                                 <div><br />
                                    <input type="text" id="keyword">
                                    <button id="search" class="fc-button fc-state-default fc-corner-left fc-corner-right">Search</button>
                                    <div id="results"></div>
                                </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/2.1.0/jquery.min.js"></script>
    <script type="text/javascript" src="http://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
    <script type="text/javascript" src="http://cdnjs.cloudflare.com/ajax/libs/moment.js/2.8.1/moment.min.js"></script>
    <script type="text/javascript" src="http://cdnjs.cloudflare.com/ajax/libs/fullcalendar/2.1.1/fullcalendar.min.js"></script>
    <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jqueryui/1.9.2/jquery-ui.min.js"></script>
    <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/2.0.1/js/toastr.min.js"></script>   
<script>
"use strict";

var xhr = new XMLHttpRequest();
var $item = $('#item');
var $bestBefore = $('#bestBefore');
var dataSet;

$(document).ready(function () {

    initialize();

    $(".date-picker").on("change", function () {
        //jump to a specific month
    });

    $('#calendar').fullCalendar({
        defaultDate: new Date(),
        loading: function (bool) {
            if (bool) $('#loading').show();
            else $('#loading').hide();
        },
        eventRender: function (event, element) {
            element.css('cursor', 'pointer'); //on hovering over events in calendar, hand pointer should appear not cursor
        },
        eventClick: function (calEvent, jsEvent, view) {
            $('#itemId').val(calEvent.id);
            $('#eitem').val(calEvent.title);
            $('#ebestBefore').val(calEvent.start.format());
            $(this).css('border-color', 'green');
            showElem('#editForm');
            $('#myTab li:eq(2) a').tab('show');
        },
        dayClick: function (date, jsEvent, view) {
            $('#bestBefore').val(date.format());
            $(this).css('background-color', 'cyan');
            $('#myTab li:eq(1) a').tab('show');
            $('#item').focus();
        },
        events: function (start, end, timezone, callback) {
            $.ajax({
                url: "http://example.azure-mobile.net/tables/food?$filter=bestbefore gt '" + start.toISOString() + "' and bestbefore lt '" + end.toISOString() + "'&$orderby=bestbefore",
                dataType: 'json',
                beforeSend: setHeader,
                success: function (data) {
                    var events = [];
                    $.each(data, function (i) {
                        var bbdate = data[i].bestbefore.split("T");
                        events.push({
                            "id": data[i].id,
                            "title": data[i].item,
                            "start": bbdate[0]
                        });

                    });
                    callback(events);
                    $('#tracker').empty();
                    $('#myTab #viewTab').tab('show');
                    listResults(events, "#tracker");
                },
                error: function () { toastr.error('Operation failed! Please retry'); }
            });
        }
    });
});
//SEARCH
function searchItem(keyword) {
    $.getJSON("http://example.azure-mobile.net/tables/food?$filter=substringof('" + keyword + "',item)&$orderby=bestbefore", function (data) {
        var events = [];
        $.each(data, function (i) {
            var bbdate = data[i].bestbefore.split("T");
            events.push({
                "id": data[i].id,
                "title": data[i].item,
                "start": bbdate[0]
            });
        });
        listResults(events, "#results")
    });
}
//CREATE
/* POST our newly entered data to the server
********************************************/
function restPost(food) {
    $.ajax({
        url: 'https://example.azure-mobile.net/tables/food',
        type: 'POST',
        datatype: 'json',
        beforeSend: setHeader,
        data: food,
        success: function (data) {
            toastr.success('Added ' + data.item);
        },
        error: function () { toastr.error('Operation failed! Please retry'); }
    });
}

function listResults(events, container) {
    var results = "";
    for (var i = 0; i < events.length; i++) {
        var stuff = '[{  "id":"' + events[i].id + '", "title":"' + events[i].title + '" , "start":"' + events[i].start + '" }]';
        results += "<li><a data-stuff='" + stuff + "' class='items clickable btn' data-editid='" + events[i].id + "' >Edit</a>&nbsp;|&nbsp;<a class='del clickable btn' data-deleteitem='" + events[i].title + "' data-deleteid='" + events[i].id + "' >Delete</a> | " + events[i].title + " X " + events[i].start + "</li>"
    }

    if (results == "") {
        $(container).html("Nothing to show :-(");
    }
    else {
        $(container).html(results);

        $(".items").bind('click', function () {
            var foodItem = $(this).data('stuff');
            getItem.apply(this, foodItem);
            showElem('#editForm');
            $('#myTab li:eq(2) a').tab('show');

        });

        $(".del").bind('click', function () {
            var delId = $(this).data("deleteid");
            var delItem = $(this).data("deleteitem");
            if (confirm('Are you sure you want to delete the record?')) {
                deleteItem(delId, delItem);
            }
        });
    }
}
//UPDATE
function restPatch(food) {
    $.ajax({
        url: 'https://example.azure-mobile.net/tables/food/' + food.id,
        type: 'PATCH',
        datatype: 'json',
        beforeSend: setHeader,
        data: food,
        success: function (data) {
            toastr.success('Edited ' + data.item);
        },
        error: function () { toastr.error('Operation failed! Please retry'); }
    });
}

//DELETE
function deleteItem(delId, delItem) {
    $.ajax({
        url: 'https://example.azure-mobile.net/tables/food/' + delId,
        type: 'DELETE',
        success: function (result) {
            toastr.success('Deleted ' + delItem);
            $('#calendar').fullCalendar('refetchEvents');
        },
        error: function () { toastr.error('Operation failed! Please retry'); }
    });
}

$('#search').on('click', function (e) {
    searchItem($('#keyword').val());
});

$('#submit').on('click', function (e) {
    e.preventDefault();
    var food = {
        item: $item.val(),
        bestBefore: $bestBefore.val()
    };

    restPost(food);
    $('#calendar').fullCalendar('refetchEvents');
    $('#myTab li:eq(0) a').tab('show');
    resetForm('#editForm');
});

$('#update').on('click', function (e) {
    e.preventDefault();
    var food = {
        id: $('#itemId').val(),
        item: $('#eitem').val(),
        bestBefore: $('#ebestBefore').val()
    };
    restPatch(food);
    $('#calendar').fullCalendar('refetchEvents');
    hideElem('#editForm');
    $('#myTab li:eq(0) a').tab('show');
    resetForm('#editForm');
});

$.ajaxSetup({
    headers: {
        'X-ZUMO-APPLICATION': '--paste your APPLICATION KEY here--'
    }
});

function getItem(foodItem) {
    $('#itemId').val(foodItem.id);
    $('#eitem').val(foodItem.title);
    $('#ebestBefore').val(foodItem.start);
}

/* Used for authorization, to access the JSON data in the Azure Mobile Service
******************************************************************************/
function setHeader(xhr) {
    xhr.setRequestHeader('X-ZUMO-APPLICATION', '--paste your APPLICATION KEY here--');
}

function initialize() {
    hideElem('#editForm');
    $.datepicker.setDefaults({ dateFormat: 'yy-mm-dd' });
    $(".date-picker").datepicker();

    toastr.options.timeOut = 1500; // 1.5s
    toastr.options.closeButton = true;
    toastr.options.positionClass = "toast-top-right";
}

function showElem(elem) {
    $(elem).show();
}

function hideElem(elem) {
    $(elem).hide();
}

function resetForm(form) {
    $(form).find("input[type=text]").val("");
}
</script>
</body>
</html>

To keep the code short, I have used minimal styling and omitted several things such as validation of input fields. The sample currently works for a single user but it is possible to authenticate users against multiple providers with Azure Mobile Services.

Unresolved issues: 

Points of Interest

Azure Mobile Services helped me cut down the time in building an app with CRUD functionality. I was impressed with the FullCalendar and Toastr  jQuery plugins as they are powerful and have comprehensive documentation. Getting all these utilities to play together & also solve a practical problem (at least for myself) of not wasting food and other consumables by using it before its best-before date, was fun.

History

Updated with additional notes: 2014-12-11

First published: 2014-12-10

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)