refactor(policies): migrate to block-based content editor

Replace the legacy section-based policy editor with a flexible block-based system. This transition enables real-time previews, a wider variety of content types (headings, quotes, callouts), and improved data normalization.
- Implement `policies-block-editor.js` for dynamic content management
- Add `normalizePoliciesDocument` and `validateContent` utilities to handle data migration and integrity
- Update `policiesController.js` to support the new block structure and validation logic
- Migrate `policies.json` from `sections` array to a `content.blocks` structure
- Remove obsolete section editor views and partials
- Update `policiesConfig.js` to reflect the new editor capabilities
This commit is contained in:
Tống Thành Đạt
2026-04-22 10:14:54 +07:00
parent f26b072954
commit cdaddddfeb
10 changed files with 2438 additions and 1265 deletions
+7 -107
View File
@@ -13,7 +13,7 @@ const {
const baseConfig = {
key: "policies",
title: "Policies Management",
subtitle: "Manage the content for the policies page",
subtitle: "Manage policy metadata and block-based content for the policies page",
routeBase: "/admin/policies",
apiPath: "/api/policies",
previewPath: "/policies",
@@ -69,7 +69,7 @@ const baseConfig = {
emptyText: "No policies yet.",
itemActions: [
{
label: "Edit Sections",
label: "Edit Content",
icon: "fas fa-pen-to-square",
hrefTemplate: "/admin/policies/{id}/section",
className: "btn btn-outline-primary btn-sm",
@@ -90,114 +90,14 @@ function createPoliciesSectionEditorConfig(policy, allPolicies = []) {
}));
return {
key: "policySections",
title: `Policy Sections: ${policy.title || policy.id}`,
subtitle: "Manage section content and paragraph order for this policy",
key: "policyContent",
title: `Policy Content: ${policy.title || policy.id}`,
subtitle: "Edit block-based policy content with realtime preview",
routeBase: `/admin/policies/${policy.id}/section`,
previewPath: "/policies",
imageType: "policies",
tabs: [
{
key: "sections",
label: "Sections",
icon: "fas fa-file-lines",
schema: variantList(
"sections",
"Sections",
{
text: {
label: "Text section",
schema: object("section", "Text section", [
text("heading", "Section heading", { maxLength: 60 }),
objectList(
"paragraphs",
"Paragraphs",
[
textarea("text", "Paragraph text", {
maxLength: 500,
rows: 4,
}),
objectList(
"links",
"Inline links",
[
text("label", "Link label", { maxLength: 50 }),
url("href", "Link URL", { maxLength: 255 }),
combobox("tabId", "Switch to policy", {
maxLength: 40,
options: policyOptions,
helpText:
"Optional. Choose another policy to open when this link is clicked.",
}),
],
{
itemLabel: "Link",
sortable: true,
emptyText: "No inline links yet.",
},
),
],
{
itemLabel: "Paragraph",
sortable: true,
emptyText: "No paragraphs yet.",
},
),
]),
},
list: {
label: "List section",
schema: object("section", "List section", [
text("heading", "Section heading", { maxLength: 60 }),
textarea("intro", "Intro text", { maxLength: 220, rows: 3 }),
stringList("items", "List items", {
itemLabel: "List item",
maxLength: 180,
fieldType: "textarea",
sortable: true,
emptyText: "No list items yet.",
}),
]),
},
cards: {
label: "Card section",
schema: object("section", "Card section", [
objectList(
"cards",
"Cards",
[
icon("icon", "Card icon"),
text("title", "Card title", { maxLength: 50 }),
textarea("description", "Card description", {
maxLength: 220,
rows: 4,
}),
object("link", "Card link", [
text("label", "Link label", { maxLength: 50 }),
url("href", "Link URL", { maxLength: 255 }),
combobox("tabId", "Switch to policy", {
maxLength: 40,
options: policyOptions,
}),
]),
],
{
itemLabel: "Card",
sortable: true,
emptyText: "No cards yet.",
},
),
]),
},
},
{
itemLabel: "Section",
sortable: true,
emptyText: "No sections yet.",
},
),
},
],
policyId: policy.id,
policyOptions,
};
}
+404
View File
@@ -0,0 +1,404 @@
const { ensureUniqueIds, slugifyContentId } = require("./contentEditorIds");
const BLOCK_TYPES = new Set([
"heading",
"paragraph",
"list",
"quote",
"divider",
"callout",
]);
function createBlockId(prefix, value, index) {
return slugifyContentId(value, `${prefix}-${index + 1}`);
}
function stripHtml(html = "") {
return String(html || "")
.replace(/<br\s*\/?>/gi, " ")
.replace(/<\/p>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
function escapeHtml(value = "") {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function ensureParagraphHtml(value = "") {
const raw = String(value || "").trim();
if (!raw) {
return "";
}
if (/<[a-z][\s\S]*>/i.test(raw)) {
return raw;
}
return `<p>${escapeHtml(raw)}</p>`;
}
function createInternalAnchor(policyId, label) {
return `<a href="#${escapeHtml(policyId)}" data-policy-id="${escapeHtml(
policyId,
)}" data-link-kind="internal">${escapeHtml(label)}</a>`;
}
function createExternalAnchor(href, label) {
return `<a href="${escapeHtml(href)}">${escapeHtml(label)}</a>`;
}
function paragraphToHtml(paragraph = {}) {
const text = String(paragraph.text || "");
const links = Array.isArray(paragraph.links) ? paragraph.links : [];
let html = escapeHtml(text);
links.forEach((link, index) => {
const label = String(link.label || "").trim();
if (!label) {
return;
}
const anchor = link.tabId
? createInternalAnchor(link.tabId, label)
: link.href
? createExternalAnchor(link.href, label)
: escapeHtml(label);
if (html.includes(escapeHtml(label))) {
html = html.replace(escapeHtml(label), anchor);
return;
}
const suffix = index === 0 ? "" : " ";
html = `${html}${suffix}${anchor}`;
});
return `<p>${html}</p>`;
}
function legacySectionToBlocks(section = {}, sectionIndex = 0) {
const blocks = [];
const heading = String(section.heading || section.title || "").trim();
if (heading) {
blocks.push({
id: createBlockId("heading", heading, sectionIndex),
type: "heading",
level: 2,
html: `<span>${escapeHtml(heading)}</span>`,
});
}
if (section.type === "text") {
const paragraphs = Array.isArray(section.paragraphs) ? section.paragraphs : [];
paragraphs.forEach((paragraph, paragraphIndex) => {
blocks.push({
id: createBlockId(
"paragraph",
paragraph.text || `${heading || "paragraph"}-${paragraphIndex + 1}`,
paragraphIndex,
),
type: "paragraph",
html: paragraphToHtml(paragraph),
});
});
return blocks;
}
if (section.type === "list") {
if (section.intro) {
blocks.push({
id: createBlockId("paragraph", `${heading || "list"}-intro`, sectionIndex),
type: "paragraph",
html: ensureParagraphHtml(section.intro),
});
}
blocks.push({
id: createBlockId("list", heading || `list-${sectionIndex + 1}`, sectionIndex),
type: "list",
style: "unordered",
items: (Array.isArray(section.items) ? section.items : []).map((item, itemIndex) => ({
id: createBlockId("item", item || `item-${itemIndex + 1}`, itemIndex),
html: ensureParagraphHtml(item),
})),
});
return blocks;
}
if (section.type === "cards") {
const cards = Array.isArray(section.cards) ? section.cards : [];
cards.forEach((card, cardIndex) => {
const description = String(card.description || "");
const linkHtml = card.link?.tabId
? `<p>${createInternalAnchor(card.link.tabId, card.link.label || "Open")}</p>`
: card.link?.href
? `<p>${createExternalAnchor(card.link.href, card.link.label || card.link.href)}</p>`
: "";
blocks.push({
id: createBlockId("callout", card.title || `card-${cardIndex + 1}`, cardIndex),
type: "callout",
tone: "info",
title: String(card.title || ""),
icon: String(card.icon || ""),
html: `${ensureParagraphHtml(description)}${linkHtml}`,
});
});
}
return blocks;
}
function legacySectionsToBlocks(sections = []) {
return ensureUniqueIds(
(Array.isArray(sections) ? sections : []).flatMap(legacySectionToBlocks),
(block) => block.id,
(block, index) => stripHtml(block.html) || `${block.type}-${index + 1}`,
"block",
);
}
function normalizeListItems(items = []) {
return ensureUniqueIds(
(Array.isArray(items) ? items : [])
.map((item, index) => {
if (typeof item === "string") {
return {
id: createBlockId("item", item, index),
html: ensureParagraphHtml(item),
};
}
return {
id: item.id || createBlockId("item", stripHtml(item.html), index),
html: ensureParagraphHtml(item.html),
};
})
.filter((item) => item.html),
(item) => item.id,
(item, index) => stripHtml(item.html) || `item-${index + 1}`,
"item",
);
}
function normalizeBlock(block = {}, index = 0) {
const type = BLOCK_TYPES.has(block.type) ? block.type : "paragraph";
const normalized = {
id:
block.id ||
createBlockId(type, block.title || stripHtml(block.html) || type, index),
type,
};
if (type === "heading") {
normalized.level = [1, 2, 3].includes(Number(block.level))
? Number(block.level)
: 2;
normalized.html = ensureParagraphHtml(block.html || block.content || block.title || "");
return normalized;
}
if (type === "paragraph" || type === "quote") {
normalized.html = ensureParagraphHtml(block.html || block.content || "");
if (type === "quote") {
normalized.caption = String(block.caption || "");
}
return normalized;
}
if (type === "list") {
normalized.style = block.style === "ordered" ? "ordered" : "unordered";
normalized.items = normalizeListItems(block.items);
return normalized;
}
if (type === "divider") {
return normalized;
}
normalized.tone = ["info", "warning", "success"].includes(block.tone)
? block.tone
: "info";
normalized.title = String(block.title || "");
normalized.icon = String(block.icon || "");
normalized.html = ensureParagraphHtml(block.html || block.content || "");
return normalized;
}
function normalizeContent(content = {}) {
const blocks = ensureUniqueIds(
(Array.isArray(content.blocks) ? content.blocks : []).map(normalizeBlock),
(block) => block.id,
(block, index) => stripHtml(block.title || block.html) || `${block.type}-${index + 1}`,
"block",
);
return {
blocks,
};
}
function createEmptyContent() {
return {
blocks: [],
};
}
function normalizePolicyContent(policy = {}) {
if (policy.content && typeof policy.content === "object") {
return normalizeContent(policy.content);
}
if (policy.contentByLanguage && typeof policy.contentByLanguage === "object") {
const englishContent = policy.contentByLanguage.en || policy.contentByLanguage.vi;
return normalizeContent(englishContent || createEmptyContent());
}
const migratedBlocks = legacySectionsToBlocks(policy.sections);
return normalizeContent({ blocks: migratedBlocks });
}
function normalizePolicy(policy = {}) {
const normalized = {
...policy,
content: normalizePolicyContent(policy),
};
delete normalized.sections;
delete normalized.contentByLanguage;
return normalized;
}
function normalizePoliciesDocument(data = {}) {
return {
...data,
policies: (Array.isArray(data.policies) ? data.policies : []).map(normalizePolicy),
};
}
function parseAnchorAttributes(source = "") {
const attributes = {};
const regex = /([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
let match = regex.exec(source);
while (match) {
attributes[match[1]] = match[3] || match[4] || "";
match = regex.exec(source);
}
return attributes;
}
function collectLinkErrors(html = "", validPolicyIds = []) {
const errors = [];
const anchorRegex = /<a\b([^>]*)>/gi;
let match = anchorRegex.exec(String(html || ""));
while (match) {
const attrs = parseAnchorAttributes(match[1]);
const href = String(attrs.href || "").trim();
const policyId = String(attrs["data-policy-id"] || "").trim();
const isInternal = String(attrs["data-link-kind"] || "") === "internal";
if (isInternal) {
if (!policyId || !validPolicyIds.includes(policyId)) {
errors.push("Internal link points to an invalid policy.");
}
} else if (
href &&
!/^(https?:\/\/|mailto:|tel:|\/|#)/i.test(href)
) {
errors.push(`Invalid link URL "${href}".`);
}
if (!isInternal && !href) {
errors.push("Link is missing a URL.");
}
match = anchorRegex.exec(String(html || ""));
}
return errors;
}
function validateBlocks(blocks = [], validPolicyIds = []) {
const errors = [];
const warnings = [];
let h1Count = 0;
blocks.forEach((block, index) => {
const label = `Block ${index + 1}`;
if (block.type === "heading" && Number(block.level) === 1) {
h1Count += 1;
}
if (block.type === "divider") {
return;
}
if (block.type === "list") {
if (!Array.isArray(block.items) || block.items.length === 0) {
warnings.push(`${label} has no list items.`);
}
block.items.forEach((item, itemIndex) => {
if (!stripHtml(item.html)) {
warnings.push(`${label} item ${itemIndex + 1} is empty.`);
}
});
errors.push(...collectLinkErrors(JSON.stringify(block.items), validPolicyIds));
return;
}
if (block.type === "callout" && !stripHtml(block.html) && !stripHtml(block.title)) {
warnings.push(`${label} is empty.`);
} else if (!stripHtml(block.html) && block.type !== "divider") {
warnings.push(`${label} is empty.`);
}
errors.push(...collectLinkErrors(block.html, validPolicyIds));
});
if (h1Count > 1) {
errors.push(
`Content has ${h1Count} H1 blocks. Only one H1 is allowed.`,
);
}
return { errors, warnings };
}
function validateContent(content = {}, policyIds = []) {
const normalized = normalizeContent(content || createEmptyContent());
const { errors, warnings } = validateBlocks(normalized.blocks, policyIds);
if (normalized.blocks.length === 0) {
warnings.push("Content is empty.");
}
return {
content: normalized,
errors: Array.from(new Set(errors)),
warnings: Array.from(new Set(warnings)),
};
}
module.exports = {
createEmptyContent,
normalizePolicy,
normalizePoliciesDocument,
normalizePolicyContent,
validateContent,
stripHtml,
};