Tag: <span>JavaScript</span>

I recently wanted to set up Did I Hike That? to allow for a demo user that could play with the app but not affect any existing data. This required that I know who created each hike record, which I didn’t include in the initial design. I realize now that was a bit shortsighted. But no worries, that’s what database migrations are for!

I use Sequelize as the ORM for this app and have already done one migration on the database, so this would just be a new one. I originally used the Umzug library to do things programmatically since I didn’t want to mess with calling the CLI. The new migration script needed to add a new column to the hike table and then update that value for every hike. Sounded easy, but there was a bit of nuance.

I wanted everything to run inside a transaction so if something went sideways, the database would be left untouched. But I also needed the new column to be present inside the transaction so I could update it later in the script, which was a bit of a catch-22. This project is using Sequelize 6, which doesn’t allow nested transactions (it seems v7 will support them). In v6 there are a couple ways to execute a transaction:

  • Managed, where you call the built-in transaction method and pass it a callback. If an exception is thrown inside that function, the changes are rolled back, otherwise they are committed.
  • Un-managed, where you call the transaction method and use the object it returns to manually commit or roll back changes by calling an appropriate method.

The previous migration script used the un-managed approach because I wanted maximum flexibility on when things happened. But when I ran the new migration script there were a couple of problems. The entire up function is in a try/catch, but the column change would persist even if an exception happened somewhere (the raw query of the hike table had some minor issues as I was writing it that would produce an error). Once I got through that it got down to the point of updating the new column, but kept throwing a column not found error.

I don’t remember if I knew this before, but after some web searching and re-reading the docs I realized you can include that transaction object in any call of the query function on the main sequelize object. The docs say having that object will cause Sequelize to create a save point for whatever query you are executing. It won’t commit it until you call commit but it will remember the change for later queries which include the same transaction object. Which is exactly what I needed. The end result looked something like this:

const transaction = await queryInterface.sequelize.transaction();

