MVC Service Based Web Applications - Part V - Master Pages and Multi-lingual Support

While our view loading system has certainly come a long way since Phase II it still lacks a very important capability to any layout system. Currently the default.htm file sitting at the root of the static site is the only page that has a work space available to nest our views. Consider for a moment an application with a section "About Us" with a table of contents listed in a side bar. We could create the sidebar in the default page and only have it display when the user navigates to the About Us section but as the number of sections and sub-sections grows this solution would prove to be impractical. Ultimately this side bar code would need to be duplicated for each view that the table of contents linked to. In this phase of the project we'll be writing up the necessary functionality to create master pages with their own work spaces while still supporting our hash tag navigation. Along the way we'll also be adding more multi-lingual support.

The first thing we need to do is make some changes to our /Content/ui/ folder structure.

/Content/
/Content/ui/
/Content/ui/en/
/Content/ui/en/home/
/Content/ui/en/example/
/Content/ui/en/passport/

It's not terribly drastic. We're simply creating an en folder to represent our views written in English. You can actually create any folder you like as we're going to add some customization which will allow us to define the default language the site should use. Let's take a look at the first necessary code change.

/Scripts/Util.js


function AppState() {
    var dataName = __GetAppStateVariableName();
    var state = $.data(document, dataName);
    if(!state) {
        state = {
            pushMessage: function (msg) { this.messages.push(msg); },
            messages: [],
            urlCache: { count: 0, urls: [], cache: [] },
            routes: {
                base: "/Content/ui/",
                defaultFolder: "home/",
                defaultPage: "default",
                masterPage: "_master",
                extension: ".htm",
                language: {
                    _current: "",
                    getFolder: function () {
                        return (state.routes.language._current == "" ? 
                            "en" : state.routes.language._current) + "/"
                    },
                    val: function (language) {
                        if (!language) return state.routes.language._current;
                        state.routes.language._current = language;
                        return state.routes.language._current;
                    }
                },
                getLanguageBase: function () { 
                    return state.routes.base + state.routes.language.getFolder(); 
                }
            },
            workspaces: { root: "__workspace", separator: "\\.", loaded: [] }
        };
        $.data(document, dataName, state);
    }
    return state;
}

There are a lot of values here that can be changed to support your personal preferences and needs so let's take a moment to examine each one.

  • AppState().routes
    • base: The base folder where all our views reside.
    • defaultFolder: The default folder used by our __Route() method.
    • defaultPage: The default page used by our __Route() method.
    • masterPage: Our master page file name. This will be explained further on in this article.
    • extension: We've been using the .htm extension for all our views but some may prefer to use .html and can do so by changing this value.
    • language:
      • _current: The currently selected language.
      • getFolder: A function which will return the appropriate folder for each language.
      • val: Gets or sets the current language value. Note that this function can be overridden.
      • getLanguageBase: A helper method which simply concatenates the base view folder with the selected language folder.
  • AppState().workspaces
    • root: The main workspace name.
    • separator: The separator character which will be used to append child workspaces.
    • loaded: A storage location which will be explained.

We also need to make some changes to our AJAXLoadHTML function. Particularly with regard to failed requests.

function AJAXLoadHTML(url, data, successCallBack, config) {
    if (!config) config = {};

    var processError = function (msg) {
        if (config.errorCallBack)
            config.errorCallBack(msg);
        else
            AppState().pushMessage("AJAXLoadHTML failed for url: " + url);
    };

    var cache = __URLCache(url);
    if (!cache) {
        $.ajax({
            type: "GET",
            data: data,
            url: url,
            cache: false,
            contentType: "application/json; charset=utf-8",
            dataType: "html",
            success: function (msg, status, xhr) {
                var responseType = xhr.getResponseHeader("content-type") || "";
                if (responseType.toLowerCase().indexOf("html") > -1) {
                    __URLCache(url, { success: true, msg: msg });
                    if (successCallBack) successCallBack(msg);
                } else {
                    if (config.clientExceptionCallBack)
                        config.clientExceptionCallBack($.parseJSON(msg))
                    else
                        ProcessMessage($.parseJSON(msg));
                }
            },
            error: function (msg) {
                __URLCache(url, { success: false, msg: msg });
                processError(msg);
            }
        });
    } else if (!cache.success) {
        processError(cache.msg);
    } else {
        if (successCallBack) successCallBack(cache.msg);
    }
}

Since our creation of AJAXLoadHTML back in Phase II our error function passed into the jQuery AJAX call simply made a call to the AppState().pushMessage function. This means that despite the fact that any subsequent requests are just as likely to fail the method would try anyway. For the most part this wasn't an issue since requests for non-existent resources would be rare. Now that's about to change and so our URL cache is storing not just the returned message but the success value as well. Let's finish up editing this file with a couple more support methods.

