feat(cms): enhance content editors with dynamic UI configs and validation

Implement a more flexible, configuration-driven approach for CMS editors across accreditation, admissions, and partnerships modules.
- Move UI labels, help texts, and field limits from hardcoded views to `editorUi` configurations in config files.
- Add server-side and client-side validation to prevent duplicate category tabs in accreditation and partnerships editors.
- Refactor partnership and admission views to dynamically render tabs and fields based on the provided configuration.
- Update field length constraints and default values across multiple content editors to better align with frontend requirements.
- Improve the admissions calculator editor with dynamic field configurations and default value fallbacks.
This commit is contained in:
Tống Thành Đạt
2026-04-22 18:06:03 +07:00
parent a44d005c13
commit a39c336dd4
19 changed files with 790 additions and 228 deletions
@@ -2,6 +2,8 @@
(function () {
const initialData = window.partnershipsPageData;
const backendUrl = (window.partnershipsBackendUrl || "").replace(/\/$/, "");
const editorConfig = window.partnershipsEditorConfig || {};
const editorUi = window.partnershipsEditorUi || {};
const form = document.getElementById("cmsEditorForm");
const pageJsonInput = document.getElementById("pageJson");
const activeTabInput = document.getElementById("activeTabInput");
@@ -14,6 +16,10 @@
}
const state = JSON.parse(JSON.stringify(initialData));
const directoryUi = editorUi.directory || {};
const inquiryUi = editorUi.inquiryForm || {};
const partnerFields = directoryUi.partnerFields || {};
const inquiryFieldFields = inquiryUi.fieldFields || {};
const slugifyValue = (value, fallback) =>
String(value || "")
.trim()
@@ -32,6 +38,42 @@
renderAll();
initStaticCounters();
function getTabConfig(tabKey) {
return (editorConfig.tabs || []).find((tab) => tab.key === tabKey) || {};
}
function getObjectFieldConfig(tabKey, fieldKey) {
const fields = getTabConfig(tabKey)?.schema?.fields || [];
return fields.find((field) => field.key === fieldKey) || {};
}
function getDefaultPartner() {
return {
name: "",
category: "",
summary: "",
logo: "",
logoAlt: "",
about: "",
collabType: "",
benefits: "",
};
}
function getDefaultInquiryField() {
const typeOptions = inquiryFieldFields.type?.options || [];
const widthOptions = inquiryFieldFields.width?.options || [];
return {
label: "",
placeholder: "",
type: typeOptions[0]?.value || "text",
width: widthOptions[0]?.value || "full",
required: true,
options: [],
};
}
function bindStaticEvents() {
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
tabTrigger.addEventListener("shown.bs.tab", function () {
@@ -50,29 +92,13 @@
});
document.getElementById("addPartnerBtn")?.addEventListener("click", function () {
state.directory.partners.push({
name: "",
category: "",
summary: "",
logo: "",
logoAlt: "",
about: "",
collabType: "",
benefits: "",
});
state.directory.partners.push(getDefaultPartner());
ensurePartnershipIds();
renderPartners();
});
document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () {
state.inquiryForm.fields.push({
label: "",
placeholder: "",
type: "text",
width: "full",
required: true,
options: [],
});
state.inquiryForm.fields.push(getDefaultInquiryField());
ensurePartnershipIds();
renderInquiryFields();
});
@@ -81,8 +107,24 @@
window.location.reload();
});
form.addEventListener("submit", function () {
form.addEventListener("submit", function (event) {
syncStaticFields();
const duplicateTabs = getDuplicateTabs(state?.directory?.tabs);
clearDirectoryTabsValidation();
if (duplicateTabs.length > 0) {
const tabsLabel = directoryUi.tabs?.label || "Category tab";
event.preventDefault();
highlightDuplicateDirectoryTabs(duplicateTabs);
showToast(
`Duplicate ${tabsLabel.toLowerCase()}`,
`${tabsLabel} "${duplicateTabs[0]}" already exists. Please use unique tab names before saving.`,
"danger",
);
return;
}
ensurePartnershipIds();
pageJsonInput.value = JSON.stringify(state);
});
@@ -152,6 +194,55 @@
});
}
function getDuplicateTabs(tabs) {
if (!Array.isArray(tabs)) {
return [];
}
const seen = new Set();
const duplicates = [];
tabs.forEach((tab) => {
const trimmedTab = String(tab || "").trim();
const normalizedTab = trimmedTab.toLowerCase();
if (!normalizedTab) {
return;
}
if (seen.has(normalizedTab)) {
duplicates.push(trimmedTab);
return;
}
seen.add(normalizedTab);
});
return duplicates;
}
function clearDirectoryTabsValidation() {
directoryTabsList
?.querySelectorAll(".is-invalid")
.forEach((element) => element.classList.remove("is-invalid"));
}
function highlightDuplicateDirectoryTabs(duplicateTabs) {
const normalizedDuplicates = new Set(
duplicateTabs.map((tab) => String(tab || "").trim().toLowerCase()),
);
const duplicateInputs = Array.from(
directoryTabsList?.querySelectorAll('[data-field="label"]') || [],
).filter((input) => {
const inputValue = String(input.value || "").trim().toLowerCase();
return inputValue && normalizedDuplicates.has(inputValue);
});
duplicateInputs.forEach((input) => input.classList.add("is-invalid"));
duplicateInputs[0]?.focus();
}
function renderAll() {
renderDirectoryTabs();
renderPartners();
@@ -205,7 +296,7 @@
input.addEventListener("input", function () {
partner[field] = input.value;
if (field === "name") {
title.textContent = input.value || `Partner ${index + 1}`;
title.textContent = input.value || `${partnerFields.name?.label || "Partner"} ${index + 1}`;
}
if (field === "category") {
subtitle.textContent = input.value || "";
@@ -287,8 +378,8 @@
input.value = field[key] || "";
input.addEventListener("input", function () {
field[key] = input.value;
if (key === "label") {
title.textContent = input.value || `Field ${index + 1}`;
if (key === "label") {
title.textContent = input.value || `${inquiryFieldFields.label?.label || "Field"} ${index + 1}`;
}
if (key === "type") {
subtitle.textContent = input.value || "";