ASP.NET MVC Optimization Series: Part 3 - Transform Your Client-Side Web App

The client-side of a web application can be daunting, but can also be gratifying. Today, we continue focusing on building our web app to rival a native app. We also have some adjustments that need to be made from the data layer post.

Written by Jonathan "JD" Danylko • Last Updated: • MVC •
Girl with Smartphone

Last week, I mentioned I was up for the challenge of building a web application to go toe-to-toe with a native app. I gave a brief introduction and setup and then started building the data layer.

Today, I'll work on the web page to access the data layer through our controller and partial views...which brings me to my next update: the Data Layer.

I seem to have missed a couple items in the data layer as I progressed further through this development effort.

Fixes to the Data Layer

These modifications are in regards to the post last week. I made a couple of mistakes to the data layer.

  • If you look at the SessionRepository and SpeakerRepository, you'll notice that I wrote code for an XmlSerializer for deserializing XML into an object. XML takes a lot of time and is bloated to serialize/deserialize.

    Since the Codemash API could be called using either XML or JSON and JSON doesn't have as much cruft as XML does (no extra elements), I decided to replace the XML with JSON so it would download faster.

    Here is the new code for the SessionRepository:
    public class SessionRepository : WebRepository<Session>
    {
        public SessionRepository(Uri url): base(url) { }
        public override IEnumerable<Session> GetRecords(string data)
        {
            var serializer = new JavaScriptSerializer();
            return serializer.Deserialize<List<Session>>(data);
        }
        public override Session GetRecord(string data)
        {
            var serializer = new JavaScriptSerializer();
            return serializer.Deserialize<Session>(data);
        }
    }
    

    The SpeakerRepository is exactly the same flow, but if you replace Session with Speaker, you'll be fine.

    If you notice, I also included a GetRecord for our web service call to retrieve a single record.

  • The WebRepository underwent some changes as well.
    public class WebRepository<T>: IWebRepository<T> where T: class
    {
        public Uri Uri { getset; }
        
        public WebRepository(Uri url)
        {
            Uri = url;
        }
        public virtual IEnumerable<T> GetRecords(string data)
        {
            return null;
        }
        public virtual T GetRecord(string data)
        {
            return null;
        }
        private string GetData(Uri uri, string headerType = "xml")
        {
            using (var web = new WebClient())
            {
                web.Encoding = Encoding.UTF8;
                web.Headers["Content-Type"] = headerType;
                return web.DownloadString(uri);
            }
        }
        public IQueryable<T> Find(Expression<Func<T, bool>> predicate)
        {
            return null;
        }
        public virtual IEnumerable<T> GetAll()
        {
            var data = GetData(Uri"json");
            return GetRecords(data);
        }
        public T GetById(string id)
        {
            var newUri = new Uri(Uri+"/"+id);
            var data = GetData(newUri);
            return GetRecord(data);
        }
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }
        protected virtual void Dispose(bool disposing)
        {
            if (!disposing) return;
        }
    }
    

    We had to change the Encoding of the WebClient to UTF8 for everything to look proper, added a virtual GetRecord method, and changed our content-type from XML to JSON.

All of these changes will be included in the code when I publish it.

Moving to the Client

With the data layer behind us, we can now focus on the browser's interface.

To layout our design, we want to have a full listing of the sessions and speakers. So for right now, we'll have a general Introduction (Index) page.

We'll start with the _Layout.cshtml.

The Layout page will consist of bundled CSS at the top and bundled JavaScript at the bottom. The less number of external files we call, the faster we can render the page.

Our App_Start\BundleConfig.cs will contain the following lines.

bundles.Add(new ScriptBundle("~/bundles/bootstrap").Include(
      "~/Scripts/bootstrap.js"
    , "~/Scripts/jasny-bootstrap.js"
    , "~/Scripts/codemash.js"
));
bundles.Add(new StyleBundle("~/Content/css").Include(
          "~/Content/bootstrap-slate.min.css"
          ,"~/Content/font-awesome.css"
          ,"~/Content/jasny-bootstrap.css"
          ,"~/Content/site.css"));

