Similar Solutions - Different Approaches

Heads Up!

This article is several years old now, and much has happened since then, so please keep that in mind while reading it.

When I started the first draft of this yarn, I had grand plans of exploring how I structured my workflow package, Plumber. Given it’s a reasonably large piece of work, there’s a whole lot of interesting bits and pieces to explore – a custom section, custom trees, overlays, dashboards, poco mapping and so on.

Umbraco provides a heap of handy endpoints and sensible defaults to help speed up developing backoffice extensions, and since I was using a range of these, I thought my insights might be helpful for other package developers.

I started writing and reviewing my code, and quickly came to realise the way I building my trees wasn't quite right. Draft went in the bin, and the time I’d put aside for writing was instead spent refactoring.

Call this take two – a look at how custom sections and trees work, how I’d built mine incorrectly, and how building them correctly gives much more control over how they are routed.

Perhaps more importantly (especially since there are plenty of examples of tree and section registration out there on the web already), this tale is also a look at how Umbraco gives us multiple ways of achieving similar results, but leaves it to us as developers to choose the most appropriate for our situation.

Custom sections

Adding a custom section to an Umbraco install is quick and easy – a new class inheriting from IApplication, decorated with the Application attribute, in which we give the section an alias, a name, and icon and a sort order.

using umbraco.businesslogic;
using umbraco.interfaces;

namespace Workflow.Trees
{
    [Application("workflow", "Workflow", "icon-path", 10)]
    public class WorkflowApplication : IApplication { }
}

Already we hit an option - we can achieve the same using the SectionService. My preference is to define the class, since I feel it's more obvious in it's purpose than adding an event handler to call the service.

This registers the new section on startup, but you’ll still need to grant individual users permission to see it in the backoffice. You could also do this programmatically for all users or particular types/groups, but that’s beyond the scope of this discussion. Another option!

Custom tree

The tree is added in much the same manner – either via a class or the ApplicationTreeService. The Tree attribute defines the section, tree alias and tree name. The PluginController associates our new tree with our plugin/extension (more on this in a second).

The controller has two required methods – GetTreeNodes and GetMenuForNode – which are responsible for populating the tree.

If the example below doesn't feel right, that's because it's not. We'll come back to that, but for now, it's an example of how we can populate a tree in a custom section.

using System;
using System.Net.Http.Formatting;
using Umbraco.Core;
using Umbraco.Web.Models.Trees;
using Umbraco.Web.Mvc;
using Umbraco.Web.Trees;

namespace Workflow.Trees
{
    [Tree("workflow", "tree", "Workflow")]
    [PluginController("Workflow")]
    public class WorkflowTreeController : TreeController
    {
        protected override TreeNodeCollection GetTreeNodes(string id, FormDataCollection queryStrings)
        {
            if (id == Constants.System.Root.ToInvariantString())
            {
                var nodes = new TreeNodeCollection();
                
                // add nodes to the root level of the tree

                return nodes;
            }

            if (id == "approval-groups")
            {
                // removed for brevity - populates child nodes of approval-groups node
            }
            throw new NotSupportedException();
        }

        protected override MenuItemCollection GetMenuForNode(string id, FormDataCollection queryStrings)
        {
            var menu = new MenuItemCollection();
            
            // populate the children for the selected node

            return menu;
        }
    }
}

The plugin folders

A shiny new section and tree is worth nought if we don’t fill it with data. To do so, we need to define a new plugin in the App_Plugins folder. This will store the JS, HTML and CSS assets required by our plugin.

Umbraco sets some sane defaults for backoffice routing, which for our new plugin are defined by the values in the attributes set on our section and tree controllers.

By default, Umbraco will search for HTML views in /App_Plugins/{plugin name}/BackOffice/{tree alias}/{method}.html, where the default method is edit.

We also need a folder for language definitions, at /App_Plugins/{plugin name}/Lang, where we add language files for our plugin. In those language files we also need to set the displayed name for our custom section:

<area alias="sections">
  <key alias="workflow">Workflow</key>
</area>

This is enough to generate a new section, with a simple tree, using the default routing.

The next bit is where I went wrong.

The Plumber application has three root nodes – one for settings, one for approval groups, and one for history. Only approval groups has child nodes, the other two simply display a default view. To create the nodes, I created a single tree, added the required root-level nodes and wired up the required functions for populating sub menus:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http.Formatting;
using Umbraco.Core;
using Umbraco.Core.Models;
using Umbraco.Web;
using Umbraco.Web.Models.Trees;
using Umbraco.Web.Mvc;
using Umbraco.Web.Trees;
using Workflow.Models;

namespace Workflow.Trees
{
    [Tree("workflow", "tree", "Workflow")]
    [PluginController("Workflow")]
    public class WorkflowTreeController : TreeController
    {
        protected override TreeNodeCollection GetTreeNodes(string id, FormDataCollection queryStrings)
        {
            if (id == Constants.System.Root.ToInvariantString())
            {

                var nodes = new TreeNodeCollection();
                const string route = "workflow/tree/view/";
                var treeNodes = new List<SectionTreeNode>();

                var user = UmbracoContext.Current.Security.CurrentUser;

                if (user.IsAdmin())
                {
                    treeNodes.Add(new SectionTreeNode { Id = "settings", Title = "Settings", Icon = "icon-umb-settings", Route = $"{route}settings" });
                    treeNodes.Add(new SectionTreeNode { Id = "approval-groups", Title = "Approval groups", Icon = "icon-users", Route = $"{route}approval-groups" });
                }
                treeNodes.Add(new SectionTreeNode { Id = "history", Title = "History", Icon = "icon-directions-alt", Route = $"{route}history" });

                nodes.AddRange(treeNodes.Select(n => CreateTreeNode(n.Id, id, queryStrings, n.Title, n.Icon, n.Id == "groups", n.Route)));

                return nodes;
            }

            if (id == "approval-groups")
            {
                // removed for brevity - populates child nodes of approval-groups node
            }
            throw new NotSupportedException();
        }