try {
    const hikeTableDefinition = await queryInterface.describeTable('hikes');

    if (!hikeTableDefinition.userId) {
        // Including the transaction here will make Sequelize aware of the new column so we can update it later
        await queryInterface.addColumn('hikes', 'userId', { type: DataTypes.STRING }, { transaction });
    }

    ...

    await queryInterface.sequelize.query("Update hikes Set userId = 'some_user_id' Where id = 'some_hike_id'", { transaction });

    await transaction.commit();
catch (error) {
    await transaction.rollback();
    throw error;
}

The two queries that modify the database include the top-level transaction so they are both aware of any changes being made. If the hike table update blows up for some reason it will roll back everything, which was perfect since I was looping through all the hike records and wanted it to be all or nothing. I added the check for the existence of the new column just to make doubly-sure it doesn’t throw unnecessarily, since trying to add a column that’s already there will definitely throw.

This is part 2 in a series of posts about creating a mobile web app for browsing music databases. Part 1 can be found here.

The first task in building the front end was testing my API to make sure I knew what was being returned by Rovi and that it had everything I wanted. I added some test JavaScript to the default MVC view that would call my API. It was a bit of trial and error going through the data and seeing where I needed to adjust my requests on the back end. My plan was to simply copy the test code into the official script files. The Rovi service itself is easy to use and well documented.

Next was setting up the base AngularJS implementation. I fired up Google to try and find a good online example of how to structure the app. The web site has a tutorial so I started stepping through it. But as I began to have questions on how to do certain things, and what the best practices are, I noticed the code I found online differed from what the tutorial was doing. More searching uncovered tools like angular-seed and angular-enterprise-seed. They were comprehensive but included way too much stuff to absorb for someone just learning the framework. They seem to be more for large scale web applications. I eventually came up with what looked like a good way to set up my module, controller, and service declarations, along with the source file structure to use. I followed suggestions from places like yeoman.io and various others found online. I don’t know if it’s exactly what is considered good by the Angular community but it’s close. My main app script ended up looking like this:

'use strict';

// The name of the module for the main app must match the ng-app attribute of the  tag in the
// startup page. The contents of the array are dependencies for the app:
//   ngRoute:                  Provides routing support
//   ngAnimate:                Provides animation support
//   ngSanitize:               Sanitizes text that contains markup before binding it to a view, needed
//                             for artist bios and album reviews which need to have line breaks and
//                             possibly hyperlinks to other views
//   jmdobry.angular-cache:    Custom caching implementation for use with the $http service
//   musicBrowserControllers:  Module which will hold all controllers for the app
var musicBrowserApp = angular.module('MusicBrowserApp', [
    'ngRoute',
    'ngAnimate',
    'ngSanitize',
    'ui.bootstrap',
    'jmdobry.angular-cache',
    'musicBrowserControllers'
]);

var musicBrowserControllers = angular.module('musicBrowserControllers', []);

// /                        Home page, currently redirected to the search page
// /search                  Page that shows the search UI
// /search/artist/          Shows the results of an artist search for
// /search/album/           Shows the results of an album search for
// /search/song/            Shows the results of a song search for
// /artist/                 Shows the details for the artist represented by
// /artist//full-bio        Shows only the complete bio for the artist represented by
// /album/                  Shows the details for the album represented by
// /album//full-review      Shows only the complete review for the album represented by
// /genre/                  Shows the details for the genre represented by
// /style/                  Shows the details for the style represented by
// /options                 Page for changing app options
musicBrowserApp.config(['$routeProvider', '$provide', function ($routeProvider, $provide) {
    $routeProvider.when('/', { templateUrl: 'views/search.html', controller: 'SearchCtrl', title: "Search" });
    $routeProvider.when('/search', { templateUrl: 'views/search.html', controller: 'SearchCtrl', title: "Search" });
    $routeProvider.when('/search/artist/:searchTerm', { templateUrl: 'views/artistSearch.html', controller: 'ArtistSearchCtrl', title: "Artist Search" });
    $routeProvider.when('/search/album/:searchTerm', { templateUrl: 'views/albumSearch.html', controller: 'AlbumSearchCtrl', title: "Album Search" });
    $routeProvider.when('/search/song/:searchTerm', { templateUrl: 'views/songSearch.html', controller: 'SongSearchCtrl', title: "Song Search" });
    $routeProvider.when('/artist/:id', { templateUrl: 'views/artist.html', controller: 'ArtistLookupCtrl', title: "Artist" });
    $routeProvider.when('/artist/:id/full-bio', { templateUrl: 'views/artistBio.html', controller: 'ArtistLookupCtrl', title: "Artist Bio" });
    $routeProvider.when('/album/:id', { templateUrl: 'views/album.html', controller: 'AlbumLookupCtrl', title: "Album" });
    $routeProvider.when('/album/:id/full-review', { templateUrl: 'views/albumReview.html', controller: 'AlbumLookupCtrl', title: "Album Review" });
    $routeProvider.when('/style/:id', { templateUrl: 'views/style.html', controller: 'StyleLookupCtrl', title: "Style" });
    $routeProvider.when('/genre/:id', { templateUrl: 'views/genre.html', controller: 'GenreLookupCtrl', title: "Genre" });
    $routeProvider.when('/options', { templateUrl: 'views/options.html', controller: 'OptionsCtrl', title: "Options" });
    $routeProvider.otherwise({ redirectTo: '/' });
}]);

// Any startup code needed by the app should go here
musicBrowserApp.run(['$rootScope', '$http', '$angularCacheFactory', function ($rootScope, $http, $angularCacheFactory) {
    $http.defaults.headers.common["Accept-Encoding"] = "gzip,deflate";

    $rootScope.$on("$routeChangeSuccess", function (event, currentRoute, previousRoute) {
        // Change page title based on the current route
        $rootScope.title = currentRoute.title;
    });

    // Create a custom cache for our data, and set the $http service to use it for its caching
    $angularCacheFactory('dataCache', {
        // Items added to this cache expire after 60 minutes
        maxAge: 3600000,
        // This cache will clear itself every two hours
        cacheFlushInterval: 12000000,
        // Items will be deleted from this cache right when they expire
        deleteOnExpire: 'aggressive'
    });

    $http.defaults.cache = $angularCacheFactory.get('dataCache');
}]);

What to say about AngularJS? I like it. The framework strives to make it easy to separate your app logic from your markup from your data access, and largely succeeds. It includes tons of built-in stuff to further that goal. I ended up creating two custom services, one for common code and one for data access. The latter looked like this:

musicBrowserApp.factory('mbData', ['mbCommon', '$http', function (mbCommon, $http) {
    var curInstance = this;

    Object.defineProperty(curInstance, "maxShortDescriptionLength", {
        value: 150,
        writable: false,
        enumerable: true,
        configurable: true
    });

    curInstance.searchForArtist = function (query, size, offset) {
        var url = "api/search/artist/" + encodeURIComponent(query) + "?size=" + size + "&offset=" + offset;

        return $http.get(url, { cache: true }).
            success(function (data, status, headers, config) {
                var result = JSON.parse(data.Content);

                if (result.searchResponse) {
                    data.searchResult = result.searchResponse.results.map(function (element) {
                        setPrimaryImage(element, mbCommon.placeholderImageMedium);
                        return element;
                    });
                }
            });
    };
    
    curInstance.lookupArtist = function(id) {
        var url = "api/artist/" + encodeURIComponent(id);

        return $http.get(url, { cache: true }).
            success(function (data, status, headers, config) {
                var result = JSON.parse(data.Content);
                var primaryImage;
                var formattedBioText;
                var mbConfig = mbCommon.getConfiguration();
                var styleIndex;

                for (var i = 0; i < result.name.musicGenres.length; i++) {
                    styleIndex = getIndexOfId(result.name.musicStyles, result.name.musicGenres[i].id);

                    if (styleIndex > -1) {
                        result.name.musicStyles.splice(styleIndex, 1);
                    }
                }

                if (result.name.discography) {
                    if (mbConfig && mbConfig.albumChrono) {
                        result.name.discography.reverse();
                    }

                    for (var i = 0; i < result.name.discography.length; i++) {
                        if (result.name.discography[i].year) {
                            result.name.discography[i].year = mbCommon.formatDate(result.name.discography[i].year, true);
                        }

                        if (result.name.discography[i].images && result.name.discography[i].images.length > 0) {
                            primaryImage = result.name.discography[i].images[0].url;
                        }
                        else {
                            primaryImage = mbCommon.placeholderImageSmall;
                        }

                        result.name.discography[i].primaryImage = primaryImage;
                        result.name.discography[i].formattedType = "Album"

                        if (result.name.discography[i].flags && result.name.discography[i].flags.indexOf("Compilation") > -1) {
                            result.name.discography[i].formattedType = "Compilation";
                        }

                        if (result.name.discography[i].type === "Single" || result.name.discography[i].type === "EP") {
                            result.name.discography[i].formattedType = "SingleOrEP";
                        }
                    }
                }

                if (result.name.isGroup) {
                    result.name.originLabel = "Formed:";
                    result.name.endLabel = "Disbanded:"
                }
                else {
                    result.name.originLabel = "Born:";
                    result.name.endLabel = "Died:"
                }

                if (result.name.active) {
                    for (var i = 0; i < result.name.active.length; i++) {
                        result.name.active[i] = mbCommon.formatDate(result.name.active[i], true);
                    }
                }

                if (result.name.birth) {
                    var newDate = mbCommon.formatDate(result.name.birth.date, false, true);

                    if (newDate === "") {
                        result.name.birth.date = "N/A";
                    }
                    else {
                        result.name.birth.date = newDate;
                    }
                }

                if (result.name.death) {
                    var newDate = mbCommon.formatDate(result.name.death.date, false, true);

                    if (newDate != "") {
                        result.name.death.date = newDate;
                    }
                }

                setPrimaryImage(result, mbCommon.placeholderImageLarge);

                if (result.name.musicBio) {
                    formattedBioText = replaceRoviLinks(result.name.musicBio.text);
                    formattedBioText = formattedBioText.split("\r\n").join("");
                    result.name.musicBioFormatted = formattedBioText;
                }

                if (result.name.headlineBio) {
                    result.name.headlineBioFormatted = replaceRoviLinks(result.name.headlineBio);
                }
                else {
                    if (result.name.musicBio) {
                        result.name.headlineBioFormatted = getShortDescription(result.name.musicBioFormatted);
                    }
                }

                data.lookupResult = result.name;
            })
    };

    curInstance.searchForAlbum = function (query, size, offset) {
        var url = "api/search/album/" + encodeURIComponent(query) + "?size=" + size + "&offset=" + offset;

        return $http.get(url, { cache: true }).
            success(function (data, status, headers, config) {
                var result = JSON.parse(data.Content);

                if (result.searchResponse) {
                    data.searchResult = result.searchResponse.results.map(function (element) {
                        if (element.album.images && element.album.images.length > 0) {
                            element.album.primaryImage = element.album.images[0].url;
                        }
                        else {
                            element.album.primaryImage = mbCommon.placeholderImageMedium;
                        }

                        return element;
                    });
                }
            })
    };

    curInstance.lookupAlbum = function (id) {
        var url = "api/album/" + encodeURIComponent(id);

        return $http.get(url, { cache: true }).
            success(function (data, status, headers, config) {
                var result = JSON.parse(data.Content);
                var primaryImage;
                var formattedReviewText;

                if (result.album.originalReleaseDate) {
                    result.album.originalReleaseDate = mbCommon.formatDate(result.album.originalReleaseDate, false, true);
                }

                if (result.album.duration) {
                    result.album.durationFormatted = mbCommon.formatDuration(result.album.duration);
                }

                if (result.album.images && result.album.images.length > 0) {
                    primaryImage = result.album.images[0].url;
                }
                else {
                    primaryImage = mbCommon.placeholderImageLarge;
                }

                result.album.primaryImage = primaryImage;

                if (result.album.primaryReview) {
                    formattedReviewText = replaceRoviLinks(result.album.primaryReview.text);
                    formattedBioText = formattedReviewText.split("\r\n").join("");
                    result.album.primaryReviewFormatted = formattedReviewText;
                }

                if (result.album.headlineReview) {
                    result.album.headlineReviewFormatted = replaceRoviLinks(result.album.headlineReview.text);
                }
                else {
                    if (result.album.primaryReview) {
                        result.album.headlineReviewFormatted = getShortDescription(result.album.primaryReviewFormatted);
                    }
                }

                if (result.album.tracks && result.album.tracks.length > 0) {
                    for (var i = 0; i < result.album.tracks.length; i++) {
                        result.album.tracks[i].durationFormatted = mbCommon.formatDuration(result.album.tracks[i].duration);
                    }
                }

                data.lookupResult = result.album;
            })
    };

    curInstance.searchForSong = function (query, size, offset) {
        var url = "api/search/song/" + encodeURIComponent(query) + "?size=" + size + "&offset=" + offset;

        return $http.get(url, { cache: true }).
            success(function (data, status, headers, config) {
                var result = JSON.parse(data.Content);

                if (result.searchResponse) {
                    data.searchResult = result.searchResponse.results.map(function (element) {
                        if (element.song.images && element.song.images.length > 0) {
                            element.song.primaryImage = element.song.images[0].url;
                        }
                        else {
                            element.song.primaryImage = mbCommon.placeholderImageMedium;
                        }

                        return element;
                    });
                }
            })
    };

    curInstance.lookupStyle = function (id) {
        var url = "api/style/" + encodeURIComponent(id);

        return $http.get(url, { cache: true }).
            success(function (data, status, headers, config) {
                var result = JSON.parse(data.Content);
                var items;

                if (result.styles) {
                    items = result.styles;
                }
                else {
                    items = result.subgenres;
                }

                data.lookupResult = items.map(function (element) {
                    element.formattedDescription = replaceRoviLinks(element.description);
                    return element;
                });
            })
    }

    curInstance.lookupGenre = function (id) {
        var url = "api/genre/" + encodeURIComponent(id);

        return $http.get(url, { cache: true }).
            success(function (data, status, headers, config) {
                var result = JSON.parse(data.Content);

                data.lookupResult = result.genres.map(function (element) {
                    element.formattedDescription = replaceRoviLinks(element.description);
                    return element;
                });
            })
    }

    return curInstance;
}]);

The data service uses the built-in Angular $http service and does any required massaging of the data before handing it off to the controller that called it. The controllers then set various properties of the current scope as needed.

I created several different views based on what needed to be shown; one for artist search results, one for data on a specific artist, etc. Whenever I ran into a case where I needed the markup to be different based on the data, I was pleasantly surprised to find an Angular directive that would allow it to be driven by the model. Things like ng-show and ng-href were invaluable. The general rule in the Angular world is that you shouldn’t make any changes to the DOM in your controllers, and if you find yourself reaching for jQuery you might be doing something wrong. I’m happy to say I didn’t have any need to use jQuery to manipulate the DOM.

Next is incorporating animations for view transitions, adding something to the options page, and filling out some missing features.

Update:
As of Dec 31, 2021, long-term support for AngularJS has ended. And so the code above isn’t useful anymore, and the Music Browser is currently offline while I give it a complete overhaul using a different front end framework (maybe Angular?) and connect it to a different source of music metadata using a new back end built on Python. I greatly enjoyed using the framework for both personal projects and in my job for several years. May it rest in peace.

This is part 4 in a series of posts about creating a web-based replacement for the Firefox Home iOS app. Part 3 can be found here.

Now that I had an ironclad way of getting bookmark data, I needed to display it and provide a clean method of navigating through it. I looked around the web for a suitable client-side data binding solution, and KnockoutJS seemed like a good way to go. I wasn’t familiar with the MVVM pattern but thought it would be a good learning experience. In a nutshell, the way I understand MVVM is you have a model of your data that is kept strictly separate from the client-side view of it. A view model lives on the client that takes in the data itself and knows where to put it in the UI (admittedly, I may be simplifying or leaving something out).

A quick note about how Firefox organizes bookmarks: it stores them in two high-level containers, the bookmarks toolbar and the bookmarks menu. No big mystery where these are: it’s the built-in toolbar for single-click access to bookmarks, and the Bookmarks menu in the main menu bar at the top of the window.

I set up an unordered list for the bookmarks that looked like this:

<div id="bookmarkContainer">
    <ul id="bmMain" class="bookmarkList" data-role="listview" data-divider-theme="e">
        <li id="toolbarDivider" data-role="list-divider">Bookmarks Toolbar</li>

        <!-- ko foreach: BookmarksToolbar -->
        <li data-bind="bookmarkItemType: ItemType" data-icon="false">
            <div class="imageBlock"><img class="ui-li-icon" alt="Item icon" width="16px" height="16px" /></div>
            <div class="nameAndLocationBlock">
                <div data-bind="text: Name"></div>
                <div class="locationBlock" data-bind="text: Location"></div>
            </div>
        </li>
        <!-- /ko -->

        <li id="menuDivider" data-role="list-divider">Bookmarks Menu</li>

        <!-- ko foreach: BookmarksMenu -->
        <li data-bind="bookmarkItemType: ItemType" data-icon="false">
            <div class="imageBlock"><img class="ui-li-icon" alt="Item icon" width="16px" height="16px" /></div>
            <div class="nameAndLocationBlock">
                <div data-bind="text: Name"></div>
                <div class="locationBlock" data-bind="text: Location"></div>
            </div>
        </li>
        <!-- /ko -->
    </ul>
</div>

I went with the foreach binding in Knockout, since it made the most sense for what I was doing. I briefly tried the template binding but it turned out to be more involved than necessary. I initially tried to have a single unordered list that held everything, including the dividers for toolbar bookmarks and menu bookmarks. But I had a hard time changing the theme for the divider items on the fly, which I wanted to be a different color. Knockout lets you define binding event handlers, like this one:

ko.bindingHandlers.bookmarkItemType = {
    init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
        if (viewModel.ItemType === 0) {
            $(element).jqmData('icon', 'arrow-r');
            $(element).find("a").attr("href", "#");
            $(element).find("a").attr("onclick", "doNavigation(this);");
            $(element).find("img").attr("src", "Images/folder.png");
            $(element).find(".locationBlock").css("display", "none");
        }
        else if (viewModel.ItemType === 1) {
            $(element).find("a").attr("href", viewModel.Location);
            $(element).find("a").attr("target", "_blank");
            $(element).find("img").attr("src", "Images/bookmark.png");
            $(element).find(".locationBlock").css("display", "block");
        }
    }
};

