feat(cms): enhance content editors and implement automatic ID generation

Improve the CMS administration interface across multiple pages (Accreditation, Admissions, History, Partnerships, and Policies) with a focus on usability and data integrity.
Key changes include:
- Implement `ensureUniqueIds` utility to automatically generate and maintain unique slugs for content items, removing the need for manual ID entry in the UI.
- Refactor the Admissions calculator to support detailed per-option editing via a new dedicated view and routes.
- Replace basic datalists with a custom, searchable icon combobox component for better visual selection.
- Update `_renderSingletonPageView` to handle active tab persistence via query parameters.
- Streamline editor configurations by removing redundant fields and improving help text.
- Enhance the Admissions "Key Dates" editor with a dynamic table interface for managing columns and rows.
- Normalize data payloads in controllers to ensure consistent API responses and internal linking.
This commit is contained in:
Tống Thành Đạt
2026-04-21 12:37:19 +07:00
parent 122df1a46e
commit 5d0fae6d51
30 changed files with 2174 additions and 477 deletions
@@ -14,6 +14,12 @@
}
const state = JSON.parse(JSON.stringify(initialData));
const slugifyValue = (value, fallback) =>
String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || fallback;
const templates = {
tab: document.getElementById("directoryTabTemplate"),
partner: document.getElementById("partnerTemplate"),
@@ -21,8 +27,10 @@
inquiryOption: document.getElementById("inquiryOptionTemplate"),
};
ensurePartnershipIds();
bindStaticEvents();
renderAll();
initStaticCounters();
function bindStaticEvents() {
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
@@ -43,7 +51,6 @@
document.getElementById("addPartnerBtn")?.addEventListener("click", function () {
state.directory.partners.push({
id: "",
name: "",
category: "",
summary: "",
@@ -53,12 +60,12 @@
collabType: "",
benefits: "",
});
ensurePartnershipIds();
renderPartners();
});
document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () {
state.inquiryForm.fields.push({
id: "",
label: "",
placeholder: "",
type: "text",
@@ -66,6 +73,7 @@
required: true,
options: [],
});
ensurePartnershipIds();
renderInquiryFields();
});
@@ -75,6 +83,7 @@
form.addEventListener("submit", function () {
syncStaticFields();
ensurePartnershipIds();
pageJsonInput.value = JSON.stringify(state);
});
@@ -105,7 +114,6 @@
state.directory.heading = getValue("directoryHeading");
state.directory.description = getValue("directoryDescription");
state.directory.loadMoreLabel = getValue("directoryLoadMoreLabel");
state.cta = {
heading: getValue("ctaHeading"),
@@ -114,7 +122,34 @@
};
state.inquiryForm.title = getValue("inquiryTitle");
state.inquiryForm.submitLabel = getValue("inquirySubmitLabel");
}
function ensurePartnershipIds() {
const usedPartnerIds = new Set();
state.directory.partners = (state.directory.partners || []).map((partner, index) => {
let id = String(partner.id || "").trim() || slugifyValue(partner.name, `partner-${index + 1}`);
let suffix = 2;
while (usedPartnerIds.has(id)) {
id = `${slugifyValue(partner.name, "partner")}-${suffix}`;
suffix += 1;
}
usedPartnerIds.add(id);
return { ...partner, id };
});
const usedFieldIds = new Set();
state.inquiryForm.fields = (state.inquiryForm.fields || []).map((field, index) => {
let id =
String(field.id || "").trim() ||
slugifyValue(field.label || field.placeholder, `field-${index + 1}`);
let suffix = 2;
while (usedFieldIds.has(id)) {
id = `${slugifyValue(field.label || field.placeholder, "field")}-${suffix}`;
suffix += 1;
}
usedFieldIds.add(id);
return { ...field, id };
});
}
function renderAll() {
@@ -143,6 +178,7 @@
node.querySelector("[data-toggle-item]")?.addEventListener("click", function () {
node.classList.toggle("is-collapsed");
});
attachCounters(node);
directoryTabsList.appendChild(node);
});
@@ -210,6 +246,7 @@
node.querySelector("[data-toggle-item]")?.addEventListener("click", function () {
node.classList.toggle("is-collapsed");
});
attachCounters(node);
partnersList.appendChild(node);
});
@@ -304,6 +341,7 @@
field.options.splice(optionIndex, 1);
renderInquiryFields();
});
attachCounters(optionNode);
optionsList.appendChild(optionNode);
});
initSortable(optionsList, field.options, renderInquiryFields, '[data-item="inquiry-option"]');
@@ -311,6 +349,7 @@
toggleOptions();
renderOptions();
attachCounters(node);
inquiryFieldsList.appendChild(node);
});
@@ -339,6 +378,74 @@
return template.content.firstElementChild.cloneNode(true);
}
function initStaticCounters() {
attachCounters(form);
}
function attachCounters(root) {
root.querySelectorAll("input[maxlength], textarea[maxlength]").forEach((input, index) => {
if (input.dataset.counterReady === "true") {
updateInputCounter(input);
return;
}
const counterId =
input.id ||
input.name ||
input.dataset.field ||
input.dataset.optionValue ||
`counter-${index}-${Math.random().toString(36).slice(2, 8)}`;
let counter = root.querySelector(`[data-counter-for="${counterId}"]`);
if (!counter) {
counter = document.createElement("div");
counter.className = "field-char-count";
counter.dataset.counterFor = counterId;
const next = input.nextElementSibling;
if (next && next.classList.contains("form-text")) {
let metaRow = next.nextElementSibling;
if (!metaRow || !metaRow.classList.contains("field-meta-row")) {
metaRow = document.createElement("div");
metaRow.className = "field-meta-row";
next.insertAdjacentElement("afterend", metaRow);
}
metaRow.appendChild(counter);
} else {
const metaRow = document.createElement("div");
metaRow.className = "field-meta-row";
metaRow.appendChild(counter);
input.insertAdjacentElement("afterend", metaRow);
}
}
const sync = function () {
updateInputCounter(input);
};
input.addEventListener("input", sync);
input.addEventListener("change", sync);
input.dataset.counterReady = "true";
updateInputCounter(input);
});
}
function updateInputCounter(input) {
const maxLength = Number(input.getAttribute("maxlength"));
if (!maxLength) return;
const counterId =
input.id || input.name || input.dataset.field || input.dataset.optionValue;
const scope = input.closest("[data-item]") || input.parentElement || form;
let counter = scope.querySelector(`[data-counter-for="${counterId}"]`);
if (!counter) {
counter = form.querySelector(`[data-counter-for="${counterId}"]`);
}
if (!counter) return;
counter.textContent = `${String(input.value || "").length}/${maxLength}`;
}
function getValue(id) {
return document.getElementById(id)?.value || "";
}