Custom Multi-Select Tag & Category Editors | Optimizely CMS 12 Dojo

Content editors working with Optimizely CMS 12 often encounter a significant friction point in their daily workflow: managing taxonomies. The default [SelectMany] and [SelectOne] attributes provide basic dropdown or checkbox lists, but they lack the flexibility required for rapidly growing blog tags or categories. Editors frequently find themselves trapped in endlessly scrolling lists, and worse, they must navigate away from the current page to create a new tag in the Category management tool, breaking their creative flow.

In this comprehensive technical deep dive, we will solve this by engineering custom Dojo widgets. These widgets provide a modern editing experience: a checkbox-based multi-select for tags and a radio-based single-select for categories, both featuring inline add capabilities. This means editors can create new taxonomy items directly within the page properties without ever leaving the editor view.

The Architectural Blueprint

To implement this, we need a bridge between the Optimizely CMS UI (Dojo/JavaScript) and the Optimizely Backend (C#/.NET 8). Our architecture relies on four distinct layers:

  • Data Layer (Optimizely Category API): Interacting with the native CategoryRepository to persist and retrieve data.
  • Communication Layer (REST API): A custom C# controller that exposes taxonomy data to the client-side.
  • Presentation Layer (Dojo Widgets): Custom JavaScript components that render the UI and handle user interaction.
  • Binding Layer (Editor Descriptors): Metadata classes that tell Optimizely to use our custom widgets for specific properties.

Step 1: Implementing the Taxonomy REST API

The first step is creating a robust backend to handle GET (fetching existing tags) and POST (creating new ones) requests. In Optimizely CMS 12, we use standard ASP.NET Core Web APIs. We must ensure the controller is protected so only authorized editors can access it.


using EPiServer.DataAbstraction;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Linq;

namespace CmsIv.Web.Controllers.Api
{
    [ApiController]
    [Route("api/blog-taxonomy")]
    [Authorize(Roles = "CmsEditors, CmsAdmins, WebEditors, WebAdmins, Administrators")]
    public class BlogTaxonomyApiController : ControllerBase
    {
        private readonly CategoryRepository _categoryRepository;

        public BlogTaxonomyApiController(CategoryRepository categoryRepository)
        {
            _categoryRepository = categoryRepository;
        }

        [HttpGet("tags")]
        public IActionResult GetTags()
        {
            var root = _categoryRepository.Get("Blog Tags");
            if (root == null) return Ok(Enumerable.Empty<string>());

            return Ok(root.Categories
                .Where(c => c.Selectable)
                .Select(c => c.Name)
                .OrderBy(n => n).ToList());
        }

        [HttpPost("tags")]
        public IActionResult CreateTag([FromBody] CreateTaxonomyRequest request)
        {
            if (string.IsNullOrWhiteSpace(request?.Name))
                return BadRequest("Tag name is required.");

            var root = _categoryRepository.Get("Blog Tags");
            if (root == null) return NotFound("Root 'Blog Tags' not found.");

            var existing = root.Categories
                .FirstOrDefault(c => c.Name.Equals(request.Name.Trim(), StringComparison.OrdinalIgnoreCase));

            if (existing != null)
                return Ok(new { name = existing.Name, message = "Already exists." });

            var newCat = new Category(root, request.Name.Trim())
            {
                Description = request.Name.Trim(),
                Selectable = true
            };
            _categoryRepository.Save(newCat);

            return Ok(new { name = newCat.Name, message = "Created." });
        }
    }

    public class CreateTaxonomyRequest
    {
        public string Name { get; set; }
    }
}
    

Step 2: Developing the Multi-Select Tag Widget (Dojo)

The Dojo widget is the core of the user experience. We utilize dijit/_TemplatedMixin to define our HTML structure and epi/shell/widget/_ValueRequiredMixin to integrate seamlessly with the CMS validation engine. The widget needs to handle asynchronous data loading and update the property value in a format the CMS understands (typically a comma-separated string for multi-selects).


define([
    "dojo/_base/declare",
    "dojo/_base/lang",
    "dojo/_base/xhr",
    "dojo/on",
    "dojo/query",
    "dijit/_Widget",
    "dijit/_TemplatedMixin",
    "epi/shell/widget/_ValueRequiredMixin"
], function (declare, lang, xhr, on, query, _Widget, _TemplatedMixin, _ValueRequiredMixin) {

    return declare("app/Editors/BlogTagEditor", [_Widget, _TemplatedMixin, _ValueRequiredMixin], {
        // The HTML template for our widget
        templateString:
            '<div class="blog-tag-editor">' +
                '<div style="display:flex;gap:8px;margin-bottom:12px;">' +
                    '<input data-dojo-attach-point="newTagInput" type="text" placeholder="Add new tag..." ' +
                    'style="flex:1;padding:8px;border:1px solid #ddd;border-radius:4px;" />' +
                    '<button data-dojo-attach-point="addBtn" type="button" ' +
                    'style="padding:8px 16px;background:#0078d4;color:#fff;border:none;border-radius:4px;cursor:pointer;">+ Add</button>' +
                '</div>' +
                '<div data-dojo-attach-point="tagListContainer" style="max-height:250px;overflow-y:auto;border:1px solid #ccc;padding:8px;background:#fff;">' +
                    '<span style="color:#666;">Fetching tags...</span>' +
                '</div>' +
            '</div>',

        value: null,
        _tags: [],

        postCreate: function () {
            this.inherited(arguments);
            // Bind click event for the Add button
            on(this.addBtn, "click", lang.hitch(this, "_onAddTag"));
            this._loadTags();
        },

        _loadTags: function () {
            var self = this;
            xhr.get({
                url: "/api/blog-taxonomy/tags",
                handleAs: "json",
                load: function (data) {
                    self._tags = data || [];
                    self._renderCheckboxes();
                },
                error: function() {
                    self.tagListContainer.innerHTML = "<span style='color:red;'>Error loading tags.</span>";
                }
            });
        },

        _renderCheckboxes: function () {
            var selected = (this.value || "").split(",").map(s => s.trim()).filter(s => s);
            var html = "";
            this._tags.forEach(function(tag) {
                var isChecked = selected.indexOf(tag) >= 0 ? "checked" : "";
                html += '<label style="display:flex;align-items:center;margin-bottom:4px;cursor:pointer;">' +
                        '<input type="checkbox" class="tag-checkbox" value="' + tag + '" ' + isChecked + ' />' +
                        '<span style="margin-left:8px;">' + tag + '</span></label>';
            });
            this.tagListContainer.innerHTML = html || "<em>No tags found.</em>";
            
            // Bind change events to all checkboxes
            query(".tag-checkbox", this.tagListContainer).on("change", lang.hitch(this, "_onCheckboxChange"));
        },

        _onCheckboxChange: function () {
            var selectedValues = [];
            query(".tag-checkbox:checked", this.tagListContainer).forEach(function(node) {
                selectedValues.push(node.value);
            });
            var finalValue = selectedValues.join(",");
            this._set("value", finalValue);
            this.onChange(finalValue);
        },

        _onAddTag: function () {
            var tagName = this.newTagInput.value.trim();
            if (!tagName) return;

            var self = this;
            xhr.post({
                url: "/api/blog-taxonomy/tags",
                postData: JSON.stringify({ Name: tagName }),
                handleAs: "json",
                headers: { "Content-Type": "application/json" },
                load: function (data) {
                    self.newTagInput.value = "";
                    var currentValues = (self.value || "").split(",").filter(s => s.trim());
                    if (currentValues.indexOf(data.name) === -1) {
                        currentValues.push(data.name);
                    }
                    self._set("value", currentValues.join(","));
                    self._loadTags(); // Refresh list to include new tag
                    self.onChange(self.value);
                }
            });
        },

        // Required for Dojo to synchronize property value changes
        _setValueAttr: function (value) {
            this._set("value", value);
            if (this._tags.length > 0) {
                this._renderCheckboxes();
            }
        },

        _getValueAttr: function () {
            return this.value;
        },

        onChange: function (value) {}
    });
});
    

Step 3: Creating the Single-Select Category Editor

The implementation for the Category editor follows a similar pattern but uses Radio Buttons to enforce single-selection. Additionally, when a new category is added inline, the widget should automatically select it, as categories are usually mutually exclusive.

Key Differences for Category Widget:

  • UI element: <input type="radio" name="cat_group" /> instead of checkboxes.
  • Storage: Returns a single string value instead of a comma-separated list.
  • Behavior: Replaces existing selection when a new category is created.

Step 4: Wiring Everything via EditorDescriptors

To make our widgets available in the CMS, we must register them using EditorDescriptor. This links a specific UIHint (a string key) to our JavaScript class path.


[EditorDescriptorRegistration(TargetType = typeof(string), UIHint = "BlogTagEditor")]
public class BlogTagEditorDescriptor : EditorDescriptor
{
    public BlogTagEditorDescriptor()
    {
        // Path matches the 'define' signature in JS, usually mapping to modules/_protected
        ClientEditingClass = "app/Editors/BlogTagEditor";
    }
}
    

In your Page Type or Block Type model, you can now apply these custom editors using the [UIHint] attribute:


public class BlogDetailPage : PageData
{
    [Display(Name = "Blog Category", GroupName = SystemTabNames.Content, Order = 100)]
    [UIHint("BlogCategoryEditor")]
    public virtual string BlogCategory { get; set; }

    [Display(Name = "Blog Tags", GroupName = SystemTabNames.Content, Order = 110)]
    [UIHint("BlogTagEditor")]
    public virtual string BlogTags { get; set; }
}
    

Strategic Insights & Troubleshooting

1. Module Location and 404 Errors

In Optimizely CMS 12, custom Dojo files belong in the modules/_protected directory. If your script fails to load, verify your module.config file in the root of your project. It should look like this:


<?xml version="1.0" encoding="utf-8"?>
<module name="CmsIv.Web">
  <dojo>
    <paths>
      <add name="app" path="Scripts" />
    </paths>
  </dojo>
</module>
    

2. Handling Authentication in AJAX Calls

When using xhr.post, Dojo automatically includes the session cookies. However, if you have Antiforgery validation enabled globally in your API, you may need to manually extract the request token from the epi-anti-forgery-token meta tag or use Optimizely's built-in epi/shell/RequestService which handles tokens automatically.

3. Backward Compatibility

Since we are storing these tags as strings, they remain fully compatible with existing data. If you previously used a standard [SelectMany] with a fixed list, our new widget will simply pick up the existing comma-separated values and render them as checked boxes.

Conclusion

By moving beyond the standard Optimizely editors and building custom Dojo components, we significantly improve the efficiency of content editors. This implementation provides a scalable way to manage taxonomies while keeping the editor focused on content creation. The pattern shown here—C# API + Dojo Template + EditorDescriptor—can be extended to any complex UI requirement within the Optimizely CMS shell, from color pickers to complex relational data selectors.

← Quay lại Blog