Here I’m basically setting attributes on the elements in each list item based on whether it’s a directory or an actual bookmark. I tried modifying the data-theme attribute used by JQM to indicate a list divider, but the change never seemed to get applied. Fortunately Knockout lets you apply the binding logic to a subset of items in a ul via containerless control flow syntax. There was some duplication of markup, but not enough to worry about.

The various samples at knockoutjs.com and the web at large had two different ways of setting up the view model: as a JavaScript variable or a function. I went the function route since I needed to do a bit of data manipulation before assigning the bookmark data. Knockout has the capability of updating the UI automatically when the bound data changes, such as in response to a user clicking on something. That sort of fit my usage pattern, though I would be updating the data via code. The mechanism Knockout uses is observables, and you simply declare them in your view model. Mine looked like this:

var bookmarksViewModel = function () {
    var self = this;
    var currentBookmarks = JSON.parse(localStorage.getItem("CurrentBookmarks"));
    self.BookmarksToolbar = ko.observableArray(currentBookmarks.BookmarkItems[0].BookmarkItems);
    self.BookmarksMenu = ko.observableArray(currentBookmarks.BookmarkItems[1].BookmarkItems);

    self.setBookmarks = function (node) {
        if (node) {
            self.BookmarksToolbar(node.BookmarkItems);
            self.BookmarksMenu([]);

            $("#toolbarDivider").hide();
            $("#menuDivider").hide();
        }
        else {
            // We are back at the topmost level, so show the toolbar and menu bookmarks
            self.BookmarksToolbar(currentBookmarks.BookmarkItems[0].BookmarkItems);
            self.BookmarksMenu(currentBookmarks.BookmarkItems[1].BookmarkItems);

            $("#toolbarDivider").show();
            $("#menuDivider").show();
        }
    }
};

