Files
cms.techvanguard.vn/views/admin/admissions/partials/editor-script.ejs
T
Tống Thành Đạt a39c336dd4 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.
2026-04-22 18:06:03 +07:00

1533 lines
52 KiB
Plaintext

<script>
(function () {
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");
if (!config || !initialData || !form || !pageJsonInput || !activeTabInput) {
return;
}
const state = JSON.parse(JSON.stringify(initialData));
const persistedCalculatorOptionIds = new Set(
Array.isArray(initialData?.calculator?.options)
? initialData.calculator.options.map((item) => item.id).filter(Boolean)
: [],
);
const iconOptions = Array.from(
new Set(
(config.tabs || [])
.flatMap((tab) => collectIcons(tab.schema))
.filter(Boolean),
),
);
ensureIconDatalist(iconOptions);
renderAllSections();
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
tabTrigger.addEventListener("shown.bs.tab", function () {
const tabKey = this.dataset.tabKey;
if (!tabKey) {
return;
}
activeTabInput.value = tabKey;
updateTabUrl(tabKey);
});
});
form.addEventListener("submit", function () {
pageJsonInput.value = JSON.stringify(state);
});
form.addEventListener("reset", function () {
window.setTimeout(function () {
Object.keys(state).forEach((key) => delete state[key]);
Object.assign(state, JSON.parse(JSON.stringify(initialData)));
renderAllSections();
}, 0);
});
function renderAllSections() {
config.tabs.forEach((tab) => renderSection(tab.key));
}
function updateTabUrl(tabKey) {
const url = new URL(window.location.href);
url.searchParams.set("tab", tabKey);
window.history.replaceState(
{},
"",
`${url.pathname}?${url.searchParams.toString()}${url.hash}`,
);
}
function renderSection(tabKey) {
const tab = config.tabs.find((item) => item.key === tabKey);
const container = document.querySelector(
`.page-editor-section[data-section-key="${tabKey}"]`,
);
if (!tab || !container) return;
container.innerHTML = "";
if (tabKey === "keyDates") {
renderKeyDatesSection(container);
return;
}
if (tabKey === "calculator") {
renderCalculatorSection(container);
return;
}
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
path: tab.schema.key,
root: state,
item: null,
});
}
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: titleField.label || "Section title",
type: titleField.type || "text",
maxLength: titleField.maxLength || 60,
helpText: titleField.helpText,
},
row,
keyDates,
"title",
{ path: "keyDates.title", root: state, item: keyDates },
);
const tableCol = createCol("col-12");
const card = document.createElement("div");
card.className = "cms-editor-group";
const header = document.createElement("div");
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">${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>${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>${escapeHtml(keyDatesUi.addRowLabel || "Add Row")}
</button>
</div>
`;
card.appendChild(header);
const tableWrap = document.createElement("div");
tableWrap.className = "table-responsive";
const table = document.createElement("table");
table.className = "table align-middle mb-0";
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
keyDates.columns.forEach((column, columnIndex) => {
const th = document.createElement("th");
th.style.minWidth = "220px";
const group = document.createElement("div");
group.className = "d-flex align-items-start gap-2";
const input = document.createElement("input");
input.type = "text";
input.className = "form-control";
input.maxLength = keyDatesUi.columnLabelMaxLength || 40;
input.placeholder = keyDatesUi.columnPlaceholder || "Column name";
input.value = column.label || "";
input.addEventListener("input", function () {
column.label = input.value;
});
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "cms-remove-button mt-1";
removeButton.title = "Remove column";
removeButton.innerHTML = '<i class="fas fa-trash-alt"></i>';
removeButton.disabled = keyDates.columns.length <= 1;
removeButton.addEventListener("click", function () {
if (keyDates.columns.length <= 1) {
return;
}
keyDates.columns.splice(columnIndex, 1);
keyDates.rows.forEach((rowItem) => {
rowItem.cells.splice(columnIndex, 1);
});
renderSection("keyDates");
});
group.appendChild(input);
group.appendChild(removeButton);
th.appendChild(group);
headRow.appendChild(th);
});
const actionHead = document.createElement("th");
actionHead.className = "text-end";
actionHead.style.width = "72px";
actionHead.textContent = keyDatesUi.actionsLabel || "Actions";
headRow.appendChild(actionHead);
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = document.createElement("tbody");
if (keyDates.rows.length === 0) {
const emptyRow = document.createElement("tr");
const emptyCell = document.createElement("td");
emptyCell.colSpan = keyDates.columns.length + 1;
emptyCell.className = "text-center text-muted py-4";
emptyCell.textContent = keyDatesUi.emptyRowsText || "No rows yet.";
emptyRow.appendChild(emptyCell);
tbody.appendChild(emptyRow);
} else {
keyDates.rows.forEach((rowItem, rowIndex) => {
const tr = document.createElement("tr");
keyDates.columns.forEach((column, columnIndex) => {
const td = document.createElement("td");
const input = document.createElement("input");
input.type = "text";
input.className = "form-control";
input.maxLength = keyDatesUi.cellMaxLength || 60;
input.placeholder = column.label || `Column ${columnIndex + 1}`;
input.value = rowItem.cells[columnIndex] || "";
input.addEventListener("input", function () {
rowItem.cells[columnIndex] = input.value;
});
td.appendChild(input);
tr.appendChild(td);
});
const actionCell = document.createElement("td");
actionCell.className = "text-end";
const removeRowButton = document.createElement("button");
removeRowButton.type = "button";
removeRowButton.className = "cms-remove-button";
removeRowButton.title = "Remove row";
removeRowButton.innerHTML = '<i class="fas fa-trash-alt"></i>';
removeRowButton.addEventListener("click", function () {
keyDates.rows.splice(rowIndex, 1);
renderSection("keyDates");
});
actionCell.appendChild(removeRowButton);
tr.appendChild(actionCell);
tbody.appendChild(tr);
});
}
table.appendChild(tbody);
tableWrap.appendChild(table);
card.appendChild(tableWrap);
tableCol.appendChild(card);
container.appendChild(tableCol);
header.querySelector('[data-add-column="true"]').addEventListener("click", function () {
addKeyDatesColumn();
renderSection("keyDates");
});
header.querySelector('[data-add-row="true"]').addEventListener("click", function () {
addKeyDatesRow();
renderSection("keyDates");
});
}
function normalizeKeyDatesState() {
if (!isObject(state.keyDates)) {
state.keyDates = {};
}
const keyDates = state.keyDates;
if (typeof keyDates.id !== "string") {
keyDates.id = "";
}
if (typeof keyDates.title !== "string") {
keyDates.title = "";
}
const legacyColumns = Array.isArray(keyDates.columns) ? keyDates.columns : [];
const legacyRows = Array.isArray(keyDates.rows) ? keyDates.rows : [];
keyDates.columns = normalizeKeyDatesColumns(legacyColumns, legacyRows);
keyDates.rows = normalizeKeyDatesRows(legacyRows, keyDates.columns);
}
function normalizeKeyDatesColumns(columns, rows) {
if (Array.isArray(columns) && columns.length > 0) {
return columns.map((column, index) => {
if (isObject(column)) {
return {
id: column.id || `column-${index + 1}`,
label: column.label || `Column ${index + 1}`,
};
}
return {
id: sanitizeId(column) || `column-${index + 1}`,
label: String(column || `Column ${index + 1}`),
};
});
}
const defaultLabels = extractLegacyKeyDateLabels(rows);
return defaultLabels.map((label, index) => ({
id: sanitizeId(label) || `column-${index + 1}`,
label,
}));
}
function normalizeKeyDatesRows(rows, columns) {
if (!Array.isArray(rows)) {
return [];
}
return rows.map((row, rowIndex) => {
if (isObject(row) && Array.isArray(row.cells)) {
return {
id: row.id || `row-${rowIndex + 1}`,
cells: columns.map((_, columnIndex) => String(row.cells[columnIndex] || "")),
};
}
return {
id: isObject(row) && row.id ? row.id : `row-${rowIndex + 1}`,
cells: columns.map((column, columnIndex) =>
extractLegacyKeyDateCell(row, column, columnIndex),
),
};
});
}
function extractLegacyKeyDateLabels(rows) {
const defaultLabels = ["Term", "Application Deadline", "Classes Start"];
if (!Array.isArray(rows) || rows.length === 0 || !isObject(rows[0])) {
return defaultLabels;
}
const row = rows[0];
if ("term" in row || "applicationDeadline" in row || "classesStart" in row) {
return defaultLabels;
}
const keys = Object.keys(row).filter((key) => key !== "id");
return keys.length ? keys : defaultLabels;
}
function extractLegacyKeyDateCell(row, column, columnIndex) {
if (!isObject(row)) {
return "";
}
const legacyKeys = ["term", "applicationDeadline", "classesStart"];
const legacyKey = legacyKeys[columnIndex];
if (legacyKey && typeof row[legacyKey] !== "undefined") {
return String(row[legacyKey] || "");
}
if (column && column.id && typeof row[column.id] !== "undefined") {
return String(row[column.id] || "");
}
return "";
}
function addKeyDatesColumn() {
normalizeKeyDatesState();
const keyDates = state.keyDates;
const nextIndex = keyDates.columns.length + 1;
keyDates.columns.push({
id: `column-${nextIndex}`,
label: `Column ${nextIndex}`,
});
keyDates.rows.forEach((row) => {
row.cells.push("");
});
}
function addKeyDatesRow() {
normalizeKeyDatesState();
const keyDates = state.keyDates;
keyDates.rows.push({
id: `row-${Date.now()}`,
cells: keyDates.columns.map(() => ""),
});
}
function normalizeCalculatorState() {
if (!isObject(state.calculator)) {
state.calculator = {};
}
const calculator = state.calculator;
calculator.title = String(calculator.title || "");
calculator.description = String(calculator.description || "");
calculator.cta = {
label: String(calculator?.cta?.label || "").slice(0, 15),
href: String(calculator?.cta?.href || ""),
};
const rawOptions = Array.isArray(calculator.options)
? calculator.options
: 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, 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;
delete calculator.paceLabel;
delete calculator.minPaceLabel;
delete calculator.maxPaceLabel;
delete calculator.resultLabel;
delete calculator.monthlyAmount;
delete calculator.monthlySuffix;
delete calculator.noteIcon;
delete calculator.note;
}
function renderCalculatorSection(container) {
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: 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: descriptionField.label || "Card description",
type: descriptionField.type || "textarea",
maxLength: descriptionField.maxLength || 120,
rows: descriptionField.rows || 3,
helpText: descriptionField.helpText,
},
row,
calculator,
"description",
{ path: "calculator.description", root: state, item: calculator },
);
const ctaCardCol = createCol("col-12");
const ctaCard = document.createElement("div");
ctaCard.className = "cms-editor-group";
const ctaHeader = document.createElement("div");
ctaHeader.className = "mb-3";
ctaHeader.innerHTML = `
<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: 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: ctaHrefField.label || "Button URL",
type: ctaHrefField.type || "text",
maxLength: ctaHrefField.maxLength || 255,
helpText: ctaHrefField.helpText,
},
ctaRow,
calculator.cta,
"href",
{ path: "calculator.cta.href", root: state, item: calculator.cta },
);
ctaCardCol.appendChild(ctaCard);
container.appendChild(ctaCardCol);
const optionsCol = createCol("col-12");
const optionsCard = document.createElement("div");
optionsCard.className = "cms-editor-group";
const optionsHeader = document.createElement("div");
optionsHeader.className = "mb-3";
optionsHeader.innerHTML = `
<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);
const list = document.createElement("div");
list.className = "d-flex flex-column gap-3";
optionsCard.appendChild(list);
if (!calculator.options.length) {
const empty = document.createElement("div");
empty.className = "text-muted small";
empty.textContent = optionsField.emptyText || calculatorUi.optionsEmptyText || "No calculator options yet.";
list.appendChild(empty);
} else {
calculator.options.forEach((option, index) => {
const item = document.createElement("div");
item.className = "card cms-item-card";
item.innerHTML = `
<div class="card-header d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div>
<div class="fw-semibold">${escapeHtml(option.label || `Option ${index + 1}`)}</div>
<div class="small text-muted">${escapeHtml(option.monthlyAmount)}${escapeHtml(option.monthlySuffix || "")}</div>
</div>
<div class="d-flex align-items-center gap-2">
${
persistedCalculatorOptionIds.has(option.id)
? `<a href="/admin/admissions/calculator/${encodeURIComponent(option.id)}" class="btn btn-outline-primary btn-sm"><i class="fas fa-pen me-1"></i>Edit option</a>`
: `<button type="button" class="btn btn-outline-secondary btn-sm" disabled><i class="fas fa-save me-1"></i>Save first</button>`
}
<button type="button" class="cms-remove-button" data-remove-option="${index}" title="Remove option">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
`;
item
.querySelector(`[data-remove-option="${index}"]`)
.addEventListener("click", function () {
calculator.options.splice(index, 1);
renderSection("calculator");
});
list.appendChild(item);
});
}
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>${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: 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");
});
optionsCard.appendChild(addButton);
} else {
const limitNote = document.createElement("div");
limitNote.className = "form-text mt-3";
limitNote.textContent = calculatorUi.limitHelpText || `You can add up to ${maxOptions} calculator options.`;
optionsCard.appendChild(limitNote);
}
optionsCol.appendChild(optionsCard);
container.appendChild(optionsCol);
}
function renderField(schema, container, parent, key, tabKey, context) {
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
return;
}
if (schema.type === "object") {
if (!isObject(parent[key])) {
parent[key] = {};
}
const groupWrapper = document.createElement("div");
groupWrapper.className = "row g-3";
container.appendChild(groupWrapper);
(schema.fields || []).forEach((field) => {
renderField(field, groupWrapper, parent[key], field.key, tabKey, {
path: appendPath(context.path, field.key),
root: context.root,
item: parent[key],
});
});
return;
}
if (schema.type === "array") {
if (!Array.isArray(parent[key])) {
parent[key] = [];
}
applyAutoSequenceToArray(schema, parent[key]);
const col = createCol(schema.colClass || "col-12");
const card = document.createElement("div");
card.className = "cms-editor-group";
const header = document.createElement("div");
header.className = "mb-3";
header.innerHTML = `
<div>
<label class="form-label fw-semibold mb-1">${escapeHtml(schema.label)}</label>
${schema.helpText ? `<div class="form-text mt-0">${escapeHtml(schema.helpText)}</div>` : ""}
</div>
`;
card.appendChild(header);
if (parent[key].length === 0) {
const empty = document.createElement("div");
empty.className = "text-muted small";
empty.textContent = schema.emptyText || `No ${schema.itemLabel || "items"} yet.`;
card.appendChild(empty);
} else {
const list = document.createElement("div");
list.className = "page-editor-array-list";
card.appendChild(list);
parent[key].forEach((item, index) => {
const itemCard = document.createElement("div");
itemCard.className = "card cms-item-card mb-3";
itemCard.dataset.index = String(index);
const itemHeader = document.createElement("div");
itemHeader.className = "card-header d-flex justify-content-between align-items-center gap-2 flex-wrap";
const title = getArrayItemTitle(schema, item, index);
const subtitle = getArrayItemSubtitle(schema, item);
itemHeader.innerHTML = `
<div class="d-flex align-items-center gap-2 flex-grow-1">
${
schema.sortable
? '<button type="button" class="drag-handle" title="Drag to reorder"><i class="fas fa-grip-vertical"></i></button>'
: ""
}
<div>
<div class="fw-semibold">${escapeHtml(title)}</div>
${subtitle ? `<div class="small text-muted">${escapeHtml(subtitle)}</div>` : ""}
</div>
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<button type="button" class="cms-collapse-toggle" data-toggle-item="true" title="Collapse section">
<i class="fas fa-chevron-down"></i>
</button>
${renderItemActions(schema.itemActions, item)}
<button type="button" class="cms-remove-button" data-remove-item="true" title="Remove item">
<i class="fas fa-trash-alt"></i>
</button>
</div>
`;
itemHeader
.querySelector('[data-toggle-item="true"]')
.addEventListener("click", function () {
itemCard.classList.toggle("is-collapsed");
});
itemHeader
.querySelector('[data-remove-item="true"]')
.addEventListener("click", function () {
parent[key].splice(index, 1);
applyAutoSequenceToArray(schema, parent[key]);
renderSection(tabKey);
});
itemHeader.querySelectorAll("[data-item-href]").forEach((actionButton) => {
actionButton.addEventListener("click", function () {
window.location.href = actionButton.dataset.itemHref;
});
});
const itemBody = document.createElement("div");
itemBody.className = "card-body";
if (schema.itemSchema.type === "primitive") {
renderPrimitiveArrayItem(schema, itemBody, parent[key], index, tabKey, context);
} else if (schema.itemSchema.type === "variant") {
renderVariantArrayItem(
schema.itemSchema,
itemBody,
parent[key],
index,
tabKey,
{
path: appendPath(context.path, String(index)),
root: context.root,
item: parent[key][index],
},
);
} else {
const bodyRow = document.createElement("div");
bodyRow.className = "row g-3";
itemBody.appendChild(bodyRow);
(schema.itemSchema.fields || []).forEach((field) => {
renderField(field, bodyRow, parent[key][index], field.key, tabKey, {
path: appendPath(context.path, `${index}.${field.key}`),
root: context.root,
item: parent[key][index],
});
});
}
itemCard.appendChild(itemHeader);
itemCard.appendChild(itemBody);
list.appendChild(itemCard);
});
const addButton = document.createElement("button");
addButton.type = "button";
addButton.className = "cms-add-button mt-2";
addButton.innerHTML = `<i class="fas fa-plus me-2"></i>${escapeHtml(schema.addLabel || `Add ${schema.itemLabel || "Item"}`)}`;
addButton.addEventListener("click", function () {
parent[key].push(createDefaultValue(schema.itemSchema));
applyAutoSequenceToArray(schema, parent[key]);
renderSection(tabKey);
});
card.appendChild(addButton);
if (schema.sortable && window.Sortable) {
window.Sortable.create(list, {
animation: 150,
handle: ".drag-handle",
onEnd: function (event) {
if (
typeof event.oldIndex !== "number" ||
typeof event.newIndex !== "number" ||
event.oldIndex === event.newIndex
) {
return;
}
const movedItem = parent[key].splice(event.oldIndex, 1)[0];
parent[key].splice(event.newIndex, 0, movedItem);
applyAutoSequenceToArray(schema, parent[key]);
renderSection(tabKey);
},
});
}
}
col.appendChild(card);
container.appendChild(col);
return;
}
if (schema.type === "checkbox") {
renderCheckbox(schema, container, parent, key);
return;
}
renderLeafField(schema, container, parent, key, context);
}
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index, tabKey, context) {
const fieldSchema = arraySchema.itemSchema;
const row = document.createElement("div");
row.className = "row g-3";
container.appendChild(row);
const holder = { value: targetArray[index] || "" };
renderLeafField(
{
key: "value",
label: fieldSchema.label || arraySchema.itemLabel || "Value",
type: fieldSchema.fieldType || "text",
maxLength: fieldSchema.maxLength,
min: fieldSchema.min,
max: fieldSchema.max,
step: fieldSchema.step,
placeholder: fieldSchema.placeholder,
helpText: fieldSchema.helpText,
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
},
row,
holder,
"value",
{
path: appendPath(context.path, String(index)),
root: context.root,
item: holder,
},
);
const input = row.querySelector("input, textarea, select");
if (input) {
const sync = function () {
targetArray[index] =
fieldSchema.fieldType === "number" ? Number(holder.value || 0) : holder.value;
};
input.addEventListener("input", sync);
input.addEventListener("change", sync);
}
}
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey, context) {
const item = targetArray[index];
if (!isObject(item)) {
targetArray[index] = {};
}
const currentType =
targetArray[index][variantSchema.discriminator] ||
variantSchema.options[0].value;
targetArray[index][variantSchema.discriminator] = currentType;
const currentVariant = variantSchema.variants[currentType];
const typeRow = document.createElement("div");
typeRow.className = "row g-3 mb-2";
container.appendChild(typeRow);
renderLeafField(
{
key: variantSchema.discriminator,
label: "Section type",
type: "select",
options: variantSchema.options,
},
typeRow,
targetArray[index],
variantSchema.discriminator,
context,
);
const selectInput = typeRow.querySelector("select");
if (selectInput) {
selectInput.addEventListener("change", function () {
const newType = this.value;
targetArray[index] = { type: newType };
renderSection(tabKey);
});
}
if (currentVariant && currentVariant.schema) {
const sectionRow = document.createElement("div");
sectionRow.className = "row g-3";
container.appendChild(sectionRow);
(currentVariant.schema.fields || []).forEach((field) => {
renderField(field, sectionRow, targetArray[index], field.key, tabKey, {
path: appendPath(context.path, field.key),
root: context.root,
item: targetArray[index],
});
});
}
}
function renderLeafField(schema, container, parent, key, context) {
if (schema.type === "hidden") {
if (typeof parent[key] === "undefined" || parent[key] === null) {
parent[key] = schema.defaultValue || "";
}
return;
}
if (typeof parent[key] === "undefined" || parent[key] === null) {
parent[key] = schema.type === "number" ? 0 : "";
}
const col = createCol(schema.colClass || inferColClass(schema.type));
const label = document.createElement("label");
label.className = "form-label fw-semibold";
label.textContent = schema.label || key;
col.appendChild(label);
if (schema.type === "textarea") {
const textarea = document.createElement("textarea");
textarea.className = "form-control";
textarea.rows = schema.rows || 4;
textarea.value = parent[key] || "";
if (schema.placeholder) textarea.placeholder = schema.placeholder;
if (schema.maxLength) textarea.maxLength = schema.maxLength;
textarea.addEventListener("input", function () {
parent[key] = textarea.value;
updateCounter(counter, textarea.value.length, schema.maxLength);
});
col.appendChild(textarea);
const counter = appendHelp(col, schema, parent[key]);
container.appendChild(col);
return;
}
if (schema.type === "image") {
const group = document.createElement("div");
group.className = "input-group";
const input = document.createElement("input");
input.type = "text";
input.className = "form-control";
input.value = parent[key] || "";
input.addEventListener("input", function () {
parent[key] = input.value;
preview.src = resolveImageUrl(input.value);
preview.classList.toggle("d-none", !input.value);
});
const button = document.createElement("button");
button.type = "button";
button.className = "btn btn-outline-primary";
button.innerHTML = '<i class="fas fa-upload me-1"></i>Upload';
button.addEventListener("click", function () {
openImagePicker(schema.imageType || config.imageType, function (path) {
parent[key] = path;
input.value = path;
preview.src = resolveImageUrl(path);
preview.classList.toggle("d-none", !path);
});
});
group.appendChild(input);
group.appendChild(button);
col.appendChild(group);
const preview = document.createElement("img");
preview.className = "img-thumbnail uploaded-preview mt-2";
preview.style.maxHeight = "200px";
preview.src = resolveImageUrl(parent[key]);
preview.classList.toggle("d-none", !parent[key]);
col.appendChild(preview);
appendHelp(col, schema, parent[key], schema.imageHint);
container.appendChild(col);
return;
}
if (schema.type === "icon") {
renderIconField(schema, col, parent, key);
container.appendChild(col);
return;
}
if (schema.type === "select") {
const input = document.createElement("select");
input.className = "form-select";
const options = resolveOptions(schema, context.root);
options.forEach((option) => {
const optionEl = document.createElement("option");
if (typeof option === "string") {
optionEl.value = option;
optionEl.textContent = option;
} else {
optionEl.value = option.value;
optionEl.textContent = option.label;
}
input.appendChild(optionEl);
});
input.value = parent[key] || input.options[0]?.value || "";
parent[key] = input.value;
input.addEventListener("change", function () {
parent[key] = input.value;
renderAllSections();
});
col.appendChild(input);
appendHelp(col, schema, parent[key]);
container.appendChild(col);
return;
}
if (schema.type === "combobox") {
const input = document.createElement("input");
const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
input.className = "form-control";
input.type = "text";
input.value = parent[key] || "";
if (schema.placeholder) input.placeholder = schema.placeholder;
if (schema.maxLength) input.maxLength = schema.maxLength;
input.setAttribute("list", listId);
input.addEventListener("input", function () {
parent[key] = input.value;
updateCounter(counter, input.value.length, schema.maxLength);
});
const dataList = document.createElement("datalist");
dataList.id = listId;
resolveOptions(schema, context.root).forEach((option) => {
const item = document.createElement("option");
item.value = typeof option === "string" ? option : option.value;
item.label = typeof option === "string" ? option : option.label;
dataList.appendChild(item);
});
col.appendChild(input);
col.appendChild(dataList);
const counter = appendHelp(col, schema, parent[key]);
container.appendChild(col);
return;
}
const input = document.createElement("input");
input.className = "form-control";
input.type =
schema.type === "number" || schema.type === "color" ? schema.type : "text";
input.value = parent[key] || "";
if (schema.placeholder) input.placeholder = schema.placeholder;
if (schema.maxLength) input.maxLength = schema.maxLength;
if (typeof schema.min !== "undefined") input.min = String(schema.min);
if (typeof schema.max !== "undefined") input.max = String(schema.max);
if (schema.step) input.step = schema.step;
input.addEventListener("input", function () {
if (schema.type === "number") {
const normalizedValue = normalizeNumberInput(schema, input.value);
input.value = normalizedValue.displayValue;
parent[key] = normalizedValue.numericValue;
} else {
parent[key] = input.value;
}
updateCounter(counter, String(input.value || "").length, schema.maxLength);
});
input.addEventListener("change", function () {
if (schema.type !== "number") {
return;
}
const normalizedValue = normalizeNumberInput(schema, input.value);
input.value = normalizedValue.displayValue;
parent[key] = normalizedValue.numericValue;
});
col.appendChild(input);
const counter = appendHelp(col, schema, parent[key]);
container.appendChild(col);
}
function renderIconField(schema, col, parent, key) {
const selected = parent[key] || "";
const wrapper = document.createElement("div");
wrapper.className = "cms-icon-combobox";
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "cms-icon-dropdown-trigger";
const triggerValue = document.createElement("div");
triggerValue.className = "cms-icon-dropdown-value";
const preview = document.createElement("div");
preview.className = "cms-icon-preview";
triggerValue.appendChild(preview);
const triggerText = document.createElement("div");
triggerText.className = "cms-icon-dropdown-text";
triggerValue.appendChild(triggerText);
trigger.appendChild(triggerValue);
const caret = document.createElement("i");
caret.className = "fas fa-chevron-down cms-icon-dropdown-caret";
trigger.appendChild(caret);
wrapper.appendChild(trigger);
const panel = document.createElement("div");
panel.className = "cms-icon-dropdown-panel d-none";
const searchInput = document.createElement("input");
searchInput.type = "text";
searchInput.className = "form-control";
searchInput.placeholder = "Search icon name";
panel.appendChild(searchInput);
const optionsWrap = document.createElement("div");
optionsWrap.className = "cms-icon-options";
panel.appendChild(optionsWrap);
const empty = document.createElement("div");
empty.className = "cms-icon-option-empty d-none";
empty.textContent = "No matching icons";
panel.appendChild(empty);
wrapper.appendChild(panel);
const setOpen = function (isOpen) {
wrapper.classList.toggle("is-open", isOpen);
panel.classList.toggle("d-none", !isOpen);
if (isOpen) {
searchInput.focus();
searchInput.select();
}
};
const updateTrigger = function (value) {
preview.innerHTML = value
? `<i class="fa-solid ${escapeHtml(value)}"></i>`
: '<span class="text-muted small">--</span>';
triggerText.innerHTML = value
? `<strong>${escapeHtml(value)}</strong><span class="text-muted small">Selected icon</span>`
: '<strong>Select icon</strong><span class="text-muted small">No icon selected</span>';
};
const renderOptions = function (searchTerm = "") {
const normalized = String(searchTerm || "").trim().toLowerCase();
const filteredOptions = (schema.options || []).filter((option) =>
option.toLowerCase().includes(normalized),
);
optionsWrap.innerHTML = "";
empty.classList.toggle("d-none", filteredOptions.length > 0);
filteredOptions.forEach((option) => {
const button = document.createElement("button");
button.type = "button";
button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`;
button.innerHTML = `<span class="cms-icon-option-main"><i class="fa-solid ${escapeHtml(option)}"></i><span>${escapeHtml(option)}</span></span>${parent[key] === option ? '<i class="fas fa-check small"></i>' : ""}`;
button.addEventListener("click", function () {
parent[key] = option;
searchInput.value = option;
updateTrigger(option);
renderOptions(option);
setOpen(false);
});
optionsWrap.appendChild(button);
});
};
trigger.addEventListener("click", function () {
const nextOpen = panel.classList.contains("d-none");
setOpen(nextOpen);
if (nextOpen) {
renderOptions(searchInput.value || parent[key] || "");
}
});
searchInput.addEventListener("input", function () {
renderOptions(searchInput.value);
});
wrapper.addEventListener("focusout", function () {
window.setTimeout(function () {
if (!wrapper.contains(document.activeElement)) {
setOpen(false);
}
}, 0);
});
searchInput.value = selected;
updateTrigger(selected);
renderOptions(selected);
col.appendChild(wrapper);
appendHelp(col, schema, parent[key]);
}
function renderCheckbox(schema, container, parent, key) {
if (typeof parent[key] !== "boolean") {
parent[key] = Boolean(parent[key]);
}
const col = createCol(schema.colClass || "col-12");
const wrapper = document.createElement("div");
wrapper.className = "form-check mt-4";
const input = document.createElement("input");
input.type = "checkbox";
input.className = "form-check-input";
input.checked = parent[key];
input.addEventListener("change", function () {
parent[key] = input.checked;
});
const label = document.createElement("label");
label.className = "form-check-label fw-semibold";
label.textContent = schema.label || key;
wrapper.appendChild(input);
wrapper.appendChild(label);
col.appendChild(wrapper);
if (schema.helpText) {
const help = document.createElement("div");
help.className = "form-text";
help.textContent = schema.helpText;
col.appendChild(help);
}
container.appendChild(col);
}
function appendHelp(col, schema, value, extraHint) {
const wrapper = document.createElement("div");
wrapper.className = "field-meta-row";
const help = document.createElement("div");
help.className = "form-text";
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
wrapper.appendChild(help);
let counter = null;
if (schema.maxLength) {
counter = document.createElement("div");
counter.className = "field-char-count";
updateCounter(counter, String(value || "").length, schema.maxLength);
wrapper.appendChild(counter);
}
if (help.textContent || counter) {
col.appendChild(wrapper);
}
return counter;
}
function updateCounter(counter, currentLength, maxLength) {
if (!counter || !maxLength) return;
counter.textContent = `${currentLength}/${maxLength}`;
}
function normalizeNumberInput(schema, rawValue) {
if (rawValue === "" || rawValue === null || typeof rawValue === "undefined") {
const emptyFallback =
typeof schema.min !== "undefined" ? Number(schema.min) : 0;
return {
numericValue: emptyFallback,
displayValue: String(emptyFallback),
};
}
let numericValue = Number(rawValue);
if (!Number.isFinite(numericValue)) {
numericValue = typeof schema.min !== "undefined" ? Number(schema.min) : 0;
}
if (typeof schema.min !== "undefined" && numericValue < Number(schema.min)) {
numericValue = Number(schema.min);
}
if (typeof schema.max !== "undefined" && numericValue > Number(schema.max)) {
numericValue = Number(schema.max);
}
return {
numericValue,
displayValue: String(numericValue),
};
}
function openImagePicker(imageType, onSuccess) {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "image/*";
fileInput.style.display = "none";
document.body.appendChild(fileInput);
fileInput.addEventListener("change", async function () {
if (!fileInput.files || !fileInput.files[0]) {
fileInput.remove();
return;
}
try {
const formData = new FormData();
formData.append("image", fileInput.files[0]);
const response = await fetch(
`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`,
{
method: "POST",
body: formData,
},
);
const result = await response.json();
if (!result.success || !result.path) {
throw new Error(result.error || "Upload failed");
}
onSuccess(result.path);
showToast("Success", "Image uploaded successfully", "success");
} catch (error) {
showToast("Error", error.message || "Upload failed", "danger");
} finally {
fileInput.remove();
}
});
fileInput.click();
}
function showToast(title, message, type) {
const container =
document.querySelector(".toast-container") || createToastContainer();
const toast = document.createElement("div");
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
toast.setAttribute("role", "alert");
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(
title,
)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
container.appendChild(toast);
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
toast.addEventListener("hidden.bs.toast", function () {
toast.remove();
});
}
function createToastContainer() {
const container = document.createElement("div");
container.className = "toast-container position-fixed top-0 end-0 p-3";
document.body.appendChild(container);
return container;
}
function createDefaultValue(schema) {
if (!schema) return "";
if (schema.type === "primitive") return schema.fieldType === "number" ? 0 : "";
if (schema.type === "variant") {
return { [schema.discriminator]: schema.options[0].value };
}
if (schema.type === "object") {
const value = {};
(schema.fields || []).forEach((field) => {
if (field.type === "array") value[field.key] = [];
else if (field.type === "object") value[field.key] = createDefaultValue(field);
else if (field.type === "checkbox") value[field.key] = false;
else if (field.type === "number") value[field.key] = 0;
else value[field.key] = field.defaultValue || "";
});
return value;
}
return "";
}
function createCol(colClass) {
const div = document.createElement("div");
div.className = colClass;
return div;
}
function inferColClass(type) {
if (type === "textarea" || type === "image" || type === "icon") return "col-12";
if (type === "checkbox") return "col-12";
return "col-md-6";
}
function resolveImageUrl(path) {
if (!path) return "";
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith("/")) return `${backendUrl}${path}`;
return `${backendUrl}/${path}`;
}
function resolveOptions(schema, root) {
if (schema.optionsPath) {
const value = getValueByPath(root, schema.optionsPath);
return Array.isArray(value) ? value : [];
}
return schema.options || [];
}
function collectIcons(schema) {
if (!schema) return [];
if (schema.type === "icon") return schema.options || [];
if (schema.type === "object") return (schema.fields || []).flatMap(collectIcons);
if (schema.type === "array") return collectIcons(schema.itemSchema);
if (schema.type === "variant") {
return Object.values(schema.variants || {}).flatMap((variant) =>
collectIcons(variant.schema),
);
}
return [];
}
function ensureIconDatalist(options) {
const existing = document.getElementById("cms-icon-options");
if (existing) existing.remove();
const dataList = document.createElement("datalist");
dataList.id = "cms-icon-options";
options.forEach((option) => {
const item = document.createElement("option");
item.value = option;
dataList.appendChild(item);
});
document.body.appendChild(dataList);
}
function applyAutoSequenceToArray(schema, targetArray) {
if (!schema || !Array.isArray(targetArray) || schema.itemSchema.type !== "object") {
return;
}
(schema.itemSchema.fields || []).forEach((field) => {
if (field.type !== "hidden" || !field.autoSequence) {
return;
}
targetArray.forEach((item, index) => {
const value = String(index + 1);
const padLength = field.autoSequence.padLength || 0;
item[field.key] = padLength > 0 ? value.padStart(padLength, "0") : value;
});
});
}
function getArrayItemTitle(schema, item, index) {
const value =
item && schema.itemTitleKey && typeof item[schema.itemTitleKey] !== "undefined"
? item[schema.itemTitleKey]
: null;
return value || `${schema.itemLabel || "Item"} ${index + 1}`;
}
function getArrayItemSubtitle(schema, item) {
if (!item || !schema.itemSubtitleKey) return "";
return item[schema.itemSubtitleKey] || "";
}
function renderItemActions(actions, item) {
if (!Array.isArray(actions) || !actions.length || !item) {
return "";
}
return actions
.map((action) => {
const href = fillTemplate(action.hrefTemplate, item);
if (!href) return "";
return `<button type="button" class="${escapeHtml(
action.className || "btn btn-outline-primary btn-sm",
)}" data-item-href="${escapeHtml(href)}">${
action.icon ? `<i class="${escapeHtml(action.icon)} me-1"></i>` : ""
}${escapeHtml(action.label || "Open")}</button>`;
})
.join("");
}
function passesVisibility(condition, parent, context) {
if (!condition || !condition.path) return true;
const target =
condition.path === "$item"
? context.item
: getValueByPath(parent, condition.path) ??
getValueByPath(context.item, condition.path) ??
getValueByPath(context.root, condition.path);
if (Array.isArray(condition.equals)) {
return condition.equals.includes(target);
}
return target === condition.equals;
}
function appendPath(basePath, segment) {
return basePath ? `${basePath}.${segment}` : segment;
}
function getValueByPath(target, path) {
if (!target || !path) return undefined;
return String(path)
.split(".")
.reduce((current, segment) => {
if (current === null || typeof current === "undefined") return undefined;
return current[segment];
}, target);
}
function fillTemplate(template, item) {
if (!template) return "";
return template.replace(/\{([^}]+)\}/g, function (_, key) {
return item[key] || "";
});
}
function sanitizeId(value) {
return String(value || "")
.replace(/[^a-zA-Z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function isObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
})();
</script>