In the CSS bundle, there is a bootstrap-slate.min.css file that I included. This is a bootstrap theme from the exceptional website called Bootswatch. It has a number of different themes built for Bootstrap complete with previews and a simple download of CSS files. So I grabbed slate to give my site a native "look and feel."

Now that we've got our bundles...uhh...bundled, we can focus on the menu in the _Layout.cshtml file. Since we should always have access to the menu, we'll be using the OffSite Canvas feature from the JASNY Bootstrap library.

@using CodemashApp.Helpers.Url
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Codemash 2.0.1.5</title>
    @Styles.Render("~/Content/css")
    @Scripts.Render("~/bundles/modernizr")
</head>
<body>
    <nav id="codemashMenu" class="navmenu navmenu-default navmenu-fixed-left offcanvas" role="navigation">
        <span class="navmenu-brand">Menu</span>
        <ul class="nav navmenu-nav">
            <li><a href="@Url.AllSessionsUrl()"><i class="fa fa-2x fa-users fa-fw"></i> All Sessions</a></li>
            <li><a href="@Url.AllSpeakersUrl()"><i class="fa fa-2x fa-bullhorn fa-fw"></i> All Speakers</a></li>
        </ul>
    </nav>
    <div class="navbar navbar-default navbar-fixed-top">
        <i class="fa fa-bars fa-2x pull-left" data-toggle="offcanvas" data-target="#codemashMenu" data-canvas="body"></i>
        <img src="http://www.codemash.org/wp-content/themes/codemash/images/codemash-icon.png"
             class="pull-left img-fluid" alt="Codemash logo" data-toggle="offcanvas"
             data-target="#codemashMenu" data-canvas="body" />
        <span class="heading">Codemash <span class="small">2.0.1.5</span></span>
    </div>
    <div class="container-fluid body-content">         <div class="row">             @RenderBody()         </div>         <footer>             <p>&copy; @DateTime.Now.Year - Built for Codemash</p>         </footer>     </div>
    @Scripts.RenderFormat("<script type=\"text/javascript\" src=\"{0}\" async></script>""~/bundles/jquery")     @Scripts.RenderFormat("<script type=\"text/javascript\" src=\"{0}\" async></script>""~/bundles/bootstrap")     @RenderSection("scripts", required: false) </body> </html>

The <nav> tag is where our menu resides when someone clicks the hamburger (you know...the three lines in the left hand corner). The <div> tag underneath the navigation is where our static bar goes across the top.

We've also created our UrlHelpers for our links. AllSessionsUrl and AllSpeakersUrl are located in the Helpers\Url directory.

public static string AllSessionsUrl(this UrlHelper helper)
{
    return helper.RouteUrl("Default",
        new { @controller = "Codemash", @action = "Sessions", @id = UrlParameter.Optional });
}
public static string AllSpeakersUrl(this UrlHelper helper)
{
    return helper.RouteUrl("Default",
        new { @controller = "Codemash", @action = "Speakers", @id = UrlParameter.Optional });
}

NOTE: If this looks strange, you can view the post titled ASP.NET MVC Url Helpers: Catalog Your Site.

We are also using FontAwesome.io fonts for most of the icons on the site. For the "hamburger", we are using the fa-bars icon.

Our process for accessing the menu is quite simple. The JASNY JavaScript that we included in our bundle looks for a data-toggle attribute, a data-target attribute, and a data-canvas attribute in an HTML element. Once we apply those attributes to any element, we can trigger the menu by tapping on that element.

Those elements we're using is, naturally, the hamburger and, since it's right next to it, the Codemash logo.

So now we have this:

Main Screen of 'native' app

Menu Screen of 'native' app

And we haven't even dug into the ASP.NET MVC part yet...all JavaScript.

We Are Now In Session(s)

The Sessions for Codemash are usually in a list for us to view their details, speakers, and location. Since the sessions are cached, this is going to make things extremely easy.