The initial state of the app would always be to show everything in the bookmarks toolbar followed by everything in the bookmarks menu, which matches how Firefox Home shows things. I set up two observable arrays holding each of those sets. The trickiest part was how to go about updating that data when the user wanted to navigate into a directory. The rule in Knockout is you only apply the bindings once, then let the framework update the UI for you when things change. And since references to the two observable arrays will be maintained by the framework, I would need to replace their contents rather than assign completely new arrays.

Several failed attempts ensued. I tried using the removeAll() method that is available on observable arrays, then the push() method which is also available on observable arrays but is slightly different than the native JavaScript one (it turns out to only take single elements rather than an array of elements). I finally found the best way to do it via a blog post from Ryan Niemeyer, who totally has the title of Knockout expert locked up.

So the setBookmarks() function in my view model allows me to update the bound data at any time. I wrote the following function to handle the navigation when the user selected a directory rather than an actual bookmark:

function doNavigation(sender) {
    var nodePath = $(sender).attr("nodePath").split("\\");
    var newHeader;

    if (sender.id === "backButton") {
        var newPath;
        nodePath.pop();

        if (nodePath.length === 0) {
            return;
        }

        if (nodePath.length === 2) {
            newPath = "Root";
            newHeader = "Bookmarks";
        }
        else {
            newPath = nodePath.join("\\");
            nodePath.shift();
            newHeader = nodePath[nodePath.length - 1];
        }

        $("#backButton").attr("nodePath", newPath);
    }
    else {
        nodePath.shift();
        newHeader = nodePath[nodePath.length - 1];
        $("#backButton").attr("nodePath", $(sender).attr("nodePath"));
    }

    var currentBookmarks = JSON.parse(localStorage.getItem("CurrentBookmarks"));
    var curNode = getNode(currentBookmarks.BookmarkItems, nodePath);
    ko.dataFor($("#bookmarkContainer")[0]).setBookmarks(curNode);
    $("#bmHeader").html(newHeader);

    // For some reason the 'slide' transition doesn't work right when using it in a call
    // to changePage. It does the animation, but then doesn't show the page content
    $.mobile.changePage($("#Bookmarks"), { allowSamePageTransition: true, transition: "slidefade" });
}

