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
+102 -40
View File
@@ -3,6 +3,7 @@
const config = window.pageEditorConfig;
const initialData = window.pageEditorData;
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
const admissionsUi = config?.editorUi || {};
const form = document.getElementById("cmsEditorForm");
const pageJsonInput = document.getElementById("pageJson");
const activeTabInput = document.getElementById("activeTabInput");
@@ -94,16 +95,39 @@
});
}
function getTabConfig(tabKey) {
return (config.tabs || []).find((tab) => tab.key === tabKey) || {};
}
function getObjectFieldConfig(tabKey, fieldKey) {
const fields = getTabConfig(tabKey)?.schema?.fields || [];
return fields.find((field) => field.key === fieldKey) || {};
}
function getObjectListItemFieldConfig(tabKey, listKey, fieldKey) {
const listField = getObjectFieldConfig(tabKey, listKey);
const fields = listField?.itemSchema?.fields || [];
return fields.find((field) => field.key === fieldKey) || {};
}
function renderKeyDatesSection(container) {
normalizeKeyDatesState();
const keyDates = state.keyDates;
const keyDatesUi = admissionsUi.keyDates || {};
const titleField = getObjectFieldConfig("keyDates", "title");
const row = document.createElement("div");
row.className = "row g-3";
container.appendChild(row);
renderLeafField(
{ key: "title", label: "Section title", type: "text", maxLength: 60 },
{
key: "title",
label: titleField.label || "Section title",
type: titleField.type || "text",
maxLength: titleField.maxLength || 60,
helpText: titleField.helpText,
},
row,
keyDates,
"title",
@@ -118,15 +142,15 @@
header.className = "d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3";
header.innerHTML = `
<div>
<label class="form-label fw-semibold mb-1">Key dates table</label>
<div class="form-text mt-0">Manage the table directly by adding or removing columns and rows.</div>
<label class="form-label fw-semibold mb-1">${escapeHtml(keyDatesUi.tableLabel || "Key dates table")}</label>
<div class="form-text mt-0">${escapeHtml(keyDatesUi.tableHelpText || "Manage the table directly by adding or removing columns and rows.")}</div>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" data-add-column="true">
<i class="fas fa-table-columns me-1"></i>Add Column
<i class="fas fa-table-columns me-1"></i>${escapeHtml(keyDatesUi.addColumnLabel || "Add Column")}
</button>
<button type="button" class="btn btn-outline-primary btn-sm" data-add-row="true">
<i class="fas fa-plus me-1"></i>Add Row
<i class="fas fa-plus me-1"></i>${escapeHtml(keyDatesUi.addRowLabel || "Add Row")}
</button>
</div>
`;
@@ -150,8 +174,8 @@
const input = document.createElement("input");
input.type = "text";
input.className = "form-control";
input.maxLength = 40;
input.placeholder = "Column name";
input.maxLength = keyDatesUi.columnLabelMaxLength || 40;
input.placeholder = keyDatesUi.columnPlaceholder || "Column name";
input.value = column.label || "";
input.addEventListener("input", function () {
column.label = input.value;
@@ -183,7 +207,7 @@
const actionHead = document.createElement("th");
actionHead.className = "text-end";
actionHead.style.width = "72px";
actionHead.textContent = "Actions";
actionHead.textContent = keyDatesUi.actionsLabel || "Actions";
headRow.appendChild(actionHead);
thead.appendChild(headRow);
table.appendChild(thead);
@@ -194,7 +218,7 @@
const emptyCell = document.createElement("td");
emptyCell.colSpan = keyDates.columns.length + 1;
emptyCell.className = "text-center text-muted py-4";
emptyCell.textContent = "No rows yet.";
emptyCell.textContent = keyDatesUi.emptyRowsText || "No rows yet.";
emptyRow.appendChild(emptyCell);
tbody.appendChild(emptyRow);
} else {
@@ -206,7 +230,7 @@
const input = document.createElement("input");
input.type = "text";
input.className = "form-control";
input.maxLength = 60;
input.maxLength = keyDatesUi.cellMaxLength || 60;
input.placeholder = column.label || `Column ${columnIndex + 1}`;
input.value = rowItem.cells[columnIndex] || "";
input.addEventListener("input", function () {
@@ -389,18 +413,21 @@
: Array.isArray(calculator.modelOptions)
? calculator.modelOptions.map((label) => ({ label }))
: [];
const calculatorUi = admissionsUi.calculator || {};
const defaultOption = calculatorUi.defaultOption || {};
const optionLabelField = getObjectListItemFieldConfig("calculator", "options", "label");
calculator.options = rawOptions.slice(0, 3).map((option, index) => ({
id: String(option?.id || sanitizeId(option?.label || `option-${index + 1}`) || `option-${index + 1}`),
label: String(option?.label || `Option ${index + 1}`).slice(0, 12),
paceLabel: String(option?.paceLabel || "Target Pace"),
minPaceLabel: String(option?.minPaceLabel || "Relaxed"),
maxPaceLabel: String(option?.maxPaceLabel || "Accelerated"),
resultLabel: String(option?.resultLabel || "Estimated Monthly Payment"),
monthlyAmount: (((String(option?.monthlyAmount || "299").match(/\d[\d,]*/) || [])[0] || "299").replace(/,/g, "")),
monthlySuffix: String(option?.monthlySuffix || "/mo"),
noteIcon: String(option?.noteIcon || "fa-bolt"),
note: String(option?.note || ""),
label: String(option?.label || `Option ${index + 1}`).slice(0, optionLabelField.maxLength || 12),
paceLabel: String(option?.paceLabel || defaultOption.paceLabel || "Target Pace"),
minPaceLabel: String(option?.minPaceLabel || defaultOption.minPaceLabel || "Relaxed"),
maxPaceLabel: String(option?.maxPaceLabel || defaultOption.maxPaceLabel || "Accelerated"),
resultLabel: String(option?.resultLabel || defaultOption.resultLabel || "Estimated Monthly Payment"),
monthlyAmount: (((String(option?.monthlyAmount || defaultOption.monthlyAmount || "299").match(/\d[\d,]*/) || [])[0] || String(defaultOption.monthlyAmount || "299")).replace(/,/g, "")),
monthlySuffix: String(option?.monthlySuffix || defaultOption.monthlySuffix || "/mo"),
noteIcon: String(option?.noteIcon || defaultOption.noteIcon || "fa-bolt"),
note: String(option?.note || defaultOption.note || ""),
}));
delete calculator.modelOptions;
@@ -418,19 +445,42 @@
normalizeCalculatorState();
const calculator = state.calculator;
const calculatorUi = admissionsUi.calculator || {};
const titleField = getObjectFieldConfig("calculator", "title");
const descriptionField = getObjectFieldConfig("calculator", "description");
const ctaField = getObjectFieldConfig("calculator", "cta");
const optionsField = getObjectFieldConfig("calculator", "options");
const ctaLabelField = (ctaField.fields || []).find((field) => field.key === "label") || {};
const ctaHrefField = (ctaField.fields || []).find((field) => field.key === "href") || {};
const optionLabelField = getObjectListItemFieldConfig("calculator", "options", "label");
const defaultOption = calculatorUi.defaultOption || {};
const maxOptions = Number(calculatorUi.maxOptions) || 3;
const row = document.createElement("div");
row.className = "row g-3";
container.appendChild(row);
renderLeafField(
{ key: "title", label: "Card title", type: "text", maxLength: 60 },
{
key: "title",
label: titleField.label || "Card title",
type: titleField.type || "text",
maxLength: titleField.maxLength || 60,
helpText: titleField.helpText,
},
row,
calculator,
"title",
{ path: "calculator.title", root: state, item: calculator },
);
renderLeafField(
{ key: "description", label: "Card description", type: "textarea", maxLength: 120, rows: 3 },
{
key: "description",
label: descriptionField.label || "Card description",
type: descriptionField.type || "textarea",
maxLength: descriptionField.maxLength || 120,
rows: descriptionField.rows || 3,
helpText: descriptionField.helpText,
},
row,
calculator,
"description",
@@ -443,22 +493,34 @@
const ctaHeader = document.createElement("div");
ctaHeader.className = "mb-3";
ctaHeader.innerHTML = `
<label class="form-label fw-semibold mb-1">Primary button</label>
<div class="form-text mt-0">This button appears at the bottom of the calculator card.</div>
<label class="form-label fw-semibold mb-1">${escapeHtml(calculatorUi.ctaLabel || ctaField.label || "Primary button")}</label>
<div class="form-text mt-0">${escapeHtml(calculatorUi.ctaHelpText || "This button appears at the bottom of the calculator card.")}</div>
`;
ctaCard.appendChild(ctaHeader);
const ctaRow = document.createElement("div");
ctaRow.className = "row g-3";
ctaCard.appendChild(ctaRow);
renderLeafField(
{ key: "label", label: "Button label", type: "text", maxLength: 15 },
{
key: "label",
label: ctaLabelField.label || "Button label",
type: ctaLabelField.type || "text",
maxLength: ctaLabelField.maxLength || 15,
helpText: ctaLabelField.helpText,
},
ctaRow,
calculator.cta,
"label",
{ path: "calculator.cta.label", root: state, item: calculator.cta },
);
renderLeafField(
{ key: "href", label: "Button URL", type: "text", maxLength: 255 },
{
key: "href",
label: ctaHrefField.label || "Button URL",
type: ctaHrefField.type || "text",
maxLength: ctaHrefField.maxLength || 255,
helpText: ctaHrefField.helpText,
},
ctaRow,
calculator.cta,
"href",
@@ -473,8 +535,8 @@
const optionsHeader = document.createElement("div");
optionsHeader.className = "mb-3";
optionsHeader.innerHTML = `
<label class="form-label fw-semibold mb-1">Calculator options</label>
<div class="form-text mt-0">Each option has its own pricing labels, amount, note, and icon. Open the edit page to update the option details.</div>
<label class="form-label fw-semibold mb-1">${escapeHtml(optionsField.label || calculatorUi.optionsLabel || "Calculator options")}</label>
<div class="form-text mt-0">${escapeHtml(optionsField.helpText || calculatorUi.optionsHelpText || "Each option has its own pricing labels, amount, note, and icon. Open the edit page to update the option details.")}</div>
`;
optionsCard.appendChild(optionsHeader);
@@ -485,7 +547,7 @@
if (!calculator.options.length) {
const empty = document.createElement("div");
empty.className = "text-muted small";
empty.textContent = "No calculator options yet.";
empty.textContent = optionsField.emptyText || calculatorUi.optionsEmptyText || "No calculator options yet.";
list.appendChild(empty);
} else {
calculator.options.forEach((option, index) => {
@@ -519,24 +581,24 @@
});
}
if (calculator.options.length < 3) {
if (calculator.options.length < maxOptions) {
const addButton = document.createElement("button");
addButton.type = "button";
addButton.className = "cms-add-button mt-3";
addButton.innerHTML = '<i class="fas fa-plus me-2"></i>Add calculator option';
addButton.innerHTML = `<i class="fas fa-plus me-2"></i>${escapeHtml(optionsField.addLabel || calculatorUi.addOptionLabel || "Add calculator option")}`;
addButton.addEventListener("click", function () {
const nextIndex = calculator.options.length + 1;
calculator.options.push({
id: `option-${Date.now()}`,
label: `Option ${nextIndex}`.slice(0, 12),
paceLabel: "Target Pace",
minPaceLabel: "Relaxed",
maxPaceLabel: "Accelerated",
resultLabel: "Estimated Monthly Payment",
monthlyAmount: "299",
monthlySuffix: "/mo",
noteIcon: "fa-bolt",
note: "",
label: String(`Option ${nextIndex}`).slice(0, optionLabelField.maxLength || 12),
paceLabel: String(defaultOption.paceLabel || "Target Pace"),
minPaceLabel: String(defaultOption.minPaceLabel || "Relaxed"),
maxPaceLabel: String(defaultOption.maxPaceLabel || "Accelerated"),
resultLabel: String(defaultOption.resultLabel || "Estimated Monthly Payment"),
monthlyAmount: String(defaultOption.monthlyAmount || "299"),
monthlySuffix: String(defaultOption.monthlySuffix || "/mo"),
noteIcon: String(defaultOption.noteIcon || "fa-bolt"),
note: String(defaultOption.note || ""),
});
renderSection("calculator");
});
@@ -544,7 +606,7 @@
} else {
const limitNote = document.createElement("div");
limitNote.className = "form-text mt-3";
limitNote.textContent = "You can add up to 3 calculator options.";
limitNote.textContent = calculatorUi.limitHelpText || `You can add up to ${maxOptions} calculator options.`;
optionsCard.appendChild(limitNote);
}