Donut Output Caching issues and Umbraco macros

This post is about how I created a re-usable A-Z macro using the hybrid framework and donut caching in Umbraco.

When re-designing the leicester.gov.uk corporate website in Umbraco I began by looking at the excellent uLocalGov package. It contains an a-z component that uses a surface controller and some partials. It works a treat but digging under the hood, the main action to get entries first caches the entire site a-z then retrieves the relevant entries. This might not be that much of an overhead when dealing with fairly small A-Z’s but since I was using the hybrid framework for development, which makes use of DonutOutputCaching, I thought it would be good if I could use that instead and cache the results on a per-letter basis. So if the user only wants to look at ‘A’ its only ‘A’ that is cached.

Also, rather than having to create an actual template for the page, I wanted to be able to re-use the a-z component in multiple places throughout the site and allow the user to set the root node of what to index. So for sections with a large number of sub-pages, an a-z just for that section could be plonked on to a page. So, I decided to create the a-z as a macro.

So, the order that things happen in is:

  1. user puts macro on standard system page
  2. macro calls action using the @Html.action method
  3. action gets data and returns a partial and the A-ZModel object that is generated
  4. partial outputs A-Z model

The main action

So, here’s the code for the ‘GetAtoZEntries’ action…

[ChildActionOnly]
[DonutOutputCache(Duration = 3600, Location = OutputCacheLocation.Server, VaryByParam = "letter")] //1
public ActionResult GetAtoZEntries(string letter, int rootNodeId = -1)
{
    var model = GetModel<AtoZModel>(); //2
    model.AtoZEntries = new SortedDictionary<string, AtoZEntry>();
    model.HasEntries = false;
    model.SelectedLetter = letter;
    model.RootNodeId = rootNodeId;
    model.RootNode = rootNodeId == -1 ? CurrentPage.TopPage() : Umbraco.TypedContent(rootNodeId);
    model.ExternalAtoZProviderEntries = new SortedDictionary<string, AtoZEntry>();
    model.ExternalAtoZProviders = getExternalAtoZProviders();

    if (letterRegex.IsMatch(model.SelectedLetter)) //3
    {
        model.SelectedLetter = letter.ToLower();
        GetInternalAtoZEntries(model, model.SelectedLetter); //4
        model.HasEntries = model.AtoZEntries.Any();
    }
    else
    {
        model.SelectedLetter = "";
    }

     return PartialView("LCCAtoZEntries", model);
}

Here’s a run-down of the numbered items above…

  1. The DonutOutputCache annotation specifies a duration of 3600 seconds, to be cached on the server and varied by the letter that is passed to it. This means a different cache is generated for each letter
  2. Since I’m using the hybrid framework, the model is returned by calling the BaseSurfaceControllers’s method ‘GetModel’ and passing the type to return.
  3. The letter that was passed to the method is matched against a regex to ensure it can only be a single letter a-z (lower and uppercase)
  4. The private method GetInternalAtoZEntries actually gets the a-z entries

Getting results

When getting results, the pages that are returned have to meet the following criteria:

  • must not be hidden pages
  • must be set to show in the a-z (a checkbox in the back office)
  • must be one of the allowed document types to return

Also, following uLocalGov’s great idea, each page can be tagged with alternate entries, so they also show up in different letters. For example, a page called ‘Waste’ could also be tagged as ‘Rubbish’ and show up in both locations. So, to do that, the following process is done:

  1. Get all tags in the ‘AtoZ’ group that start with the selected letter
  2. If there are any, return a comma separated list of those tags
  3. Get all documents that contain those tags
  4. Loop through the documents and add entries to the SortedDictionary using the tag caption and the page url

Here’s a code snippet of that last bit…

var filteredAToZTags = Tag.GetTags("AtoZTags").Where(t => t.TagCaption.ToLower().StartsWith(model.SelectedLetter));

if (filteredAToZTags.Any())
{
  var docsWithTags = Tag.GetDocumentsWithTags(string.Join(",", filteredAToZTags.Select((Func<Tag, string>)(x =>
  {
      return x.TagCaption;
  }))));

//add entries to sorted dictionary by matching tag with content
foreach (var doc in docsWithTags)
{
   var tagForDoc = Tag.GetTags(doc.Id).Where(t => filteredAToZTags.Any(ft => ft.TagCaption == t.TagCaption)).FirstOrDefault();

    if (!model.AtoZEntries.ContainsKey(tagForDoc.TagCaption))
    {
        model.AtoZEntries.Add(tagForDoc.TagCaption, new AtoZEntry()
        {
           Title = tagForDoc.TagCaption,
           Url = Umbraco.NiceUrl(doc.Id),
           Provider = "Leicester City"
         });
     }
  }
}

Delegate Functions

A side point here, I used this as a chance to learn some stuff about delegate functions. Taking a simple example, in the code above, the ‘filteredAtoZTags’ variable becomes an IEnumerable<Tag>. However, I only want the tag caption for each and I want that as a comma separated list. I could loop through these using a for loop and build up a string, but why not use a funky ‘Func?’