function PassportLoadData(url, data, successCallBack, clientExceptionCallBack) {
    url = "http://phase3.linuxtutorial.netortech.com" + url;

    $.ajax({
        type: "GET",
        data: data,
        url: url,
        contentType: "application/javascript; charset=utf-8",
        dataType: "jsonp",
        success: function (msg) {
            ProcessMessage(msg, successCallBack, clientExceptionCallBack);
        },
        error: function (msg) {
            AppState().pushMessage("AJAXLoadData failed for url: " + url);
        }
    });
}
function Login(username, password, success, failure) {
    PassportLoadData("/Service/GetChallenge/", { Username: username }, function (msg) {
        var hash = SHA256(password + msg.Data.Challenge.Salt);
        var response = SHA256(hash + msg.Data.Challenge.Value);
        PassportLoadData("/Service/AuthenticateChallenge/", {
            Username: username,
            Response: response,
            ID: msg.Data.Challenge.Id
        }, function (msg) {
            success(msg);
        }, function (msg) {
            failure(msg);
        });
    });
}

If you've been following along with the Python tutorial both of these methods should seem somewhat familiar. Due to the fact that we will have a view for each language which will need to utilize both of these functionalities it just makes sense to put them in a shared location. Later on we may move them to a different script file for better organization but for now they can live here. Before we move on to our __Init() function let's take a look at some of the changes we've made to the root default page.

/default.htm


<body>
    <script language="javascript" type="text/javascript">
        $(document).ready(function () {
            $(".obj_btnShowCacheInfo").click(function () {
                alert("There are currently " + AppState().urlCache.count + " url cache entries stored.\n"
                    + "AppState().messages: \n"
                    + "--------------------- \n"
                    + AppState().messages.join().replace(/,/g, "\n")
                );
            });

            $(".obj_ddlSelectLanguage option[value='" + AppState().routes.language.val() + "']").attr("selected""selected");
            $(".obj_ddlSelectLanguage").change(function () {
                alert("Setting language to: " + $(this).val());
                AppState().routes.language.val($(this).val());
            });
        });
    </script>
    Select Language:
    <select class="obj_ddlSelectLanguage">
        <option value="en">English</option>
        <option value="ck">Czech</option>
    </select>
    <input type="button" class="obj_btnShowCacheInfo" value="Show Cache Info" />
    <br /><br />
    <hr />
    <div id="__workspace"></div>
</body>

This page is fairly straight forward and will primarily serve to demonstrate how view languages will be changed. Notice that we've used the id __workspace as defined in AppState().workspaces.root. Let's take a look what's changed in the initialization function.

/Scripts/Internal.js


function __Init() {
    // Detect language switch
    var temp = AppState().routes.language.val;
    AppState().routes.language.val = function (language) {
        if (!language || temp() == language) {
            return temp();
        }
        var result = temp(language);
        __Route();
        return result;
    };

    // Hash navigation initialization
    $(window).hashchange(function () {
        AppState().hashInfo = __GetHashInfo();
        AppState().data = AppState().hashInfo.data;
        __Route();
    });
    $(window).hashchange();
}

Since the AppState().routes.language.val function can be overridden it will allow us to capture a change and automatically make a call to our __Route() function which will load the language appropriate views. Before we modify our routing logic let's add another support method.

function __GetWorkspaceId(path) {
    var result = AppState().workspaces.root;
    var separator = AppState().workspaces.separator;

    if (path && path.length > 0) {
        var filtered = path.filter(function (element) { return element.trim().length > 0; });
        if (filtered.length > 0) result += separator + filtered.join(separator);
    }

    return result;
}

This method will serve to construct and return the appropriate workspace name given an array of path folders by separating them with the defined separator value. Depending on your project specific needs you may find that a more robust approach is necessary but this should be sufficient for most applications.

function __Route() {
    var path = null;

    if (!AppState().hashInfo.nav) // default
        __LoadRoute((AppState().routes.defaultFolder + AppState().routes.defaultPage).split("/"));
    else if (AppState().hashInfo.parts.length == 1)
        __LoadRoute((AppState().routes.defaultFolder + AppState().hashInfo.parts[0]).split("/"));
    else
        __LoadRoute(AppState().hashInfo.parts.slice(0));
}

Since many applications will have a variety of custom routing rules it's important we keep this function as easy to alter as possible. By working with the parts of the hash information this method will allows for rewrites, slash based parameters and a plethora of other features offered by a typical routing engine.

function __LoadRoute(parts, path, workspaceId) {
    parts = parts.slice(0).reverse();

    if (!path) path = "";
    if (!workspaceId) workspaceId = __GetWorkspaceId();

    var part = parts.pop();

    if (parts.length == 0) {
        if (part == "") part = AppState().routes.defaultPage;
        path = AppState().routes.getLanguageBase() + path + part + AppState().routes.extension;
        AJAXLoadHTML(path, null, function (msg) { __LoadWorkspace(workspaceId, msg, path); });
    } else {
        path += part + "/";
        var masterpagePath =
            AppState().routes.getLanguageBase() +
            path +
            AppState().routes.masterPage + 
            AppState().routes.extension;

        AJAXLoadHTML(masterpagePath, null,
            function (msg) {
                __LoadWorkspace(workspaceId, msg, masterpagePath);
                workspaceId = __GetWorkspaceId(path.split("/"));
                __LoadRoute(parts, path, workspaceId);
            },
            {
                errorCallBack: function (msg) { __LoadRoute(parts, path, workspaceId); }
            });
    }

}