The bookmark data itself was an object that contained a list of directories and bookmarks. Each directory had a list of subdirectories and bookmarks, while bookmarks had just a name and URL. The list was stored as an array on the client, so it was just a matter of finding the right one to use in my Knockout view model. The idea in doNavigation() was to take the path to the node the user just pressed and get the bookmark items assigned to it, or if Back was pressed get the bookmark items for its parent.

Each time the user selected a bookmark a new page would be opened, and if they selected a directory I needed to bind to the proper array in my main data object. I wrote the following function to search that object to find the exact items to display:

function getNode(items, nodePath) {
    var curDir = nodePath.shift();
    var node = null;

    for (var i = 0; i < items.length; i++) {
        if (items[i].Name === curDir && items[i].ItemType === 0) {
            // We know to stop when we've found the final directory in the node's path
            if (nodePath.length === 0) {
                node = items[i];
                break;
            }
            else {
                node = getNode(items[i].BookmarkItems, nodePath);
                break;
            }
        }
    }

    return node;
}

Putting it all together gave me a reasonable imitation of the navigation in Firefox Home. The speed of the navigation wasn’t too bad, though I have an idea of how I might improve it (Update: the ‘idea’ was a CSS rule to reduce the animation duration. It didn’t work, but I suspect it might be because my rule is incomplete). I’d like to get as close to native app response time as possible. The full source is posted at GitHub.

