forked from UKSOURCE/cms.lams
Replace the legacy `cms-icon-combobox` implementation with a new, standardized `icon-picker` across multiple admin modules. This change simplifies the UI by using a Bootstrap input group with a dedicated "Pick Icon" button and a preview cell. Affected areas: - Policies block editor - Accreditation, Admissions, History, and Policies admin editor scripts - Admissions calculator options view This removes redundant icon list generation and manual dropdown management in favor of a centralized icon picker utility.
393 lines
16 KiB
Plaintext
393 lines
16 KiB
Plaintext
<div class="container">
|
|
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
|
<div>
|
|
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
|
<p class="text-muted mb-0"><%= subtitle %></p>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<a href="/admin/admissions?tab=calculator" class="btn btn-outline-secondary">
|
|
<i class="fas fa-arrow-left me-2"></i>Back to Calculator
|
|
</a>
|
|
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
|
|
<i class="fas fa-external-link-alt me-2"></i>View Page
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<form action="/admin/admissions/calculator/<%= option.id %>/update" method="POST" class="content-with-fixed-buttons" id="calculatorOptionForm" novalidate>
|
|
<div class="card shadow-sm border-0 mb-4">
|
|
<div class="card-header bg-white">
|
|
<h6 class="mb-0"><i class="fas fa-calculator me-2"></i>Option Details</h6>
|
|
</div>
|
|
<div class="card-body p-4">
|
|
<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<label for="label" class="form-label fw-semibold"><%= fieldConfig.label?.label || "Option label" %></label>
|
|
<input id="label" name="label" type="text" class="form-control" maxlength="<%= fieldLimits.label %>" value="<%= option.label %>" required />
|
|
<div class="field-meta-row">
|
|
<div class="form-text"><%= fieldConfig.label?.helpText || "" %></div>
|
|
<div class="field-char-count" data-counter-for="label">0/<%= fieldLimits.label %></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<label for="paceLabel" class="form-label fw-semibold"><%= fieldConfig.paceLabel?.label || "Pace label" %></label>
|
|
<input id="paceLabel" name="paceLabel" type="text" class="form-control" maxlength="<%= fieldLimits.paceLabel %>" value="<%= option.paceLabel %>" required />
|
|
<div class="field-meta-row">
|
|
<div class="form-text"><%= fieldConfig.paceLabel?.helpText || "" %></div>
|
|
<div class="field-char-count" data-counter-for="paceLabel">0/<%= fieldLimits.paceLabel %></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<label for="minPaceLabel" class="form-label fw-semibold"><%= fieldConfig.minPaceLabel?.label || "Minimum pace label" %></label>
|
|
<input id="minPaceLabel" name="minPaceLabel" type="text" class="form-control" maxlength="<%= fieldLimits.minPaceLabel %>" value="<%= option.minPaceLabel %>" required />
|
|
<div class="field-meta-row">
|
|
<div class="form-text"><%= fieldConfig.minPaceLabel?.helpText || "" %></div>
|
|
<div class="field-char-count" data-counter-for="minPaceLabel">0/<%= fieldLimits.minPaceLabel %></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<label for="maxPaceLabel" class="form-label fw-semibold"><%= fieldConfig.maxPaceLabel?.label || "Maximum pace label" %></label>
|
|
<input id="maxPaceLabel" name="maxPaceLabel" type="text" class="form-control" maxlength="<%= fieldLimits.maxPaceLabel %>" value="<%= option.maxPaceLabel %>" required />
|
|
<div class="field-meta-row">
|
|
<div class="form-text"><%= fieldConfig.maxPaceLabel?.helpText || "" %></div>
|
|
<div class="field-char-count" data-counter-for="maxPaceLabel">0/<%= fieldLimits.maxPaceLabel %></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<label for="resultLabel" class="form-label fw-semibold"><%= fieldConfig.resultLabel?.label || "Result label" %></label>
|
|
<input id="resultLabel" name="resultLabel" type="text" class="form-control" maxlength="<%= fieldLimits.resultLabel %>" value="<%= option.resultLabel %>" required />
|
|
<div class="field-meta-row">
|
|
<div class="form-text"><%= fieldConfig.resultLabel?.helpText || "" %></div>
|
|
<div class="field-char-count" data-counter-for="resultLabel">0/<%= fieldLimits.resultLabel %></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<label for="monthlyAmount" class="form-label fw-semibold"><%= fieldConfig.monthlyAmount?.label || "Monthly amount" %></label>
|
|
<input id="monthlyAmount" name="monthlyAmount" type="number" class="form-control" min="1" step="1" inputmode="numeric" value="<%= option.monthlyAmount %>" required />
|
|
<div class="field-meta-row">
|
|
<div class="form-text"><%= fieldConfig.monthlyAmount?.helpText || "" %></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<label for="monthlySuffix" class="form-label fw-semibold"><%= fieldConfig.monthlySuffix?.label || "Monthly suffix" %></label>
|
|
<input id="monthlySuffix" name="monthlySuffix" type="text" class="form-control" maxlength="<%= fieldLimits.monthlySuffix %>" value="<%= option.monthlySuffix %>" required />
|
|
<div class="field-meta-row">
|
|
<div class="form-text"><%= fieldConfig.monthlySuffix?.helpText || "" %></div>
|
|
<div class="field-char-count" data-counter-for="monthlySuffix">0/<%= fieldLimits.monthlySuffix %></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-12">
|
|
<label class="form-label fw-semibold"><%= fieldConfig.noteIcon?.label || "Note icon" %></label>
|
|
<div class="input-group">
|
|
<span class="input-group-text icon-preview-cell" style="min-width:38px"></span>
|
|
<input
|
|
type="text"
|
|
name="noteIcon"
|
|
id="noteIcon"
|
|
class="form-control"
|
|
value="<%= option.noteIcon %>"
|
|
placeholder="Click to pick..."
|
|
readonly
|
|
style="cursor:pointer;background:#fff"
|
|
data-icon-picker-value-mode="icon-name"
|
|
data-icon-picker-preview-prefix="fa-solid"
|
|
/>
|
|
<button type="button" class="btn btn-outline-secondary" id="noteIconButton">
|
|
<i class="fas fa-icons me-1"></i>Pick Icon
|
|
</button>
|
|
</div>
|
|
<div class="field-meta-row">
|
|
<div class="form-text"><%= fieldConfig.noteIcon?.helpText || "" %></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-12">
|
|
<label for="note" class="form-label fw-semibold"><%= fieldConfig.note?.label || "Note text" %></label>
|
|
<input id="note" name="note" type="text" class="form-control" maxlength="<%= fieldLimits.note %>" value="<%= option.note %>" required />
|
|
<div class="field-meta-row">
|
|
<div class="form-text"><%= fieldConfig.note?.helpText || "" %></div>
|
|
<div class="field-char-count" data-counter-for="note">0/<%= fieldLimits.note %></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fixed-bottom-buttons">
|
|
<a href="/admin/admissions?tab=calculator" class="btn btn-secondary">
|
|
<i class="fas fa-times"></i>
|
|
<span>Cancel</span>
|
|
</a>
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="fas fa-save"></i>
|
|
<span>Save Changes</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
const existingOptionLabels = <%- JSON.stringify(existingOptionLabels || []) %>;
|
|
const form = document.getElementById("calculatorOptionForm");
|
|
const labelInput = document.getElementById("label");
|
|
const noteIconInput = document.getElementById("noteIcon");
|
|
const noteIconButton = document.getElementById("noteIconButton");
|
|
|
|
form.querySelectorAll("[maxlength]").forEach((input) => {
|
|
const counter = form.querySelector(`[data-counter-for="${input.id}"]`);
|
|
const updateCounter = () => {
|
|
if (counter) {
|
|
counter.textContent = `${input.value.length}/${input.maxLength}`;
|
|
}
|
|
};
|
|
input.addEventListener("input", updateCounter);
|
|
updateCounter();
|
|
});
|
|
|
|
function normalizeLabel(value) {
|
|
return String(value || "").trim().toLowerCase();
|
|
}
|
|
|
|
function clearLabelValidation() {
|
|
labelInput.classList.remove("is-invalid");
|
|
}
|
|
|
|
function validateUniqueLabel() {
|
|
const normalizedValue = normalizeLabel(labelInput.value);
|
|
const isDuplicate = normalizedValue
|
|
? existingOptionLabels.some((label) => normalizeLabel(label) === normalizedValue)
|
|
: false;
|
|
|
|
labelInput.classList.toggle("is-invalid", isDuplicate);
|
|
return !isDuplicate;
|
|
}
|
|
|
|
labelInput.addEventListener("input", function () {
|
|
clearLabelValidation();
|
|
validateUniqueLabel();
|
|
});
|
|
|
|
form.addEventListener("submit", function (event) {
|
|
if (validateUniqueLabel()) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
showToast(
|
|
"Duplicate option label",
|
|
`Option label "${labelInput.value.trim()}" already exists. Please use a unique label before saving.`,
|
|
"danger",
|
|
);
|
|
labelInput.focus();
|
|
});
|
|
|
|
const monthlyAmountInput = document.getElementById("monthlyAmount");
|
|
monthlyAmountInput.addEventListener("input", function () {
|
|
this.value = this.value.replace(/[^\d]/g, "");
|
|
});
|
|
monthlyAmountInput.addEventListener("change", function () {
|
|
const numericValue = Number(this.value);
|
|
this.value = numericValue > 0 ? String(Math.floor(numericValue)) : "1";
|
|
});
|
|
|
|
noteIconInput?.addEventListener("click", function () {
|
|
openIconPickerForInput(noteIconInput);
|
|
});
|
|
|
|
noteIconButton?.addEventListener("click", function () {
|
|
openIconPickerForInput(noteIconInput);
|
|
});
|
|
|
|
syncIconPickerPreview(noteIconInput);
|
|
|
|
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 escapeHtml(value) {
|
|
return String(value || "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
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;
|
|
}
|
|
})();
|
|
</script>
|