UNPKG

@gov-cy/govcy-express-services

Version:

An Express-based system that dynamically renders services using @gov-cy/govcy-frontend-renderer and posts data to a submission API.

561 lines (513 loc) 21.3 kB
import { computeTaskListStatus } from "../utils/govcyTaskList.mjs"; import * as govcyResources from "../resources/govcyResources.mjs"; import * as dataLayer from "../utils/govcyDataLayer.mjs"; import { logger } from "../utils/govcyLogger.mjs"; import { handleMiddlewareError } from "../utils/govcyUtils.mjs"; // The CSS classes for each task status const STATUS_TAG_CLASSES = { NOT_STARTED: "govcy-tag-gray", IN_PROGRESS: "govcy-tag-cyan", COMPLETED: "", SKIPPED: "govcy-tag-gray" }; /** * Task list GET middleware – mirrors the bespoke Update My Details handler, but * instead of rebuilding the UMD template it composes a light-weight renderer * template that shows: optional top elements, a localized overall status * summary, the GOV.CY taskList element, and a continue button. * * @param {object} req Express request object * @param {object} res Express response object * @param {Function} next Express next callback * @param {object} page Task list page configuration * @param {object} service Service data (req.serviceData) */ export function govcyTaskListHandler(req, res, next, page, service) { try { const { siteId, pageUrl } = req.params; const lang = req.globalLang || service?.site?.lang || "el"; // Handle the route const route = req.query?.route === "review" ? "review" : ""; const taskListConfig = page?.taskList || {}; const taskPages = Array.isArray(taskListConfig.taskPages) ? taskListConfig.taskPages : []; const showSkippedTasks = taskListConfig.showSkippedTasks === true; // Compute task statuses and overall summary using the same logic as review const summary = computeTaskListStatus(req, siteId, service, taskPages); // Start with a simple form scaffold and progressively append renderer elements const pageTemplate = buildBaseTemplate(req, siteId, page, route); const formElements = pageTemplate.sections[0].elements[0].params.elements; // Surface any POST validation errors that were stored in the session const validationErrors = dataLayer.getPageValidationErrors(req.session, siteId, pageUrl); if (validationErrors?.errorSummary?.length > 0) { formElements.push( govcyResources.errorSummary(validationErrors.errorSummary, { body: validationErrors.body, linkToContinue: validationErrors.linkToContinue }) ); } // Allow services to prepend arbitrary content before the status table if (Array.isArray(taskListConfig.topElements) && taskListConfig.topElements.length > 0) { formElements.push(...deepClone(taskListConfig.topElements)); } // High-level status summary (localized tag + completion counter) formElements.push(buildOverallStatusSection(summary, showSkippedTasks)); // Converts the raw computeTaskListStatus output into renderer taskList rows const taskItems = buildTaskListItems({ tasks: summary.tasks, siteId, lang, route, showSkippedTasks }); if (taskItems.length > 0) { // Render the GOV.CY task list component with per-row tags formElements.push({ element: "taskList", params: { id: `${pageUrl}-task-list`, lang, items: taskItems } }); } else { // Defensive fallback if taskPages is empty/misconfigured formElements.push({ element: "inset", params: { text: govcyResources.staticResources.text.taskListEmptyState } }); } // Continue button keeps navigation consistent with standard forms formElements.push(buildContinueButton(taskListConfig)); if (taskListConfig.hasBackLink) { // Optional backlink replicates the pattern used in UMD/multipleThings pageTemplate.sections.unshift({ name: "beforeMain", elements: [{ element: "backLink", params: {} }] }); } req.processedPage = { pageData: { site: service?.site, pageData: { title: page?.pageData?.title, layout: page?.pageData?.layout || "layouts/govcyBase.njk", mainLayout: page?.pageData?.mainLayout || "two-third" } }, pageTemplate }; return next(); } catch (error) { logger.error("Failed to render task list page", { siteId: req.params?.siteId, pageUrl: req.params?.pageUrl, message: error?.message }); return next(error); } } /** * Returns a minimal page template with a single <form> element. The caller will * append the sections/elements for errors, headers, and task lists. * * @param {object} req Express request * @param {string} siteId Site identifier * @param {object} page Page configuration * @param {string} route Optional review route flag * @returns {object} Renderer page template scaffold */ function buildBaseTemplate(req, siteId, page, route) { return { sections: [ { name: "main", elements: [ { element: "form", params: { action: govcyResources.constructPageUrl(siteId, page?.pageData?.url, route), method: "POST", elements: [govcyResources.csrfTokenInput(req.csrfToken())] } } ] } ] }; } /** * Creates the HTML block that sits above the task list, showing the localized * overall status and how many tasks are complete. The GOV.CY renderer does not * have this element pre-built yet, so we emit it via htmlElement. * * @param {{status:string,tasks:Array}} summary Result from computeTaskListStatus * @param {boolean} showSkippedTasks Whether skipped rows are visible to users * @returns {object} htmlElement renderer object */ function buildOverallStatusSection(summary, showSkippedTasks) { const statusKey = summary?.status || "NOT_STARTED"; // get localized status const localizedStatus = govcyResources.staticResources.text.taskListStatus[statusKey] || govcyResources.staticResources.text.taskListStatus.NOT_STARTED; // Remove SKIPPED rows when the service opted not to show them let displayedTasks = filterDisplayedTasks(summary?.tasks || [], showSkippedTasks); // Count how many tasks are COMPLETED let completedCount = displayedTasks.filter(task => task.status === "COMPLETED").length; // Count total tasks (after filtering out SKIPPED if they are hidden) let totalCount = displayedTasks.filter(task => task.status !== "SKIPPED").length; // Build completion summary text with placeholders replaced, e.g. "You have completed 2 of 5 tasks" const summaryTemplate = govcyResources.staticResources.text.taskListCompletionSummary; const summaryText = replacePlaceholders( summaryTemplate, { completed: completedCount, total: totalCount } ); // Get localized overall status label const overallLabel = govcyResources.staticResources.text.taskListOverallLabel; return { element: "htmlElement", params: { text: { en: buildOverallHtml(overallLabel.en, localizedStatus.en, summaryText.en), el: buildOverallHtml(overallLabel.el, localizedStatus.el, summaryText.el), tr: buildOverallHtml(overallLabel.tr, localizedStatus.tr, summaryText.tr) } } }; } function buildOverallHtml(label, statusText, summaryLine) { // return `<section class="govcy-mb-4"> // <p class="govcy-fs-6 govcy-fw-400 govcy-text-muted">${label}</p> // <p class="govcy-fs-5 govcy-fw-700">${statusText}</p> // <p>${summaryLine}</p> // </section>`; return `<section class="govcy-mb-4"> <p>${summaryLine}</p> </section>`; } /** * Converts the raw computeTaskListStatus output into renderer taskList rows, * adding localized status tags, per-row links, and optional descriptions. * * @param {object} opts * @param {Array} opts.tasks Array of per-page status payloads * @param {string} opts.siteId Site identifier * @param {string} opts.lang Current language (used for localized URLs) * @param {string} opts.route Route query flag, e.g. "review" * @param {boolean} opts.showSkippedTasks Whether to include SKIPPED rows * @returns {Array} GOV.CY renderer task list items */ function buildTaskListItems({ tasks = [], siteId, lang, route, showSkippedTasks }) { const items = []; for (const task of tasks) { if (!task) continue; if (task.status === "SKIPPED" && !showSkippedTasks) { // Hidden rows still factor into overall completion but are not shown continue; } const statusKey = normalizeStatus(task.status); const statusText = statusKey === "SKIPPED" ? govcyResources.staticResources.text.taskListNotApplicable : govcyResources.staticResources.text.taskListStatus[statusKey] || govcyResources.staticResources.text.taskListStatus.NOT_STARTED; const title = task.title || govcyResources.staticResources.text.untitled; const href = statusKey === "SKIPPED" ? null : buildTaskLink(siteId, task.pageUrl, route); items.push({ id: `${sanitizeId(task.pageUrl)}-task`, task: { text: title, ...(href ? { link: href } : {}) }, description: task?.description || null, // Optional copy from service config status: { text: statusText, classes: STATUS_TAG_CLASSES[statusKey] } }); } return items; } /** * Builds the primary button element. Services can override the label via * taskList.continueButtonText; otherwise the standard Continue copy is used. * * @param {object} taskListConfig Task list config block * @returns {object} button renderer element */ function buildContinueButton(taskListConfig) { const configuredText = taskListConfig?.continueButtonText; let textObject = govcyResources.staticResources.text.continue; if (configuredText) { if (typeof configuredText === "string") { textObject = { el: configuredText, en: configuredText, tr: configuredText }; } else if (typeof configuredText === "object") { textObject = { ...textObject, ...configuredText }; } } return { element: "button", params: { variant: "primary", type: "submit", text: textObject } }; } /** * Removes SKIPPED rows when the service opted not to show them. This ensures * the completion summary only counts the rows the user can see. * * @param {Array} tasks Raw tasks array * @param {boolean} showSkippedTasks Whether to include skipped rows * @returns {Array} Filtered tasks */ function filterDisplayedTasks(tasks, showSkippedTasks) { if (showSkippedTasks) { return tasks; } return tasks.filter(task => task.status !== "SKIPPED"); } /** * Replaces {{completed}} and {{total}} placeholders while preserving the * multilingual structure of the static resource definition. * * @param {object} templateObj Multilingual template object * @param {{completed:number,total:number}} values Replacement values * @returns {object} Multilingual object with replacements applied */ function replacePlaceholders(templateObj, values) { const build = (text) => { if (typeof text !== "string") return ""; return text .replace("{{completed}}", values.completed) .replace("{{total}}", values.total); }; return { en: build(templateObj?.en || templateObj?.el || templateObj?.tr || ""), el: build(templateObj?.el || templateObj?.en || templateObj?.tr || ""), tr: build(templateObj?.tr || templateObj?.en || templateObj?.el || "") }; } /** * Generates the hyperlink for a task row. Custom URLs (starting with /) are * preserved; service-relative URLs go through constructPageUrl so language and * review routes behave like normal navigation. * * @param {string} siteId Site identifier * @param {string} pageUrl Page URL from the task config * @param {string} route Optional route query * @returns {string|null} Resolved href or null when no link should be shown */ function buildTaskLink(siteId, pageUrl, route) { if (!pageUrl) return null; if (pageUrl.startsWith("/")) { const hasQuery = pageUrl.includes("?"); if (!route) return pageUrl; return `${pageUrl}${hasQuery ? "&" : "?"}route=${route}`; } return govcyResources.constructPageUrl(siteId, pageUrl, route); } /** * Normalizes a pageUrl into a DOM-friendly ID to satisfy renderer requirements. * * @param {string} pageUrl Page URL * @returns {string} Sanitized ID string */ function sanitizeId(pageUrl = "") { return pageUrl .replace(/^\//, "") .replace(/[^a-zA-Z0-9-_]/g, "-"); } /** * Normalizes status strings so renderer logic can rely on a constrained set of * values. Unknown values revert to NOT_STARTED for safety. * * @param {string} statusKey Raw status key * @returns {string} Normalized status constant */ function normalizeStatus(statusKey = "") { const upper = (statusKey || "").toUpperCase(); if (["NOT_STARTED", "IN_PROGRESS", "COMPLETED", "SKIPPED"].includes(upper)) { return upper; } return "NOT_STARTED"; } /** * Helper for cloning config snippets before injecting them in the template. * Using JSON stringify/parse is fine here because the config only contains * simple data structures supported by the renderer schema. * * @param {*} value Serializable value * @returns {*} Deep copy of the value */ function deepClone(value) { return JSON.parse(JSON.stringify(value)); } /** * Handles POST submissions for task-list pages. These pages do not carry form * data; instead we recompute each task's status and decide whether the user can * continue. When outstanding tasks exist we persist a tailored error summary so * the renderer can surface actionable guidance. * * @param {object} req Express request * @param {object} res Express response * @param {Function} next Express next callback * @param {object} ctx Convenience bundle with page, service, siteId, pageUrl * @returns {object|void} */ export function handleTaskListPost(req, res, next, { page, service, siteId, pageUrl }) { // Task list pages do not have form data, but we still need to validate the current state of the world to see if they can continue. const taskPages = Array.isArray(page?.taskList?.taskPages) ? page.taskList.taskPages : []; const summary = computeTaskListStatus(req, siteId, service, taskPages); const nextPageHref = resolveTaskListNextPage(page, siteId, req); if (summary.status === "COMPLETED") { if (!nextPageHref) { return handleMiddlewareError("Task list page missing nextPage destination", 500, next); } return res.redirect(nextPageHref); } // Build one error-summary row per incomplete task to highlight next steps. const summaryItems = buildTaskListErrorSummary( summary.tasks, siteId, typeof req.query.route === "string" ? req.query.route : undefined ); summaryItems.unshift(govcyResources.staticResources.text.taskListCompleteAll); const allowContinue = Boolean(page?.taskList?.linkToContinue) && nextPageHref && req.query.route !== "review"; const options = {}; if (allowContinue) { options.body = govcyResources.staticResources.text.taskListAllowContinueBody; options.linkToContinue = { text: govcyResources.staticResources.text.taskListContinueLink, visuallyHiddenText: govcyResources.staticResources.text.taskListContinueHiddenText, link: nextPageHref }; } storeTaskListValidationSummary(req.session, siteId, pageUrl, summaryItems, options); return res.redirect(govcyResources.constructErrorSummaryUrl(req.originalUrl)); } /** * Resolves the destination URL for a task-list continue action. Mirrors the * logic used for regular pages (respect review route overrides). * * @param {object} page Current page configuration * @param {string} siteId Service identifier * @param {object} req Express request * @returns {string|null} */ function resolveTaskListNextPage(page, siteId, req) { if (req.query.route === "review") { return govcyResources.constructPageUrl(siteId, "review"); } const nextPage = page?.pageData?.nextPage; if (!nextPage) return null; return govcyResources.constructPageUrl(siteId, nextPage); } /** * Creates an error-summary list for every task that still needs attention. * * @param {Array} tasks Task descriptor array from computeTaskListStatus * @param {string} siteId Service identifier for building hrefs * @param {string} [route] Optional route query (e.g. \"review\") to preserve context * @returns {Array<{text:string, link?:string}>} */ function buildTaskListErrorSummary(tasks = [], siteId, route) { return tasks .filter(task => task && task.status !== "COMPLETED" && task.status !== "SKIPPED") .map(task => { const item = { text: buildTaskListErrorText(task.title) }; if (task.pageUrl) { item.link = govcyResources.constructPageUrl(siteId, task.pageUrl, route); } return item; }); } /** * Produces a multilingual message like \"Complete the section {Title}\" for each task. * * @param {object} title Multilingual task title object * @returns {object} Multilingual error summary text */ function buildTaskListErrorText(title) { const normalizedTitle = hasLocalizedContent(title) ? title : govcyResources.staticResources.text.untitled; return combineLocalizedStrings( govcyResources.staticResources.text.task?.title, normalizedTitle ); } /** * Concatenates two multilingual objects (prefix + value) while preserving fallbacks. * * @param {object|string} prefix Multilingual/string prefix * @param {object|string} value Multilingual/string value * @returns {object} Combined multilingual object */ function combineLocalizedStrings(prefix, value) { const languages = new Set(["el", "en", "tr"]); if (hasLocalizedContent(prefix)) { Object.keys(prefix).forEach(lang => languages.add(lang)); } if (hasLocalizedContent(value)) { Object.keys(value).forEach(lang => languages.add(lang)); } const result = {}; languages.forEach(lang => { const prefixText = resolveLocalizedText(prefix, lang); const valueText = resolveLocalizedText(value, lang); const combined = `${prefixText} ${valueText}`.trim(); result[lang] = combined || valueText || prefixText; }); return result; } /** * Returns true when a value looks like a multilingual object with keys. * * @param {any} value Potential multilingual object * @returns {boolean} */ function hasLocalizedContent(value) { return value && typeof value === "object" && Object.keys(value).length > 0; } /** * Safely resolves text for a single language, falling back to common locales. * * @param {object|string} source Multilingual/string source * @param {string} lang Desired language key * @returns {string} */ function resolveLocalizedText(source, lang) { if (!source) return ""; if (typeof source === "string") return source; return source[lang] ?? source.el ?? source.en ?? source.tr ?? ""; } /** * Persists the synthesized error summary back in the session so the renderer * can present GOV.CY error summary content without having to understand task * logic. * * @param {object} store Session object (req.session) * @param {string} siteId Service identifier * @param {string} pageUrl Page identifier * @param {Array} summaryItems Error summary entries * @param {object} options Optional body / linkToContinue overrides */ function storeTaskListValidationSummary(store, siteId, pageUrl, summaryItems, options = {}) { dataLayer.storePageValidationErrors(store, siteId, pageUrl, {}, null); const container = store?.siteData?.[siteId]?.inputData?.[pageUrl]?.validationErrors; if (!container) return; // Attach extra renderer-friendly metadata when provided. container.errorSummary = summaryItems; if (options.body) container.body = options.body; if (options.linkToContinue) container.linkToContinue = options.linkToContinue; }