I feel I have a fairly good grasp on jQuery Mobile now. It would be interesting to try to incorporate PhoneGap into the project and see how it works.

This is part 3 in a series of posts about creating a web-based replacement for the Firefox Home iOS app. Part 2 can be found here.

What I wanted to do next was write enough code to save Sync settings and load bookmarks, log out as the current Sync user and wipe out the locally stored bookmarks and credentials in the process, and refresh bookmark data, all without any problems of any kind. Basically everything the user could do on the settings page. I planned to do as much work on the client as possible, since that is where the bookmark data would be stored. When the user saved new credentials I wanted them to be directed to the bookmark page, and if they were simply refreshing they would stay on the settings page but would see an updated count of their bookmarks.

The biggest issue was doing the form submissions and getting the UI to behave accordingly, including when an error occurred on the server. What was tricky was the fact that to get bookmark data from the Sync servers I made an Ajax call to a server-side function, and JQuery Mobile relies mainly on Ajax calls to do its thing. That coupled with the fact that my Ajax call was non-blocking made the UI flow have a bunch of niggling problems. Mostly it wouldn’t show the right things at the right time.

The best way I found to make it all work correctly was to disable the JQuery Ajax handling of the button clicks. I added data-ajax=”false” to the form element, then canceled the posting to the server and did everything manually. Essentially I took JQuery out of the equation. In keeping with the credo of the framework that you work with pure HTML elements and not server-based ones, I replaced the asp:Button controls with input elements and made the form element a regular client element, like so:

