How to create tag property using ISelectionFactory in Episerver

A bit of premise; I have a site that uses tagging in articles. This used to be an ISelectionFactory that loaded pages that worked as my tagging data, and the editor would select which tags they needed. To make this friendlier for the editor I want to add a typeahead field in Episerver.

Our solution is inspired by the tags module from Geta. Geta's solution saves a comma separated list of tag names and stores the tags in DDS (Dynamic Data Store). This solution would be sufficient for many episerver sites, but for us with a bit of historical data need a different approach.

Our Selection Factory

Our field already contains a comma separated list of page reference ids. Which, in my opinion, is a better way to reference your data. Here's a basic implementation of retrieval of tag pages. This can be retrieved from any data layer you would like (IContent, external service, even DDS), we will use pages.

public class TagEditorDescriptor : EditorDescriptor
{

    public override void ModifyMetadata(
       ExtendedMetadata metadata,
       IEnumerable<Attribute> attributes)
    {
        SelectionFactoryType = typeof(TagPageSelectionFactory);
        
        ClientEditingClass =
            "TagsEditor/TagsEditor";
        base.ModifyMetadata(metadata, attributes);
    }

    private class TagPageSelectionFactory : ISelectionFactory
    {
        private readonly IContentLoader _contentLoader;


        public TagPageSelectionFactory()
        {
            _contentLoader = ServiceLocator.Current.GetInstance<IContentLoader>();
        }

        public IEnumerable<ISelectItem> GetSelections(ExtendedMetadata metadata)
        {
            ContentReference tagCategory = _contentLoader 
                .GetChildren<TagStartPage>(ContentReference.RootPage)
                .FilterForDisplay().FirstOrDefault()
                ?.ContentLink;

            if (!tagCategory.IsNullOrEmpty())
            {
                return _contentLoader .GetChildren<TagPage>(tagCategory)
                    .FilterForDisplay()
                    .OrderBy(p => p.Name)
                    .Select(p => new SelectItem
                    {
                        Text = p.Name,
                        Value = p.ContentLink.ToString()
                    });
            }
            return Enumerable.Empty<ISelectItem>();
        }
    }
}

// Define property like this:
[EditorDescriptor(EditorDescriptorType = typeof(TagEditorDescriptor))]
public virtual string TagList { get; set; }

In the code above, all tag pages are found and returns a list of ISelectItem. This code presumes that you have a tag container page filled with tag pages. SelectionFactory will provide readable names for these IDs in the default episerver UI.

a tag list

DOJO for tagging

So how do we make this into a slick and pretty tag field? We use DOJO! This field uses a couple of dependencies so lets pull those in. Tag field is defined by setting a ClientEditingClass in the editor descriptor (as seen in the above code).

struktur av modul

Module.config

<?xml version="1.0" encoding="utf-8"?>
<module productName="TagsEditor" clientResourceRelativePath="1.0.0.0" loadFromBin="false">
  <clientResources>
    <add name="TagsEditor" path="ClientResources/Vendors/jquery-2.1.0.min.js" resourceType="Script" sortIndex="1" />
    <add name="TagsEditor" path="ClientResources/Vendors/jquery-ui.min.js" resourceType="Script" sortIndex="2" />
    <add name="TagsEditor" path="ClientResources/Vendors/tag-it.min.js" resourceType="Script" sortIndex="3" />
    <add name="TagsEditor" path="ClientResources/Vendors/jquery.tagit.css" resourceType="Style" sortIndex="1" />
    <add name="TagsEditor" path="ClientResources/Vendors/tagit.ui-zendesk.css" resourceType="Style" sortIndex="2" />
    <add name="TagsEditor" path="ClientResources/Vendors/tagit.overrides.css" resourceType="Style" sortIndex="3" />
    <add name="TagsEditor" path="ClientResources/Scripts/NAF.TagsEditor.js" resourceType="Script" />
  </clientResources>
  <dojo>
    <packages>
      <add name="TagsEditor" location="ClientResources/Scripts" />
    </packages>
  </dojo>
  <clientModule>
    <moduleDependencies>
      <add dependency="CMS" type="RunAfter" />
    </moduleDependencies>
    <requiredResources>
      <add name="TagsEditor" />
    </requiredResources>
  </clientModule>
</module>

In our dojo script, we'll depend on the TextBox in dojo. This will render a textbox and hook some default functionality attached to that textbox. First, we'll parse our list of IDs and transform them into an array. The SelectionFactory actually injects all of the available selections into the dojo context. That means we can cross reference our list of IDs and deliver readable texts to our textbox and autocomplete. When a new tag is added that doesn't exist in our selection array, we add this value to a "newtags" array. When the field is saved we concatenate the selected tags array with the new tags array and generate a list of IDs. Since the new tags doesn't contain a ID yet, prefix this entry with "0:" and add the name of new tag (ie. "0:New Tag"). Here's a script of the implementation.

