Custom Property Editor Tutorial

Heads Up!

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

This tutorial is created courtesy of Umbraco 7’s fantastically accessible way of setting up custom property editors. It will show how to write a one that provides the functionality to create, assign, and remove tags.

You might wonder what is the difference between this editor (codename: tagged) and the existing tag editor you can find in newer versions of Umbraco. Imagine having 150 articles in your blog and discovering that you forgot an “m” in “recomendation” tag used on ? of all articles. With the existing tag editor, you would have to open each and every article, find the tag, correct the spelling and publish the article. The tag doesn’t tie back to other tags.

This can be solved by doing an resource expensive database search and replacing the spelling everywhere. Or it can be done by creating tags as documents in content tree rather than saving strings in the database. This small difference means that tags can be created, deleted and edited not only from an article but also as documents on their own.

The solution also opens up (with a bit of adjusting) for other possibilities. A hierarchical category structure where an article categorized as “6-10 year old” belongs also to category “children” or a dashboard where you can see how your articles are categorised including some stats.

What we are trying to achieve

  1. Get the list of available tags from the content tree and show them in controller
  2. On click on one of the tags, move them to applied tags so that they can be saved on the document
  3. "Create a tag" functionality - tag created as a document in content tree and is also available for applying in document
  4. "Remove a tag" functionality - tag is removed from applied tags Editing or removing tags in content tree edit or removes them in the custom property editor

What you will need

  1. Doctypes: article (that will have the custom property editor), Tags (no properties, all tags will be created under here), Tag (no properties)
  2. In App_Plugins folder create:
    1. Tagged folder package.manifest - it describes what is needed to for the custom editor
    2. tagged.controller.js - this is where AngularJS lives
    3. tagged.html - the template for controller
  3. In Controllers folder:
    1. TaggedController.cs - the c# code that will fetch data from Umbraco and send it on to the angular tagged.controller.js for processing and presentation

You might notice that I am not using any css. This is just because I am using bootstrap classes and bootstrap comes with Umbraco 7. If you wanted to use some custom css you would create css folder under Tagged folder and put your css there. You would then declare it in package.manifest:

css: [
        '~/App_Plugins/Tagged/css/main.css'
 ]

To read more about how to create a custom property editor and how to set up your manifest, see this documentation.

Step One - The Manifest

{
    propertyEditors: [
        {
            alias: "MAL.Tagged",
            name: "Tagged",
            editor: {
                view: "~/App_Plugins/Tagged/tagged.html"
            }
        }
    ]
    ,
    javascript: [
        '~/App_Plugins/Tagged/tagged.controller.js'
    ]
}

Step 2 - Fetching all available tags

First order of business is to fetch and display the available tags in the property editor.

You have to instruct your AngularJS controller to request the tags from backend and to expose them to the template once they are fetched.


angular.module("umbraco").controller("MAL.taggedController", function ($scope, taggedResource) {

        $scope.alltags = [];

        // Use taggedResource service to call surface controller
        // Once the data is returned create array of tags (alltags)
        // Each tag will contain a name and document id
        taggedResource.getTags().then(function(res){
            var data = res.data;
            for (var tag in data) {
                var name = tag;
                if (data.hasOwnProperty(tag)) {
                    $scope.alltags.push([tag, data[tag]]);
                  }
            }
        });

});

angular.module("umbraco.resources").factory("taggedResource", function($http){ 
    var taggedService = {};

    // Service makes an ajax call to 
    // umbraco surface controller and returns the result
    taggedService.getTags = function(){
        return $http.get("/umbraco/surface/Tagged/GetTags");
    };

    return taggedService;

});

The surface controller that you created at the beginning (TaggedController.cs) has GetTags action which fetches all documents in the content tree under the folder "Tags" and returns key-value (name-id) pairs in JSON.

