forked from UKSOURCE/cms.lams
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(/ /gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeHtml(value = "") {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user