Making an Angular-powered frontend with Umbraco
Heads Up!
This article is several years old now, and much has happened since then, so please keep that in mind while reading it.
In this article you will learn how to tweak Umbraco and use AngularJS to present data in the frontend. Maybe now is a good time to learn a little about how Google crawls websites containing data presented with JavaScript: http://googlewebmastercentral.blogspot.dk/2014/05/understanding-web-pages-better.html
Routing
First off we have to tame Umbraco and hand over the routing to Angular. This could be done very simple by creating a new UrlRedirect which always uses the same template. This can be done by adding this rule to the UrlRewriting.config file:
<rewrites>
<add name="Startview Templating"
virtualUrl="^~/(?![0-9]+/)(?!umbraco/)([^\?]*?)$"
rewriteUrlParameter="ExcludeFromClientQueryString"
destinationUrl="~/$1?altTemplate=StartView"
ignoreCase="true" />
</rewrites>
This Rewrite-rule takes the first request (no matter if it is www.imonaboat.com/ og www.imonaboat.com/products/unicorn/ ) and renders the "StartView" template, which we will create in a short while.
The rest of the requests (unless you do a browser refresh) will be taken care of by Angular.
The startview template
It may sound a little odd that we use the same template no matter what url we hit, but that's because Angular also takes care of the templating (more on that later). So all we want is to load an (almost) empty template along with AngularJS and some styling.
Since you are reading this I presume you are a cool dude or dudette, so of course you will be using Umbraco 7 + MVC. So go ahead and create a template in your View-folder and call it e.g. "StartView.cshtml". Insert the following markup and save your file.
@inherits UmbracoTemplatePage
@{
Layout = null;
}<!DOCTYPE html>
<html lang="en" ng-app="app">
<head>
<meta charset="UTF-8">
<base href="/">
<title></title>
</head>
<body>
<div class="menu">
<!-- you should probably assemble this with angular in the real world, for this example this will do -->
<ul>
<li><a href="/">Home</a></li>
<li><a href="/subpage-1">Page 1</a>
<ul>
<li><a href="/subpage-1/nested-subpage/">Nested</a></li>
</ul>
</li>
<li>
<a href="/subpage-2">Page 2</a>
</li>
</ul>
</div>
<div style="position:relative;">
<div class="view" ui-view></div>
</div>
<script src="scripts/angular-1.3.0.js"></script>
<script src="scripts/angular-ui-router.js"></script>
<script src="scripts/angular-sanitize.js"></script>
<script src="scripts/angular-animate.js"></script>
<script src="scripts/TweenMax.js"></script>
<script src="scripts/app.js"></script>
@Html.Partial("GlobalInfo", new GlobalModel(Model.Content))
</body>
</html>
The StartView is plain simple. In real life we would also handle navigation via the API, but for now we want to keep it simple.
The partial view in the bottom is for rendering out javascript variables that are constants. E.g. addresses and phonenumbers or dictionary items for multilingual sites; it could be anything that is constant for the whole site.
In this example i have made a GlobalModel which the partial renders out as a javascript JSON variable.
@using code
@model code.models.GlobalModel
<script>
var globalInfo = {
@for (var i = 0; i < ViewData.ModelMetadata.Properties.Count(); i++)
{
var data = ViewData.ModelMetadata.Properties.ToList();
var output = i == (ViewData.ModelMetadata.Properties.Count() - 1) ? string.Format("'{0}': '{1}'", data[i].PropertyName.FirstCharacterToLower(), Html.Display(data[i].PropertyName)) : string.Format("'{0}': '{1}',", data[i].PropertyName.FirstCharacterToLower(), Html.Display(data[i].PropertyName));
@Html.Raw(output)
}
};
</script>
I use the ModelMetadata to loop through the GlobalModel properties, and render out the propertyname (first char lower, or else my frontender will kill me) and the value of the property.
Now we are all set to start creating the Models and Controllers for Angular to communicate with Umbraco. Are you still awake and alert? Here we go!
The API
What I normally do is create a model per doctype. So to begin with I create a model for the frontend doctype. In a real situation I would create a master model first where I would put all the properties I want on all my models, but for this example we just create this single model to keep it simple:
using System.Collections.Generic;
using code.models.helper;
using Newtonsoft.Json;
using Umbraco.Core.Models;
using Umbraco.Web;
namespace code.models
{
public class FrontpageModel
{
[JsonProperty("id")]
public int Id { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("templateUrl")]
public string AngularTemplateUrl { get; set; }
[JsonProperty("contentImages")]
public IEnumerable<ImageModel> ContentImages { get; set; }
[JsonProperty("contentBody")]
public string ContentBody { get; set; }
[JsonProperty("created")]
public DateTime Created { get; set; }
[JsonProperty("updated")]
public DateTime Updated { get; set; }
public static FrontpageModel GetFromContent(IPublishedContent a)
{
return new FrontpageModel
{
Id = a.Id,
Name = a.Name,
ContentImages = ImageModel.GetImages(a, "contentImages"),
ContentBody = a.GetPropertyValue<string>("contentBody"),
AngularTemplateUrl = "/ng-views/frontpage.html", //could be done from Umbraco
Created = a.CreateDate,
Updated = a.UpdateDate
};
}
}
}
Notice two things:
- I am a lazy ass and am not creating a DAL for my datahandling. Instead I created the method GetFromContent right there in the model.
- I'm decorating the properties, so I can give them a JS-look (camelCase) when presenting them in my JSON later.
Now that I have my model I will create my controller so Angular can receive some data from Umbraco.
using System;
using System.Linq;
using System.Net;
using System.Web;
using code.models;
using Skybrud.WebApi.Json;
using Skybrud.WebApi.Json.Meta;
using Umbraco.Core.Logging;
using Umbraco.Web;
using Umbraco.Web.WebApi;
namespace code.controllers
{
[JsonOnlyConfiguration]
public class ContentApiController : UmbracoApiController
{
private UmbracoHelper _helper = new UmbracoHelper(UmbracoContext.Current);
public object GetData(string url, string langKey = "da")
{
HttpContext.Current.Response.AddHeader("Access-Control-Allow-Origin", "*");
try
{
var urlName = HandleUrlDecoding(url);
//find node by url og return null
var content = !string.IsNullOrEmpty(urlName)
? UmbracoContext.Current.ContentCache.GetByXPath(string.Format(@"//*[@isDoc and @urlName=""{0}""]", urlName)).FirstOrDefault()
: null;
if (content != null)
{
if (content.DocumentTypeAlias.ToLower() == "frontpage")
{
return
Request.CreateResponse(JsonMetaResponse.GetSuccessFromObject(content,
FrontpageModel.GetFromContent));
}
else if (content.DocumentTypeAlias.ToLower() == "subpage")
{
return
Request.CreateResponse(JsonMetaResponse.GetSuccessFromObject(content,
SubpageModel.GetFromContent));
}
//we are fucked, throw a 500
return Request.CreateResponse(JsonMetaResponse.GetError(HttpStatusCode.InternalServerError, "Der skete en fejl på serveren." + content.DocumentTypeAlias));
}
else
{
//no content found, throw a 404
return Request.CreateResponse(JsonMetaResponse.GetError(HttpStatusCode.NotFound, "Siden fandtes ikke."));
}
}
catch (Exception ex)
{
LogHelper.Info(typeof(ContentApiController), String.Format("Der skete en fejl: {0}", ex.Message));
//throw 500
return Request.CreateResponse(JsonMetaResponse.GetError(HttpStatusCode.InternalServerError, "Der skete en fejl på serveren."));
}
}
public string HandleUrlDecoding(string url)
{
url = HttpUtility.UrlDecode(url);
var urlName = url == "/"
? "home"
: url.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
if (!string.IsNullOrEmpty(urlName))
{
urlName = urlName.Replace(".aspx", "");
urlName = urlName.ToLower();
}
return urlName;
}
}
}
We now have our controller. Please notice that I added an "Access-Control-Allow-Origin" in the start of the method. This is not necessary if you don't want to access the method from another domain. I also decorated the class with [JsonOnlyConfiguration]. This attribute decoration came from the Skybrud.WebApi.Json NuGet package i have installed (read further down). This means that even though my controller is a UmbracoApiController, and thereby can return both xml and json, it will only return json. The rest of the method is self-expanatory. It looks up the content by urlname and returns the found data as a JSON-object.
The controller can now be called from Angular by using its route + url-parameter: http://www.imonaboat.com/umbraco/api/contentapi/getdata/?url=/product/enterprise/sitecore/
The controller will try to find the url specified and either return the model in JSON-format or a 404 HttpStatusCode with an error message you define.
All the magic that is used to convert our models to JSON is provided by Anders Bjerners NuGet package Skybrud.WebApi.Json
That's all folks! Now you are ready to go on with the Angular implementation. So I hand over the rudder to my good friend and colleague Filip Bruun who will navigate you through the seas of Angular. Aye aye captain!
Angular routing
Cheers everybody.
Now that René has set everything up on the server I'll try to walk you through the simplest possible solution I've found to generic routing and templating with Angular (for the sake of simplicity I won't go into handling server errors or 404s)
The Angular team have built a very basic router, which they moved out of the core and into its own module in Angular 1.2. That means we need to declare it as a dependency to use it, but it also means that you are free to use other routers (there is really only alternative at the moment: the ui.Router). I prefer the latter and this example is made for ui.Router, but all the features I use in this example also works with the ngRoute module from the Angular team. (although the syntax is a little different, so I put up a ngRoute version in the github repo too).
Angular routers work by reading the url and then intercepting all internal links so clicks will push url changes without reloading the page (while keeping the addressbar and history up to date). When the location changes the router will figure out what to display from settings in an object. So our task at hand is to set up a route object that will match any url and then do an ajax request with the path to the API that you just built in the previous section.
Now all we need from the html is to include the javascript files, provide a base-tag (so all resources (images, css, etc) are fetched from the right location) and a DOM node with a ui-view attribute (and a DOM node with an ng-app attribute to kick off Angular). We also bind the title-tag to a pageTitle value so it can easily be updated. All of this goes in Renés startview...
The javascript magic then looks like this...
angular.module('app', ['ui.router', 'ngAnimate', 'ngSanitize'])
.config(['$locationProvider', '$stateProvider', function($locationProvider, $stateProvider) {
// This removes the # in browsers that support it (so we have real urls)
$locationProvider.html5Mode(true);
$stateProvider
.state('all', {
/* This matches any url, and exposes the path to $stateParams with the name myPath */
url:'*myPath',
resolve: {
getData: ['$stateParams', '$http', function($stateParams, $http) {
return $http({
url: '/umbraco/api/contentApi/getData/',
params: {
/* Send the current path in the querystring with the key 'url' to the api */
url: encodeURIComponent($stateParams.myPath)
}
});
}]
},
template: '<div ng-include="ctrl.pageData.templateUrl"></div>',
controller: ['getData','$rootScope', function(getData,$rootScope) {
var _this = this;
/* We assign the api-reponse to the instance of the controller, so it's accessible from the view */
_this.pageData = getData.data.data;
$rootScope.pageTitle = getData.data.data.name;
}],
controllerAs: 'ctrl'
});
}])
If you have played around with Angular this won't look to scary to you. The $stateProvider.state() method takes a name as the first parameter and the config object as the second (this is where ngRoute differs a little from ui.Router).
The url value is some special syntax that basically means match anything and capture it (regex-style) into a variable named path.
Resolve is an object of methods that needs to be resolved before the route will instantiate. If you return anything other than a promise (or false) the router will take that as being resolved. If you return a promise the route won't be initiated before that promise is resolved. We return the $http-call directly as that itself returns a promise. That means the route will run on a successful response to that ajax-call.
The routes are static so we can't change the template based on data we receive. We work around this by having the minimal possible template that then uses the ngInclude directive to render a template depending on a url value (that you can set/change at the response of the server).
All that's left is the controller that takes the data the resolve (with the key "getData") returns, and makes that available to the template. At this time we also update the pageTitle value of the $rootScope so the page title is updated.
Voila, there you have it! Angular now controls the routing and templating on the frontend, and you can still have editors change the content and even the template.
Remember to include ngSanitize to allow angular to bind html content from an ajax-response to your view.
The article could easily end here, but I thought we should give you the basics of actually animating the page transitions as well.
Page transitions
Angular also has a module (also not included in the core but made by the team) for animation; it's called ngAnimate. You include its source and set it as a dependency in your module. This will enable two things for you: It will 1) add some css hooks for you when it's transitioning in and out (see the doc in the previous link), and 2) provide you with some javascript hooks for the same thing. The page transition we use could easily be done with a css transition, but I'll implement it the more empowering alternative way.
The hooks are available as methods on an object that the .animation() method returns. We'll add '.view' as our first parameter (the elements that has the hooks) and as the second a function that returns the hooks for enter and leave-animations. The methods will be called by Angular when it's changing the view, and will be called with the element (or rather, an angular.element/jqLite wrapper that contains the element) and a done function that you'll call when your animation is done. This tells angular that you are done animating, so it can remove the element from the DOM at the right time. For the sake of simplicity I'll use the GreenSock Animation Platform to actually tween the values and handle the callback (for the TweenLite calls i use element[0] to get the native element instead of the angular-wrapped one).
All put together it looks something like this:
.animation('.view', function() {
return {
enter: function (element, done) {
TweenLite.from(element[0], 1, {
opacity: 0,
onComplete: done
});
},
leave: function(element, done) {
TweenLite.set(element[0], {
position: 'absolute',
top: 0
});
TweenLite.to(element[0], 1, {
opacity: 0,
onComplete: done
});
}
}
});
I hope you can grasp the power in this humble module and start imagining what great opportunities this gives you. Good luck! I hope to see more Angular powered Umbraco one-pages in the future!
A couple of tips that could make your one-page even more awesome:
-
A spinner: You could add a spinner or something to indicate that you are loading the next page. Add it in the resolve getData method and remove it from the controller.
-
Save requests: Cache and inline your client side templates so you won't need to make an extra request for them. We do this with a grunt task named ngTemplates. Tweet at me (@filipbech), and I'll send you some configuration if you would like.
-
Error handling: The router will broadcast an event named "$stateChangeError" when resolves fail (eg. header 404, 500 etc). Listen for that event and act accordingly.
-
You should probably build your navigation based on the api as well.
Samples
You can find a sample-project on GitHub: https://github.com/skybrud/AngularPoweredUmbracoFrontend
You can find a live example-site here: http://24days.skybrud.dk/
René Pjengaard
René is on Twitter as @pjengaard
Filip Bech
Filip is on Twitter as @Filipbech