Những người quản trị nội dung làm việc với Optimizely CMS 12 thường gặp phải một điểm yếu lớn trong quy trình làm việc: quản lý hệ thống phân loại (taxonomies). Các thuộc tính mặc định như [SelectMany] và [SelectOne] chỉ cung cấp danh sách thả xuống hoặc danh sách chọn cơ bản, thiếu sự linh hoạt cho các blog có số lượng tag hoặc category tăng trưởng nhanh. Biên tập viên thường bị mắc kẹt trong những danh sách dài vô tận, và tệ hơn, họ phải rời khỏi trang hiện tại để tạo một tag mới trong công cụ quản lý Category, làm gián đoạn luồng tư duy sáng tạo.
Trong bài phân tích kỹ thuật chuyên sâu này, chúng ta sẽ giải quyết vấn đề này bằng cách xây dựng các widget Dojo tùy chỉnh. Các widget này mang lại trải nghiệm chỉnh sửa hiện đại: chọn nhiều tag bằng checkbox và chọn một category bằng radio button, cả hai đều có tính năng thêm mới trực tiếp (inline add). Điều này có nghĩa là biên tập viên có thể tạo các mục phân loại mới ngay trong thuộc tính trang mà không cần rời khỏi trình chỉnh sửa.
Kiến trúc Hệ thống
Để triển khai giải pháp này, chúng ta cần một cầu nối giữa giao diện Optimizely CMS (Dojo/JavaScript) và Backend (C#/.NET 8). Kiến trúc của chúng ta dựa trên bốn lớp riêng biệt:
- Lớp Dữ liệu (Optimizely Category API): Tương tác với
CategoryRepositoryđể lưu trữ và truy xuất dữ liệu. - Lớp Giao tiếp (REST API): Một controller C# tùy chỉnh để cung cấp dữ liệu phân loại cho phía client.
- Lớp Hiển thị (Dojo Widgets): Các thành phần JavaScript tùy chỉnh để hiển thị UI và xử lý tương tác người dùng.
- Lớp Ràng buộc (Editor Descriptors): Các class metadata chỉ định cho Optimizely sử dụng widget tùy chỉnh của chúng ta cho các thuộc tính cụ thể.
Bước 1: Triển khai REST API cho Phân loại
Bước đầu tiên là tạo một backend mạnh mẽ để xử lý các yêu cầu GET (lấy tag hiện có) và POST (tạo tag mới). Trong Optimizely CMS 12, chúng ta sử dụng ASP.NET Core Web API tiêu chuẩn. Chúng ta phải đảm bảo controller được bảo mật để chỉ các biên tập viên có quyền mới có thể truy cập.
[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(new string[0]);
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("Tên tag là bắt buộc.");
var root = _categoryRepository.Get("Blog Tags");
if (root == null) return NotFound("Không tìm thấy root 'Blog Tags'.");
var existing = root.Categories
.FirstOrDefault(c => c.Name.Equals(request.Name.Trim(), StringComparison.OrdinalIgnoreCase));
if (existing != null)
return Ok(new { name = existing.Name, message = "Đã tồn tại." });
var newCat = new Category(root, request.Name.Trim())
{
Description = request.Name.Trim(),
Selectable = true
};
_categoryRepository.Save(newCat);
return Ok(new { name = newCat.Name, message = "Đã tạo." });
}
}
Bước 2: Phát triển Tag Widget Chọn Nhiều (Dojo)
Dojo widget là cốt lõi của trải nghiệm người dùng. Chúng ta sử dụng dijit/_TemplatedMixin để định nghĩa cấu trúc HTML và epi/shell/widget/_ValueRequiredMixin để tích hợp mượt mà với hệ thống validation của CMS. Widget cần xử lý tải dữ liệu không đồng bộ và cập nhật giá trị thuộc tính theo định dạng mà CMS hiểu (thường là chuỗi phân cách bằng dấu phẩy cho multi-select).
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], {
templateString:
'<div class="blog-tag-editor">' +
'<div style="display:flex;gap:6px;margin-bottom:10px;">' +
'<input data-dojo-attach-point="newTagInput" type="text" placeholder="Thêm tag mới..." ' +
'style="flex:1;padding:6px;border:1px solid #ccc;border-radius:4px;" />' +
'<button data-dojo-attach-point="addBtn" type="button" ' +
'style="padding:6px 12px;background:#0078d4;color:#fff;border:none;border-radius:4px;cursor:pointer;">+ Thêm</button>' +
'</div>' +
'<div data-dojo-attach-point="tagListContainer" style="max-height:220px;overflow-y:auto;border:1px solid #e0e0e0;padding:6px;background:#fafafa;">' +
'<span>Đang tải tag...</span>' +
'</div>' +
'</div>',
value: null,
postCreate: function () {
this.inherited(arguments);
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();
}
});
},
_renderCheckboxes: function () {
var selected = (this.value || "").split(",").map(s => s.trim()).filter(s => s);
var html = "";
this._tags.forEach(function(tag) {
var checked = selected.indexOf(tag) >= 0 ? "checked" : "";
html += '<label style="display:flex;align-items:center;gap:6px;padding:2px 0;cursor:pointer;">' +
'<input type="checkbox" class="bte-cb" value="' + tag + '" ' + checked + ' />' +
'<span>' + tag + '</span></label>';
});
this.tagListContainer.innerHTML = html;
query(".bte-cb", this.tagListContainer).on("change", lang.hitch(this, "_onCheckboxChange"));
},
_onCheckboxChange: function () {
var vals = [];
query(".bte-cb:checked", this.tagListContainer).forEach(function(cb) {
vals.push(cb.value);
});
this._set("value", vals.join(","));
this.onChange(this.value);
},
_onAddTag: function () {
var name = this.newTagInput.value.trim();
if (!name) return;
var self = this;
xhr.post({
url: "/api/blog-taxonomy/tags",
postData: JSON.stringify({ Name: name }),
handleAs: "json",
headers: { "Content-Type": "application/json" },
load: function (data) {
self.newTagInput.value = "";
var current = (self.value || "").split(",").filter(s => s.trim());
if (current.indexOf(data.name) < 0) current.push(data.name);
self._set("value", current.join(","));
self._loadTags();
self.onChange(self.value);
}
});
},
_setValueAttr: function (v) {
this._set("value", v);
if (this.tagListContainer) this._renderCheckboxes();
},
_getValueAttr: function () { return this.value; },
onChange: function (v) {}
});
});
Bước 3: Đăng ký với EditorDescriptors
Để các widget này xuất hiện trong CMS, chúng ta cần đăng ký qua EditorDescriptor. Điều này liên kết một UIHint với đường dẫn JS của widget.
[EditorDescriptorRegistration(TargetType = typeof(string), UIHint = "BlogTagEditor")]
public class BlogTagEditorDescriptor : EditorDescriptor
{
public BlogTagEditorDescriptor()
{
ClientEditingClass = "app/Editors/BlogTagEditor";
}
}
Sau đó, áp dụng vào Model của trang:
[UIHint("BlogTagEditor")]
public virtual string BlogTags { get; set; }
Xử lý lỗi thường gặp
- Lỗi 404: Do chưa đăng ký module trong
Startup.cshoặcmodule.config. Đảm bảo đường dẫnappkhớp với cấu trúc thư mục. - Lỗi 401 Unauthorized: Xảy ra khi AJAX call thiếu quyền. Hãy kiểm tra xem tài khoản biên tập viên đã thuộc các role quy định trong API Controller chưa.
- Dữ liệu không cập nhật: Luôn gọi
this.onChange(value)sau khi thay đổithis.valueđể CMS nhận biết trạng thái "Dirty" của trang và cho phép nhấn nút Publish.
Kết luận
Việc thay thế các danh sách thả xuống tĩnh bằng các widget Dojo tương tác hỗ trợ REST API đã thay đổi hoàn toàn trải nghiệm quản lý phân loại blog. Biên tập viên giờ đây có thể quản lý tag và category ngay tại chỗ—không còn phải chuyển đổi ngữ cảnh hay điều hướng trang phức tạp. Đây là một mẫu thiết kế mạnh mẽ và có thể mở rộng cho bất kỳ thuộc tính phức tạp nào trong Optimizely CMS 12.