public JsonResult GetTags()
{
    IPublishedContent tagRoot = Umbraco.TypedContentAtRoot().Where(x => x.DocumentTypeAlias == "tags").FirstOrDefault();
    Dictionary<string, int> tags = new Dictionary<string, int>();
    foreach (var tag in tagRoot.Children)
    {
        tags.Add(tag.Name, tag.Id);
    }
    return Json(tags, JsonRequestBehavior.AllowGet);
}

Once the alltags array is ready and exposed on $scope, you can display them in the template, using ng-repeat:

<h1>Available tags</h1>
<ul class="nav nav-pills">
    <li ng-repeat="tag in alltags"><a href="" class="btn" ng-click="applyTag([tag[1], tag[0]])" data-id="{{tag[1]}}">{{tag[0]}}</a></li>
</ul>

tag[0] is the name while tag[1] is the id. Ng-click is used to hook up applyTag function, that we will create next.

Step 3 - Setting up the backoffice

Now that we have the basic function ready, we can set up the backoffice and see the property editor in action.

In Developer section go to Data Types and create new Data Type. Choose Tagged in property editor dropdown and call the editor Tagged.

In Settings section go to Article Document Type and add a property editor and choose Tagged

The editor should now be available on your documents in Content section. Make sure to clean cache if you can’t see it immediately. The editor will of course be empty until you create some tags in the content tree.

It is generally good to remember that backoffice is cached aggressively. When you make changes to your editor it is often necessary to refresh cache before they are visible.

Step 4 - Applying tags

The next step is to wire a function to a click on a tag, i.e. hook up applyTag() function.

In your AngularJS controller declare two more empty variables on the scope:

$scope.appliedIds = []; - this one will be used for fetching the current names of already applied tags. You’ll find out how in the next section.

$scope.appliedTags = []; - this one will be used to store current applied tags, and includes both names and ids.

The next part becomes a bit tricky. If we were to store tags as strings or in the database all that was needed, was to add the clicked tag to $scope.model.value. However, we are interested in being able to edit tags from the content tree, so this part will get a little bit more complicated.

First we need to populate $scope.appliedIds with the ids of tags saved in $scope.model.value.

for (var index in $scope.model.value) {
    if ($scope.model.value.hasOwnProperty(index)) {
        $scope.appliedIds.push($scope.model.value[index][0]);
    }
}


The next step is to create the service that is used to fetch current names of tags. It is connected to applying tags because each time a new tag is added, it should immediately be displayed so we need to fetch it from the content tree.

taggedService.getAppliedTags = function(appliedIds){
    return $http({
        url: "/umbraco/surface/Tagged/GetAppliedTags", 
        method: "GET",
        params: {appliedIds: appliedIds}
    });
}

The GetAppliedTags action on the surface controller does almost the same as GetTags actions, only it is limited to fetching the documents that have the ids passed to the action.

public JsonResult GetAppliedTags(List<int> appliedIds)
{
    Dictionary<string, int> tags = new Dictionary<string, int>();
    var umbracoHelper = new UmbracoHelper(UmbracoContext.Current);
   

    if (appliedIds != null)
    {
        foreach (var id in appliedIds)
        {
            if (!tags.ContainsValue(id))
            {
                IPublishedContent tag = umbracoHelper.TypedContent(id);

                if (tag != null)
                {
                    var name = tag.Name;
                    tags.Add(name, id);
                }
                else
                {
                    var name = "---";
                    tags.Add(name, id); 
                }
                
            }
            
        }
    }
    

    return Json(tags, JsonRequestBehavior.AllowGet);
}

You might notice the funny thing I do in the if/else statement. The point to it is that if a tag is deleted from content tree, I also need to remove it from $scope.model.value so it doesn’t “hang around” in the property editor. The only way to find out that a tag was deleted is from C# controller and so I need to communicate it back to AngularJS. I do it by changing a name of such tag to “---”. And in AngularJS controller I filter them out in getAppliedTags function.