This recursive function is responsible for both loading the requested view as well as any master pages that exist in each sub folder along the way. Notice that the masterpagePath is constructed using the AppState().routes.masterPage value we defined earlier. Thanks to our earlier changes we can pass in an error callback to the AJAXLoadHTML which will allow us to determine if the master page exists and since the result is cached this logic will be nearly instantaneous until the user refreshes the page.

function __LoadWorkspace(workspaceId, data, path) {
    var workspaceElement = $("#" + workspaceId);
    if (workspaceElement.length == 0
        return AppState().pushMessage("No workspace id '" + workspaceId + "' could be found.");

    var workspaces = AppState().workspaces;

    var loadedWorkspace = workspaces.loaded.filter(function (element) { return element.id == workspaceId; });
    if (loadedWorkspace.length > 0
    {
        if (loadedWorkspace[0].path == path) return// No change. Do nothing.
        workspaces.loaded = workspaces.loaded.filter(function (element) {
            return element.id.substring(0, workspaceId.length) != workspaceId; 
        });
    }

    workspaceElement.html(data);
    workspaces.loaded.push({ id: workspaceId, path: path });
}

While it may seem like a trivial task to load the downloaded HTML view into the appropriate workspace there is one minor consideration that could be easily forgotten. If a user clicks on a link inside a nested workspace and all the master pages are reloaded this means that the state of any elements in the master pages is unnecessarily lost. This can be rather frustrating to deal with as a developer so rather than just reloading the entire view stack we can intelligently load only those views and master pages that need to be updated using the AppState().workspaces.loaded variable.

Creating A Master Page

Now that the coding changes have been put into place let's test out some simple use cases. Let's begin with our default folder and page.

/Content/ui/en/home/default.htm


<script language="javascript" type="text/javascript">
    $(document).ready(function () {
        $("#txtPassword").keypress(function (e) { if (e.which == 13) DoLogin(); });
        $("#btnLogin").click(function () { DoLogin() });
        $("#btnLogout").click(function () {
            PassportLoadData("/Service/LogOut/"nullfunction (msg) {
                alert("Successfully logged out.");
            });
        });
        $("#txtUsername").focus();
    });
    function DoLogin() {
        $("#rowLoginError").hide();
        Login(
                $("#txtUsername").val(),
                $("#txtPassword").val(),
                function () {
                    alert("Login success!");
                },
                function () {
                    $("#rowLoginError").show();
                }
            );
    }
</script>
<table>
    <tr id="rowLoginError" style="display: none;"><td colspan="2">Incorrect Username or Password.</td></tr>
    <tr><td>Username:</td><td><input type="text" id="txtUsername" /></td></tr>
    <tr><td>Password:</td><td><input type="password" id="txtPassword" /></td></tr>
    <tr><td colspan="2">
        <input type="button" id="btnLogin" value="Login" />
        <input type="button" id="btnLogout" value="Logout" />
    </td></tr>
</table>

<a href="#passport/">Show master page</a><br />
<a href="#example/">Show regular content</a><br />

As you can see we've added some links for passport/ and example/.

/Content/ui/en/passport/default.htm


This is the content.

<a href="#passport/test1">Load other content</a><br />

/Content/ui/en/passport/test1.htm


This is the other content.<br />

<a href="#passport/">Back to default</a><br />

/Content/ui/en/example/default.htm


This is content with no master page.

<a href="#">Go home</a><br />

Now that we have all our typical views in place let's see what a master page looks like.

/Content/ui/en/passport/_master.htm


<table>
    <tr>
        <td style="padding:30px;">
            This is some master page content.
            <input type="text" />
        </td>
        <td id="__workspace.passport" style="border: 1px solid black; padding:30px;"></td>
    </tr>
</table>
<a href="#">Go home</a><br />

Notice that the workspace id is the base id concatenated with the folder structure separated by periods. Run the project and take note how any value places in textbox on the master page does not disappear when a content navigation occurs.

Adding Another Language

To create a set of views for a particular language all that need be done is copy our /Content/ui/en/ folder to another folder. As you may have noticed I've chosen the Czech language for this demonstration which a friend of mine was kind enough to translate for me.

Phase V Links: Demo | Download

Conclusion

Now that master pages are in place we can move forward with creating management views for the Passport application we've been working on over in the Python tutorial. If you don't have any desire to learn Python you can still follow along as the service URLs used in the tutorials will work for you as well. In the next phase we'll be taking a look at finishing up multi-lingual support as we have yet to handle the various messages that will be provided by both the ASP.Net and the Python MVC service.

Quick Links: << Previous: JSONP and Content Server Simulation