forked from UKSOURCE/cms.lams
Add functionality to manage tuition data within the admissions admin editor. This includes implementing state normalization for tuition series, label extraction, and the rendering logic for the tuition tab. - Add `renderTuitionSection` and `normalizeTuitionState` logic - Implement helper functions for tuition series and point normalization - Integrate tuition tab routing in the editor script
1944 lines
67 KiB
Plaintext
1944 lines
67 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)
|
|
: [],
|
|
);
|
|
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 === "tuition") {
|
|
renderTuitionSection(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 getNestedObjectListItemFieldConfig(tabKey, listKey, nestedListKey, fieldKey) {
|
|
const listField = getObjectFieldConfig(tabKey, listKey);
|
|
const itemFields = listField?.itemSchema?.fields || [];
|
|
const nestedListField = itemFields.find((field) => field.key === nestedListKey) || {};
|
|
const nestedItemFields = nestedListField?.itemSchema?.fields || [];
|
|
return nestedItemFields.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 normalizeTuitionState() {
|
|
if (!isObject(state.tuition)) {
|
|
state.tuition = {};
|
|
}
|
|
|
|
const tuition = state.tuition;
|
|
tuition.id = String(tuition.id || "");
|
|
tuition.title = String(tuition.title || "");
|
|
tuition.chartTitle = String(tuition.chartTitle || "");
|
|
tuition.chartDescription = String(tuition.chartDescription || "");
|
|
|
|
const rawSeries = Array.isArray(tuition.series) ? tuition.series : [];
|
|
const rawLabels = extractTuitionLabels(rawSeries);
|
|
|
|
tuition.series = rawSeries.length
|
|
? rawSeries.map((series, seriesIndex) => normalizeTuitionSeries(series, seriesIndex, rawLabels))
|
|
: [createDefaultTuitionSeries(0, rawLabels)];
|
|
|
|
const normalizedLabels = extractTuitionLabels(tuition.series);
|
|
tuition.series.forEach((series, seriesIndex) => {
|
|
series.points = normalizedLabels.map((label, pointIndex) => {
|
|
const existingPoint = Array.isArray(series.points) ? series.points[pointIndex] : null;
|
|
return {
|
|
time: label,
|
|
value: Number.isFinite(Number(existingPoint?.value))
|
|
? Number(existingPoint.value)
|
|
: 0,
|
|
};
|
|
});
|
|
|
|
if (!series.label) {
|
|
series.label = `Series ${seriesIndex + 1}`;
|
|
}
|
|
});
|
|
}
|
|
|
|
function normalizeTuitionSeries(series, seriesIndex, labels) {
|
|
const source = isObject(series) ? series : {};
|
|
const rawPoints = Array.isArray(source.points) ? source.points : [];
|
|
const effectiveLabels = labels.length ? labels : extractTuitionLabels([source]);
|
|
|
|
return {
|
|
label: String(source.label || `Series ${seriesIndex + 1}`),
|
|
color: String(source.color || defaultTuitionSeriesColor(seriesIndex)),
|
|
points: effectiveLabels.map((label, pointIndex) => {
|
|
const point = rawPoints[pointIndex];
|
|
return {
|
|
time: label,
|
|
value: Number.isFinite(Number(point?.value)) ? Number(point.value) : 0,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
function extractTuitionLabels(seriesList) {
|
|
const labels = [];
|
|
(Array.isArray(seriesList) ? seriesList : []).forEach((series) => {
|
|
(Array.isArray(series?.points) ? series.points : []).forEach((point, pointIndex) => {
|
|
const label = String(point?.time || point?.label || `Year ${pointIndex + 1}`).trim();
|
|
if (!labels.includes(label)) {
|
|
labels.push(label);
|
|
}
|
|
});
|
|
});
|
|
|
|
return labels.length ? labels : ["Year 1", "Year 2", "Year 3", "Year 4"];
|
|
}
|
|
|
|
function createDefaultTuitionSeries(seriesIndex, labels) {
|
|
const effectiveLabels = Array.isArray(labels) && labels.length
|
|
? labels
|
|
: ["Year 1", "Year 2", "Year 3", "Year 4"];
|
|
|
|
return {
|
|
label: `Series ${seriesIndex + 1}`,
|
|
color: defaultTuitionSeriesColor(seriesIndex),
|
|
points: effectiveLabels.map((label) => ({
|
|
time: label,
|
|
value: 0,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function defaultTuitionSeriesColor(seriesIndex) {
|
|
return ["#0F172A", "#c49b27", "#2563eb", "#16a34a"][seriesIndex] || "#0F172A";
|
|
}
|
|
|
|
function renderTuitionSection(container) {
|
|
normalizeTuitionState();
|
|
|
|
const tuition = state.tuition;
|
|
const titleField = getObjectFieldConfig("tuition", "title");
|
|
const chartTitleField = getObjectFieldConfig("tuition", "chartTitle");
|
|
const chartDescriptionField = getObjectFieldConfig("tuition", "chartDescription");
|
|
const seriesField = getObjectFieldConfig("tuition", "series");
|
|
const seriesLabelField = getObjectListItemFieldConfig("tuition", "series", "label");
|
|
const pointTimeField = getNestedObjectListItemFieldConfig("tuition", "series", "points", "time");
|
|
const pointValueField = getNestedObjectListItemFieldConfig("tuition", "series", "points", "value");
|
|
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 || 45,
|
|
helpText: titleField.helpText,
|
|
},
|
|
row,
|
|
tuition,
|
|
"title",
|
|
{ path: "tuition.title", root: state, item: tuition },
|
|
);
|
|
renderLeafField(
|
|
{
|
|
key: "chartTitle",
|
|
label: chartTitleField.label || "Chart title",
|
|
type: chartTitleField.type || "text",
|
|
maxLength: chartTitleField.maxLength || 40,
|
|
helpText: chartTitleField.helpText,
|
|
},
|
|
row,
|
|
tuition,
|
|
"chartTitle",
|
|
{ path: "tuition.chartTitle", root: state, item: tuition },
|
|
);
|
|
renderLeafField(
|
|
{
|
|
key: "chartDescription",
|
|
label: chartDescriptionField.label || "Chart description",
|
|
type: chartDescriptionField.type || "textarea",
|
|
maxLength: chartDescriptionField.maxLength || 140,
|
|
rows: chartDescriptionField.rows || 3,
|
|
helpText: chartDescriptionField.helpText,
|
|
},
|
|
row,
|
|
tuition,
|
|
"chartDescription",
|
|
{ path: "tuition.chartDescription", root: state, item: tuition },
|
|
);
|
|
|
|
const matrixCol = createCol("col-12");
|
|
const matrixCard = document.createElement("div");
|
|
matrixCard.className = "cms-editor-group";
|
|
|
|
const matrixHeader = document.createElement("div");
|
|
matrixHeader.className = "d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3";
|
|
matrixHeader.innerHTML = `
|
|
<div>
|
|
<label class="form-label fw-semibold mb-1">${escapeHtml(seriesField.label || "Chart series")}</label>
|
|
<div class="form-text mt-0">${escapeHtml(seriesField.helpText || "Edit chart data as a matrix: each row is an X label and each series is a column.")}</div>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-add-series="true">
|
|
<i class="fas fa-chart-line me-1"></i>Add Series
|
|
</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
|
|
</button>
|
|
</div>
|
|
`;
|
|
matrixCard.appendChild(matrixHeader);
|
|
|
|
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");
|
|
|
|
const xLabelHead = document.createElement("th");
|
|
xLabelHead.style.minWidth = "220px";
|
|
xLabelHead.innerHTML = `
|
|
<div class="fw-semibold">${escapeHtml(pointTimeField.label || "X label")}</div>
|
|
<div class="small text-muted">${escapeHtml(pointTimeField.placeholder || "Example: Year 1")}</div>
|
|
`;
|
|
headRow.appendChild(xLabelHead);
|
|
|
|
tuition.series.forEach((series, seriesIndex) => {
|
|
const th = document.createElement("th");
|
|
th.style.minWidth = "220px";
|
|
|
|
const seriesWrap = document.createElement("div");
|
|
seriesWrap.className = "d-flex flex-column gap-2";
|
|
|
|
const topRow = document.createElement("div");
|
|
topRow.className = "d-flex align-items-center gap-2";
|
|
|
|
const labelInput = document.createElement("input");
|
|
labelInput.type = "text";
|
|
labelInput.className = "form-control form-control-sm";
|
|
labelInput.maxLength = seriesLabelField.maxLength || 20;
|
|
labelInput.placeholder = seriesLabelField.label || `Series ${seriesIndex + 1}`;
|
|
labelInput.value = series.label || "";
|
|
labelInput.addEventListener("input", function () {
|
|
series.label = labelInput.value;
|
|
});
|
|
|
|
const colorInput = document.createElement("input");
|
|
colorInput.type = "color";
|
|
colorInput.className = "form-control form-control-color";
|
|
colorInput.value = series.color || defaultTuitionSeriesColor(seriesIndex);
|
|
colorInput.title = "Series color";
|
|
colorInput.addEventListener("input", function () {
|
|
series.color = colorInput.value;
|
|
});
|
|
|
|
const removeButton = document.createElement("button");
|
|
removeButton.type = "button";
|
|
removeButton.className = "cms-remove-button";
|
|
removeButton.title = "Remove series";
|
|
removeButton.innerHTML = '<i class="fas fa-trash-alt"></i>';
|
|
removeButton.disabled = tuition.series.length <= 1;
|
|
removeButton.addEventListener("click", function () {
|
|
if (tuition.series.length <= 1) {
|
|
return;
|
|
}
|
|
tuition.series.splice(seriesIndex, 1);
|
|
renderSection("tuition");
|
|
});
|
|
|
|
topRow.appendChild(labelInput);
|
|
topRow.appendChild(colorInput);
|
|
topRow.appendChild(removeButton);
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "small text-muted";
|
|
meta.textContent = `${pointValueField.label || "Value"} column`;
|
|
|
|
seriesWrap.appendChild(topRow);
|
|
seriesWrap.appendChild(meta);
|
|
th.appendChild(seriesWrap);
|
|
headRow.appendChild(th);
|
|
});
|
|
|
|
thead.appendChild(headRow);
|
|
table.appendChild(thead);
|
|
|
|
const tbody = document.createElement("tbody");
|
|
const labels = extractTuitionLabels(tuition.series);
|
|
|
|
if (!labels.length) {
|
|
const emptyRow = document.createElement("tr");
|
|
const emptyCell = document.createElement("td");
|
|
emptyCell.colSpan = tuition.series.length + 1;
|
|
emptyCell.className = "text-center text-muted py-4";
|
|
emptyCell.textContent = "No chart rows yet.";
|
|
emptyRow.appendChild(emptyCell);
|
|
tbody.appendChild(emptyRow);
|
|
} else {
|
|
labels.forEach((label, rowIndex) => {
|
|
const tr = document.createElement("tr");
|
|
|
|
const labelCell = document.createElement("td");
|
|
const labelGroup = document.createElement("div");
|
|
labelGroup.className = "d-flex align-items-start gap-2";
|
|
|
|
const labelInput = document.createElement("input");
|
|
labelInput.type = "text";
|
|
labelInput.className = "form-control";
|
|
labelInput.maxLength = pointTimeField.maxLength || 14;
|
|
labelInput.placeholder = pointTimeField.placeholder || `Year ${rowIndex + 1}`;
|
|
labelInput.value = label;
|
|
labelInput.addEventListener("input", function () {
|
|
const nextLabel = labelInput.value;
|
|
tuition.series.forEach((series) => {
|
|
if (!Array.isArray(series.points)) {
|
|
series.points = [];
|
|
}
|
|
if (!series.points[rowIndex]) {
|
|
series.points[rowIndex] = { time: nextLabel, value: 0 };
|
|
}
|
|
series.points[rowIndex].time = nextLabel;
|
|
});
|
|
});
|
|
|
|
const removeRowButton = document.createElement("button");
|
|
removeRowButton.type = "button";
|
|
removeRowButton.className = "cms-remove-button mt-1";
|
|
removeRowButton.title = "Remove row";
|
|
removeRowButton.innerHTML = '<i class="fas fa-trash-alt"></i>';
|
|
removeRowButton.disabled = labels.length <= 1;
|
|
removeRowButton.addEventListener("click", function () {
|
|
if (labels.length <= 1) {
|
|
return;
|
|
}
|
|
tuition.series.forEach((series) => {
|
|
if (Array.isArray(series.points)) {
|
|
series.points.splice(rowIndex, 1);
|
|
}
|
|
});
|
|
renderSection("tuition");
|
|
});
|
|
|
|
labelGroup.appendChild(labelInput);
|
|
labelGroup.appendChild(removeRowButton);
|
|
labelCell.appendChild(labelGroup);
|
|
tr.appendChild(labelCell);
|
|
|
|
tuition.series.forEach((series) => {
|
|
const td = document.createElement("td");
|
|
const input = document.createElement("input");
|
|
input.type = "number";
|
|
input.className = "form-control";
|
|
input.min = String(pointValueField.min || 0);
|
|
input.step = pointValueField.step || "1";
|
|
input.value = Number.isFinite(Number(series.points?.[rowIndex]?.value))
|
|
? String(series.points[rowIndex].value)
|
|
: "0";
|
|
input.addEventListener("input", function () {
|
|
if (!Array.isArray(series.points)) {
|
|
series.points = [];
|
|
}
|
|
if (!series.points[rowIndex]) {
|
|
series.points[rowIndex] = { time: labels[rowIndex], value: 0 };
|
|
}
|
|
series.points[rowIndex].value = Number(input.value || 0);
|
|
});
|
|
td.appendChild(input);
|
|
tr.appendChild(td);
|
|
});
|
|
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
table.appendChild(tbody);
|
|
tableWrap.appendChild(table);
|
|
matrixCard.appendChild(tableWrap);
|
|
|
|
matrixHeader.querySelector('[data-add-series="true"]').addEventListener("click", function () {
|
|
const labels = extractTuitionLabels(tuition.series);
|
|
tuition.series.push(createDefaultTuitionSeries(tuition.series.length, labels));
|
|
renderSection("tuition");
|
|
});
|
|
|
|
matrixHeader.querySelector('[data-add-row="true"]').addEventListener("click", function () {
|
|
const nextLabel = `Year ${extractTuitionLabels(tuition.series).length + 1}`;
|
|
tuition.series.forEach((series) => {
|
|
if (!Array.isArray(series.points)) {
|
|
series.points = [];
|
|
}
|
|
series.points.push({
|
|
time: nextLabel,
|
|
value: 0,
|
|
});
|
|
});
|
|
renderSection("tuition");
|
|
});
|
|
|
|
const note = document.createElement("div");
|
|
note.className = "form-text mt-3";
|
|
note.textContent = "Each row is one X-axis label. Each series column stores the numeric value for that label.";
|
|
matrixCard.appendChild(note);
|
|
|
|
matrixCol.appendChild(matrixCard);
|
|
container.appendChild(matrixCol);
|
|
}
|
|
|
|
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 group = document.createElement("div");
|
|
group.className = "input-group";
|
|
|
|
const preview = document.createElement("span");
|
|
preview.className = "input-group-text icon-preview-cell";
|
|
preview.style.minWidth = "38px";
|
|
group.appendChild(preview);
|
|
|
|
const input = document.createElement("input");
|
|
input.type = "text";
|
|
input.className = "form-control";
|
|
input.value = parent[key] || "";
|
|
input.readOnly = true;
|
|
input.placeholder = schema.placeholder || "Click to pick...";
|
|
input.style.cursor = "pointer";
|
|
input.style.backgroundColor = "#fff";
|
|
input.dataset.iconPickerValueMode = "icon-name";
|
|
input.dataset.iconPickerPreviewPrefix = "fa-solid";
|
|
input.addEventListener("click", function () {
|
|
openIconPickerForInput(input);
|
|
});
|
|
input.addEventListener("input", function () {
|
|
parent[key] = input.value;
|
|
});
|
|
group.appendChild(input);
|
|
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "btn btn-outline-secondary";
|
|
button.innerHTML = '<i class="fas fa-icons me-1"></i>Pick Icon';
|
|
button.addEventListener("click", function () {
|
|
openIconPickerForInput(input);
|
|
});
|
|
group.appendChild(button);
|
|
|
|
col.appendChild(group);
|
|
syncIconPickerPreview(input);
|
|
appendHelp(col, schema, parent[key]);
|
|
}
|
|
|
|
function openIconPickerForInput(input) {
|
|
window.__cmsIconPickerActiveInput = input;
|
|
patchIconPickerForIconNames();
|
|
window.IconPicker?.open(input);
|
|
}
|
|
|
|
function patchIconPickerForIconNames() {
|
|
if (!window.IconPicker || window.IconPicker.__cmsIconNamePatched) {
|
|
return;
|
|
}
|
|
|
|
const originalPick = typeof window.IconPicker.pick === "function"
|
|
? window.IconPicker.pick.bind(window.IconPicker)
|
|
: null;
|
|
|
|
if (!originalPick) {
|
|
return;
|
|
}
|
|
|
|
const patchedPick = function (value) {
|
|
originalPick(value);
|
|
|
|
const activeInput = window.__cmsIconPickerActiveInput;
|
|
if (!activeInput) {
|
|
return;
|
|
}
|
|
|
|
if (activeInput.dataset.iconPickerValueMode === "icon-name") {
|
|
activeInput.dataset.iconPickerPreviewPrefix = extractIconStyle(
|
|
activeInput.value || value,
|
|
) || determineIconPreviewPrefix(activeInput);
|
|
activeInput.value = extractIconName(activeInput.value || value);
|
|
syncIconPickerPreview(activeInput);
|
|
activeInput.dispatchEvent(new Event("input", { bubbles: true }));
|
|
activeInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
|
|
window.__cmsIconPickerActiveInput = null;
|
|
};
|
|
|
|
window.IconPicker.pick = patchedPick;
|
|
window.IconPickerPick = patchedPick;
|
|
window.IconPicker.__cmsIconNamePatched = true;
|
|
}
|
|
|
|
function syncIconPickerPreview(input) {
|
|
if (!input) return;
|
|
|
|
const previewCell = input.closest(".input-group")?.querySelector(".icon-preview-cell");
|
|
if (!previewCell) return;
|
|
|
|
const previewClass = resolveIconPreviewClass(input);
|
|
previewCell.innerHTML = previewClass ? `<i class="${escapeHtml(previewClass)}"></i>` : "";
|
|
|
|
if (input.dataset.iconPickerValueMode === "icon-name") {
|
|
loadIconStyleLookup().then(function () {
|
|
const nextPrefix = determineIconPreviewPrefix(input);
|
|
if (nextPrefix !== input.dataset.iconPickerPreviewPrefix) {
|
|
input.dataset.iconPickerPreviewPrefix = nextPrefix;
|
|
const nextPreviewClass = resolveIconPreviewClass(input);
|
|
previewCell.innerHTML = nextPreviewClass
|
|
? `<i class="${escapeHtml(nextPreviewClass)}"></i>`
|
|
: "";
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function resolveIconPreviewClass(input) {
|
|
const normalizedValue = String(input?.value || "").trim();
|
|
if (!normalizedValue) {
|
|
return "";
|
|
}
|
|
|
|
if (input?.dataset.iconPickerValueMode === "icon-name") {
|
|
const prefix = determineIconPreviewPrefix(input);
|
|
return `${prefix} ${normalizedValue}`.trim();
|
|
}
|
|
|
|
return normalizedValue;
|
|
}
|
|
|
|
function extractIconName(value) {
|
|
return String(value || "")
|
|
.trim()
|
|
.split(/\s+/)
|
|
.find(
|
|
(token) =>
|
|
/^fa-[a-z0-9-]+$/i.test(token) && !/^fa-(solid|regular|brands)$/i.test(token),
|
|
) || "";
|
|
}
|
|
|
|
function extractIconStyle(value) {
|
|
return String(value || "")
|
|
.trim()
|
|
.split(/\s+/)
|
|
.find((token) => /^fa-(solid|regular|brands)$/i.test(token)) || "";
|
|
}
|
|
|
|
function determineIconPreviewPrefix(input) {
|
|
const explicitPrefix = String(input?.dataset.iconPickerPreviewPrefix || "").trim();
|
|
if (explicitPrefix && explicitPrefix !== "fa-solid") {
|
|
return explicitPrefix;
|
|
}
|
|
|
|
const iconName = extractIconName(input?.value || "");
|
|
const knownStyles = window.__cmsIconStyleLookup?.[iconName] || [];
|
|
|
|
if (knownStyles.includes("fa-brands")) return "fa-brands";
|
|
if (knownStyles.includes("fa-regular")) return "fa-regular";
|
|
if (knownStyles.includes("fa-solid")) return "fa-solid";
|
|
|
|
return explicitPrefix || "fa-solid";
|
|
}
|
|
|
|
function loadIconStyleLookup() {
|
|
if (window.__cmsIconStyleLookupPromise) {
|
|
return window.__cmsIconStyleLookupPromise;
|
|
}
|
|
|
|
window.__cmsIconStyleLookupPromise = fetch("/js/fa-icons.json")
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then((json) => {
|
|
const lookup = {};
|
|
Object.entries(json || {}).forEach(([name, meta]) => {
|
|
lookup[`fa-${name}`] = (meta.styles || [])
|
|
.filter((style) => ["solid", "regular", "brands"].includes(style))
|
|
.map((style) => `fa-${style}`);
|
|
});
|
|
window.__cmsIconStyleLookup = lookup;
|
|
return lookup;
|
|
})
|
|
.catch(() => {
|
|
window.__cmsIconStyleLookup = window.__cmsIconStyleLookup || {};
|
|
return window.__cmsIconStyleLookup;
|
|
});
|
|
|
|
return window.__cmsIconStyleLookupPromise;
|
|
}
|
|
|
|
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 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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
})();
|
|
|
|
</script>
|
|
|