<div id="Settings" data-role="page">
    <div data-role="header" data-theme="b">
        <h5>Settings</h5>
    </div>

    <div class="smallText" data-role="content" data-theme="b">
        <div class="msgPanel">&nbsp;</div>
        <form id="settingsForm" action="Default.aspx" method="post" data-ajax="false">
            <div id="LoggedOut">
                <div class="bottomPadding20"><input id="txtUserName" type="text" data-mini="true"></div>
                <div class="bottomPadding20"><input id="txtPassword" type="password" data-mini="true"></div>
                <div class="bottomPadding20"><input id="txtSyncKey" type="password" data-mini="true"></div>
                <div class="alignCenter"><input id="Save" type="button" value="Save" data-icon="check" data-mini="true"></div>
            </div>

            <div id="LoggedIn" class="alignCenter hideMe">
                <table class="statsTable">
                    <tbody>
                        <tr>
                            <td class="labelColumn">&nbsp;</td>
                            <td class="fieldColumn">&nbsp;</td>
                        </tr>
                        <tr>
                            <td class="labelColumn">&nbsp;</td>
                            <td class="fieldColumn">&nbsp;</td>
                        </tr>
                        <tr>
                            <td class="labelColumn">&nbsp;</td>
                            <td class="fieldColumn">&nbsp;</td>
                        </tr>
                    </tbody>
                </table>

                <input id="Logout" type="button" value="Logout" data-icon="delete" data-mini="true"> <br><input id="Refresh" type="button" value="Refresh" data-icon="refresh" data-mini="true">
            </div>
        </form>
    </div>

    <div data-role="footer" data-theme="b">
        <div data-role="navbar">
            <ul>
                <li><a href="#Home" data-role="button" data-iconpos="bottom" data-icon="home">Home</a></li>
                <li><a href="#Bookmarks" data-role="button" data-iconpos="bottom" data-icon="star">Bookmarks</a></li>
                <li><a href="#Settings" data-role="button" data-iconpos="bottom" data-icon="gear">Settings</a></li>
            </ul>
        </div>
    </div>
