@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.
369 lines (330 loc) • 17.1 kB
JavaScript
/**
* @module govcyFormHandling
* @fileoverview This module provides utility functions for handling form data in a web application.
* It includes functions to populate form data with session data, handle conditional elements,
* and show error summary when there are validation errors.
*/
import { ALLOWED_FORM_ELEMENTS } from "./govcyConstants.mjs";
import * as dataLayer from "./govcyDataLayer.mjs";
import * as govcyResources from "../resources/govcyResources.mjs";
/**
* Helper function to populate form data with session data
* @param {Array} formElements The form elements
* @param {*} theData The data either from session or request
* @param {Object} validationErrors The validation errors
* @param {Object} store The session store
* @param {string} siteId The site ID
* @param {string} pageUrl The page URL
* @param {string} lang The language
* @param {Object} fileInputElements The file input elements
* @param {string} routeParam The route parameter
* @param {string} mode The mode, either "single" (default), "add", or "edit"
* @param {number|null} index The index of the item being edited (null for single or add mode)
*/
export function populateFormData(
formElements,
theData,
validationErrors,
store = {},
siteId = "",
pageUrl = "",
lang = "el",
fileInputElements = null,
routeParam = "",
mode = "single",
index = null
) {
const inputElements = ALLOWED_FORM_ELEMENTS;
const isRootCall = !fileInputElements;
let elementId = "";
let firstElementId = "";
if (isRootCall) {
fileInputElements = {};
}
// Recursively populate form data with session data
formElements.forEach(element => {
if (inputElements.includes(element.element)) {
// Get the element ID and field name
elementId = (element.element === "checkboxes" || element.element === "radios") // if checkboxes or radios
? `${element.params.id}-option-1` // use the id of the first option
: (element.element === "dateInput") //if dateInput
? `${element.params.id}_day` // use the id of the day input
: element.params.id; // else use the id of the element
const fieldName = element.params.name;
// Store the ID of the first input element (for error summary link)
if (!firstElementId) {
firstElementId = elementId;
}
// Handle `dateInput` separately
if (element.element === "dateInput") {
element.params.dayValue = theData[`${fieldName}_day`] || "";
element.params.monthValue = theData[`${fieldName}_month`] || "";
element.params.yearValue = theData[`${fieldName}_year`] || "";
//Handle `datePicker` separately
} else if (element.element === "datePicker") {
const val = theData[fieldName];
// Check if the value exists and matches the D/M/YYYY or DD/MM/YYYY pattern
if (val && /^\d{1,2}\/\d{1,2}\/\d{4}$/.test(val)) {
const [day, month, year] = val.split("/").map(Number); // Convert parts to numbers
const date = new Date(year, month - 1, day); // Month is zero-based in JS
// Check if the date is valid (e.g., not 31/02/2020)
if (
date.getFullYear() === year &&
date.getMonth() === month - 1 &&
date.getDate() === day
) {
// Format it as YYYY-MM-DD for the <input type="date"> value
const yyyy = year;
const mm = String(month).padStart(2, '0');
const dd = String(day).padStart(2, '0');
element.params.value = `${yyyy}-${mm}-${dd}`;
} else {
// Invalid date (e.g., 31/02/2020)
element.params.value = "";
}
} else {
// Invalid format (not matching D/M/YYYY or DD/MM/YYYY)
element.params.value = "";
}
} else if (element.element === "fileInput") {
// For fileInput, we change the element.element to "fileView" and set the
// fileId and sha256 from the session store
// const fileData = dataLayer.getFormDataValue(store, siteId, pageUrl, fieldName);
// 1) Prefer file from theData (could be draft in add mode, or item object in edit)
let fileData = theData[fieldName];
// 2) If not found, fall back to dataLayer (normal page behaviour)
if (!fileData) {
if (mode === "edit" && index !== null) {
// In edit mode, try to get the file for the specific item index
fileData = dataLayer.getFormDataValue(store, siteId, pageUrl, fieldName, index);
} else {
// In single or add mode, get the file normally
fileData = dataLayer.getFormDataValue(store, siteId, pageUrl, fieldName);
}
}
if (fileData) {
element.element = "fileView";
element.params.fileId = fileData.fileId;
element.params.sha256 = fileData.sha256;
element.params.visuallyHiddenText = element.params.label;
// Build base path based on mode
let basePath = `/${siteId}/${pageUrl}`;
if (mode === "add") {
basePath += "/multiple/add";
} else if (mode === "edit" && index !== null) {
basePath += `/multiple/edit/${index}`;
}
// View link
element.params.viewHref = `${basePath}/view-file/${fieldName}`;
element.params.viewTarget = "_blank";
// Delete link (preserve ?route=review if present)
element.params.deleteHref = `${basePath}/delete-file/${fieldName}${(routeParam) ? `?route=${routeParam}` : ''}`
} else {
// TODO: Ask Andreas how to handle empty file inputs
element.params.value = "";
}
fileInputElements[fieldName] = element;
// Handle all other input elements (textInput, checkboxes, radios, etc.)
} else {
element.params.value = theData[fieldName] || "";
}
// if there are validation errors, populate the error message
if (validationErrors?.errors?.[fieldName]) {
element.params.error = validationErrors.errors[fieldName].message;
//populate the error summary
// const elementId = (element.element === "checkboxes" || element.element === "radios") // if checkboxes or radios
// ? `${element.params.id}-option-1` // use the id of the first option
// : (element.element === "dateInput") //if dateInput
// ? `${element.params.id}_day` // use the id of the day input
// : element.params.id; // else use the id of the element
validationErrors.errorSummary.push({
link: `#${elementId}`,
text: validationErrors.errors[fieldName].message
});
}
}
// Handle conditional elements inside radios
if (element.element === "radios" && element.params.items) {
element.params.items.forEach(item => {
if (item.conditionalElements) {
populateFormData(item.conditionalElements, theData, validationErrors, store, siteId, pageUrl, lang, fileInputElements, routeParam);
// Check if any conditional element has an error and add to the parent "conditionalHasErrors": true
if (item.conditionalElements.some(condEl => condEl.params?.error)) {
item.conditionalHasErrors = true;
}
}
});
}
});
// 🔴 Handle _global validation errors (collection-level, not tied to a field)
if (isRootCall && validationErrors?.errors?._global) {
validationErrors.errorSummary = validationErrors.errorSummary || [];
// Decide where the link should point
let linkTarget = `#${firstElementId}`; // default anchor at top of the form
if (validationErrors.errors._global.pageUrl) {
// If pageUrl is provided (e.g. for max items), point back to hub
linkTarget = `${validationErrors.errors._global.pageUrl}`;
}
// Push into the error summary
validationErrors.errorSummary.push({
link: linkTarget,
text: validationErrors.errors._global.message
});
}
// add file input elements's definition in js object
if (isRootCall && Object.keys(fileInputElements).length > 0) {
const scriptTag = `
<script type="text/javascript">
window._govcyFileInputs = ${JSON.stringify(fileInputElements)};
window._govcySiteId = "${siteId}";
window._govcyPageUrl = "${pageUrl}";
window._govcyLang = "${lang}";
</script>
<div id="_govcy-upload-status" class="govcy-visually-hidden" role="status" aria-live="assertive"></div>
<div id="_govcy-upload-error" class="govcy-visually-hidden" role="alert" aria-live="assertive"></div>
`.trim();
formElements.push({
element: 'htmlElement',
params: {
text: {
en: scriptTag,
el: scriptTag,
tr: scriptTag
}
}
});
}
}
/**
* Filters form data based on the form definition, including conditionals.
*
* @param {Array} elements - The form elements (including conditional ones).
* @param {Object} formData - The submitted form data.
* @param {Object} store - The session store .
* @param {string} siteId - The site ID .
* @param {string} pageUrl - The page URL .
* @param {number|null} index - The index of the item being edited for multiple items
* @returns {Object} filteredData - The filtered form data.
*/
export function getFormData(elements, formData, store = {}, siteId = "", pageUrl = "", index = null) {
const filteredData = {};
elements.forEach(element => {
const { name } = element.params || {};
// Check if the element is allowed and has a name
if (ALLOWED_FORM_ELEMENTS.includes(element.element) && name) {
// Handle conditional elements (e.g., checkboxes, radios, select)
if (["checkboxes", "radios", "select"].includes(element.element)) {
const value = formData[name];
filteredData[name] = value !== undefined && value !== null ? value : "";
// Process conditional elements inside items
if (element.element === "radios" && element.params.items) {
element.params.items.forEach(item => {
if (item.conditionalElements) {
Object.assign(
filteredData,
getFormData(item.conditionalElements, formData, store, siteId, pageUrl, index)
);
}
});
}
}
// Handle dateInput
else if (element.element === "dateInput") {
const day = formData[`${name}_day`];
const month = formData[`${name}_month`];
const year = formData[`${name}_year`];
filteredData[`${name}_day`] = day !== undefined && day !== null ? day : "";
filteredData[`${name}_month`] = month !== undefined && month !== null ? month : "";
filteredData[`${name}_year`] = year !== undefined && year !== null ? year : "";
// handle fileInput
} else if (element.element === "fileInput") {
// fileInput elements are already stored in the store when it was uploaded
// so we just need to check if the file exists in the dataLayer in the store and add it the filteredData
// unneeded handle of `Attachment` at the end
// const fileData = dataLayer.getFormDataValue(store, siteId, pageUrl, name + "Attachment");
const fileData = dataLayer.getFormDataValue(store, siteId, pageUrl, name, index);
if (fileData) {
// unneeded handle of `Attachment` at the end
// filteredData[name + "Attachment"] = fileData;
filteredData[name] = fileData;
} else {
//TODO: Ask Andreas how to handle empty file inputs
// unneeded handle of `Attachment` at the end
// filteredData[name + "Attachment"] = ""; // or handle as needed
filteredData[name] = ""; // or handle as needed
}
// Handle other elements (e.g., textInput, textArea, datePicker)
} else {
filteredData[name] = formData[name] !== undefined && formData[name] !== null ? formData[name] : "";
}
// Always process conditional elements directly attached to the current element
// if (element.conditionalElements) {
// Object.assign(filteredData, getFormData(element.conditionalElements, formData));
// }
}
});
return filteredData;
}
/**
* Get empty form data for multipleThings elements.
* Used to fill in empty multipleThings pages with the correct structure
*
* @param {object|Array} pageOrElements The page or elements of a conditional radio
* @param {object} emptyObject The object to populate with empty values
* @returns {object} An object with empty values for each form element
*/
export function getMultipleThingsEmptyFormData(pageOrElements, emptyObject = {}) {
// Determine if we're given a full page or just an array of elements
let elements = [];
if (Array.isArray(pageOrElements)) {
elements = pageOrElements; // recursion case
} else if (pageOrElements?.pageTemplate?.sections) {
// Deep copy to avoid modifying the original
const pageTemplateCopy = JSON.parse(JSON.stringify(pageOrElements.pageTemplate));
// Find the first form element in sections
for (const section of pageTemplateCopy.sections) {
const form = section.elements?.find(el => el.element === "form");
if (form) {
elements = form?.params?.elements || [];
break;
}
}
} else {
// No valid elements
return emptyObject;
}
// Iterate through elements in order (like getFormData)
elements.forEach(element => {
const { name } = element.params || {};
if (ALLOWED_FORM_ELEMENTS.includes(element.element) && name) {
// Handle different element types
if (["checkboxes", "radios", "select"].includes(element.element)) {
emptyObject[name] = "";
// Handle conditional radios (same recursion pattern as getFormData)
if (element.element === "radios" && Array.isArray(element.params?.items)) {
element.params.items.forEach(item => {
if (item.conditionalElements) {
Object.assign(
emptyObject,
getMultipleThingsEmptyFormData(item.conditionalElements, {})
);
}
});
}
}
// Handle dateInput (single name, per your clarification)
else if (element.element === "dateInput") {
emptyObject[name] = "";
}
// Handle fileInput (suffix Attachment, per submission schema)
else if (element.element === "fileInput") {
emptyObject[`${name}Attachment`] = "";
}
// Handle all other elements (textInput, textArea, datePicker, etc.)
else {
emptyObject[name] = "";
}
}
});
return emptyObject;
}