First, our ViewModel will have the following fields:

public string Title { getset; }
public string MetaKeywords { getset; }
public IEnumerable<Session> Sessions { getset; }
public Session Session { getset; }
public IEnumerable<Speaker> Speakers { getset; }
public Speaker Speaker { getset; }
public IEnumerable<Session> SessionItems { getset; }
public IEnumerable<Session> ScheduleItems { getset; }

With our ViewModel built, now we need a ViewModelBuilder to construct the ViewModel.

NOTE: If this is looking a little strange, I'll refer you to the posts titled UPDATE: Using Dependency Injection On The ViewModelBuilder and The Skinniest ASP.NET MVC Controller You've Ever Seen, Part 1.

public class CodemashSessionViewModelBuilder : BaseViewModelBuilder, 
    IViewModelBuilder<CodemashController, CodemashSessionViewModel, string>
{
    public CodemashSessionViewModel Build(CodemashController controller, 
        CodemashSessionViewModel viewModel, string day)
    {
        viewModel.Title = "Sessions | Codemash v2.0.1.5";
        viewModel.MetaKeywords = "Codemash v2.0.1.5 Sessions";
        var codemashUnitOfWork = new CodemashUnitOfWork();
        var records = codemashUnitOfWork.SessionRepository.GetAll();
        viewModel.SessionItems = records
            .Where(e => e.SessionType != "CodeMash Schedule Item");
        viewModel.ScheduleItems = records
            .Where(e => e.SessionType == "CodeMash Schedule Item");
        return viewModel;
    }
}

Once we've loaded the data through the CodemashUnitOfWork, we can return the ViewModel back to the Controller.

Oh yeah, the Controller.

Setting Up The Controller