taggedResource.getAppliedTags($scope.appliedIds).then(function(res) {
    var data = res.data;
    for (var tag in data) {
        if (data.hasOwnProperty(tag)) {

            if (tag != "---") {
                $scope.appliedTags.push([res.data[tag], tag]);
            }
            else
            {
                var index = $scope.appliedIds.indexOf(res.data[tag]);
                $scope.appliedIds.splice(index ,1);

               for(var i = 0; i < $scope.model.value.length; i++) {
                  if($scope.model.value[i][0] == res.data[tag]) {
                      $scope.model.value.splice(i, 1);
                  }
              }
            }
        }
    }
});

Finally, it is time to write applyTag function.

$scope.applyTag = function(tag) {
    if(!jQuery.isArray($scope.model.value)) {
        $scope.model.value = [];
    }

    //if the tag hasn't been applied yet
    if ($scope.appliedIds.indexOf(tag[0]) == -1) {
        //add it to model.value and appliedIds
        $scope.model.value.push(tag);   
        $scope.appliedIds.push(tag[0]);

        //clean appliedTags variable
        $scope.appliedTags = [];

        //and using getAppliedTags service, fetch current tags and push them to appliedTags
        taggedResource.getAppliedTags($scope.appliedIds).then(function(res) {
            var data = res.data;
            for (var tag in data) {
                if (data.hasOwnProperty(tag)) {
                    $scope.appliedTags.push([res.data[tag], tag]);
                  }
            }
        });
    }
};

In the template again use ng-repeat to iterate through appliedTags and display name/id pairs.

<ul class="nav nav-pills">
    <li ng-repeat="tag in appliedTags"><a class="btn" data-id="{{tag[0]}}">{{tag[1]}} <span ng-click="removeTag(tag)">x</span></a></li>
</ul>

Step 5 - Removing tags

The next order of business it to wire removeTag(tag) function. On click on an "x" sign, the tag should be removed from applied tags. To do this we need to remove the tag from $scope.model.value, $scope.appliedTags, and $scope.appliedIds.

$scope.removeTag = function(tag) {
    for(var i = 0; i < $scope.model.value.length; i++) {

        if($scope.model.value[i][0] == tag[0]) {
            $scope.model.value.splice(i, 1);
        }
    }

    for(var i = 0; i < $scope.appliedTags.length; i++) {

        if($scope.appliedTags[i][0] == tag[0]) {
            $scope.appliedTags.splice(i, 1);
        }
    }

    for(var i = 0; i < $scope.appliedIds.length; i++) {

        if($scope.appliedIds[i] == tag[0]) {
            $scope.appliedIds.splice(i, 1);
        }
    }
    
    return;
}

Step 6 - Creating Tags

In the template create a form and on submit call backend action to create a new document in content tree.

Template:

<h2>Create new tag</h2>
<form ng-submit="createTag(newTag)">
      <input type="text" ng-model="newTag"/>
      <button type="submit">Create tag</button>
</form>

AngularJS service:

taggedService.createTag = function(newTag) {
    return $http({
        url: "/umbraco/surface/Tagged/CreateTag", 
        method: "GET",
        params: {tag: newTag}
     });
};

AngularJS function:

$scope.createTag = function(newTag) {
    //make sure tag does not include empty spaces at the end
    if(jQuery.trim(newTag).length > 0) {
        //backend controller creates the document 
        taggedResource.createTag({ tag: newTag }).then(function (res) {
            $scope.alltags = [];
            
            taggedResource.getTags().then(function(res){
                var data = res.data;
                for (var tag in data) {
                    var name = tag;
                    if (data.hasOwnProperty(tag)) {
                        $scope.alltags.push([tag, data[tag]]);
                      }
                }
            });
        });
        $scope.newTag = '';
    }
    else {
        alert('Fill in the tag!');
    }
};

Surface Controller action

