From a39c336dd493089fd7f3d6494d6b4172d918ec64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=E1=BB=91ng=20Th=C3=A0nh=20=C4=90=E1=BA=A1t?= <84076965+tongthanhdat009@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:06:03 +0700 Subject: [PATCH] feat(cms): enhance content editors with dynamic UI configs and validation Implement a more flexible, configuration-driven approach for CMS editors across accreditation, admissions, and partnerships modules. - Move UI labels, help texts, and field limits from hardcoded views to `editorUi` configurations in config files. - Add server-side and client-side validation to prevent duplicate category tabs in accreditation and partnerships editors. - Refactor partnership and admission views to dynamically render tabs and fields based on the provided configuration. - Update field length constraints and default values across multiple content editors to better align with frontend requirements. - Improve the admissions calculator editor with dynamic field configurations and default value fallbacks. --- controllers/accreditationController.js | 28 ++++ controllers/admissionsController.js | 94 ++++++++++-- controllers/partnershipsController.js | 119 ++++++++++++++- utils/contentEditors/accreditationConfig.js | 2 +- utils/contentEditors/admissionsConfig.js | 95 +++++++++--- utils/contentEditors/historyConfig.js | 4 +- utils/contentEditors/partnershipsConfig.js | 11 +- utils/contentEditors/policiesConfig.js | 2 +- utils/contentEditors/sharedFields.js | 2 +- .../accreditation/partials/editor-script.ejs | 73 ++++++++- views/admin/admissions/calculator-option.ejs | 126 ++++++++++++---- .../admissions/partials/editor-script.ejs | 142 +++++++++++++----- views/admin/partnerships/index.ejs | 39 ++--- views/admin/partnerships/partials/cta-tab.ejs | 13 +- .../partnerships/partials/directory-tab.ejs | 21 +-- .../partnerships/partials/editor-script.ejs | 135 ++++++++++++++--- .../admin/partnerships/partials/hero-tab.ejs | 25 +-- .../partials/inquiry-form-tab.ejs | 11 +- .../admin/partnerships/partials/templates.ejs | 76 +++++----- 19 files changed, 790 insertions(+), 228 deletions(-) diff --git a/controllers/accreditationController.js b/controllers/accreditationController.js index 8bead64..705038f 100644 --- a/controllers/accreditationController.js +++ b/controllers/accreditationController.js @@ -9,6 +9,34 @@ const controller = createPageContentController({ modelName: "AccreditationPage", auditAction: AUDIT_ACTIONS.UPDATE_ACCREDITATION, editorConfig: accreditationConfig, + preparePayload(rawPayload) { + const payload = JSON.parse(JSON.stringify(rawPayload || {})); + const tabs = Array.isArray(payload?.grid?.tabs) ? payload.grid.tabs : []; + const seenTabs = new Set(); + const duplicateTabs = []; + + tabs.forEach((tab) => { + const normalizedTab = String(tab || "").trim().toLowerCase(); + if (!normalizedTab) { + return; + } + + if (seenTabs.has(normalizedTab)) { + duplicateTabs.push(String(tab || "").trim()); + return; + } + + seenTabs.add(normalizedTab); + }); + + if (duplicateTabs.length > 0) { + throw new Error( + `Category tab already exists: ${duplicateTabs[0]}. Please use unique tab names.`, + ); + } + + return payload; + }, }); controller.index = async function index(req, res) { diff --git a/controllers/admissionsController.js b/controllers/admissionsController.js index d88c1d0..2a082aa 100644 --- a/controllers/admissionsController.js +++ b/controllers/admissionsController.js @@ -9,6 +9,38 @@ const diffObject = require("../audit/diffObject"); const { ensureUniqueIds } = require("../utils/contentEditorIds"); const { ICON_OPTIONS } = require("../utils/contentEditors/sharedFields"); +function getAdmissionsEditorUi() { + return admissionsConfig.editorUi || {}; +} + +function getDefaultCalculatorOptionValues() { + return getAdmissionsEditorUi().calculator?.defaultOption || {}; +} + +function getCalculatorOptionFieldConfig(fieldKey) { + const calculatorTab = (admissionsConfig.tabs || []).find((tab) => tab.key === "calculator"); + const calculatorFields = calculatorTab?.schema?.fields || []; + const optionsField = calculatorFields.find((field) => field.key === "options"); + const optionItemFields = optionsField?.itemSchema?.fields || []; + + return optionItemFields.find((field) => field.key === fieldKey) || {}; +} + +function hasDuplicateCalculatorOptionLabel(options, currentOptionId, nextLabel) { + const normalizedLabel = String(nextLabel || "").trim().toLowerCase(); + if (!normalizedLabel) { + return false; + } + + return (options || []).some((option) => { + if (String(option?.id || "") === String(currentOptionId || "")) { + return false; + } + + return String(option?.label || "").trim().toLowerCase() === normalizedLabel; + }); +} + function normalizePositiveAmount(value, fallback = "1") { const match = String(value || "").match(/\d[\d,]*/); const numericValue = Number((match ? match[0] : "").replace(/,/g, "")); @@ -17,18 +49,25 @@ function normalizePositiveAmount(value, fallback = "1") { function normalizeCalculatorOption(option, index, calculator) { const source = typeof option === "string" ? { label: option } : { ...(option || {}) }; + const defaultOption = getDefaultCalculatorOptionValues(); + const labelMaxLength = getCalculatorOptionFieldConfig("label").maxLength || 12; return { ...source, - label: String(source.label || source.title || `Option ${index + 1}`).slice(0, 12), - paceLabel: String(source.paceLabel || calculator.paceLabel || "Target Pace"), - minPaceLabel: String(source.minPaceLabel || calculator.minPaceLabel || "Relaxed"), - maxPaceLabel: String(source.maxPaceLabel || calculator.maxPaceLabel || "Accelerated"), - resultLabel: String(source.resultLabel || calculator.resultLabel || "Estimated Monthly Payment"), - monthlyAmount: normalizePositiveAmount(source.monthlyAmount || calculator.monthlyAmount || "299", "299"), - monthlySuffix: String(source.monthlySuffix || calculator.monthlySuffix || "/mo"), - noteIcon: String(source.noteIcon || calculator.noteIcon || "fa-bolt"), - note: String(source.note || calculator.note || ""), + label: String(source.label || source.title || `Option ${index + 1}`).slice(0, labelMaxLength), + paceLabel: String(source.paceLabel || calculator.paceLabel || defaultOption.paceLabel || "Target Pace"), + minPaceLabel: String(source.minPaceLabel || calculator.minPaceLabel || defaultOption.minPaceLabel || "Relaxed"), + maxPaceLabel: String(source.maxPaceLabel || calculator.maxPaceLabel || defaultOption.maxPaceLabel || "Accelerated"), + resultLabel: String( + source.resultLabel || calculator.resultLabel || defaultOption.resultLabel || "Estimated Monthly Payment", + ), + monthlyAmount: normalizePositiveAmount( + source.monthlyAmount || calculator.monthlyAmount || defaultOption.monthlyAmount || "299", + String(defaultOption.monthlyAmount || "299"), + ), + monthlySuffix: String(source.monthlySuffix || calculator.monthlySuffix || defaultOption.monthlySuffix || "/mo"), + noteIcon: String(source.noteIcon || calculator.noteIcon || defaultOption.noteIcon || "fa-bolt"), + note: String(source.note || calculator.note || defaultOption.note || ""), }; } @@ -174,12 +213,41 @@ controller.editCalculatorOption = async function editCalculatorOption(req, res) const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const backendUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`; + const fieldKeys = [ + "label", + "paceLabel", + "minPaceLabel", + "maxPaceLabel", + "resultLabel", + "monthlyAmount", + "monthlySuffix", + "noteIcon", + "note", + ]; + const fieldConfig = fieldKeys.reduce((acc, key) => { + acc[key] = getCalculatorOptionFieldConfig(key); + return acc; + }, {}); return res.render("admin/admissions/calculator-option", { layout: "layouts/main", title: `Edit ${option.label}`, subtitle: "Update the calculator option details", option, + existingOptionLabels: data.calculator.options + .filter((item) => item.id !== optionId) + .map((item) => item.label) + .filter(Boolean), + fieldLimits: { + label: fieldConfig.label.maxLength || 12, + paceLabel: fieldConfig.paceLabel.maxLength || 20, + minPaceLabel: fieldConfig.minPaceLabel.maxLength || 7, + maxPaceLabel: fieldConfig.maxPaceLabel.maxLength || 7, + resultLabel: fieldConfig.resultLabel.maxLength || 40, + monthlySuffix: fieldConfig.monthlySuffix.maxLength || 10, + note: fieldConfig.note.maxLength || 60, + }, + fieldConfig, iconOptions: ICON_OPTIONS, editorConfig: admissionsConfig, previewUrl: `${frontendUrl}${admissionsConfig.previewPath}`, @@ -207,9 +275,15 @@ controller.updateCalculatorOption = async function updateCalculatorOption(req, r return req.session.save(() => res.redirect("/admin/admissions?tab=calculator")); } + const nextLabel = String(req.body.label || "").trim().slice(0, getCalculatorOptionFieldConfig("label").maxLength || 12); + if (hasDuplicateCalculatorOptionLabel(payload.calculator.options, optionId, nextLabel)) { + req.flash("error_msg", `Option label "${nextLabel}" already exists. Please use a unique label.`); + return req.session.save(() => res.redirect(`/admin/admissions/calculator/${optionId}`)); + } + payload.calculator.options[optionIndex] = { ...payload.calculator.options[optionIndex], - label: String(req.body.label || "").trim().slice(0, 12), + label: nextLabel, paceLabel: String(req.body.paceLabel || "").trim(), minPaceLabel: String(req.body.minPaceLabel || "").trim(), maxPaceLabel: String(req.body.maxPaceLabel || "").trim(), diff --git a/controllers/partnershipsController.js b/controllers/partnershipsController.js index 40723d1..be5df7e 100644 --- a/controllers/partnershipsController.js +++ b/controllers/partnershipsController.js @@ -2,9 +2,71 @@ const PartnershipsPage = require("../models/partnerships"); const AUDIT_ACTIONS = require("../constants/auditAction"); const partnershipsConfig = require("../utils/contentEditors/partnershipsConfig"); const createPageContentController = require("./_createPageContentController"); -const createRenderSingletonPageView = require("./_renderSingletonPageView"); const { ensureUniqueIds } = require("../utils/contentEditorIds"); +function getTabConfig(tabKey) { + return (partnershipsConfig.tabs || []).find((tab) => tab.key === tabKey) || {}; +} + +function getObjectField(tabKey, fieldKey) { + const fields = getTabConfig(tabKey)?.schema?.fields || []; + return fields.find((field) => field.key === fieldKey) || {}; +} + +function getArrayItemField(tabKey, arrayKey, fieldKey) { + const arrayField = getObjectField(tabKey, arrayKey); + const itemFields = arrayField?.itemSchema?.fields || []; + return itemFields.find((field) => field.key === fieldKey) || {}; +} + +function getPartnershipsEditorUi() { + return { + hero: { + badge: getObjectField("hero", "badge"), + title: getObjectField("hero", "title"), + description: getObjectField("hero", "description"), + linkLabel: getObjectField("hero", "linkLabel"), + image: getObjectField("hero", "image"), + imageAlt: getObjectField("hero", "imageAlt"), + }, + directory: { + heading: getObjectField("directory", "heading"), + description: getObjectField("directory", "description"), + tabs: getObjectField("directory", "tabs"), + partnerFields: { + name: getArrayItemField("directory", "partners", "name"), + category: getArrayItemField("directory", "partners", "category"), + summary: getArrayItemField("directory", "partners", "summary"), + logo: getArrayItemField("directory", "partners", "logo"), + logoAlt: getArrayItemField("directory", "partners", "logoAlt"), + about: getArrayItemField("directory", "partners", "about"), + collabType: getArrayItemField("directory", "partners", "collabType"), + benefits: getArrayItemField("directory", "partners", "benefits"), + }, + tabsFrontendHint: partnershipsConfig.editorUi?.directory?.tabsFrontendHint || "", + partnersHelpText: partnershipsConfig.editorUi?.directory?.partnersHelpText || "", + }, + cta: { + heading: getObjectField("cta", "heading"), + description: getObjectField("cta", "description"), + buttonLabel: getObjectField("cta", "buttonLabel"), + }, + inquiryForm: { + title: getObjectField("inquiryForm", "title"), + fields: getObjectField("inquiryForm", "fields"), + fieldFields: { + label: getArrayItemField("inquiryForm", "fields", "label"), + placeholder: getArrayItemField("inquiryForm", "fields", "placeholder"), + type: getArrayItemField("inquiryForm", "fields", "type"), + width: getArrayItemField("inquiryForm", "fields", "width"), + options: getArrayItemField("inquiryForm", "fields", "options"), + required: getArrayItemField("inquiryForm", "fields", "required"), + }, + fieldsHelpText: partnershipsConfig.editorUi?.inquiryForm?.fieldsHelpText || "", + }, + }; +} + function toInquiryField(id, field, type, width) { return { id, @@ -53,9 +115,34 @@ function prepareInquiryPayload(payload) { const fields = Array.isArray(normalized?.inquiryForm?.fields) ? normalized.inquiryForm.fields : []; + const tabs = Array.isArray(normalized?.directory?.tabs) + ? normalized.directory.tabs + : []; const partners = Array.isArray(normalized?.directory?.partners) ? normalized.directory.partners : []; + const seenTabs = new Set(); + const duplicateTabs = []; + + tabs.forEach((tab) => { + const normalizedTab = String(tab || "").trim().toLowerCase(); + if (!normalizedTab) { + return; + } + + if (seenTabs.has(normalizedTab)) { + duplicateTabs.push(String(tab || "").trim()); + return; + } + + seenTabs.add(normalizedTab); + }); + + if (duplicateTabs.length > 0) { + throw new Error( + `Category tab already exists: ${duplicateTabs[0]}. Please use unique tab names.`, + ); + } return { ...normalized, @@ -100,12 +187,32 @@ const controller = createPageContentController({ controller.index = async function index(req, res) { try { - return await createRenderSingletonPageView({ - model: PartnershipsPage, + const doc = await PartnershipsPage.getSingle(); + const rawData = doc.toObject(); + const data = normalizeInquiryForm(rawData); + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const backendUrl = + process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`; + const defaultTab = partnershipsConfig.tabs[0]?.key; + const requestedTab = req.query.tab; + const activeTab = partnershipsConfig.tabs.some((tab) => tab.key === requestedTab) + ? requestedTab + : defaultTab; + + return res.render("admin/partnerships/index", { + layout: "layouts/main", + title: partnershipsConfig.title, + subtitle: partnershipsConfig.subtitle, + data, editorConfig: partnershipsConfig, - view: "admin/partnerships/index", - normalizeForEditor: normalizeInquiryForm, - })(req, res); + editorUi: getPartnershipsEditorUi(), + activeTab, + frontendUrl, + backendUrl, + previewUrl: `${frontendUrl}${partnershipsConfig.previewPath}`, + currentPath: req.path, + user: req.session.user, + }); } catch (error) { console.error("partnerships index error:", error); req.flash("error_msg", "Error loading Partnerships Management"); diff --git a/utils/contentEditors/accreditationConfig.js b/utils/contentEditors/accreditationConfig.js index 21c7854..a0ce8f6 100644 --- a/utils/contentEditors/accreditationConfig.js +++ b/utils/contentEditors/accreditationConfig.js @@ -30,7 +30,7 @@ module.exports = { label: "Hero", icon: "fas fa-image", schema: object("hero", "Hero", [ - text("badge", "Eyebrow label", { maxLength: 40 }), + text("badge", "Eyebrow label", { maxLength: 30 }), text("title", "Headline", { maxLength: 60 }), textarea("description", "Supporting text", { maxLength: 420, rows: 5 }), ]), diff --git a/utils/contentEditors/admissionsConfig.js b/utils/contentEditors/admissionsConfig.js index afed38f..e9bcb55 100644 --- a/utils/contentEditors/admissionsConfig.js +++ b/utils/contentEditors/admissionsConfig.js @@ -20,6 +20,39 @@ module.exports = { previewPath: "/admissions", dataFile: "admissions", imageType: "admissions", + editorUi: { + keyDates: { + tableLabel: "Key dates table", + tableHelpText: "Manage the table directly by adding or removing columns and rows.", + addColumnLabel: "Add Column", + addRowLabel: "Add Row", + columnPlaceholder: "Column name", + emptyRowsText: "No rows yet.", + actionsLabel: "Actions", + columnLabelMaxLength: 40, + cellMaxLength: 60, + }, + calculator: { + ctaLabel: "Primary button", + ctaHelpText: "This button appears at the bottom of the calculator card.", + optionsLabel: "Calculator options", + optionsHelpText: "Each option has its own pricing labels, amount, note, and icon. Open the edit page to update the option details.", + optionsEmptyText: "No calculator options yet.", + addOptionLabel: "Add calculator option", + maxOptions: 3, + limitHelpText: "You can add up to 3 calculator options.", + defaultOption: { + paceLabel: "Target Pace", + minPaceLabel: "Relaxed", + maxPaceLabel: "Accelerated", + resultLabel: "Estimated Monthly Payment", + monthlyAmount: "299", + monthlySuffix: "/mo", + noteIcon: "fa-bolt", + note: "", + }, + }, + }, tabs: [ { key: "hero", @@ -102,8 +135,8 @@ module.exports = { label: "Tuition", icon: "fas fa-chart-column", schema: object("tuition", "Tuition", [ - text("title", "Section title", { maxLength: 60 }), - text("chartTitle", "Chart title", { maxLength: 60 }), + text("title", "Section title", { maxLength: 45 }), + text("chartTitle", "Chart title", { maxLength: 40 }), textarea("chartDescription", "Chart description", { maxLength: 140, rows: 3, @@ -112,14 +145,14 @@ module.exports = { "series", "Chart series", [ - text("label", "Series label", { maxLength: 40 }), + text("label", "Series label", { maxLength: 20 }), { key: "color", label: "Series color", type: "color" }, objectList( "points", "Data points", [ text("time", "Time label", { - maxLength: 30, + maxLength: 14, placeholder: "Year 1", }), { @@ -170,18 +203,44 @@ module.exports = { objectList( "options", "Calculator options", - [ - hidden("id"), - text("label", "Option label", { maxLength: 12 }), - text("paceLabel", "Pace label", { maxLength: 30 }), - text("minPaceLabel", "Minimum pace label", { maxLength: 20 }), - text("maxPaceLabel", "Maximum pace label", { maxLength: 20 }), - text("resultLabel", "Result label", { maxLength: 40 }), - text("monthlyAmount", "Monthly amount", { maxLength: 20 }), - text("monthlySuffix", "Monthly suffix", { maxLength: 10 }), - icon("noteIcon", "Note icon"), - text("note", "Note text", { maxLength: 60 }), - ], + [ + hidden("id"), + text("label", "Option label", { + maxLength: 12, + helpText: "This label appears in the calculator option switcher.", + }), + text("paceLabel", "Pace label", { + maxLength: 20, + helpText: "This appears above the pace slider.", + }), + text("minPaceLabel", "Minimum pace label", { + maxLength: 7, + helpText: "This appears on the left side of the pace slider.", + }), + text("maxPaceLabel", "Maximum pace label", { + maxLength: 7, + helpText: "This appears on the right side of the pace slider.", + }), + text("resultLabel", "Result label", { + maxLength: 40, + helpText: "This label appears above the calculated amount.", + }), + text("monthlyAmount", "Monthly amount", { + maxLength: 20, + helpText: "Enter digits only. The currency symbol is added on the website automatically.", + }), + text("monthlySuffix", "Monthly suffix", { + maxLength: 10, + helpText: "Example: /mo", + }), + icon("noteIcon", "Note icon", { + helpText: "Choose the icon shown beside the note.", + }), + text("note", "Note text", { + maxLength: 60, + helpText: "This short note appears under the amount.", + }), + ], { itemLabel: "Option", sortable: true, @@ -210,8 +269,8 @@ module.exports = { "items", "Scholarship items", [ - text("title", "Title", { maxLength: 50 }), - text("amount", "Amount", { maxLength: 24 }), + text("title", "Title", { maxLength: 40 }), + text("amount", "Amount", { maxLength: 12 }), textarea("description", "Description", { maxLength: 160, rows: 3, diff --git a/utils/contentEditors/historyConfig.js b/utils/contentEditors/historyConfig.js index 1d98b2a..342d210 100644 --- a/utils/contentEditors/historyConfig.js +++ b/utils/contentEditors/historyConfig.js @@ -73,7 +73,7 @@ module.exports = { "Milestones", [ text("year", "Year", { - maxLength: 30, + maxLength: 4, }), combobox("yearRange", "Year range", { maxLength: 30, @@ -83,7 +83,7 @@ module.exports = { maxLength: 40, optionsPath: "filters.categoryOptions", }), - text("categoryLabel", "Category badge label", { maxLength: 30 }), + text("categoryLabel", "Category badge label", { maxLength: 25 }), text("title", "Milestone title", { maxLength: 90 }), textarea("description", "Description", { maxLength: 260, diff --git a/utils/contentEditors/partnershipsConfig.js b/utils/contentEditors/partnershipsConfig.js index 43a17b1..9fbfdbf 100644 --- a/utils/contentEditors/partnershipsConfig.js +++ b/utils/contentEditors/partnershipsConfig.js @@ -30,6 +30,15 @@ module.exports = { previewPath: "/about/partnerships", dataFile: "partnerships", imageType: "partnerships", + editorUi: { + directory: { + tabsFrontendHint: "The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.", + partnersHelpText: "Each partner card keeps its own open or closed state automatically.", + }, + inquiryForm: { + fieldsHelpText: "Manage labels, placeholders, type, width, and dropdown options.", + }, + }, tabs: [ { key: "hero", @@ -115,7 +124,7 @@ module.exports = { label: "Inquiry Form", icon: "fas fa-envelope", schema: object("inquiryForm", "Inquiry form", [ - text("title", "Modal title", { maxLength: 60 }), + text("title", "Modal title", { maxLength: 35 }), objectList( "fields", "Form fields", diff --git a/utils/contentEditors/policiesConfig.js b/utils/contentEditors/policiesConfig.js index 95b100e..97f132d 100644 --- a/utils/contentEditors/policiesConfig.js +++ b/utils/contentEditors/policiesConfig.js @@ -46,7 +46,7 @@ const baseConfig = { schema: object("sidebar", "Sidebar", [ text("heading", "Sidebar heading", { maxLength: 30 }), text("helperText", "Helper text", { maxLength: 60 }), - text("contactLabel", "Contact link label", { maxLength: 40 }), + text("contactLabel", "Contact link label", { maxLength: 25 }), url("contactHref", "Contact URL", { maxLength: 255 }), ]), }, diff --git a/utils/contentEditors/sharedFields.js b/utils/contentEditors/sharedFields.js index 6da8d58..23f5791 100644 --- a/utils/contentEditors/sharedFields.js +++ b/utils/contentEditors/sharedFields.js @@ -169,7 +169,7 @@ const variantList = (key, label, variants, options = {}) => ({ }); const linkFields = (prefix = "Link") => [ - text("label", `${prefix} label`, { maxLength: 60 }), + text("label", `${prefix} label`, { maxLength: 40 }), url("href", `${prefix} URL`, { maxLength: 255 }), ]; diff --git a/views/admin/accreditation/partials/editor-script.ejs b/views/admin/accreditation/partials/editor-script.ejs index ecf4b04..f7b66fa 100644 --- a/views/admin/accreditation/partials/editor-script.ejs +++ b/views/admin/accreditation/partials/editor-script.ejs @@ -36,7 +36,22 @@ }); }); - form.addEventListener("submit", function () { + form.addEventListener("submit", function (event) { + const duplicateTabs = getDuplicateTabs(state?.grid?.tabs); + + clearCategoryTabsValidation(); + + if (duplicateTabs.length > 0) { + event.preventDefault(); + highlightDuplicateCategoryTabs(duplicateTabs); + showToast( + "Duplicate category tab", + `Category tab "${duplicateTabs[0]}" already exists. Please use unique tab names before saving.`, + "danger", + ); + return; + } + pageJsonInput.value = JSON.stringify(state); }); @@ -52,6 +67,62 @@ config.tabs.forEach((tab) => renderSection(tab.key)); } + 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 clearCategoryTabsValidation() { + const tabsList = document.querySelector( + '[data-section-key="grid"] .page-editor-array-list', + ); + + tabsList + ?.querySelectorAll(".is-invalid") + .forEach((element) => element.classList.remove("is-invalid")); + } + + function highlightDuplicateCategoryTabs(duplicateTabs) { + const tabsList = document.querySelector( + '[data-section-key="grid"] .page-editor-array-list', + ); + const normalizedDuplicates = new Set( + duplicateTabs.map((tab) => String(tab || "").trim().toLowerCase()), + ); + + const duplicateInputs = Array.from( + tabsList?.querySelectorAll(".form-control") || [], + ).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 updateTabUrl(tabKey) { const url = new URL(window.location.href); url.searchParams.set("tab", tabKey); diff --git a/views/admin/admissions/calculator-option.ejs b/views/admin/admissions/calculator-option.ejs index 295b7d1..51f7c0d 100644 --- a/views/admin/admissions/calculator-option.ejs +++ b/views/admin/admissions/calculator-option.ejs @@ -24,69 +24,69 @@