forked from UKSOURCE/cms.lams
Implement a more flexible, configuration-driven approach for CMS editors across accreditation, admissions, and partnerships modules. - Move UI labels, help texts, and field limits from hardcoded views to `editorUi` configurations in config files. - Add server-side and client-side validation to prevent duplicate category tabs in accreditation and partnerships editors. - Refactor partnership and admission views to dynamically render tabs and fields based on the provided configuration. - Update field length constraints and default values across multiple content editors to better align with frontend requirements. - Improve the admissions calculator editor with dynamic field configurations and default value fallbacks.
621 lines
20 KiB
Plaintext
621 lines
20 KiB
Plaintext
<script>
|
|
(function () {
|
|
const initialData = window.partnershipsPageData;
|
|
const backendUrl = (window.partnershipsBackendUrl || "").replace(/\/$/, "");
|
|
const editorConfig = window.partnershipsEditorConfig || {};
|
|
const editorUi = window.partnershipsEditorUi || {};
|
|
const form = document.getElementById("cmsEditorForm");
|
|
const pageJsonInput = document.getElementById("pageJson");
|
|
const activeTabInput = document.getElementById("activeTabInput");
|
|
const directoryTabsList = document.getElementById("directoryTabsList");
|
|
const partnersList = document.getElementById("partnersList");
|
|
const inquiryFieldsList = document.getElementById("inquiryFieldsList");
|
|
|
|
if (!initialData || !form || !pageJsonInput || !activeTabInput) {
|
|
return;
|
|
}
|
|
|
|
const state = JSON.parse(JSON.stringify(initialData));
|
|
const directoryUi = editorUi.directory || {};
|
|
const inquiryUi = editorUi.inquiryForm || {};
|
|
const partnerFields = directoryUi.partnerFields || {};
|
|
const inquiryFieldFields = inquiryUi.fieldFields || {};
|
|
const slugifyValue = (value, fallback) =>
|
|
String(value || "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "") || fallback;
|
|
const templates = {
|
|
tab: document.getElementById("directoryTabTemplate"),
|
|
partner: document.getElementById("partnerTemplate"),
|
|
inquiryField: document.getElementById("inquiryFieldTemplate"),
|
|
inquiryOption: document.getElementById("inquiryOptionTemplate"),
|
|
};
|
|
|
|
ensurePartnershipIds();
|
|
bindStaticEvents();
|
|
renderAll();
|
|
initStaticCounters();
|
|
|
|
function getTabConfig(tabKey) {
|
|
return (editorConfig.tabs || []).find((tab) => tab.key === tabKey) || {};
|
|
}
|
|
|
|
function getObjectFieldConfig(tabKey, fieldKey) {
|
|
const fields = getTabConfig(tabKey)?.schema?.fields || [];
|
|
return fields.find((field) => field.key === fieldKey) || {};
|
|
}
|
|
|
|
function getDefaultPartner() {
|
|
return {
|
|
name: "",
|
|
category: "",
|
|
summary: "",
|
|
logo: "",
|
|
logoAlt: "",
|
|
about: "",
|
|
collabType: "",
|
|
benefits: "",
|
|
};
|
|
}
|
|
|
|
function getDefaultInquiryField() {
|
|
const typeOptions = inquiryFieldFields.type?.options || [];
|
|
const widthOptions = inquiryFieldFields.width?.options || [];
|
|
|
|
return {
|
|
label: "",
|
|
placeholder: "",
|
|
type: typeOptions[0]?.value || "text",
|
|
width: widthOptions[0]?.value || "full",
|
|
required: true,
|
|
options: [],
|
|
};
|
|
}
|
|
|
|
function bindStaticEvents() {
|
|
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
|
tabTrigger.addEventListener("shown.bs.tab", function () {
|
|
const tabKey = this.dataset.tabKey;
|
|
if (!tabKey) return;
|
|
activeTabInput.value = tabKey;
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set("tab", tabKey);
|
|
window.history.replaceState({}, "", `${url.pathname}?${url.searchParams.toString()}`);
|
|
});
|
|
});
|
|
|
|
document.getElementById("addDirectoryTabBtn")?.addEventListener("click", function () {
|
|
state.directory.tabs.push("");
|
|
renderDirectoryTabs();
|
|
});
|
|
|
|
document.getElementById("addPartnerBtn")?.addEventListener("click", function () {
|
|
state.directory.partners.push(getDefaultPartner());
|
|
ensurePartnershipIds();
|
|
renderPartners();
|
|
});
|
|
|
|
document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () {
|
|
state.inquiryForm.fields.push(getDefaultInquiryField());
|
|
ensurePartnershipIds();
|
|
renderInquiryFields();
|
|
});
|
|
|
|
document.getElementById("resetPartnershipsForm")?.addEventListener("click", function () {
|
|
window.location.reload();
|
|
});
|
|
|
|
form.addEventListener("submit", function (event) {
|
|
syncStaticFields();
|
|
const duplicateTabs = getDuplicateTabs(state?.directory?.tabs);
|
|
|
|
clearDirectoryTabsValidation();
|
|
|
|
if (duplicateTabs.length > 0) {
|
|
const tabsLabel = directoryUi.tabs?.label || "Category tab";
|
|
event.preventDefault();
|
|
highlightDuplicateDirectoryTabs(duplicateTabs);
|
|
showToast(
|
|
`Duplicate ${tabsLabel.toLowerCase()}`,
|
|
`${tabsLabel} "${duplicateTabs[0]}" already exists. Please use unique tab names before saving.`,
|
|
"danger",
|
|
);
|
|
return;
|
|
}
|
|
|
|
ensurePartnershipIds();
|
|
pageJsonInput.value = JSON.stringify(state);
|
|
});
|
|
|
|
document.querySelectorAll("[data-upload-target]").forEach((button) => {
|
|
button.addEventListener("click", function () {
|
|
const targetId = button.getAttribute("data-upload-target");
|
|
const previewId = button.getAttribute("data-preview-target");
|
|
const input = document.getElementById(targetId);
|
|
const preview = document.getElementById(previewId);
|
|
openImagePicker(function (path) {
|
|
input.value = path;
|
|
preview.src = resolveImageUrl(path);
|
|
preview.classList.toggle("d-none", !path);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function syncStaticFields() {
|
|
state.hero = {
|
|
badge: getValue("heroBadge"),
|
|
title: getValue("heroTitle"),
|
|
description: getValue("heroDescription"),
|
|
linkLabel: getValue("heroLinkLabel"),
|
|
image: getValue("heroImage"),
|
|
imageAlt: getValue("heroImageAlt"),
|
|
};
|
|
|
|
state.directory.heading = getValue("directoryHeading");
|
|
state.directory.description = getValue("directoryDescription");
|
|
|
|
state.cta = {
|
|
heading: getValue("ctaHeading"),
|
|
description: getValue("ctaDescription"),
|
|
buttonLabel: getValue("ctaButtonLabel"),
|
|
};
|
|
|
|
state.inquiryForm.title = getValue("inquiryTitle");
|
|
}
|
|
|
|
function ensurePartnershipIds() {
|
|
const usedPartnerIds = new Set();
|
|
state.directory.partners = (state.directory.partners || []).map((partner, index) => {
|
|
let id = String(partner.id || "").trim() || slugifyValue(partner.name, `partner-${index + 1}`);
|
|
let suffix = 2;
|
|
while (usedPartnerIds.has(id)) {
|
|
id = `${slugifyValue(partner.name, "partner")}-${suffix}`;
|
|
suffix += 1;
|
|
}
|
|
usedPartnerIds.add(id);
|
|
return { ...partner, id };
|
|
});
|
|
|
|
const usedFieldIds = new Set();
|
|
state.inquiryForm.fields = (state.inquiryForm.fields || []).map((field, index) => {
|
|
let id =
|
|
String(field.id || "").trim() ||
|
|
slugifyValue(field.label || field.placeholder, `field-${index + 1}`);
|
|
let suffix = 2;
|
|
while (usedFieldIds.has(id)) {
|
|
id = `${slugifyValue(field.label || field.placeholder, "field")}-${suffix}`;
|
|
suffix += 1;
|
|
}
|
|
usedFieldIds.add(id);
|
|
return { ...field, id };
|
|
});
|
|
}
|
|
|
|
function getDuplicateTabs(tabs) {
|
|
if (!Array.isArray(tabs)) {
|
|
return [];
|
|
}
|
|
|
|
const seen = new Set();
|
|
const duplicates = [];
|
|
|
|
tabs.forEach((tab) => {
|
|
const trimmedTab = String(tab || "").trim();
|
|
const normalizedTab = trimmedTab.toLowerCase();
|
|
|
|
if (!normalizedTab) {
|
|
return;
|
|
}
|
|
|
|
if (seen.has(normalizedTab)) {
|
|
duplicates.push(trimmedTab);
|
|
return;
|
|
}
|
|
|
|
seen.add(normalizedTab);
|
|
});
|
|
|
|
return duplicates;
|
|
}
|
|
|
|
function clearDirectoryTabsValidation() {
|
|
directoryTabsList
|
|
?.querySelectorAll(".is-invalid")
|
|
.forEach((element) => element.classList.remove("is-invalid"));
|
|
}
|
|
|
|
function highlightDuplicateDirectoryTabs(duplicateTabs) {
|
|
const normalizedDuplicates = new Set(
|
|
duplicateTabs.map((tab) => String(tab || "").trim().toLowerCase()),
|
|
);
|
|
|
|
const duplicateInputs = Array.from(
|
|
directoryTabsList?.querySelectorAll('[data-field="label"]') || [],
|
|
).filter((input) => {
|
|
const inputValue = String(input.value || "").trim().toLowerCase();
|
|
return inputValue && normalizedDuplicates.has(inputValue);
|
|
});
|
|
|
|
duplicateInputs.forEach((input) => input.classList.add("is-invalid"));
|
|
duplicateInputs[0]?.focus();
|
|
}
|
|
|
|
function renderAll() {
|
|
renderDirectoryTabs();
|
|
renderPartners();
|
|
renderInquiryFields();
|
|
}
|
|
|
|
function renderDirectoryTabs() {
|
|
directoryTabsList.innerHTML = "";
|
|
|
|
state.directory.tabs.forEach((tab, index) => {
|
|
const node = cloneTemplate(templates.tab);
|
|
const input = node.querySelector('[data-field="label"]');
|
|
input.value = tab || "";
|
|
input.addEventListener("input", function () {
|
|
state.directory.tabs[index] = input.value;
|
|
refreshPartnerCategoryLists();
|
|
});
|
|
|
|
node.querySelector("[data-remove-item]").addEventListener("click", function () {
|
|
state.directory.tabs.splice(index, 1);
|
|
renderDirectoryTabs();
|
|
renderPartners();
|
|
});
|
|
node.querySelector("[data-toggle-item]")?.addEventListener("click", function () {
|
|
node.classList.toggle("is-collapsed");
|
|
});
|
|
attachCounters(node);
|
|
|
|
directoryTabsList.appendChild(node);
|
|
});
|
|
|
|
initSortable(directoryTabsList, state.directory.tabs, renderDirectoryTabs, '[data-item="directory-tab"]');
|
|
}
|
|
|
|
function renderPartners() {
|
|
partnersList.innerHTML = "";
|
|
|
|
state.directory.partners.forEach((partner, index) => {
|
|
const node = cloneTemplate(templates.partner);
|
|
const title = node.querySelector("[data-title]");
|
|
const subtitle = node.querySelector("[data-subtitle]");
|
|
const categoryInput = node.querySelector('[data-field="category"]');
|
|
const categoryListId = `partner-category-options-${index}`;
|
|
|
|
title.textContent = partner.name || `Partner ${index + 1}`;
|
|
subtitle.textContent = partner.category || "";
|
|
|
|
node.querySelectorAll("[data-field]").forEach((input) => {
|
|
const field = input.getAttribute("data-field");
|
|
input.value = partner[field] || "";
|
|
input.addEventListener("input", function () {
|
|
partner[field] = input.value;
|
|
if (field === "name") {
|
|
title.textContent = input.value || `${partnerFields.name?.label || "Partner"} ${index + 1}`;
|
|
}
|
|
if (field === "category") {
|
|
subtitle.textContent = input.value || "";
|
|
}
|
|
});
|
|
});
|
|
|
|
categoryInput.setAttribute("list", categoryListId);
|
|
const dataList = document.createElement("datalist");
|
|
dataList.id = categoryListId;
|
|
state.directory.tabs.forEach((tab) => {
|
|
const option = document.createElement("option");
|
|
option.value = tab;
|
|
dataList.appendChild(option);
|
|
});
|
|
node.appendChild(dataList);
|
|
|
|
const preview = node.querySelector("[data-preview]");
|
|
const logoInput = node.querySelector('[data-field="logo"]');
|
|
if (partner.logo) {
|
|
preview.src = resolveImageUrl(partner.logo);
|
|
preview.classList.remove("d-none");
|
|
}
|
|
|
|
node.querySelector("[data-upload-button]").addEventListener("click", function () {
|
|
openImagePicker(function (path) {
|
|
partner.logo = path;
|
|
logoInput.value = path;
|
|
preview.src = resolveImageUrl(path);
|
|
preview.classList.remove("d-none");
|
|
});
|
|
});
|
|
|
|
node.querySelector("[data-remove-item]").addEventListener("click", function () {
|
|
state.directory.partners.splice(index, 1);
|
|
renderPartners();
|
|
});
|
|
node.querySelector("[data-toggle-item]")?.addEventListener("click", function () {
|
|
node.classList.toggle("is-collapsed");
|
|
});
|
|
attachCounters(node);
|
|
|
|
partnersList.appendChild(node);
|
|
});
|
|
|
|
initSortable(partnersList, state.directory.partners, renderPartners, '[data-item="partner"]');
|
|
}
|
|
|
|
function refreshPartnerCategoryLists() {
|
|
partnersList.querySelectorAll("datalist").forEach((list) => list.remove());
|
|
renderPartners();
|
|
}
|
|
|
|
function renderInquiryFields() {
|
|
inquiryFieldsList.innerHTML = "";
|
|
|
|
state.inquiryForm.fields.forEach((field, index) => {
|
|
const node = cloneTemplate(templates.inquiryField);
|
|
const title = node.querySelector("[data-title]");
|
|
const subtitle = node.querySelector("[data-subtitle]");
|
|
const optionsWrap = node.querySelector("[data-options-wrap]");
|
|
const optionsList = node.querySelector("[data-options-list]");
|
|
const typeSelect = node.querySelector('[data-field="type"]');
|
|
|
|
title.textContent = field.label || `Field ${index + 1}`;
|
|
subtitle.textContent = field.type || "";
|
|
|
|
node.querySelectorAll("[data-field]").forEach((input) => {
|
|
const key = input.getAttribute("data-field");
|
|
|
|
if (input.type === "checkbox") {
|
|
input.checked = Boolean(field[key]);
|
|
input.addEventListener("change", function () {
|
|
field[key] = input.checked;
|
|
});
|
|
return;
|
|
}
|
|
|
|
input.value = field[key] || "";
|
|
input.addEventListener("input", function () {
|
|
field[key] = input.value;
|
|
if (key === "label") {
|
|
title.textContent = input.value || `${inquiryFieldFields.label?.label || "Field"} ${index + 1}`;
|
|
}
|
|
if (key === "type") {
|
|
subtitle.textContent = input.value || "";
|
|
toggleOptions();
|
|
}
|
|
});
|
|
if (input.tagName === "SELECT") {
|
|
input.addEventListener("change", function () {
|
|
field[key] = input.value;
|
|
if (key === "type") {
|
|
subtitle.textContent = input.value || "";
|
|
if (input.value !== "select") {
|
|
field.options = [];
|
|
}
|
|
toggleOptions();
|
|
renderInquiryFields();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
node.querySelector("[data-remove-item]").addEventListener("click", function () {
|
|
state.inquiryForm.fields.splice(index, 1);
|
|
renderInquiryFields();
|
|
});
|
|
|
|
node.querySelector("[data-add-option]").addEventListener("click", function () {
|
|
field.options = Array.isArray(field.options) ? field.options : [];
|
|
field.options.push("");
|
|
renderInquiryFields();
|
|
});
|
|
node.querySelector("[data-toggle-item]")?.addEventListener("click", function () {
|
|
node.classList.toggle("is-collapsed");
|
|
});
|
|
|
|
function toggleOptions() {
|
|
optionsWrap.classList.toggle("d-none", typeSelect.value !== "select");
|
|
}
|
|
|
|
function renderOptions() {
|
|
optionsList.innerHTML = "";
|
|
(field.options || []).forEach((optionValue, optionIndex) => {
|
|
const optionNode = cloneTemplate(templates.inquiryOption);
|
|
const optionInput = optionNode.querySelector("[data-option-value]");
|
|
optionInput.value = optionValue || "";
|
|
optionInput.addEventListener("input", function () {
|
|
field.options[optionIndex] = optionInput.value;
|
|
});
|
|
optionNode.querySelector("[data-remove-item]").addEventListener("click", function () {
|
|
field.options.splice(optionIndex, 1);
|
|
renderInquiryFields();
|
|
});
|
|
attachCounters(optionNode);
|
|
optionsList.appendChild(optionNode);
|
|
});
|
|
initSortable(optionsList, field.options, renderInquiryFields, '[data-item="inquiry-option"]');
|
|
}
|
|
|
|
toggleOptions();
|
|
renderOptions();
|
|
attachCounters(node);
|
|
inquiryFieldsList.appendChild(node);
|
|
});
|
|
|
|
initSortable(inquiryFieldsList, state.inquiryForm.fields, renderInquiryFields, '[data-item="inquiry-field"]');
|
|
}
|
|
|
|
function initSortable(container, list, rerender, draggableSelector) {
|
|
if (!window.Sortable || !container) return;
|
|
if (container._sortableInstance) {
|
|
container._sortableInstance.destroy();
|
|
}
|
|
container._sortableInstance = window.Sortable.create(container, {
|
|
animation: 150,
|
|
handle: ".drag-handle",
|
|
draggable: draggableSelector,
|
|
onEnd: function (event) {
|
|
if (event.oldIndex === event.newIndex) return;
|
|
const moved = list.splice(event.oldIndex, 1)[0];
|
|
list.splice(event.newIndex, 0, moved);
|
|
rerender();
|
|
},
|
|
});
|
|
}
|
|
|
|
function cloneTemplate(template) {
|
|
return template.content.firstElementChild.cloneNode(true);
|
|
}
|
|
|
|
function initStaticCounters() {
|
|
attachCounters(form);
|
|
}
|
|
|
|
function attachCounters(root) {
|
|
root.querySelectorAll("input[maxlength], textarea[maxlength]").forEach((input, index) => {
|
|
if (input.dataset.counterReady === "true") {
|
|
updateInputCounter(input);
|
|
return;
|
|
}
|
|
|
|
const counterId =
|
|
input.id ||
|
|
input.name ||
|
|
input.dataset.field ||
|
|
input.dataset.optionValue ||
|
|
`counter-${index}-${Math.random().toString(36).slice(2, 8)}`;
|
|
let counter = root.querySelector(`[data-counter-for="${counterId}"]`);
|
|
|
|
if (!counter) {
|
|
counter = document.createElement("div");
|
|
counter.className = "field-char-count";
|
|
counter.dataset.counterFor = counterId;
|
|
|
|
const next = input.nextElementSibling;
|
|
if (next && next.classList.contains("form-text")) {
|
|
let metaRow = next.nextElementSibling;
|
|
if (!metaRow || !metaRow.classList.contains("field-meta-row")) {
|
|
metaRow = document.createElement("div");
|
|
metaRow.className = "field-meta-row";
|
|
next.insertAdjacentElement("afterend", metaRow);
|
|
}
|
|
metaRow.appendChild(counter);
|
|
} else {
|
|
const metaRow = document.createElement("div");
|
|
metaRow.className = "field-meta-row";
|
|
metaRow.appendChild(counter);
|
|
input.insertAdjacentElement("afterend", metaRow);
|
|
}
|
|
}
|
|
|
|
const sync = function () {
|
|
updateInputCounter(input);
|
|
};
|
|
|
|
input.addEventListener("input", sync);
|
|
input.addEventListener("change", sync);
|
|
input.dataset.counterReady = "true";
|
|
updateInputCounter(input);
|
|
});
|
|
}
|
|
|
|
function updateInputCounter(input) {
|
|
const maxLength = Number(input.getAttribute("maxlength"));
|
|
if (!maxLength) return;
|
|
|
|
const counterId =
|
|
input.id || input.name || input.dataset.field || input.dataset.optionValue;
|
|
const scope = input.closest("[data-item]") || input.parentElement || form;
|
|
let counter = scope.querySelector(`[data-counter-for="${counterId}"]`);
|
|
if (!counter) {
|
|
counter = form.querySelector(`[data-counter-for="${counterId}"]`);
|
|
}
|
|
if (!counter) return;
|
|
|
|
counter.textContent = `${String(input.value || "").length}/${maxLength}`;
|
|
}
|
|
|
|
function getValue(id) {
|
|
return document.getElementById(id)?.value || "";
|
|
}
|
|
|
|
function openImagePicker(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=partnerships", {
|
|
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 resolveImageUrl(path) {
|
|
if (!path) return "";
|
|
if (/^https?:\/\//i.test(path)) return path;
|
|
if (path.startsWith("/")) return `${backendUrl}${path}`;
|
|
return `${backendUrl}/${path}`;
|
|
}
|
|
|
|
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 escapeHtml(value) {
|
|
return String(value || "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
})();
|
|
|
|
</script>
|