        protected override MenuItemCollection GetMenuForNode(string id, FormDataCollection queryStrings)
        {
            var menu = new MenuItemCollection();
            int result;

            if (id == "approval-groups")
            {
                menu.Items.Add(new MenuItem()
                {
                    Alias = "add",
                    Name = "Create",
                    Icon = "add"
                });
                menu.Items.Add<RefreshNode, umbraco.BusinessLogic.Actions.ActionRefresh>("Reload nodes", true);
            }
            else if (int.TryParse(id, out result))
            {
                menu.Items.Add<umbraco.BusinessLogic.Actions.ActionDelete>("Delete group");
            }

            return menu;
        }
    }
}

Given that means a single tree controller, everything was using a common route as defined by the attributes decorating the tree controller:

[Tree("workflow", "tree", "Workflow")]
[PluginController("Workflow")]

And the route property added to each SectionTreeNode:

// the route param is defined earlier, as 'workflow/tree/view'
treeNodes.Add(new SectionTreeNode { Id = "settings", Title = "Settings", Icon = "icon-umb-settings", Route = $"{route}settings" });

Which translated into routes looked like:

/App_Plugins/workflow/Backoffice/tree/view/settings

All my routes are sent through /tree/view so that I have a central controller from which to manage menu and loading state, so they then translate into URLs similar to this one:

/umbraco#/workflow/tree/view/settings

Not great, is it? In the interest of looking after our users, the visible URL should be logical, concise, and follow a format similar to Umbraco’s default sections. We've currently got URL segments which mean absolutely nothing.

Why? Because somewhere along the line, I’d interpreted the three root nodes as one tree, when it should be three. Refactoring the single tree controller into one each for settings, approval groups and history means we have a lot more control over routing, as each tree has its own attributes and hence its own route. While this means three separate controllers, each one is concise and contains only the logic required to render that particular tree.

For example, the settings tree controller:

using System.Net.Http.Formatting;
using Umbraco.Web.Models.Trees;
using Umbraco.Web.Mvc;
using Umbraco.Web.Trees;

namespace Workflow.Trees
{
    [Tree("workflow", "settings", "Settings")]
    [PluginController("Workflow")]
    public class WorkflowSettingsTreeController : TreeController
    {

        protected override TreeNode CreateRootNode(FormDataCollection queryStrings)
        {
            var root = base.CreateRootNode(queryStrings);

            root.RoutePath = "workflow/settings";
            root.Icon = "icon-umb-settings";
            root.HasChildren = false;

            return root;
        }

        protected override TreeNodeCollection GetTreeNodes(string id, FormDataCollection queryStrings)
        {
            return new TreeNodeCollection();
        }

        protected override MenuItemCollection GetMenuForNode(string id, FormDataCollection queryStrings)
        {
            return new MenuItemCollection();
        }
    }
}

Much better, right? Notice the lack of logic, for starters. It's concise, and does one job - building the settings tree.

We're defining a tree with a single node, and giving it the route /workflow/settings, which will resolve to a particular view in my plugin folders. It's still defined as part of the Workflow section (or application, choose your own nomenclature), but in its own tree.

But it won't load, at least not yet. Umbraco defines a set of default routes, but not one to handle :section/:tree (AKA /workflow/settings). We can easily remedy this by adding the route to the Umbraco AngularJs application, to manage the root node in each of the three trees:

(function() {
  'use strict';

  var app = angular.module('umbraco');

  app.config(function ($routeProvider) {
    $routeProvider.when('/workflow/:tree',
      {
        template: '<div ng-include="\'/app_plugins/workflow/backoffice/tree/view.html\'"></div>'
      });
  });
}());

All my routes are still sent through .../tree/view/, where the AngularJs controller takes the tree name from $routeParams and builds the correct URL for the required view.

With the new route configured, Plumber now has clean, simple tree registration and tidy backoffice routes:

  • /umbraco#/workflow/settings
  • /umbraco#/workflow/approval-groups
  • /umbraco#/workflow/approval-groups/edit/{id}
  • /umbraco#/workflow/history

Is it a change the end user will notice? Maybe not, but it keeps the plugin code clean and readable.

Even though defining a single tree works acceptably, breaking it out into distinct trees means we can modify one in the future without breaking another.

The extensible nature of Umbraco means a given goal can often be achieved through multiple different coding approaches, each with its own benefits and trade-offs. The responsibility ultimately falls to us as developers to identify those differences, and find the solution which best solves our particular problem.

If anyone cares to fill me in on the differences between registering custom sections and/or trees via the service compared to a class declaration, please, do.

Nathan Woulfe

Nathan is on Twitter as