define([
    "dojo/_base/declare",
    "dijit/form/TextBox"
],
function (
    declare,
    TextBox
) {
    return declare([TextBox], {

        _tagWidget: null,
        _newTags: [],
        _selectedTags: [],

        postCreate: function () {
            this.inherited(arguments);
            this.intermediateChanges = false;
            this._createTags();
        },

        destroy: function () {
            this._destroyTags();
            this.inherited(arguments);
        },

        _createTags: function () {
            this._destroyTags();
            var $textbox = $(this.textbox);

            if (this.textbox.value) {
                var idlist = this.textbox.value.split(',');

                var newtags = idlist.filter(function (obj){
                    if (obj.indexOf('0:') !== -1) {
                        return obj;
                    }
                }).map(function(obj) {
                    return { text: obj.replace('0:', ''), value: 0 };
                });

                var selectedtags = this.selections.filter(function(obj) {
                     return !!~idlist.indexOf(obj.value);
                });

                var alltags = selectedtags.concat(newtags);
                $textbox.val(alltags.map(function(obj) {
                    return obj.text;
                }).join());

            }
                
            this._tagWidget = $textbox.tagit({
                availableTags: this.selections.map(function(obj) { return obj.text; }),
                allowSpaces: true,
                allowDuplicates: false,
                caseSensitive: true,
                readOnly: false,
                beforeTagAdded: function () {
                    this.onFocus();
                }.bind(this),
                afterTagAdded: this._addTag.bind(this),
                afterTagRemoved: this._removeTag.bind(this)
            });
        },

        _addTag: function(e, tag) {
            var tagid = this.selections.filter(function (obj) { return obj.text === tag.tagLabel; })[0];
            if (tagid) {
                this._selectedTags.push(tagid);
            } else {
                this._newTags.push({
                    text: tag.tagLabel,
                    value: 0
                });
            }

            this._setTag();
        },

        _removeTag: function(e, tag) {
            var tagid = this.selections.filter(function (obj) { return obj.text === tag.tagLabel; })[0];
            if (tagid) {
                for (var i = 0; i < this._selectedTags.length; i++) {
                    if (this._selectedTags[i].value && this._selectedTags[i].value === tagid.value) {
                        this._selectedTags.splice(i, 1);
                        break;
                    }
                }
            } else {
                for (var i = 0; i < this._newTags.length; i++) {
                    if (this._newTags[i].text && this._newTags[i].text === tag.tagLabel) {
                        this._newTags.splice(i, 1);
                        break;
                    }
                }
            }

            this._setTag();
        },

        _setTag: function() {
            var value = this._generateTagFieldValue();
            this._set("value", value);
            this.onChange(value);
        },

        _onChange: function (event) {
            this._setTag();
        },
        _onBlur: function () {
            return;
        },
        _destroyTags: function () {
            this._tagWidget && this._tagWidget.tagit("destroy");
            this._tagWidget = null;
            this._newTags = [];
            this._selectedTags = [];
        },

        _generateTagFieldValue: function() {
            var alltags = this._selectedTags.concat(this._newTags);
            return alltags.map(function(obj) {
                if (obj.value === 0) {
                    return '0:' + obj.text;
                }
                return obj.value;
            }).join();
        },

        _setValueAttr: function (value, priorityChange) {
            this.inherited(arguments);
            this._started && !priorityChange && this._createTags();
        }
    });
});

Hook on to the publish event and create tags

These new tags needs to be created in episerver at some point. I added a publish event that finds the tag field, generates new tag pages and saves the new ID of created tag to the tag field.

[ModuleDependency(typeof(InitializationModule))]
public class ContentInitializer : IInitializableModule
{
    public void Initialize(InitializationEngine context)
    {
        IContentEvents contentEvents = context.Locate.ContentEvents();
        contentEvents.PublishingContent += SetTags;
    }

    public void Preload(string[] parameters)
    {
    }

    public void Uninitialize(InitializationEngine context)
    {
    }

    public static void SetTags(object sender, ContentEventArgs e)
    {
        ITagService tagService = ServiceLocator.Current.GetInstance<ITagService>();
        ArticlePage page = e.Content as ArticlePage;
        if (featurepage != null)
        {
            tagService.SetTagField(featurepage);
            return;
        }
    }
}

//in TagService.cs

public class TagService : ITagService
{
    private const string PrefixNewTag = "0:";
    private readonly IContentRepository _contentRepository;
    private readonly IUrlSegmentCreator _urlSegmentCreator;
    private readonly ITagRepository _tagRepository;
    public TagService(
        IContentRepository contentRepository,
        IUrlSegmentCreator urlSegmentCreator,
        ITagRepository tagRepository
        )
    {
        _contentRepository = contentRepository;
        _urlSegmentCreator = urlSegmentCreator;
        _tagRepository = tagRepository;
    }

    public bool SetTagField(ArticlePage page)
    {
        string newtaglist;
        bool isok = GenerateNewTags(page.TagList, out newtaglist);

        if (isok)
        {
            ArticlePage clone = page.IsReadOnly
                ? page.CreateWritableClone() as FeatureArticlePage
                : page;
            if (clone != null)
            {
                clone.TagList = newtaglist;
                clone.URLSegment = _urlSegmentCreator.Create(clone);
                SaveAction saveAction = SaveAction.ForceCurrentVersion;
                _contentRepository.Save(clone, saveAction, AccessLevel.NoAccess);
            }
        }
        return isok;
    }

    private bool GenerateNewTags(string taglist, out string newtaglist)
    {
        if (taglist != null)
        {
            var splittaglist = taglist.Split(',');
            var newtags = splittaglist.FindAll(x => x.Contains(PrefixNewTag));

            if (newtags.Any())
            {
                var newtagsclean = newtags.Select(x => x.Replace(PrefixNewTag, String.Empty));
                // Where _tagRepository generates new tags and returns their new page IDs
                var createdtags = newtagsclean.Select(x => _tagRepository.CreateTag(x));
                string[] newtaglistarray = splittaglist.Where(x => !x.Contains(PrefixNewTag))
                    .Concat(createdtags.Select(x => x.PageLink.ID.ToString())).ToArray();
                newtaglist = string.Join(",", newtaglistarray);
                return true;
            }
        }
        newtaglist = String.Empty;
        return false;
    }
}

Now we should be able to generate new tags on the fly and get a nice slick interface that is a joy to use :)

Tag successful