forked from UKSOURCE/cms.lams
feat(cms): enhance content editors and implement automatic ID generation
Improve the CMS administration interface across multiple pages (Accreditation, Admissions, History, Partnerships, and Policies) with a focus on usability and data integrity. Key changes include: - Implement `ensureUniqueIds` utility to automatically generate and maintain unique slugs for content items, removing the need for manual ID entry in the UI. - Refactor the Admissions calculator to support detailed per-option editing via a new dedicated view and routes. - Replace basic datalists with a custom, searchable icon combobox component for better visual selection. - Update `_renderSingletonPageView` to handle active tab persistence via query parameters. - Streamline editor configurations by removing redundant fields and improving help text. - Enhance the Admissions "Key Dates" editor with a dynamic table interface for managing columns and rows. - Normalize data payloads in controllers to ensure consistent API responses and internal linking.
This commit is contained in:
@@ -12,6 +12,11 @@
|
||||
}
|
||||
|
||||
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 || [])
|
||||
@@ -71,6 +76,17 @@
|
||||
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,
|
||||
@@ -78,6 +94,464 @@
|
||||
});
|
||||
}
|
||||
|
||||
function renderKeyDatesSection(container) {
|
||||
normalizeKeyDatesState();
|
||||
|
||||
const keyDates = state.keyDates;
|
||||
const row = document.createElement("div");
|
||||
row.className = "row g-3";
|
||||
container.appendChild(row);
|
||||
|
||||
renderLeafField(
|
||||
{ key: "title", label: "Section title", type: "text", maxLength: 60 },
|
||||
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">Key dates table</label>
|
||||
<div class="form-text mt-0">Manage the table directly by adding or removing columns and rows.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-add-column="true">
|
||||
<i class="fas fa-table-columns me-1"></i>Add Column
|
||||
</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>
|
||||
`;
|
||||
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 = 40;
|
||||
input.placeholder = "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 = "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 = "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 = 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 }))
|
||||
: [];
|
||||
|
||||
calculator.options = rawOptions.slice(0, 3).map((option, index) => ({
|
||||
id: String(option?.id || sanitizeId(option?.label || `option-${index + 1}`) || `option-${index + 1}`),
|
||||
label: String(option?.label || `Option ${index + 1}`).slice(0, 12),
|
||||
paceLabel: String(option?.paceLabel || "Target Pace"),
|
||||
minPaceLabel: String(option?.minPaceLabel || "Relaxed"),
|
||||
maxPaceLabel: String(option?.maxPaceLabel || "Accelerated"),
|
||||
resultLabel: String(option?.resultLabel || "Estimated Monthly Payment"),
|
||||
monthlyAmount: (((String(option?.monthlyAmount || "299").match(/\d[\d,]*/) || [])[0] || "299").replace(/,/g, "")),
|
||||
monthlySuffix: String(option?.monthlySuffix || "/mo"),
|
||||
noteIcon: String(option?.noteIcon || "fa-bolt"),
|
||||
note: String(option?.note || ""),
|
||||
}));
|
||||
|
||||
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 row = document.createElement("div");
|
||||
row.className = "row g-3";
|
||||
container.appendChild(row);
|
||||
|
||||
renderLeafField(
|
||||
{ key: "title", label: "Card title", type: "text", maxLength: 60 },
|
||||
row,
|
||||
calculator,
|
||||
"title",
|
||||
{ path: "calculator.title", root: state, item: calculator },
|
||||
);
|
||||
renderLeafField(
|
||||
{ key: "description", label: "Card description", type: "textarea", maxLength: 120, rows: 3 },
|
||||
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">Primary button</label>
|
||||
<div class="form-text mt-0">This button appears at the bottom of the calculator card.</div>
|
||||
`;
|
||||
ctaCard.appendChild(ctaHeader);
|
||||
const ctaRow = document.createElement("div");
|
||||
ctaRow.className = "row g-3";
|
||||
ctaCard.appendChild(ctaRow);
|
||||
renderLeafField(
|
||||
{ key: "label", label: "Button label", type: "text", maxLength: 15 },
|
||||
ctaRow,
|
||||
calculator.cta,
|
||||
"label",
|
||||
{ path: "calculator.cta.label", root: state, item: calculator.cta },
|
||||
);
|
||||
renderLeafField(
|
||||
{ key: "href", label: "Button URL", type: "text", maxLength: 255 },
|
||||
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">Calculator options</label>
|
||||
<div class="form-text mt-0">Each option has its own pricing labels, amount, note, and icon. Open the edit page to update the option details.</div>
|
||||
`;
|
||||
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 = "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 < 3) {
|
||||
const addButton = document.createElement("button");
|
||||
addButton.type = "button";
|
||||
addButton.className = "cms-add-button mt-3";
|
||||
addButton.innerHTML = '<i class="fas fa-plus me-2"></i>Add calculator option';
|
||||
addButton.addEventListener("click", function () {
|
||||
const nextIndex = calculator.options.length + 1;
|
||||
calculator.options.push({
|
||||
id: `option-${Date.now()}`,
|
||||
label: `Option ${nextIndex}`.slice(0, 12),
|
||||
paceLabel: "Target Pace",
|
||||
minPaceLabel: "Relaxed",
|
||||
maxPaceLabel: "Accelerated",
|
||||
resultLabel: "Estimated Monthly Payment",
|
||||
monthlyAmount: "299",
|
||||
monthlySuffix: "/mo",
|
||||
noteIcon: "fa-bolt",
|
||||
note: "",
|
||||
});
|
||||
renderSection("calculator");
|
||||
});
|
||||
optionsCard.appendChild(addButton);
|
||||
} else {
|
||||
const limitNote = document.createElement("div");
|
||||
limitNote.className = "form-text mt-3";
|
||||
limitNote.textContent = "You can add up to 3 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;
|
||||
@@ -283,6 +757,9 @@
|
||||
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,
|
||||
@@ -510,11 +987,28 @@
|
||||
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 () {
|
||||
parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value;
|
||||
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]);
|
||||
@@ -524,52 +1018,113 @@
|
||||
function renderIconField(schema, col, parent, key) {
|
||||
const selected = parent[key] || "";
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "border rounded-3 bg-white p-3";
|
||||
wrapper.className = "cms-icon-combobox";
|
||||
const trigger = document.createElement("button");
|
||||
trigger.type = "button";
|
||||
trigger.className = "cms-icon-dropdown-trigger";
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = selected;
|
||||
input.placeholder = "fa-shield-check";
|
||||
input.setAttribute("list", "cms-icon-options");
|
||||
top.appendChild(input);
|
||||
const triggerValue = document.createElement("div");
|
||||
triggerValue.className = "cms-icon-dropdown-value";
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
||||
preview.style.width = "52px";
|
||||
preview.innerHTML = selected
|
||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
top.appendChild(preview);
|
||||
wrapper.appendChild(top);
|
||||
preview.className = "cms-icon-preview";
|
||||
triggerValue.appendChild(preview);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "d-flex flex-wrap gap-2";
|
||||
(schema.options || []).forEach((option) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
||||
button.addEventListener("click", function () {
|
||||
parent[key] = option;
|
||||
input.value = option;
|
||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
||||
renderAllSections();
|
||||
});
|
||||
grid.appendChild(button);
|
||||
});
|
||||
wrapper.appendChild(grid);
|
||||
const triggerText = document.createElement("div");
|
||||
triggerText.className = "cms-icon-dropdown-text";
|
||||
triggerValue.appendChild(triggerText);
|
||||
trigger.appendChild(triggerValue);
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.innerHTML = input.value
|
||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
||||
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]);
|
||||
}
|
||||
@@ -636,6 +1191,35 @@
|
||||
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";
|
||||
@@ -883,3 +1467,4 @@
|
||||
})();
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user