To do that, the ‘select’ lambda expression can be used and supplied with a delegate function. This function is inline and applies an expression to a collection, returning some data for each element. The ‘Func<Tag, string>’ bit specifies that the function will accept an object of type ‘Tag’ and return a string. So the entire function returns an IEnumerable<string> which can be used by the string.join method. The inner body of the function simply returns the tag caption.

A tricky bit

Now, in leicester.gov.uk’s main a-z, it not only has to consume internal pages, it has to consume other local authorities a-z’s if required. This is done via an xml feed. So, the user should be able to tick which authorities he wants to see, click submit and the page refreshes showing that feed.

The first pain was actually getting a checkbox list to be output and persisted to the model. The model has an IEnumerable<ExternalAtoZProvider> which is a hard-coded list of providers containing the text to show, the value to pass to the feed and a boolean variable to indicate whether it had been selected. After a few hours of searching round and ranting incoherently I pieced together a solution, shown below…

@using (Html.BeginUmbracoForm("GetAtoZEntries", "LCCAtoZ", new { letter = Model.SelectedLetter }))
{
  @for (int i = 0; i < Model.ExternalAtoZProviders.Count; i++) 
  {
    @Html.CheckBoxFor(m => m.ExternalAtoZProviders[i].Selected, new { @id = Model.ExternalAtoZProviders[i].Value })
    @Html.Label(Model.ExternalAtoZProviders[i].Value, Model.ExternalAtoZProviders[i].Title) 
    @Html.HiddenFor(m => m.ExternalAtoZProviders[i].Value) 
    @Html.HiddenFor(m => m.ExternalAtoZProviders[i].Title)
} 
  @Html.HiddenFor(m => m.SelectedLetter) 
  @Html.HiddenFor(m => m.RootNodeId) 
  @Html.HiddenFor(m => m.HasEntries) 
  @Html.HiddenFor(m => m.AtoZEntries)
  <button class="button radius expand" type="submit">Submit<button> 
}

So, I use the BeginUmbracoForm helper method which, when posted, goes to the ‘GetAtoZEntries’ method (the one marked with a HttpPost verb). It loops through the ExternalAtoZProviders (I tried a foreach, it didn’t like it :() and creates a checkbox for the selected state of that element as well as hidden fields for the other values (title and value). I also created hidden fields for each of the other relevant bits of the model.

Now, the issue was getting the model to persist. The method that the form posts to must be marked as follows…

[HttpPost]
[NotChildAction]
public ActionResult GetAtoZEntries(AtoZModel model)
{
//...
}

This allows the internal routing to differentiate between this method and the other ‘GetAtoZEntries’ action. Just specifying the HttpPost verb isn’t enough.

What to return and where to?

So, after getting the external a-z entries and populating them in the model, how to pass the model back? This is being called from a macro remember so it could be any page making the call. If I just return a partial view, this is all that is rendered (see this post). So, I got the method to return to the calling page using the ‘RedirectoToCurrentUmbracoPage’ method. This returns to the correct page but the posted data isn’t there! Argh! What now!?

Well, the link just referenced above has quite a nice solution but it involves putting the model into the tempdata bag, then re-mapping it in the view. Seems a bit inelegant but it works. As follows…

//Controller
[HttpPost]
[NotChildAction]
public ActionResult GetAtoZEntries(AtoZModel model)
{
//get the data and populate the model
//...
TempData["AtoZModel"] = model;

return RedirectToCurrentUmbracoPage();
}
//view
@{
    this.MapModel();
}

This requires an extension method as follows…

public static void MapModel<T>(this WebViewPage<T> page) where T : class
{
   var models = page.ViewContext.TempData.Where(item => item.Value is T);
   if (models.Any())
   {
     page.ViewData.Model = (T)models.First().Value;
     page.ViewContext.TempData.Remove(models.First().Key);
    }
 }

This takes the tempdata object and maps it to the specified model, so it can then be used in the view. Doing this resulted in a solution that allowed a user to click any of the checkboxes, the posted data was used in the lookup, then the populated model was returned to the view. Almost there…

Caching issues

Unfortunately, with this solution, if DonutOutputCaching is applied to the main action, once a particular letter’s results are cached, selecting additional providers and pressing submit does not load the damn results! This seems to be a side-effect of the DonutOutputCaching jazz. So, to get around this, I used the cache manager object to remove the cache for a particular letter if the post action was called, like so…

[HttpPost]
[NotChildAction]
public ActionResult GetAtoZEntries(AtoZModel model)
{
//...
var cacheManager = new OutputCacheManager();
cacheManager.RemoveItem("LCCAtoZ", "GetAtoZEntries", new { letter = model.SelectedLetter });

So, if the user wants to see additional providers for the given letter, the cache for that letter is cleared, then the new data is retrieved and re-cached.

So, in summary

I learnt a few things here which (I think) should be useful, namely, using DonutOutputCaching and clearing certain bits of the cache; using delegate functions to avoid long-winded for loops; working with forms and posted data in umbraco macros.

Any thoughts, suggestions, improvements very welcome.

Leave a Reply