The CodemashController requires a Sessions Action with an id parameter (we'll discuss this at a later time). Based on our skinny controllers from before (see the NOTE in blue from above for a refresher on the ViewModelBuilder), our action is pretty thin.

public ActionResult Sessions(string id)
{
    return View(_factory.GetViewModel<CodemashController, CodemashSessionViewModel, string>(this, id));
}

Now that our controller is setup, we need to focus on our Sessions View.

Change Your View

To be clear with our objective here, we want the sessions to load relatively fast. We focused on optimizing the code on the server-side using application caching.

When we display our list of sessions and a user wants to view the details of that session, do we want to click a session and load a full page complete with network latency? Or do we just want to load a portion of a page and then return back to our session list?

My vote is for loading a portion of the page instead of a full-page load.

At the bottom of my Sessions.cshtml View, I added the following:

<div class="text-center loading-status"><i class="fa fa-3x fa-spin fa-spinner"></i>
</div>
<div class="session-detail"></div>

Why? Well, the "loading-status" is the spinner...juuuusst in case we need to wait for a response from the server where the session-detail is used strictly as a placeholder.

When we pick a session, we'll use jQuery to display the spinner, load the PartialView, and then hide the spinner once everything is loaded.

Here is our Sessions View.

@model CodemashApp.ViewModel.CodemashSessionViewModel
@{
    Layout = "~/Views/Shared/_Layout.cshtml";
}
<div class="panel-group" id="accordion" role="tablist" aria-multiselectable="true">
    <div class="panel panel-default">
        <div class="panel-heading" role="tab" id="headingTwo">
            <h4 class="panel-title">
                <a class="collapsed" role="button" data-toggle="collapse"
                   data-parent="#accordion" href="#collapseTwo" aria-expanded="false" 
                   aria-controls="collapseTwo">
                    Sessions
                </a>
                <i class="fa fa-spin fa-spinner hide text-right"></i>
            </h4>
        </div>
        <div id="collapseTwo" class="panel-collapse collapse in" role="tabpanel" 
             aria-labelledby="headingTwo">
            <ul class="list-group">
                @foreach (var session in Model.SessionItems.OrderBy(e => e.SessionStartTime))
                {
                    @Html.Partial("_SessionItem", session)
                }
            </ul>
        </div>
    </div>
    <div class="panel panel-default">
        <div class="panel-heading" role="tab" id="headingOne">
            <h4 class="panel-title">
                <a role="button" data-toggle="collapse" data-parent="#accordion" 
                   href="#collapseOne" aria-expanded="False" aria-controls="collapseOne">
                    Schedule Items
                </a>
            </h4>
        </div>
        <div id="collapseOne" class="panel-collapse collapse" role="tabpanel" 
             aria-labelledby="headingOne">
            <ul class="list-group">
                @foreach (var session in Model.ScheduleItems.OrderBy(e => e.SessionStartTime))
                {
                    @Html.Partial("_ScheduleItem", session)
                }
            </ul>
        </div>
    </div>
</div>
<div class="text-center loading-status"><i class="fa fa-3x fa-spin fa-spinner"></i>
</div>
<div class="session-detail"></div>

The interface is using the Accordion control in Bootstrap with the first accordion panel opened (by attaching the 'in' CSS class). 

The ScheduleItems are specific to Codemash events happening around the Kalahari Resort, like Registration, Shuttle times, etc. I thought of placing those types of details at the bottom since most users want to see the scheduled sessions more than the Kalahari registration events.

The Partials are smaller HTML components. Here is the SessionItem and ScheduleItem Partials.

Views/Shared/_ScheduleItem.cshtml

@using CodemashApp.Helpers.Url
@model CodemashApp.Models.Session
<li>
    <a name="@Model.Id" href="javascript:void(0);" class="schedule list-group-item"
        data-target="@Url.SessionDetailUrl(Model.Id)">
        <h4 class="list-group-item-heading">@Model.Title</h4>
        <ul class="list-inline list-unstyled small">
            <li><i class="fa fa-calendar fa-fw"></i> @Model.StartDate from @Model.StartTime - @Model.EndTime</li>
            <li>
                <i class="fa fa-map-marker fa-fw"></i> @Model.Rooms.Aggregate((i, j) => i + ", " + j)
            </li>
        </ul>
    </a>
</li>

Views/Shared/_SessionItem.cshtml

@using CodemashApp.Helpers.Url
@model CodemashApp.Models.Session
<li>
    <a name="@Model.Id" href="javascript:void(0);" class="session list-group-item" 
       data-target="@Url.SessionDetailUrl(Model.Id)">
        <h4 class="list-group-item-heading">@Model.Title</h4>
        <ul class="list-inline list-unstyled small">
            <li><i class="fa fa-user fa-fw"></i> @Model.Speakers.Select(i => i.FirstName + " " + i.LastName).Aggregate((i, j) => i + ", " + j)</li>
            <li><i class="fa fa-calendar fa-fw"></i> @Model.StartDate from @Model.StartTime - @Model.EndTime</li>
            <li>
                <i class="fa fa-map-marker fa-fw"></i> @Model.Rooms.Aggregate((i, j) => i + ", " + j)
            </li>
        </ul>
    </a>
</li>

If we look at each "Session," both have a UrlHelper called SessionDetailUrl in an attribute called data-target. I added this to signify when we click a session link, this is the url that would load into the session-detail element from above.

Our SessionDetailUrl UrlHelper looks like this:

public static string SessionDetailUrl(this UrlHelper helper, int sessionId)
{
    return helper.RouteUrl("Default",
        new { @controller = "Codemash", @action = "SessionDetail", @id=sessionId });
}

Now, when we run our web app, our session screen looks like this:

Session List of 'native' app

Wait a minute! A SessionDetail Action? Uh oh, We didn't write that yet.

We will now.

Let's Get The Session Details

For our Session detail, we just need one record...the session Id. We create a new ViewModelBuilder to pull that record (along with other necessary data) and inject a partial view back to our regular view.

Hang on, because I'm going to be moving a little quick through this.

ViewModelBuilder/SessionDetailViewModelBuilder.cs

public class SessionDetailViewModelBuilder : BaseViewModelBuilder, 
    IViewModelBuilder<CodemashController, SessionDetailViewModel, int>
{
    public SessionDetailViewModel Build(CodemashController controller, 
        SessionDetailViewModel viewModel, int id)
    {
        var codemashUnitOfWork = new CodemashUnitOfWork();
        viewModel.Session = codemashUnitOfWork.SessionRepository
            .GetAll()
            .FirstOrDefault(e=> e.Id == id);
        viewModel.Title = viewModel.Session.Title;
        viewModel.MetaKeywords = viewModel.Session.Title;
        return viewModel;
    }
}

Controllers/CodemashController.cs

public PartialViewResult SessionDetail(string id)
{
    var sessionId = Int32.Parse(id);
    return PartialView("_partialSession", 
        _factory.GetViewModel<CodemashController, SessionDetailViewModel, int>(this, sessionId));
}

Once we have the controller and SessionDetailViewModelBuilder in place, we are ready for our JavaScript.

jQuery AJAX Fun

Back at the BundleConfig.cs at the top of this post, I snuck in a Codemash.js file. The contents of this file contains a simple jQuery click event...that does a lot of things.

Scripts/codemash.js

$(function() {
    var jumper = "";
    $("a.session").on("click"function(e) {
        e.preventDefault();
        var url = $(this).attr("data-target");
        jumper = $(this).attr("name");
        function sessionDetailLoadCompleted() {
            $(".loading-status").fadeOut("fast"function() {
                $(".session-detail").fadeIn("fast");
            });
            $(".back-button").on("click"function(f) {
                f.preventDefault();
                $(".session-detail").fadeOut("fast"function() {
                    $(".panel-group").fadeIn("fast");
                    var item = $("[name='" + jumper + "']");
                    $(document).scrollTop(item.offset().top - 50);
                });
            });
        }
        function panelGroupFadeOutComplete() {
            $(".loading-status").fadeIn("fast"function() {
                $(".session-detail").load(url, sessionDetailLoadCompleted);
            });
        }
        $(".panel-group").fadeOut("fast", panelGroupFadeOutComplete);
    });
 
});

The last event is the entry point into the other functions in the click event.

The .panel-group contains the list of sessions while the .loading-status is the spinner and the session-detail is the details of the session (duh!).

Here is the process on a session click.

  1. We fade out the .panel-group (session list)
  2. After the fade out is done, we fade in the loading-status icon to show the user something is happening.
  3. While we are fading out, we load and fade in the session-detail.
  4. Once that session-detail is available (and done fading), we assign the back-button to perform the reverse process.
  5. When the panel-group fades back into view, we have to "jump" to where they last clicked so we perform a scrollTop to locate the position.

After we add the JavaScript, the transition seems to run smooth.

Our Codemash web app is done...for now.

Conclusion

Phew! Long post, but the results are pretty apparent. Not only do we have a snazzy session picker that is responsive and quick, but it looks like a native app that you would purchase.

That's just my opinion. ;-)

However, while the speed is relatively quick, I do not like the waiting period when you click All Sessions. I understand it's a network latency issue, but I may focus on that in a later post.

In the next post, we focus on the Speakers list.

Did I miss anything? Post your comments below.

ASP.NET MVC Optimization Series:

ASP.NET 8 Best Practices on Amazon

ASP.NET 8 Best Practices by Jonathan Danylko


Reviewed as a "comprehensive guide" and a "roadmap to excellence" with over 120 Best Practices for ASP.NET Core 8, Jonathan's first book by Packt Publishing explores proven techniques for every phase of the SDLC.

Learn industry-standard concepts to improve your coding, debugging, and deployment of ASP.NET Core websites.

Order now on Amazon.com button

Picture of Jonathan "JD" Danylko

Jonathan "JD" Danylko is an author, web architect, and entrepreneur who's been programming for over 30 years. He's developed websites for small, medium, and Fortune 500 companies since 1996.

He currently works at Insight Enterprises as an Architect.

When asked what he likes to do in his spare time, he replies, "I like to write and I like to code. I also like to write about code."

comments powered by Disqus