From 2cee0172f2cf3e5b882692c0bb6a1379a5a5ee4b 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 20:27:42 +0700 Subject: [PATCH] refactor(cms): replace custom icon comboboxes with standardized icon picker 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. --- public/js/policies-block-editor.js | 281 ++++++++++------ .../accreditation/partials/editor-script.ejs | 314 ++++++++++-------- views/admin/admissions/calculator-option.ejs | 246 +++++++++----- .../admissions/partials/editor-script.ejs | 314 ++++++++++-------- .../admin/history/partials/editor-script.ejs | 314 ++++++++++-------- .../admin/policies/partials/editor-script.ejs | 314 ++++++++++-------- 6 files changed, 1043 insertions(+), 740 deletions(-) diff --git a/public/js/policies-block-editor.js b/public/js/policies-block-editor.js index 2a249d6..a00284f 100644 --- a/public/js/policies-block-editor.js +++ b/public/js/policies-block-editor.js @@ -549,24 +549,24 @@
-
- - -
@@ -583,7 +583,7 @@ block.html = value; }); - initializeIconCombobox(body.querySelector('[data-role="icon-combobox"]'), block); + initializeIconPicker(body.querySelector('[data-role="icon-picker-group"]'), block); body.querySelectorAll("input[data-field], select[data-field], textarea[data-field]").forEach((input) => { const syncField = function () { @@ -661,111 +661,184 @@ return editor; } - function initializeIconCombobox(wrapper, block) { + function initializeIconPicker(wrapper, block) { if (!wrapper) { return; } - const hiddenInput = wrapper.querySelector('input[data-field="icon"]'); - const trigger = wrapper.querySelector(".icon-combobox-trigger"); - const panel = wrapper.querySelector(".icon-combobox-panel"); - const searchInput = wrapper.querySelector(".icon-combobox-search"); - const optionsContainer = wrapper.querySelector(".icon-combobox-options"); - const emptyState = wrapper.querySelector(".icon-combobox-empty"); - const preview = wrapper.querySelector(".icon-combobox-preview"); - const title = wrapper.querySelector(".icon-combobox-text strong"); - const card = wrapper.closest(".block-card"); + const input = wrapper.querySelector('input[data-field="icon"]'); + const button = wrapper.querySelector('[data-action="pick-icon"]'); - if (!hiddenInput || !trigger || !panel || !searchInput || !optionsContainer || !emptyState || !preview || !title) { + if (!input || !button) { return; } - const setOpen = function (isOpen) { - wrapper.classList.toggle("is-open", isOpen); - panel.classList.toggle("is-hidden", !isOpen); - if (card) { - card.classList.toggle("has-floating-ui", isOpen); - } - if (isOpen) { - searchInput.focus(); - searchInput.select(); - } - }; - const syncIconValue = function (value) { const nextValue = String(value || "").trim(); - hiddenInput.value = nextValue; - searchInput.value = nextValue; + input.value = nextValue; + input.dataset.iconPickerPreviewPrefix = resolveCalloutIconPreviewPrefix(nextValue); block.icon = nextValue; - preview.innerHTML = nextValue - ? `` - : ''; - title.textContent = nextValue || "Select icon"; + syncCalloutIconPreview(input); renderPreview(); renderValidation(validateState()); }; - const renderOptions = function (query) { - const normalizedQuery = String(query || "").trim().toLowerCase(); - const filteredOptions = iconOptions.filter((option) => - option.toLowerCase().includes(normalizedQuery), - ); - - optionsContainer.innerHTML = ""; - emptyState.classList.toggle("is-hidden", filteredOptions.length > 0); - - filteredOptions.forEach((option) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "icon-combobox-option"; - button.classList.toggle("is-active", option === hiddenInput.value); - button.innerHTML = ` - - - ${escapeHtml(option)} - - ${option === hiddenInput.value ? '' : ""} - `; - button.addEventListener("click", function () { - syncIconValue(option); - renderOptions(option); - setOpen(false); - }); - optionsContainer.appendChild(button); - }); - }; - - trigger.addEventListener("click", function () { - const nextIsOpen = panel.classList.contains("is-hidden"); - setOpen(nextIsOpen); - if (nextIsOpen) { - renderOptions(searchInput.value || hiddenInput.value); - } + input.addEventListener("click", function () { + openSharedIconPicker(input, syncIconValue); }); - searchInput.addEventListener("input", function () { - renderOptions(searchInput.value); - }); - - searchInput.addEventListener("keydown", function (event) { - if (event.key === "Enter") { - event.preventDefault(); - syncIconValue(searchInput.value); - renderOptions(searchInput.value); - setOpen(false); - } - }); - - wrapper.addEventListener("focusout", function () { - window.setTimeout(function () { - if (!wrapper.contains(document.activeElement)) { - setOpen(false); - } - }, 0); + button.addEventListener("click", function () { + openSharedIconPicker(input, syncIconValue); }); syncIconValue(block.icon || ""); - renderOptions(block.icon || ""); + } + + function openSharedIconPicker(input, onPicked) { + if (!window.IconPicker || typeof window.IconPicker.open !== "function") { + return; + } + + patchSharedIconPicker(); + window.__policyBlockIconPickerInput = input; + window.__policyBlockIconPickerOnPicked = onPicked; + window.IconPicker.open(input); + } + + function patchSharedIconPicker() { + if (!window.IconPicker || window.IconPicker.__policyBlockPatched) { + 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.__policyBlockIconPickerInput; + const onPicked = window.__policyBlockIconPickerOnPicked; + + if (!activeInput || typeof onPicked !== "function") { + return; + } + + activeInput.dataset.iconPickerPreviewPrefix = extractIconStyle(value) + || resolveCalloutIconPreviewPrefix(activeInput.value || value); + + onPicked(extractIconName(value)); + activeInput.dispatchEvent(new Event("input", { bubbles: true })); + activeInput.dispatchEvent(new Event("change", { bubbles: true })); + + window.__policyBlockIconPickerInput = null; + window.__policyBlockIconPickerOnPicked = null; + }; + + window.IconPicker.pick = patchedPick; + window.IconPickerPick = patchedPick; + window.IconPicker.__policyBlockPatched = true; + } + + function syncCalloutIconPreview(input) { + if (!input) { + return; + } + + const previewCell = input.closest(".input-group")?.querySelector(".icon-preview-cell"); + if (!previewCell) { + return; + } + + const previewClass = resolveCalloutIconPreviewClass(input.value, input); + previewCell.innerHTML = previewClass ? `` : ""; + + loadIconStyleLookup().then(function () { + const nextPrefix = resolveCalloutIconPreviewPrefix(input.value, input); + if (nextPrefix !== input.dataset.iconPickerPreviewPrefix) { + input.dataset.iconPickerPreviewPrefix = nextPrefix; + const refreshedPreviewClass = resolveCalloutIconPreviewClass(input.value, input); + previewCell.innerHTML = refreshedPreviewClass + ? `` + : ""; + } + }); + } + + function resolveCalloutIconPreviewClass(iconName, input) { + const normalizedValue = String(iconName || "").trim(); + if (!normalizedValue) { + return ""; + } + + return `${resolveCalloutIconPreviewPrefix(normalizedValue, input)} ${normalizedValue}`.trim(); + } + + function resolveCalloutIconPreviewPrefix(iconName, input) { + const explicitPrefix = String(input?.dataset?.iconPickerPreviewPrefix || "").trim(); + if (explicitPrefix && explicitPrefix !== "fa-solid") { + return explicitPrefix; + } + + const normalizedIconName = extractIconName(iconName); + const knownStyles = window.__policyBlockIconStyleLookup?.[normalizedIconName] || []; + + 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 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 loadIconStyleLookup() { + if (window.__policyBlockIconStyleLookupPromise) { + return window.__policyBlockIconStyleLookupPromise; + } + + window.__policyBlockIconStyleLookupPromise = 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.__policyBlockIconStyleLookup = lookup; + return lookup; + }) + .catch(() => { + window.__policyBlockIconStyleLookup = window.__policyBlockIconStyleLookup || {}; + return window.__policyBlockIconStyleLookup; + }); + + return window.__policyBlockIconStyleLookupPromise; } function bindEditable(element, onChange, options) { @@ -1150,7 +1223,7 @@ callout.className = "callout-block"; callout.dataset.tone = block.tone || "info"; callout.innerHTML = ` - ${block.title ? `
${escapeHtml(block.title)}
` : ""} + ${block.title ? `
${escapeHtml(block.title)}
` : ""}
${block.html || ""}
`; node.appendChild(callout); diff --git a/views/admin/accreditation/partials/editor-script.ejs b/views/admin/accreditation/partials/editor-script.ejs index f7b66fa..f088024 100644 --- a/views/admin/accreditation/partials/editor-script.ejs +++ b/views/admin/accreditation/partials/editor-script.ejs @@ -12,15 +12,6 @@ } const state = JSON.parse(JSON.stringify(initialData)); - const iconOptions = Array.from( - new Set( - (config.tabs || []) - .flatMap((tab) => collectIcons(tab.schema)) - .filter(Boolean), - ), - ); - - ensureIconDatalist(iconOptions); renderAllSections(); document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => { @@ -602,119 +593,191 @@ } function renderIconField(schema, col, parent, key) { - const selected = parent[key] || ""; - const wrapper = document.createElement("div"); - wrapper.className = "cms-icon-combobox"; - const trigger = document.createElement("button"); - trigger.type = "button"; - trigger.className = "cms-icon-dropdown-trigger"; + const group = document.createElement("div"); + group.className = "input-group"; - const triggerValue = document.createElement("div"); - triggerValue.className = "cms-icon-dropdown-value"; + const preview = document.createElement("span"); + preview.className = "input-group-text icon-preview-cell"; + preview.style.minWidth = "38px"; + group.appendChild(preview); - const preview = document.createElement("div"); - preview.className = "cms-icon-preview"; - triggerValue.appendChild(preview); - - const triggerText = document.createElement("div"); - triggerText.className = "cms-icon-dropdown-text"; - triggerValue.appendChild(triggerText); - trigger.appendChild(triggerValue); - - 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 - ? `` - : '--'; - triggerText.innerHTML = value - ? `${escapeHtml(value)}Selected icon` - : 'Select iconNo icon selected'; - }; - - 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 = `${escapeHtml(option)}${parent[key] === option ? '' : ""}`; - 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] || ""); - } + 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); }); - - searchInput.addEventListener("input", function () { - renderOptions(searchInput.value); + input.addEventListener("input", function () { + parent[key] = input.value; }); + group.appendChild(input); - wrapper.addEventListener("focusout", function () { - window.setTimeout(function () { - if (!wrapper.contains(document.activeElement)) { - setOpen(false); - } - }, 0); + const button = document.createElement("button"); + button.type = "button"; + button.className = "btn btn-outline-secondary"; + button.innerHTML = 'Pick Icon'; + button.addEventListener("click", function () { + openIconPickerForInput(input); }); + group.appendChild(button); - searchInput.value = selected; - updateTrigger(selected); - renderOptions(selected); - col.appendChild(wrapper); + 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 ? `` : ""; + + 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 + ? `` + : ""; + } + }); + } + } + + 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]); @@ -892,33 +955,6 @@ return schema.options || []; } - function collectIcons(schema) { - if (!schema) return []; - if (schema.type === "icon") return schema.options || []; - if (schema.type === "object") return (schema.fields || []).flatMap(collectIcons); - if (schema.type === "array") return collectIcons(schema.itemSchema); - if (schema.type === "variant") { - return Object.values(schema.variants || {}).flatMap((variant) => - collectIcons(variant.schema), - ); - } - return []; - } - - function ensureIconDatalist(options) { - const existing = document.getElementById("cms-icon-options"); - if (existing) existing.remove(); - - const dataList = document.createElement("datalist"); - dataList.id = "cms-icon-options"; - options.forEach((option) => { - const item = document.createElement("option"); - item.value = option; - dataList.appendChild(item); - }); - document.body.appendChild(dataList); - } - function applyAutoSequenceToArray(schema, targetArray) { if (!schema || !Array.isArray(targetArray) || schema.itemSchema.type !== "object") { return; diff --git a/views/admin/admissions/calculator-option.ejs b/views/admin/admissions/calculator-option.ejs index 51f7c0d..5839e97 100644 --- a/views/admin/admissions/calculator-option.ejs +++ b/views/admin/admissions/calculator-option.ejs @@ -87,20 +87,23 @@
-
- - -
- -
-
No matching icons
-
<%= fieldConfig.noteIcon?.helpText || "" %>
@@ -136,19 +139,11 @@