public JsonResult CreateTag(string tag)
{
    JObject tagJ = JObject.Parse(tag);
    var cs = Services.ContentService;
    int parentId = Umbraco.TypedContentAtRoot().Where(x => x.DocumentTypeAlias == "tags").FirstOrDefault().Id;
    var content = cs.CreateContent(tagJ["tag"].ToString(), parentId, "tag", 0);
    cs.SaveAndPublishWithStatus(content);

    Dictionary<string, int> tagitem = new Dictionary<string, int>();
    tagitem.Add(tagJ["tag"].ToString(), content.Id);

    return Json(tagitem, JsonRequestBehavior.AllowGet);
}

This is it. The property editor is all wired up and ready to go.

Obviously, the styling and UX weren’t given much care in this mini-tutorial. And I am sure the code could be made simpler and more elegant. But this short tutorial shows how easy it is to start building custom property editors even for a person who hasn't worked with extending backoffice before.

Here are all the files in their entirety once again:

tagged.controller.js:

angular.module("umbraco").controller("MAL.taggedController", function ($scope, taggedResource) {
        $scope.newTag = '';
        $scope.alltags = [];
        $scope.appliedIds = [];
        $scope.appliedTags = [];

         
        taggedResource.getTags().then(function(res){
            var data = res.data;
            for (var tag in data) {
                var name = tag;
                if (data.hasOwnProperty(tag)) {
                    $scope.alltags.push([tag, data[tag]]);
                  }
            }
        });


        taggedResource.getAppliedTags($scope.appliedIds).then(function(res) {
            var data = res.data;
            for (var tag in data) {
                if (data.hasOwnProperty(tag)) {

                    if (tag != "---") {
                        $scope.appliedTags.push([res.data[tag], tag]);
                    }
                    else
                    {
                        var index = $scope.appliedIds.indexOf(res.data[tag]);
                        $scope.appliedIds.splice(index ,1);

                       for(var i = 0; i < $scope.model.value.length; i++) {
                          if($scope.model.value[i][0] == res.data[tag]) {
                              $scope.model.value.splice(i, 1);
                          }
                      }
                    }
                }
            }
        });



        for (var index in $scope.model.value) {
            if ($scope.model.value.hasOwnProperty(index)) {
                $scope.appliedIds.push($scope.model.value[index][0]);
            }
        }

        $scope.applyTag = function(tag) {
            if(!jQuery.isArray($scope.model.value)) {
                $scope.model.value = [];
            }

            //if the tag hasn't been applied yet
            if ($scope.appliedIds.indexOf(tag[0]) == -1) {
                //add it to model.value and appliedIds
                $scope.model.value.push(tag);   
                $scope.appliedIds.push(tag[0]);

                //clean appliedTags variable
                $scope.appliedTags = [];

                //and using getAppliedTags service, fetch current tags and push them to appliedTags
                taggedResource.getAppliedTags($scope.appliedIds).then(function(res) {
                    var data = res.data;
                    for (var tag in data) {
                        if (data.hasOwnProperty(tag)) {
                            $scope.appliedTags.push([res.data[tag], tag]);
                          }
                    }
                });
            }

        };

        $scope.removeTag = function(tag) {
            for(var i = 0; i < $scope.model.value.length; i++) {

                if($scope.model.value[i][0] == tag[0]) {
                    $scope.model.value.splice(i, 1);
                }
            }

            for(var i = 0; i < $scope.appliedTags.length; i++) {

                if($scope.appliedTags[i][0] == tag[0]) {
                    $scope.appliedTags.splice(i, 1);
                }
            }

            for(var i = 0; i < $scope.appliedIds.length; i++) {

                if($scope.appliedIds[i] == tag[0]) {
                    $scope.appliedIds.splice(i, 1);
                }
            }
            
            return;
        }

        $scope.createTag = function(newTag) {
            //make sure tag does not include empty spaces at the end
            if(jQuery.trim(newTag).length > 0) {
                //backend controller creates the document 
                taggedResource.createTag({ tag: newTag }).then(function (res) {
                    $scope.alltags = [];
                    
                    taggedResource.getTags().then(function(res){
                        var data = res.data;
                        for (var tag in data) {
                            var name = tag;
                            if (data.hasOwnProperty(tag)) {
                                $scope.alltags.push([tag, data[tag]]);
                              }
                        }
                    });
                });
                $scope.newTag = '';
            }
            else {
                alert('Fill in the tag!');
            }
        };

        if ($scope.model.value.length == 0) {
            $scope.model.value = null;
        }
});

