@gov-cy/govcy-express-services 1.1.1 → 1.3.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,132 @@
1
+ // utils/govcyMultipleThingsValidation.mjs
2
+ import { validateFormElements } from "./govcyValidator.mjs";
3
+ import * as govcyResources from "../resources/govcyResources.mjs";
4
+ import { getPageConfigData } from "../utils/govcyLoadConfigData.mjs";
5
+
6
+ /**
7
+ * Validate multipleThings items against the page's form definition.
8
+ * @param {object} page the page configuration object
9
+ * @param {array} items the array of items to validate
10
+ * @param {string} lang the language code for error messages
11
+ * @returns {object} errors object containing validation errors, if any
12
+ */
13
+ export function validateMultipleThings(page, items, lang) {
14
+ const errors = {};
15
+
16
+ // 1. Min check
17
+ if (items.length < page.multipleThings.min) {
18
+ // Deep copy page title (so we don’t mutate template)
19
+ let minMsg = JSON.parse(JSON.stringify(govcyResources.staticResources.text.multipleThingsMinMessage));
20
+
21
+ // Replace label placeholders on page title
22
+ for (const lang of Object.keys(minMsg)) {
23
+ minMsg[lang] = minMsg[lang].replace("{{min}}", page.multipleThings.min);
24
+ }
25
+ errors._global = {
26
+ message: minMsg,
27
+ link: "#addNewItem0"
28
+ };
29
+
30
+ return errors; // early exit
31
+ }
32
+
33
+ // 2. Max check (rare, but safe)
34
+ if (items.length > page.multipleThings.max) {
35
+ // Deep copy page title (so we don’t mutate template)
36
+ let maxMsg = JSON.parse(JSON.stringify(govcyResources.staticResources.text.multipleThingsMaxMessage));
37
+
38
+ // Replace label placeholders on page title
39
+ for (const lang of Object.keys(maxMsg)) {
40
+ maxMsg[lang] = maxMsg[lang].replace("{{max}}", page.multipleThings.max);
41
+ }
42
+ errors._global = {
43
+ message: maxMsg,
44
+ link: "#multipleThingsList"
45
+ };
46
+ return errors; // early exit
47
+ }
48
+
49
+ // 3. Per-item validation
50
+ items.forEach((item, idx) => {
51
+ const formElement = page.pageTemplate.sections
52
+ .flatMap(s => s.elements)
53
+ .find(el => el.element === "form");
54
+ if (!formElement) return; // safety
55
+
56
+ const vErrors = validateFormElements(formElement.params.elements, item);
57
+ if (Object.keys(vErrors).length > 0) {
58
+ errors[idx] = vErrors;
59
+ }
60
+ });
61
+
62
+ return errors;
63
+ }
64
+
65
+
66
+ /**
67
+ * Normalize validation errors into a summary list array.
68
+ * Works for both hub- and review-level errors.
69
+ *
70
+ * @param {object} service - The full service configuration object
71
+ * @param {object} hubErrors - Validation errors object (from dataLayer)
72
+ * @param {string} siteId - Current site id
73
+ * @param {string} pageUrl - Page url (for hub links)
74
+ * @param {object} req - Express request (for lang + route info)
75
+ * @param {string} route - Current route (e.g. "review" or "")
76
+ * @param {boolean} isHub - Whether this is for the hub page (true) or review page (false)
77
+ *
78
+ * @returns {Array<{text: object, link: string}>}
79
+ */
80
+ export function buildMultipleThingsValidationSummary(service, hubErrors, siteId, pageUrl, req, route = "", isHub = true) {
81
+ let validationErrors = [];
82
+
83
+ // For each error
84
+ for (const [key, err] of Object.entries(hubErrors)) {
85
+ let pageTitle = { en: "", el: "", tr: "" };
86
+ if (!isHub) {
87
+ // 🔍 Find the page by pageUrl
88
+ const page = getPageConfigData(service, pageUrl);
89
+ for (const lang of Object.keys(page.multipleThings.listPage.title)) {
90
+ pageTitle[lang] = (page.multipleThings.listPage.title[lang] || "") + " - ";
91
+ }
92
+ }
93
+
94
+ if (key === "_global") {
95
+ let msg = err.message;
96
+ // Replace label placeholders on page title
97
+ for (const lang of Object.keys(msg)) {
98
+ msg[lang] = pageTitle[lang]
99
+ + (msg[lang] || '');
100
+ }
101
+
102
+ // Add to validationErrors array
103
+ validationErrors.push({
104
+ text: msg,
105
+ link: (isHub
106
+ ? err.link || `#multipleThingsList`
107
+ : `/${siteId}/${pageUrl}` + (route === "review" ? "?route=review" : ""))
108
+ });
109
+ } else {
110
+ // Deep copy page title (so we don’t mutate template)
111
+ let msg = JSON.parse(JSON.stringify(govcyResources.staticResources.text.multipleThingsItemsValidationPrefix));
112
+ const idx = parseInt(key, 10);
113
+
114
+ for (const fieldErr of Object.values(err)) {
115
+ // Replace label placeholders on page title
116
+ for (const lang of Object.keys(msg)) {
117
+ msg[lang] = pageTitle[lang]
118
+ + msg[lang].replace("{{index}}", idx + 1)
119
+ + (fieldErr.message?.[req.globalLang] || '');
120
+ }
121
+
122
+ // Add to validationErrors array
123
+ validationErrors.push({
124
+ text: msg,
125
+ link: `/${siteId}/${pageUrl}/multiple/edit/${idx}${route === "review" ? "?route=review" : ""}`
126
+ });
127
+ }
128
+ }
129
+ }
130
+
131
+ return validationErrors;
132
+ }
@@ -4,7 +4,9 @@ import * as dataLayer from "./govcyDataLayer.mjs";
4
4
  import { DSFEmailRenderer } from '@gov-cy/dsf-email-templates';
