forked from UKSOURCE/cms.lams
feat(cms): enhance content editors and implement automatic ID generation
Improve the CMS administration interface across multiple pages (Accreditation, Admissions, History, Partnerships, and Policies) with a focus on usability and data integrity. Key changes include: - Implement `ensureUniqueIds` utility to automatically generate and maintain unique slugs for content items, removing the need for manual ID entry in the UI. - Refactor the Admissions calculator to support detailed per-option editing via a new dedicated view and routes. - Replace basic datalists with a custom, searchable icon combobox component for better visual selection. - Update `_renderSingletonPageView` to handle active tab persistence via query parameters. - Streamline editor configurations by removing redundant fields and improving help text. - Enhance the Admissions "Key Dates" editor with a dynamic table interface for managing columns and rows. - Normalize data payloads in controllers to ensure consistent API responses and internal linking.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
function slugifyContentId(value, fallback = "item") {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function ensureUniqueIds(items, getExistingId, getSourceValue, fallbackPrefix) {
|
||||
const usedIds = new Set();
|
||||
|
||||
return (Array.isArray(items) ? items : []).map((item, index) => {
|
||||
const existingId = String(getExistingId(item, index) || "").trim();
|
||||
let nextId = existingId || slugifyContentId(getSourceValue(item, index), `${fallbackPrefix}-${index + 1}`);
|
||||
let suffix = 2;
|
||||
|
||||
while (usedIds.has(nextId)) {
|
||||
nextId = `${existingId || slugifyContentId(getSourceValue(item, index), fallbackPrefix)}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
usedIds.add(nextId);
|
||||
return {
|
||||
...item,
|
||||
id: nextId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
slugifyContentId,
|
||||
ensureUniqueIds,
|
||||
};
|
||||
@@ -3,7 +3,6 @@ const {
|
||||
textarea,
|
||||
image,
|
||||
icon,
|
||||
url,
|
||||
select,
|
||||
combobox,
|
||||
object,
|
||||
@@ -19,40 +18,13 @@ const statusOptions = [
|
||||
module.exports = {
|
||||
key: "accreditation",
|
||||
title: "Accreditation Management",
|
||||
subtitle: "Edit content displayed on the accreditation page",
|
||||
subtitle: "Manage the content for the accreditation page",
|
||||
routeBase: "/admin/accreditation",
|
||||
apiPath: "/api/accreditation",
|
||||
previewPath: "/about/accreditation",
|
||||
dataFile: "accreditation",
|
||||
imageType: "accreditation",
|
||||
tabs: [
|
||||
{
|
||||
key: "trustBanner",
|
||||
label: "Trust Banner",
|
||||
icon: "fas fa-shield-check",
|
||||
schema: object("trustBanner", "Trust banner", [
|
||||
icon("icon", "Banner icon"),
|
||||
text("text", "Banner message", {
|
||||
maxLength: 120,
|
||||
helpText:
|
||||
"This is the slim credibility strip shown above the accreditation content.",
|
||||
}),
|
||||
objectList(
|
||||
"links",
|
||||
"Banner links",
|
||||
[
|
||||
text("label", "Link label", { maxLength: 40 }),
|
||||
url("href", "Link URL", { maxLength: 255 }),
|
||||
icon("icon", "Link icon"),
|
||||
],
|
||||
{
|
||||
itemLabel: "Link",
|
||||
sortable: true,
|
||||
emptyText: "No trust banner links yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "hero",
|
||||
label: "Hero",
|
||||
@@ -72,8 +44,7 @@ module.exports = {
|
||||
itemLabel: "Tab",
|
||||
maxLength: 30,
|
||||
sortable: true,
|
||||
helpText:
|
||||
"The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.",
|
||||
helpText: "Add and reorder the category tabs shown on the page.",
|
||||
}),
|
||||
objectList(
|
||||
"items",
|
||||
@@ -82,7 +53,7 @@ module.exports = {
|
||||
icon("icon", "Fallback icon"),
|
||||
image("image", "Card image", {
|
||||
imageHint: "Recommended 118x58 px minimum visible ratio",
|
||||
helpText: "Logo or badge used at the top of the card.",
|
||||
helpText: "Logo or badge shown at the top of the card.",
|
||||
}),
|
||||
select("status", "Status", statusOptions),
|
||||
combobox("category", "Category", {
|
||||
@@ -91,19 +62,13 @@ module.exports = {
|
||||
}),
|
||||
text("title", "Card title", {
|
||||
maxLength: 17,
|
||||
helpText: "Frontend title space is capped at 17 characters.",
|
||||
helpText: "Keep this very short. Around 17 characters fits best in the card title area.",
|
||||
}),
|
||||
textarea("description", "Card description", {
|
||||
maxLength: 300,
|
||||
rows: 5,
|
||||
helpText: "Frontend description preview is capped at 300 characters.",
|
||||
helpText: "Keep this concise. Around 300 characters fits best in the card description area.",
|
||||
}),
|
||||
text("scopeLabel", "Scope label", { maxLength: 20 }),
|
||||
text("scope", "Scope text", { maxLength: 60 }),
|
||||
text("validUntilLabel", "Validity label", { maxLength: 24 }),
|
||||
text("validUntil", "Validity text", { maxLength: 40 }),
|
||||
text("buttonLabel", "Certificate button label", { maxLength: 30 }),
|
||||
url("certificateHref", "Certificate URL", { maxLength: 255 }),
|
||||
],
|
||||
{
|
||||
itemLabel: "Accreditation",
|
||||
|
||||
@@ -14,7 +14,7 @@ const {
|
||||
module.exports = {
|
||||
key: "admissions",
|
||||
title: "Admissions Management",
|
||||
subtitle: "Edit content displayed on the admissions page",
|
||||
subtitle: "Manage the content for the admissions page",
|
||||
routeBase: "/admin/admissions",
|
||||
apiPath: "/api/admissions",
|
||||
previewPath: "/admissions",
|
||||
@@ -43,7 +43,6 @@ module.exports = {
|
||||
label: "Admissions Process",
|
||||
icon: "fas fa-list-ol",
|
||||
schema: object("process", "Admissions process", [
|
||||
text("id", "Section key", { maxLength: 40 }),
|
||||
text("title", "Section title", { maxLength: 60 }),
|
||||
textarea("description", "Section description", {
|
||||
maxLength: 180,
|
||||
@@ -76,7 +75,6 @@ module.exports = {
|
||||
label: "Eligibility",
|
||||
icon: "fas fa-check-circle",
|
||||
schema: object("eligibility", "Eligibility", [
|
||||
text("id", "Section key", { maxLength: 40 }),
|
||||
text("title", "Section title", { maxLength: 60 }),
|
||||
objectList(
|
||||
"cards",
|
||||
@@ -104,7 +102,6 @@ module.exports = {
|
||||
label: "Tuition",
|
||||
icon: "fas fa-chart-column",
|
||||
schema: object("tuition", "Tuition", [
|
||||
text("id", "Section key", { maxLength: 40 }),
|
||||
text("title", "Section title", { maxLength: 60 }),
|
||||
text("chartTitle", "Chart title", { maxLength: 60 }),
|
||||
textarea("chartDescription", "Chart description", {
|
||||
@@ -120,6 +117,7 @@ module.exports = {
|
||||
stringList("values", "Data points", {
|
||||
itemLabel: "Point",
|
||||
fieldType: "number",
|
||||
min: 0,
|
||||
sortable: true,
|
||||
}),
|
||||
],
|
||||
@@ -137,30 +135,7 @@ module.exports = {
|
||||
label: "Key Dates",
|
||||
icon: "fas fa-calendar-days",
|
||||
schema: object("keyDates", "Key dates", [
|
||||
text("id", "Section key", { maxLength: 40 }),
|
||||
text("title", "Section title", { maxLength: 60 }),
|
||||
stringList("columns", "Table columns", {
|
||||
itemLabel: "Column",
|
||||
maxLength: 40,
|
||||
sortable: true,
|
||||
}),
|
||||
objectList(
|
||||
"rows",
|
||||
"Table rows",
|
||||
[
|
||||
text("term", "Term", { maxLength: 40 }),
|
||||
text("applicationDeadline", "Application deadline", {
|
||||
maxLength: 40,
|
||||
}),
|
||||
text("classesStart", "Classes start", { maxLength: 40 }),
|
||||
],
|
||||
{
|
||||
itemLabel: "Row",
|
||||
sortable: true,
|
||||
itemTitleKey: "term",
|
||||
emptyText: "No key date rows yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
@@ -173,20 +148,37 @@ module.exports = {
|
||||
maxLength: 120,
|
||||
rows: 3,
|
||||
}),
|
||||
stringList("modelOptions", "Model options", {
|
||||
itemLabel: "Option",
|
||||
maxLength: 30,
|
||||
sortable: true,
|
||||
}),
|
||||
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 }),
|
||||
object("cta", "Button", linkFields("Button")),
|
||||
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 }),
|
||||
],
|
||||
{
|
||||
itemLabel: "Option",
|
||||
sortable: true,
|
||||
itemTitleKey: "label",
|
||||
emptyText: "No calculator options yet.",
|
||||
itemActions: [
|
||||
{
|
||||
label: "Edit option",
|
||||
icon: "fas fa-pen",
|
||||
className: "btn btn-outline-primary btn-sm",
|
||||
hrefTemplate: "/admin/admissions/calculator/{id}",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,7 +14,7 @@ const {
|
||||
module.exports = {
|
||||
key: "history",
|
||||
title: "History Management",
|
||||
subtitle: "Edit content displayed on the history page",
|
||||
subtitle: "Manage the content for the history page",
|
||||
routeBase: "/admin/history",
|
||||
apiPath: "/api/history",
|
||||
previewPath: "/about/history",
|
||||
@@ -29,7 +29,10 @@ module.exports = {
|
||||
icon("icon", "Highlight icon"),
|
||||
text("text", "Message", { maxLength: 110 }),
|
||||
text("linkLabel", "Link label", { maxLength: 30 }),
|
||||
url("href", "Link URL", { maxLength: 255 }),
|
||||
url("href", "Link URL", {
|
||||
maxLength: 255,
|
||||
helpText: "Use an anchor or website path only. Examples: #milestones, /about/history, /contact",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
{
|
||||
@@ -47,14 +50,11 @@ module.exports = {
|
||||
label: "Filter Controls",
|
||||
icon: "fas fa-filter",
|
||||
schema: object("filters", "Filter controls", [
|
||||
text("yearLabel", "Year filter label", { maxLength: 30 }),
|
||||
text("categoryLabel", "Category filter label", { maxLength: 30 }),
|
||||
text("buttonLabel", "Apply button label", { maxLength: 30 }),
|
||||
stringList("yearOptions", "Year options", {
|
||||
itemLabel: "Year option",
|
||||
maxLength: 30,
|
||||
sortable: true,
|
||||
helpText: "Reorder to control the dropdown order shown to users.",
|
||||
helpText: "Reorder these options to change the order in the Year range dropdown.",
|
||||
}),
|
||||
stringList("categoryOptions", "Category options", {
|
||||
itemLabel: "Category option",
|
||||
@@ -68,13 +68,13 @@ module.exports = {
|
||||
label: "Timeline",
|
||||
icon: "fas fa-clock-rotate-left",
|
||||
schema: object("timeline", "Timeline", [
|
||||
text("loadMoreLabel", "Load more button label", { maxLength: 40 }),
|
||||
objectList(
|
||||
"items",
|
||||
"Milestones",
|
||||
[
|
||||
text("id", "Milestone key", { maxLength: 60 }),
|
||||
text("year", "Year", { maxLength: 10 }),
|
||||
text("year", "Year", {
|
||||
maxLength: 30,
|
||||
}),
|
||||
combobox("yearRange", "Year range", {
|
||||
maxLength: 30,
|
||||
optionsPath: "filters.yearOptions",
|
||||
@@ -90,7 +90,7 @@ module.exports = {
|
||||
rows: 4,
|
||||
}),
|
||||
image("image", "Milestone image", {
|
||||
imageHint: "Recommended 436x190 px",
|
||||
imageHint: "Recommended 436x190 px, 16:9 aspect ratio.",
|
||||
helpText: "Wide image used inside the milestone card.",
|
||||
}),
|
||||
text("imageAlt", "Image alt text", { maxLength: 120 }),
|
||||
|
||||
@@ -24,7 +24,7 @@ const widthOptions = [
|
||||
module.exports = {
|
||||
key: "partnerships",
|
||||
title: "Partnerships Management",
|
||||
subtitle: "Edit content displayed on the partnerships page",
|
||||
subtitle: "Manage the content for the partnerships page",
|
||||
routeBase: "/admin/partnerships",
|
||||
apiPath: "/api/partnerships",
|
||||
previewPath: "/about/partnerships",
|
||||
@@ -62,19 +62,12 @@ module.exports = {
|
||||
maxLength: 30,
|
||||
placeholder: "Industry",
|
||||
sortable: true,
|
||||
helpText:
|
||||
"The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.",
|
||||
helpText: "Add and reorder the category tabs shown on the page.",
|
||||
}),
|
||||
text("loadMoreLabel", "Load more button label", { maxLength: 40 }),
|
||||
objectList(
|
||||
"partners",
|
||||
"Partners",
|
||||
[
|
||||
text("id", "Partner key", {
|
||||
maxLength: 50,
|
||||
helpText:
|
||||
"Use a short unique key. It keeps each card state separate on the frontend.",
|
||||
}),
|
||||
text("name", "Partner name", { maxLength: 90 }),
|
||||
combobox("category", "Category", {
|
||||
maxLength: 30,
|
||||
@@ -83,7 +76,7 @@ module.exports = {
|
||||
textarea("summary", "Card summary", {
|
||||
maxLength: 130,
|
||||
rows: 3,
|
||||
helpText: "The card preview is capped at 130 characters.",
|
||||
helpText: "Keep this short. Around 130 characters works best in the card layout.",
|
||||
}),
|
||||
image("logo", "Partner logo", {
|
||||
imageHint: "Recommended 105x80 px minimum visible ratio",
|
||||
@@ -127,11 +120,6 @@ module.exports = {
|
||||
"fields",
|
||||
"Form fields",
|
||||
[
|
||||
text("id", "Field key", {
|
||||
maxLength: 40,
|
||||
helpText:
|
||||
"Use a short unique key such as firstName or organization.",
|
||||
}),
|
||||
text("label", "Field label", { maxLength: 40 }),
|
||||
text("placeholder", "Placeholder text", { maxLength: 80 }),
|
||||
select("type", "Field type", inquiryFieldOptions),
|
||||
@@ -153,7 +141,6 @@ module.exports = {
|
||||
emptyText: "No inquiry fields yet.",
|
||||
},
|
||||
),
|
||||
text("submitLabel", "Submit button label", { maxLength: 40 }),
|
||||
]),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -13,7 +13,7 @@ const {
|
||||
const baseConfig = {
|
||||
key: "policies",
|
||||
title: "Policies Management",
|
||||
subtitle: "Edit content displayed on the policies page",
|
||||
subtitle: "Manage the content for the policies page",
|
||||
routeBase: "/admin/policies",
|
||||
apiPath: "/api/policies",
|
||||
previewPath: "/policies",
|
||||
@@ -56,7 +56,6 @@ const baseConfig = {
|
||||
"policies",
|
||||
"Policies",
|
||||
[
|
||||
text("id", "Policy key", { maxLength: 40 }),
|
||||
text("navLabel", "Sidebar label", { maxLength: 40 }),
|
||||
text("title", "Policy title", { maxLength: 70 }),
|
||||
text("effectiveDate", "Effective date", { maxLength: 50 }),
|
||||
@@ -128,7 +127,7 @@ function createPoliciesSectionEditorConfig(policy, allPolicies = []) {
|
||||
maxLength: 40,
|
||||
options: policyOptions,
|
||||
helpText:
|
||||
"Optional. Choose another policy to switch tabs on the frontend.",
|
||||
"Optional. Choose another policy to open when this link is clicked.",
|
||||
}),
|
||||
],
|
||||
{
|
||||
|
||||
@@ -112,6 +112,9 @@ const stringList = (key, label, options = {}) => ({
|
||||
fieldType: options.fieldType || "text",
|
||||
label: options.itemLabel || "Item",
|
||||
maxLength: options.maxLength,
|
||||
min: options.min,
|
||||
max: options.max,
|
||||
step: options.step,
|
||||
placeholder: options.placeholder,
|
||||
helpText: options.itemHelpText,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user