angular.module("umbraco.resources").factory("taggedResource", function($http){

    var taggedService = {};
    
    taggedService.getTags = function(){
        return $http.get("/umbraco/surface/Tagged/GetTags");
    };

    taggedService.createTag = function(newTag) {
        return $http({
            url: "/umbraco/surface/Tagged/CreateTag", 
            method: "GET",
            params: {tag: newTag}
         });
    };

    taggedService.getAppliedTags = function(appliedIds){
        return $http({
            url: "/umbraco/surface/Tagged/GetAppliedTags", 
            method: "GET",
            params: {appliedIds: appliedIds}
        });
    }
    
    return taggedService;


});

tagged.html:

<div ng-controller="MAL.taggedController">
    <h1>Available tags</h1>
    <ul class="nav nav-pills">
        <li ng-repeat="tag in alltags"><a href="" class="btn" ng-click="applyTag([tag[1], tag[0]])" data-id="{{tag[1]}}">{{tag[0]}}</a></li>
    </ul>

    <h2>Create new tag</h2>
    <form ng-submit="createTag(newTag)">
          <input type="text" ng-model="newTag"/>
          <button type="submit">Create tag</button>
    </form>

    <h1>Applied tags</h1>
    <ul class="nav nav-pills">
        <li ng-repeat="tag in appliedTags"><a class="btn" data-id="{{tag[0]}}">{{tag[1]}} <span ng-click="removeTag(tag)">x</span></a></li>
    </ul>
</div>

TaggedController.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using Umbraco.Web;
using Umbraco.Core.Models;
using Umbraco.Core.Services;
using Umbraco.Web.Mvc;
using Newtonsoft.Json.Linq;

namespace tagged.Controllers
{
    public class TaggedController : SurfaceController
    {

        public JsonResult GetTags()
        {
            IPublishedContent tagRoot = Umbraco.TypedContentAtRoot().Where(x => x.DocumentTypeAlias == "tags").FirstOrDefault();
            Dictionary<string, int> tags = new Dictionary<string, int>();
            foreach (var tag in tagRoot.Children)
            {
                tags.Add(tag.Name, tag.Id);
            }
            return Json(tags, JsonRequestBehavior.AllowGet);
        }

        public JsonResult CreateTag(string tag)
        {
            JObject tagJ = JObject.Parse(tag);
            var cs = Services.ContentService;
            int parentId = Umbraco.TypedContentAtRoot().Where(x => x.DocumentTypeAlias == "tags").FirstOrDefault().Id;
            var content = cs.CreateContent(tagJ["tag"].ToString(), parentId, "tag", 0);
            cs.SaveAndPublishWithStatus(content);

            Dictionary<string, int> tagitem = new Dictionary<string, int>();
            tagitem.Add(tagJ["tag"].ToString(), content.Id);

            return Json(tagitem, JsonRequestBehavior.AllowGet);
        }

        public JsonResult GetAppliedTags(List<int> appliedIds)
        {
            Dictionary<string, int> tags = new Dictionary<string, int>();
            var umbracoHelper = new UmbracoHelper(UmbracoContext.Current);
           

            if (appliedIds != null)
            {
                foreach (var id in appliedIds)
                {
                    if (!tags.ContainsValue(id))
                    {
                        IPublishedContent tag = umbracoHelper.TypedContent(id);

                        if (tag != null)
                        {
                            var name = tag.Name;
                            tags.Add(name, id);
                        }
                        else
                        {
                            var name = "---";
                            tags.Add(name, id); 
                        }
                        
                    }
                    
                }
            }
            

            return Json(tags, JsonRequestBehavior.AllowGet);
        }
    }
}

Maria Lind

Maria is on Twitter as