feat(cms): enhance policy editor with fullscreen mode and date picking

Improve the policy content management experience by adding a dedicated fullscreen editing mode and a structured date picker for effective dates.
- Implement fullscreen toggle in `policies-block-editor.js` with corresponding CSS for an expanded workspace
- Add `date` field type to `sharedFields.js` and integrate it into `policiesConfig.js`
- Implement date parsing and formatting utilities in `editor-script.ejs` to handle "Effective Date" strings
- Update `policiesController.js` to use `updateOne` for more reliable document persistence and state reloading
- Add support for `ICON_OPTIONS` in the policy editor configuration
- Refine editor UI layout and overflow handling for better stability with long content strings
This commit is contained in:
Tống Thành Đạt
2026-04-22 12:02:00 +07:00
parent cdaddddfeb
commit a44d005c13
7 changed files with 620 additions and 25 deletions
+192 -4
View File
@@ -7,6 +7,7 @@
const previewContent = document.getElementById("previewContent");
const blockTypeSelect = document.getElementById("blockTypeSelect");
const addBlockButton = document.getElementById("addBlockButton");
const toggleFullscreenButton = document.getElementById("toggleFullscreenButton");
const validationBanner = document.getElementById("validationBanner");
const validationSummary = document.getElementById("validationSummary");
const previewDevice = document.getElementById("previewDevice");
@@ -43,6 +44,7 @@
toolbarTarget: null,
sortable: null,
previewTargetTimer: null,
isFullscreen: false,
};
const stylePalette = [
@@ -50,6 +52,7 @@
{ value: "warning", label: "Warning" },
{ value: "success", label: "Success" },
];
const iconOptions = Array.isArray(config.iconOptions) ? config.iconOptions : [];
bindStaticEvents();
render();
@@ -80,6 +83,12 @@
insertBlock(blockTypeSelect.value || "paragraph");
});
if (toggleFullscreenButton) {
toggleFullscreenButton.addEventListener("click", function () {
setEditorFullscreen(!ui.isFullscreen);
});
}
form.querySelectorAll("[data-intent]").forEach((button) => {
button.addEventListener("click", function () {
intentInput.value = this.dataset.intent || "save";
@@ -101,9 +110,31 @@
});
document.addEventListener("selectionchange", handleSelectionChange);
root.addEventListener(
"wheel",
function (event) {
if (!ui.isFullscreen) {
return;
}
const interactiveTarget = event.target.closest(
".icon-combobox-panel, .icon-combobox-options",
);
if (interactiveTarget) {
return;
}
root.scrollTop += event.deltaY;
event.preventDefault();
},
{ passive: false },
);
document.addEventListener("click", handleGlobalClick);
document.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
if (ui.isFullscreen) {
setEditorFullscreen(false);
}
hideFloatingToolbar();
hideLinkPopover();
hideSlashMenu();
@@ -164,11 +195,39 @@
hideLinkPopover();
hideSlashMenu();
updatePreviewModeButtons();
updateFullscreenToggle();
renderBlocks();
renderPreview();
renderValidation(validateState());
}
function setEditorFullscreen(nextValue) {
ui.isFullscreen = Boolean(nextValue);
root.classList.toggle("is-editor-fullscreen", ui.isFullscreen);
document.body.classList.toggle("policy-editor-fullscreen", ui.isFullscreen);
updateFullscreenToggle();
}
function updateFullscreenToggle() {
if (!toggleFullscreenButton) {
return;
}
const label = toggleFullscreenButton.querySelector('[data-role="fullscreen-label"]');
const icon = toggleFullscreenButton.querySelector('[data-role="fullscreen-icon"]');
toggleFullscreenButton.setAttribute("aria-pressed", ui.isFullscreen ? "true" : "false");
toggleFullscreenButton.classList.toggle("btn-outline-secondary", !ui.isFullscreen);
toggleFullscreenButton.classList.toggle("btn-outline-primary", ui.isFullscreen);
if (label) {
label.textContent = ui.isFullscreen ? "Exit full screen" : "Full screen edit";
}
if (icon) {
icon.className = `fas ${ui.isFullscreen ? "fa-compress" : "fa-expand"} me-2`;
}
}
function updatePreviewModeButtons() {
root.querySelectorAll("[data-preview-mode]").forEach((button) => {
button.classList.toggle("is-active", button.dataset.previewMode === ui.previewMode);
@@ -490,7 +549,25 @@
</div>
<div>
<label class="form-label fw-semibold">Icon</label>
<input type="text" class="form-control" data-field="icon" value="${escapeAttribute(block.icon || "")}" placeholder="fa-circle-info" />
<div class="icon-combobox" data-role="icon-combobox">
<input type="hidden" data-field="icon" value="${escapeAttribute(block.icon || "")}" />
<button type="button" class="icon-combobox-trigger">
<span class="icon-combobox-value">
<span class="icon-combobox-preview">
${block.icon ? `<i class="fa-solid ${escapeAttribute(block.icon)}"></i>` : '<i class="fas fa-icons"></i>'}
</span>
<span class="icon-combobox-text">
<strong>${escapeHtml(block.icon || "Select icon")}</strong>
</span>
</span>
<i class="fas fa-chevron-down icon-combobox-caret"></i>
</button>
<div class="icon-combobox-panel is-hidden">
<input type="text" class="form-control icon-combobox-search" placeholder="Search icon name" value="${escapeAttribute(block.icon || "")}" />
<div class="icon-combobox-options"></div>
<div class="icon-combobox-empty is-hidden">No matching icons. Press Enter to use custom value.</div>
</div>
</div>
</div>
<div>
<label class="form-label fw-semibold">Title</label>
@@ -506,12 +583,16 @@
block.html = value;
});
body.querySelectorAll("[data-field]").forEach((input) => {
input.addEventListener("input", function () {
initializeIconCombobox(body.querySelector('[data-role="icon-combobox"]'), block);
body.querySelectorAll("input[data-field], select[data-field], textarea[data-field]").forEach((input) => {
const syncField = function () {
block[this.dataset.field] = this.value;
renderPreview();
renderValidation(validateState());
});
};
input.addEventListener("input", syncField);
input.addEventListener("change", syncField);
});
return;
}
@@ -580,6 +661,113 @@
return editor;
}
function initializeIconCombobox(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");
if (!hiddenInput || !trigger || !panel || !searchInput || !optionsContainer || !emptyState || !preview || !title) {
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;
block.icon = nextValue;
preview.innerHTML = nextValue
? `<i class="fa-solid ${escapeAttribute(nextValue)}"></i>`
: '<i class="fas fa-icons"></i>';
title.textContent = nextValue || "Select icon";
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 = `
<span class="icon-combobox-option-main">
<i class="fa-solid ${escapeAttribute(option)}"></i>
<span>${escapeHtml(option)}</span>
</span>
${option === hiddenInput.value ? '<i class="fas fa-check small"></i>' : ""}
`;
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);
}
});
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);
});
syncIconValue(block.icon || "");
renderOptions(block.icon || "");
}
function bindEditable(element, onChange, options) {
element.addEventListener("input", function () {
normalizeEditorHtml(element);