5
5
  import { ALLOWED_FORM_ELEMENTS } from "./govcyConstants.mjs";
6
6
  import { evaluatePageConditions } from "./govcyExpressions.mjs";
7
+ import { getPageConfigData } from "./govcyLoadConfigData.mjs";
7
8
  import { logger } from "./govcyLogger.mjs";
9
+ import nunjucks from "nunjucks";
8
10
 
9
11
  /**
10
12
  * Prepares the submission data for the service, including raw data, print-friendly data, and renderer data.
@@ -48,61 +50,110 @@ export function prepareSubmissionData(req, siteId, service) {
48
50
 
49
51
  if (!formElement) continue; // ⛔ Skip pages without a <form> element
50
52
 
51
- // submissionData[pageUrl] = { formData: {} }; // Now initialize only if a form is present
52
- submissionData[pageUrl] = {}; // ✅ Now initialize only if a form is present
53
+ // 🔹 Case A: multipleThings page array of items
54
+ if (page.multipleThings) {
55
+ // get the items array from session (or empty array if not found / not an array)
56
+ let items = dataLayer.getPageData(req.session, siteId, pageUrl);
57
+ if (!Array.isArray(items)) items = [];
58
+
59
+ submissionData[pageUrl] = [];
60
+
61
+ items.forEach((item, idx) => {
62
+ const itemData = {};
63
+ for (const el of formElement.params.elements || []) {
64
+ if (!ALLOWED_FORM_ELEMENTS.includes(el.element)) continue;
65
+ const elId = el.params?.id || el.params?.name;
66
+ if (!elId) continue;
67
+
68
+ // ✅ normalized with index
69
+ let value = getValue(el, pageUrl, req, siteId, idx);
70
+ itemData[elId] = value;
71
+
72
+ // handle fileInput special naming
73
+ if (el.element === "fileInput") {
74
+ itemData[elId + "Attachment"] = value;
75
+ delete itemData[elId];
76
+ }
53
77
 
54
- // Traverse the form elements inside the form
55
- for (const element of formElement.params.elements || []) {
56
- const elType = element.element;
78
+ // radios conditional elements
79
+ if (el.element === "radios") {
80
+ for (const radioItem of el.params.items || []) {
81
+ for (const condEl of radioItem.conditionalElements || []) {
82
+ if (!ALLOWED_FORM_ELEMENTS.includes(condEl.element)) continue;
83
+ const condId = condEl.params?.id || condEl.params?.name;
84
+ if (!condId) continue;
85
+ let condValue = getValue(condEl, pageUrl, req, siteId, idx);
86
+ itemData[condId] = condValue;
87
+
88
+ if (condEl.element === "fileInput") {
89
+ itemData[condId + "Attachment"] = condValue;
90
+ delete itemData[condId];
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+ submissionData[pageUrl].push(itemData);
97
+ });
98
+ } else {
99
+ // 🔹 Case B: normal page → single object
57
100
 
58
- // ✅ Skip non-input elements like buttons
59
- if (!ALLOWED_FORM_ELEMENTS.includes(elType)) continue;
101
+ // submissionData[pageUrl] = { formData: {} }; // Now initialize only if a form is present
102
+ submissionData[pageUrl] = {}; // ✅ Now initialize only if a form is present
60
103
 
61
- const elId = element.params?.id || element.params?.name;
62
- if (!elId) continue; // ⛔ Skip elements with no id/name
104
+ // Traverse the form elements inside the form
105
+ for (const element of formElement.params.elements || []) {
106
+ const elType = element.element;
63
107
 
64
- // 🟢 Use helper to get session value (or "" fallback if missing)
65
- const value = getValue(element, pageUrl, req, siteId) ?? "";
108
+ // Skip non-input elements like buttons
109
+ if (!ALLOWED_FORM_ELEMENTS.includes(elType)) continue;
66
110
 
67
- // Store in submissionData
68
- // submissionData[pageUrl].formData[elId] = value;
69
- submissionData[pageUrl][elId] = value;
111
+ const elId = element.params?.id || element.params?.name;
112
+ if (!elId) continue; // ⛔ Skip elements with no id/name
70
113
 
71
- // handle fileInput
72
- if (elType === "fileInput") {
73
- // change the name of the key to include "Attachment" at the end but not have the original key
74
- // submissionData[pageUrl].formData[elId + "Attachment"] = value;
75
- submissionData[pageUrl][elId + "Attachment"] = value;
76
- // delete submissionData[pageUrl].formData[elId];
77
- delete submissionData[pageUrl][elId];
78
- }
114
+ // 🟢 Use helper to get session value (or "" fallback if missing)
115
+ const value = getValue(element, pageUrl, req, siteId) ?? "";
79
116
 
80
- // 🔄 If radios with conditionalElements, walk ALL options
81
- if (elType === "radios" && Array.isArray(element.params?.items)) {
82
- for (const radioItem of element.params.items) {
83
- const condEls = radioItem.conditionalElements;
84
- if (!Array.isArray(condEls)) continue;
85
-
86
- for (const condElement of condEls) {
87
- const condType = condElement.element;
88
- if (!ALLOWED_FORM_ELEMENTS.includes(condType)) continue;
89
-
90
- const condId = condElement.params?.id || condElement.params?.name;
91
- if (!condId) continue;
92
-
93
- // Again: read from session or fallback to ""
94
- const condValue = getValue(condElement, pageUrl, req, siteId) ?? "";
95
-
96
- // Store even if the field was not visible to user
97
- // submissionData[pageUrl].formData[condId] = condValue;
98
- submissionData[pageUrl][condId] = condValue;
99
- // handle fileInput
100
- if (condType === "fileInput") {
101
- // change the name of the key to include "Attachment" at the end but not have the original key
102
- // submissionData[pageUrl].formData[condId + "Attachment"] = condValue;
103
- submissionData[pageUrl][condId + "Attachment"] = condValue;
104
- // delete submissionData[pageUrl].formData[condId];
105
- delete submissionData[pageUrl][condId];
117
+ // Store in submissionData
118
+ // submissionData[pageUrl].formData[elId] = value;
119
+ submissionData[pageUrl][elId] = value;
120
+
121
+ // handle fileInput
122
+ if (elType === "fileInput") {
123
+ // change the name of the key to include "Attachment" at the end but not have the original key
124
+ // submissionData[pageUrl].formData[elId + "Attachment"] = value;
125
+ submissionData[pageUrl][elId + "Attachment"] = value;
126
+ // delete submissionData[pageUrl].formData[elId];
127
+ delete submissionData[pageUrl][elId];
128
+ }
129
+
130
+ // 🔄 If radios with conditionalElements, walk ALL options
131
+ if (elType === "radios" && Array.isArray(element.params?.items)) {
132
+ for (const radioItem of element.params.items) {
133
+ const condEls = radioItem.conditionalElements;
134
+ if (!Array.isArray(condEls)) continue;
135
+
136
+ for (const condElement of condEls) {
137
+ const condType = condElement.element;
138
+ if (!ALLOWED_FORM_ELEMENTS.includes(condType)) continue;
139
+
140
+ const condId = condElement.params?.id || condElement.params?.name;
141
+ if (!condId) continue;
142
+
143
+ // Again: read from session or fallback to ""
144
+ const condValue = getValue(condElement, pageUrl, req, siteId) ?? "";
145
+
146
+ // Store even if the field was not visible to user
147
+ // submissionData[pageUrl].formData[condId] = condValue;
148
+ submissionData[pageUrl][condId] = condValue;
149
+ // handle fileInput
150
+ if (condType === "fileInput") {
151
+ // change the name of the key to include "Attachment" at the end but not have the original key
152
+ // submissionData[pageUrl].formData[condId + "Attachment"] = condValue;
153
+ submissionData[pageUrl][condId + "Attachment"] = condValue;
154
+ // delete submissionData[pageUrl].formData[condId];
155
+ delete submissionData[pageUrl][condId];
156
+ }
106
157
  }
107
158
  }
108
159
  }
@@ -122,7 +173,7 @@ export function prepareSubmissionData(req, siteId, service) {
122
173
  submissionUsername: dataLayer.getUser(req.session).name,
123
174
  submissionEmail: dataLayer.getUser(req.session).email,
124
175
  submissionData: submissionData, // Raw data as submitted by the user in each page
125
- submissionDataVersion: service.site?.submissionDataVersion || service.site?.submission_data_version ||"", // The submission data version
176
+ submissionDataVersion: service.site?.submissionDataVersion || service.site?.submission_data_version || "", // The submission data version
126
177
  printFriendlyData: printFriendlyData, // Print-friendly data
127
178
  rendererData: reviewSummaryList, // Renderer data of the summary list
128
179
  rendererVersion: service.site?.rendererVersion || service.site?.renderer_version || "", // The renderer version
@@ -174,7 +225,9 @@ export function preparePrintFriendlyData(req, siteId, service) {
174
225
  // and extract the form data from the session
175
226
  for (const page of service.pages) {
176
227
  const fields = [];
228
+ const items = []; // ✅ new
177
229
  // const currentPageUrl = page.pageData.url;
230
+
178
231
  // ----- Conditional logic comes here
179
232
  // Skip page if conditions indicate it should redirect (i.e. not be shown)
180
233
  const conditionResult = evaluatePageConditions(page, req.session, siteId, req);
@@ -196,7 +249,11 @@ export function preparePrintFriendlyData(req, siteId, service) {
196
249
 
197
250
  //create the field object and push it to the fields array
198
251
  // value of the field is handled by getValueLabel function
199
- const field = createFieldObject(formElement, rawValue, getValueLabel(formElement, rawValue, page.pageData.url, req, siteId, service), service);
252
+ const field = createFieldObject(
253
+ formElement,
254
+ rawValue,
255
+ getValueLabel(formElement, rawValue, page.pageData.url, req, siteId, service),
256
+ service);
200
257
  fields.push(field);
201
258
 
202
259
  // Handle conditional elements (only for radios for now)
@@ -223,11 +280,24 @@ export function preparePrintFriendlyData(req, siteId, service) {
223
280
  }
224
281
  }
225
282
 
283
+ // Special case: multipleThings page → extract item titles // ✅ new
284
+ if (page.multipleThings) {
285
+ let mtItems = dataLayer.getPageData(req.session, siteId, page.pageData.url);
286
+ if (Array.isArray(mtItems)) {
287
+ const env = new nunjucks.Environment(null, { autoescape: false });
288
+ for (const item of mtItems) {
289
+ const itemTitle = env.renderString(page.multipleThings.itemTitleTemplate, item);
290
+ items.push({ itemTitle, ...item });
291
+ }
292
+ }
293
+ }
294
+
226
295
  if (fields.length > 0) {
227
296
  submissionData.push({
228
297
  pageUrl: page.pageData.url,
229
298
  pageTitle: page.pageData.title,
230
- fields
299
+ fields,
300
+ items: (page.multipleThings ? items : null) // ✅ new
231
301
  });
232
302
  }
233
303
  }
@@ -243,12 +313,13 @@ export function preparePrintFriendlyData(req, siteId, service) {
243
313
  * @param {string} name The name of the form element
244
314
  * @param {object} req The request object
245
315
  * @param {string} siteId The site ID
316
+ * @param {number} index The index of the date input
246
317
  * @returns {string} The raw date input in ISO format (YYYY-MM-DD) or an empty string if not found
247
318
  */