</div>

The Save onclick handler looked like this:

function Save_OnClick() {
    var userName = $("#txtUserName").val();
    var password = $("#txtPassword").val();
    var syncKey = $("#txtSyncKey").val();

    if (!userName) {
        displayMessage("User name cannot be blank", "Settings");
        return false;
    }
    if (!password) {
        displayMessage("Password cannot be blank", "Settings");
        return false;
    }
    if (!syncKey) {
        displayMessage("Sync key cannot be blank", "Settings");
        return false;
    }

    clearMessagePanel("Settings");
    $.mobile.showPageLoadingMsg();
    loadBookmarks(userName, password, syncKey, "Save");

    return false;
}

It called this common function for getting bookmark data:

function loadBookmarks(userName, password, syncKey, action) {
    $.ajax({
        type: "POST",
        url: "Default.aspx/LoadBookmarks",
        contentType: "application/json; charset=utf-8",
        data: "{'userName':'" + userName + "', 'password':'" + password + "', 'syncKey':'" + syncKey + "'}",
        dataType: "json",
        headers: {"LoadAction":action},
        error: function (error) {
            var resp = JSON.parse(error.responseText);
            displayMessage(resp.Message, "Settings");
        },
        success: function (data) {
            localStorage.setItem("CurrentBookmarks", JSON.stringify(data.d));

            if (action === "Refresh") {
                localStorage.setItem("BookmarkCount", data.d.Tag);
            }
            else if (action === "Save") {
                localStorage.setItem("UserName", userName);
                localStorage.setItem("Password", password);
                localStorage.setItem("SyncKey", syncKey);
            }
        }
    });
}

I added a handler for the jQuery ajaxCompleted event and used that to handle the post-retrieval stuff, like hiding the input controls or updating the bookmark count.

function ajaxCompleted(e, xhr, settings) {
    if (settings.url === "Default.aspx/LoadBookmarks") {
        if (xhr.status < 400) {
            switch (settings.headers["LoadAction"]) {
                case "Save":
                    $.mobile.changePage("#Bookmarks");
                    break;
                case "Refresh":
                    loadSettingsPage();
                    break;
            }
        }

        $.mobile.hidePageLoadingMsg();
    }
}

At this point I tried everything out in Electric Plum to get an idea of how it might look on an actual iPhone. It was fairly good, only a couple of cosmetic issue came up that might not even be issues on a real phone.

Next, listviews and data binding.