248
- function getDateInputISO(pageUrl, name, req, siteId) {
249
- const day = dataLayer.getFormDataValue(req.session, siteId, pageUrl, `${name}_day`);
250
- const month = dataLayer.getFormDataValue(req.session, siteId, pageUrl, `${name}_month`);
251
- const year = dataLayer.getFormDataValue(req.session, siteId, pageUrl, `${name}_year`);
319
+ function getDateInputISO(pageUrl, name, req, siteId, index = null) {
320
+ const day = dataLayer.getFormDataValue(req.session, siteId, pageUrl, `${name}_day`, index);
321
+ const month = dataLayer.getFormDataValue(req.session, siteId, pageUrl, `${name}_month`, index);
322
+ const year = dataLayer.getFormDataValue(req.session, siteId, pageUrl, `${name}_year`, index);
252
323
  if (!day || !month || !year) return "";
253
324
 
254
325
  // Pad day and month with leading zero if needed
@@ -265,12 +336,13 @@ function getDateInputISO(pageUrl, name, req, siteId) {
265
336
  * @param {string} name The name of the form element
266
337
  * @param {object} req The request object
267
338
  * @param {string} siteId The site ID
339
+ * @param {number} index The index of the date input
268
340
  * @returns {string} The raw date input in DMY format (DD/MM/YYYY) or an empty string if not found
269
341
  */
270
- function getDateInputDMY(pageUrl, name, req, siteId) {
271
- const day = dataLayer.getFormDataValue(req.session, siteId, pageUrl, `${name}_day`);
272
- const month = dataLayer.getFormDataValue(req.session, siteId, pageUrl, `${name}_month`);
273
- const year = dataLayer.getFormDataValue(req.session, siteId, pageUrl, `${name}_year`);
342
+ function getDateInputDMY(pageUrl, name, req, siteId, index = null) {
343
+ const day = dataLayer.getFormDataValue(req.session, siteId, pageUrl, `${name}_day`, index);
344
+ const month = dataLayer.getFormDataValue(req.session, siteId, pageUrl, `${name}_month`, index);
345
+ const year = dataLayer.getFormDataValue(req.session, siteId, pageUrl, `${name}_year`, index);
274
346
  if (!day || !month || !year) return "";
275
347
  return `${day}/${month}/${year}`; // EU format: DD/MM/YYYY
276
348
  }
@@ -303,19 +375,20 @@ function createFieldObject(formElement, value, valueLabel, service) {
303
375
  * @param {string} pageUrl The page URL
304
376
  * @param {object} req The request object
305
377
  * @param {string} siteId The site ID
378
+ * @param {number} index The index of the form element (for multipleThings)
306
379
  * @returns {string} The value of the form element from the session or an empty string if not found
307
380
  */
308
- function getValue(formElement, pageUrl, req, siteId) {
381
+ function getValue(formElement, pageUrl, req, siteId, index = null) {
309
382
  // handle raw value
310
383
  let value = ""
311
384
  if (formElement.element === "dateInput") {
312
- value = getDateInputISO(pageUrl, formElement.params.name, req, siteId);
385
+ value = getDateInputISO(pageUrl, formElement.params.name, req, siteId, index);
313
386
  } else if (formElement.element === "fileInput") {
314
387
  // unneeded handle of `Attachment` at the end
315
388
  // value = dataLayer.getFormDataValue(req.session, siteId, pageUrl, formElement.params.name + "Attachment");
316
- value = dataLayer.getFormDataValue(req.session, siteId, pageUrl, formElement.params.name);
389
+ value = dataLayer.getFormDataValue(req.session, siteId, pageUrl, formElement.params.name, index);
317
390
  } else {
318
- value = dataLayer.getFormDataValue(req.session, siteId, pageUrl, formElement.params.name);
391
+ value = dataLayer.getFormDataValue(req.session, siteId, pageUrl, formElement.params.name, index);
319
392
  }
320
393
 
321
394
  // 🔁 Normalize checkboxes: always return an array
@@ -486,26 +559,49 @@ export function generateReviewSummary(submissionData, req, siteId, showChangeLin
486
559
 
487
560
 
488
561
 
562
+ const env = new nunjucks.Environment(null, { autoescape: true });
563
+ // One template renders multiple things
564
+ const multipleThingsTemplate = `
565
+ <div><strong>${govcyResources.staticResources.text.multipleThingsEntries[req.globalLang] || govcyResources.staticResources.text.multipleThingsEntries["el"]}</strong></div>
566
+ <ol class="govcy-mt-2">
567
+ {% for it in items -%}
568
+ <li>{{ it.itemTitle | trim }}</li>
569
+ {%- endfor %}
570
+ </ol>
571
+ `;
489
572
 
490
573
  // Loop through each page in the submission data
491
574
  for (const page of submissionData) {
492
575
  // Get the page URL, title, and fields
493
- const { pageUrl, pageTitle, fields } = page;
576
+ const { pageUrl, pageTitle, fields, items } = page;
494
577
 
495
578
 
496
579
  let summaryListInner = { element: "summaryList", params: { items: [] } };
497
580
 
498
- // loop through each field and add it to the summary entry
499
- for (const field of fields) {
500
- const label = field.label;
501
- const valueLabel = getSubmissionValueLabelString(field.valueLabel, req.globalLang);
502
- // --- HACK --- to see if this is a file element
503
- // check if field.value is an object with `sha256` and `fileId` properties
504
- if (typeof field.value === "object" && field.value.hasOwnProperty("sha256") && field.value.hasOwnProperty("fileId") && showChangeLinks) {
505
- summaryListInner.params.items.push(createSummaryListItemFileLink(label, valueLabel, siteId, pageUrl, field.name));
581
+ // Special handling: multipleThings page show <ol> of itemTitle only
582
+ if (Array.isArray(items)) {
583
+ summaryListInner = { element: "htmlElement", params: { text: {} } };
584
+ if (items.length == 0) {
585
+ summaryListInner.params.text = govcyResources.staticResources.text.multipleThingsEmptyStateReview
506
586
  } else {
507
- // add the field to the summary entry
508
- summaryListInner.params.items.push(createSummaryListItem(label, valueLabel));
587
+ // Build ordered list HTML
588
+ let htmlByLang = env.renderString(multipleThingsTemplate, { items });
589
+ // Set the HTML for each language
590
+ summaryListInner.params.text = govcyResources.getMultilingualObject(htmlByLang, htmlByLang, htmlByLang);
591
+ }
592
+ } else { // Normal page → keep old behavior
593
+ // loop through each field and add it to the summary entry
594
+ for (const field of fields) {
595
+ const label = field.label;
596
+ const valueLabel = getSubmissionValueLabelString(field.valueLabel, req.globalLang);
597
+ // --- HACK --- to see if this is a file element
598
+ // check if field.value is an object with `sha256` and `fileId` properties
599
+ if (typeof field.value === "object" && field.value.hasOwnProperty("sha256") && field.value.hasOwnProperty("fileId") && showChangeLinks) {
600
+ summaryListInner.params.items.push(createSummaryListItemFileLink(label, valueLabel, siteId, pageUrl, field.name));
601
+ } else {
602
+ // add the field to the summary entry
603
+ summaryListInner.params.items.push(createSummaryListItem(label, valueLabel));
604
+ }
509
605
  }
510
606
  }
511
607
 
@@ -557,7 +653,8 @@ export function generateSubmitEmail(service, submissionData, submissionId, req)
557
653
  {
558
654
  component: "bodySuccess",
559
655
  params: {
560
- title: govcyResources.getLocalizeContent(govcyResources.staticResources.text.submissionSuccessTitle, req.globalLang),
656
+ title: service?.site?.successEmailHeader?.[req.globalLang]
657
+ || govcyResources.getLocalizeContent(govcyResources.staticResources.text.submissionSuccessTitle, req.globalLang),
561
658
  body: `${govcyResources.getLocalizeContent(govcyResources.staticResources.text.yourSubmissionId, req.globalLang)} ${submissionId}`
562
659
  }
563
660
  }
@@ -572,10 +669,12 @@ export function generateSubmitEmail(service, submissionData, submissionId, req)
572
669
  }
573
670
  );
574
671
 
672
+ const env = new nunjucks.Environment(null, { autoescape: true })
673
+
575
674
  // For each page in the submission data
576
675
  for (const page of submissionData) {
577
676
  // Get the page URL, title, and fields
578
- const { pageUrl, pageTitle, fields } = page;
677
+ const { pageUrl, pageTitle, fields, items } = page;
579
678
 
580
679
  // Add data title to the body
581
680
  body.push(
@@ -586,19 +685,54 @@ export function generateSubmitEmail(service, submissionData, submissionId, req)
586
685
  }
587
686
  );
588
687
 
589
- let dataUl = [];
590
- // loop through each field and add it to the summary entry
591
- for (const field of fields) {
592
- const label = govcyResources.getLocalizeContent(field.label, req.globalLang);
593
- const valueLabel = getSubmissionValueLabelString(field.valueLabel, req.globalLang);
594
- dataUl.push({ key: label, value: valueLabel });
688
+ if (Array.isArray(items)) {
689
+ // 🔹 MultipleThings page loop through items
690
+ // multipleThings use itemTitleTemplate
691
+ const pageConfig = getPageConfigData(service, pageUrl);
692
+ const template = pageConfig.multipleThings?.itemTitleTemplate || "{{itemTitle}}";
693
+
694
+ if (items.length === 0) {
695
+ // Empty state message
696
+ body.push({
697
+ component: "bodyParagraph",
698
+ body: govcyResources.getLocalizeContent(
699
+ govcyResources.staticResources.text.multipleThingsEmptyStateReview,
700
+ req.globalLang
701
+ )
702
+ });
703
+ } else {
704
+ // Build ordered list of item titles
705
+ const listItems = items.map(item => {
706
+ const safeTitle = env.renderString(template, item);
707
+ return safeTitle; // already escaped
708
+ });
709
+
710
+ // Add ordered list to the body
711
+ body.push({
712
+ component: "bodyList",
713
+ params: {
714
+ type: "ol",
715
+ items: listItems
716
+ }
717
+ });
718
+ }
719
+ } else {
720
+ // 🔹 Normal page → continue below
721
+ let dataUl = [];
722
+ // loop through each field and add it to the summary entry
723
+ for (const field of fields) {
724
+ const label = govcyResources.getLocalizeContent(field.label, req.globalLang);
725
+ const valueLabel = getSubmissionValueLabelString(field.valueLabel, req.globalLang);
726
+ dataUl.push({ key: label, value: valueLabel });
727
+ }
728
+ // add data to the body
729
+ body.push(
730
+ {
731
+ component: "bodyKeyValue",
732
+ params: { type: "ul", items: dataUl },
733
+ });
595
734
  }
596
- // add data to the body
597
- body.push(
598
- {
599
- component: "bodyKeyValue",
600
- params: { type: "ul", items: dataUl },
601
- });
735
+
602
736
 
603
737
  }
604
738
 
@@ -42,7 +42,7 @@ function validateValue(value, rules) {
42
42
  mobileCY: (val) => {
43
43
  const normalized = val.replace(/[\s\-()]/g, ''); // Remove spaces, hyphens, and parentheses
44
44
  return /^(?:\+357|00357)?9\d{7}$/.test(normalized); // Match Cypriot mobile numbers
45
- },
45
+ },
46
46
  iban: (val) => {
47
47
  const cleanedIBAN = val.replace(/[\s-]/g, '').toUpperCase(); // Remove spaces/hyphens and convert to uppercase
48
48
  const regex = /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/;
@@ -96,6 +96,13 @@ function validateValue(value, rules) {
96
96
  }
97
97
  return normalizedVal <= max;
98
98
  },
99
+ // ✅ New rule: maxCurrentYear
100
+ maxCurrentYear: (val) => {
101
+ const normalizedVal = normalizeNumber(val);
102
+ if (isNaN(normalizedVal)) return false;
103
+ const currentYear = new Date().getFullYear();
104
+ return normalizedVal <= currentYear;
105
+ },
99
106
  minValueDate: (val, minDate) => {
100
107
  const valueDate = parseDate(val); // Parse the input date
101
108
  const min = parseDate(minDate); // Parse the minimum date
@@ -129,6 +136,10 @@ function validateValue(value, rules) {
129
136
  }
130
137
  }
131
138
 
139
+ // Skip validation if the value is empty
140
+ if (value === null || value === undefined || (typeof value === 'string' && value.trim() === "")) {
141
+ continue; // let "required" handle emptiness
142
+ }
132
143
  // Check for "valid" rules (e.g., numeric, telCY, etc.)
133
144
  if (check === "valid" && validationRules[checkValue]) {
134
145
  const isValid = validationRules[checkValue](value);
@@ -157,12 +168,12 @@ function validateValue(value, rules) {
157
168
  if (check === 'minValue' && !validationRules.minValue(value, checkValue)) {
158
169
  return message;
159
170
  }
160
-
171
+
161
172
  // Check for "maxValue"
162
173
  if (check === 'maxValue' && !validationRules.maxValue(value, checkValue)) {
163
174
  return message;
164
175
  }
165
-
176
+
166
177
  // Check for "minValueDate"
167
178
  if (check === 'minValueDate' && !validationRules.minValueDate(value, checkValue)) {
168
179
  return message;
@@ -178,6 +189,7 @@ function validateValue(value, rules) {
178
189
  return message;
179
190
  }
180
191
 
192
+
181
193
  }
182
194
 
183
195
  return null;
@@ -314,10 +326,10 @@ export function validateFormElements(elements, formData, pageUrl) {
314
326
  .filter(Boolean) // Remove empty values
315
327
  .join("-") // Join remaining parts
316
328
  : (conditionalElement.element === "fileInput") // Handle fileInput
317
- // unneeded handle of `Attachment` at the end
318
- // ? formData[`${conditionalElement.params.name}Attachment`] || ""
319
- ? formData[`${conditionalElement.params.name}`] || ""
320
- : formData[conditionalElement.params.name] || ""; // Get submitted value
329
+ // unneeded handle of `Attachment` at the end
330
+ // ? formData[`${conditionalElement.params.name}Attachment`] || ""
331
+ ? formData[`${conditionalElement.params.name}`] || ""
332
+ : formData[conditionalElement.params.name] || ""; // Get submitted value
321
333
 
322
334
  //Autocheck: check for "checkboxes", "radios", "select" if `fieldValue` is one of the `field.params.items`
323
335
  if (["checkboxes", "radios", "select"].includes(conditionalElement.element) && conditionalFieldValue !== "") {