From 06b78558263c6e5cbde3345d48b406be95dd3a9f Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 09:34:50 +0100 Subject: [PATCH 01/22] 22500 Phase 1: extract pure helpers from elements index --- .../elements/helpers/fileUploadHelpers.js | 110 ++++++++++ .../elements/helpers/translationHelpers.js | 36 ++++ components/elements/index.js | 193 ++---------------- memory-bank/change-log.md | 34 +++ memory-bank/refactor-backlog.md | 53 +++++ 5 files changed, 252 insertions(+), 174 deletions(-) create mode 100644 components/elements/helpers/fileUploadHelpers.js create mode 100644 components/elements/helpers/translationHelpers.js diff --git a/components/elements/helpers/fileUploadHelpers.js b/components/elements/helpers/fileUploadHelpers.js new file mode 100644 index 00000000..abfce895 --- /dev/null +++ b/components/elements/helpers/fileUploadHelpers.js @@ -0,0 +1,110 @@ +export const getThumbnailIconByMimeType = (fileType) => { + switch (fileType) { + case "text/html": + return "/assets/images/documenttypes/html.png"; + case "text/plain": + return "/assets/images/documenttypes/txt.png"; + case "application/msword": + return "/assets/images/documenttypes/doc.png"; + case "application/pdf": + return "/assets/images/documenttypes/pdf.png"; + case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + return "/assets/images/documenttypes/docx.png"; + case "text/csv": + return "/assets/images/documenttypes/csv.png"; + case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": + return "/assets/images/documenttypes/xlsx.png"; + case "image/jpeg": + return "/assets/images/documenttypes/jpg.png"; + case "image/png": + return "/assets/images/documenttypes/png.png"; + default: + return "/assets/images/documenttypes/default.png"; + } +}; + +export const getDocumentTypePrefix = (docCode) => { + const repDate = new Date(); + + let day = repDate.getDate(); + let month = repDate.getMonth() + 1; + let year = repDate.getFullYear(); + + const pdfFileNameDateStamp = + year + "-" + ("0" + month).slice(-2) + "-" + ("0" + day).slice(-2); + + switch (docCode) { + case "000000": + return pdfFileNameDateStamp + "_-_Statement_of_Case"; + case "000001": + return pdfFileNameDateStamp + "_-_Application_Form"; + case "000002": + return pdfFileNameDateStamp + "_-_Site_Ownership_Certificate"; + case "000003": + return pdfFileNameDateStamp + "_-_Decision_Notice"; + case "000004": + return pdfFileNameDateStamp + "_-_Site_Location_Plan"; + case "000005": + return pdfFileNameDateStamp + "_-_Plans_Drawing_Documents"; + case "000006": + return ( + pdfFileNameDateStamp + "_-_Additional_Plans_Drawings_Documents" + ); + case "000007": + return pdfFileNameDateStamp + "_-_Design_and_Access_Statement"; + case "000008": + return pdfFileNameDateStamp + "_-_NSB_LPA_Additional_Documents"; + case "000009": + return pdfFileNameDateStamp + "_-_LPA_Correspondence"; + case "000010": + return pdfFileNameDateStamp + "_-_LPA_Original_Permission"; + case "000011": + return pdfFileNameDateStamp + "_-_LPA's_Registration_Letter"; + case "000012": + return pdfFileNameDateStamp + "_-_Environmental_Statement"; + case "000013": + return pdfFileNameDateStamp + "_-_Cost_of_Application"; + case "000014": + return pdfFileNameDateStamp + "_-_Other_Relevant_Material"; + case "000015": + return ( + pdfFileNameDateStamp + + "_-_S106_Agreement_or_Unilateral_Undertaking" + ); + default: + return pdfFileNameDateStamp + "_-_000000_-_"; + } +}; + +const FILENAME_ALLOWED = /^[A-Za-z0-9 ._\-:()—']+$/; + +export const validateUploadFilename = ( + file, + t, + invalidFilenameLabelKey = "newappeal:new-appeal-fileupload-file-error-invalid-filename-label" +) => { + const name = file.name; + + if (name.includes("#")) { + return { + code: "filename-invalid-chars", + message: `${name} ${t(invalidFilenameLabelKey)}` + }; + } + + if (!FILENAME_ALLOWED.test(name)) { + return { + code: "filename-invalid-chars", + message: `${name} ${t(invalidFilenameLabelKey)}` + }; + } + + if (/[<>"/\\|?*]/.test(name)) { + return { + code: "filename-invalid-chars", + message: `${name} ${t(invalidFilenameLabelKey)}` + }; + } + + return null; +}; diff --git a/components/elements/helpers/translationHelpers.js b/components/elements/helpers/translationHelpers.js new file mode 100644 index 00000000..7ab6aa56 --- /dev/null +++ b/components/elements/helpers/translationHelpers.js @@ -0,0 +1,36 @@ +import { JSONPath as jsonpath } from "jsonpath-plus"; + +export const getFieldTranslation = ({ + label, + locale, + appealtypes, + fieldLookup +}) => { + let formObj = jsonpath({ + path: "$['" + appealtypes + "']", + json: fieldLookup, + eval: true + }); + + return locale == "cy" + ? jsonpath({ + path: '$..[?(@ && @.value=="' + label + '")].value_cy', + json: formObj, + eval: true + })[0] + : label; +}; + +export const getPickListTranslation = ({ + optionValue, + locale, + pickListLookup +}) => { + return locale == "cy" + ? jsonpath({ + path: '$..[?(@ && @.value=="' + optionValue + '")].value_cy', + json: pickListLookup, + eval: true + }) + : optionValue; +}; diff --git a/components/elements/index.js b/components/elements/index.js index e86b88e7..9748dea7 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -42,6 +42,15 @@ const ReactQuill = dynamic(() => import("react-quill-new"), { ssr: false }); import { useStore as store, useSelector } from "react-redux"; import { useDispatch } from "react-redux"; import { validateField } from "./validationUtils"; // Import the validation function +import { + getDocumentTypePrefix, + getThumbnailIconByMimeType, + validateUploadFilename +} from "./helpers/fileUploadHelpers"; +import { + getFieldTranslation, + getPickListTranslation +} from "./helpers/translationHelpers"; const RenderTextfield = ({ id, @@ -1876,41 +1885,20 @@ export const FieldsTranslations = (label) => { const { locale } = router; const { appealtypes } = router.query; - let formObj = jsonpath({ - path: "$['" + appealtypes + "']", - json: fieldLookup, - eval: true + return getFieldTranslation({ + label, + locale, + appealtypes, + fieldLookup }); - - let labelTrans = - router.locale == "cy" - ? jsonpath({ - path: '$..[?(@ && @.value=="' + label + '")].value_cy', - json: formObj, - eval: true - })[0] - : label; - - //labelTrans = labelTrans.length > 1 ? labelTrans[0] : labelTrans; - - return labelTrans; }; const PickListTranslations = (optionValue) => { const router = useRouter(); const { locale } = router; const { appealtypes } = router.query; - //console.log(optionValue); - let optionTrans = - router.locale == "cy" - ? jsonpath({ - path: '$..[?(@ && @.value=="' + optionValue + '")].value_cy', - json: pickListLookup, - eval: true - }) - : optionValue; - //console.log(optionTrans, optionValue); - return optionTrans; + + return getPickListTranslation({ optionValue, locale, pickListLookup }); }; export function FileUploadField(props) { @@ -1999,111 +1987,6 @@ const RenderFileUpload = (field) => { )); - const getThumbnailIcon = (fileObj, fileType) => { - switch (fileType) { - case "text/html": - return "/assets/images/documenttypes/html.png"; - break; - case "text/plain": - return "/assets/images/documenttypes/txt.png"; - break; - case "application/msword": - return "/assets/images/documenttypes/doc.png"; - break; - case "application/pdf": - return "/assets/images/documenttypes/pdf.png"; - break; - case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": - return "/assets/images/documenttypes/docx.png"; - break; - case "text/csv": - return "/assets/images/documenttypes/csv.png"; - break; - case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": - return "/assets/images/documenttypes/xlsx.png"; - break; - case "image/jpeg": - return "/assets/images/documenttypes/jpg.png"; - case "image/png": - return "/assets/images/documenttypes/png.png"; - //return URL.createObjectURL(fileObj); - break; - default: - return "/assets/images/documenttypes/default.png"; - break; - } - }; - - const getDocumentType = (docCode) => { - const repDate = new Date(); - - let day = repDate.getDate(); - let month = repDate.getMonth() + 1; - let year = repDate.getFullYear(); - - const pdfFileNameDateStamp = - year + "-" + ("0" + month).slice(-2) + "-" + ("0" + day).slice(-2); - - switch (docCode) { - case "000000": - return pdfFileNameDateStamp + "_-_Statement_of_Case"; - case "000001": - return pdfFileNameDateStamp + "_-_Application_Form"; - break; - case "000002": - return pdfFileNameDateStamp + "_-_Site_Ownership_Certificate"; - break; - case "000003": - return pdfFileNameDateStamp + "_-_Decision_Notice"; - break; - case "000004": - return pdfFileNameDateStamp + "_-_Site_Location_Plan"; - break; - case "000005": - return pdfFileNameDateStamp + "_-_Plans_Drawing_Documents"; - break; - case "000006": - return ( - pdfFileNameDateStamp + - "_-_Additional_Plans_Drawings_Documents" - ); - break; - case "000007": - return pdfFileNameDateStamp + "_-_Design_and_Access_Statement"; - break; - case "000008": - return pdfFileNameDateStamp + "_-_NSB_LPA_Additional_Documents"; - break; - case "000009": - return pdfFileNameDateStamp + "_-_LPA_Correspondence"; - break; - case "000010": - return pdfFileNameDateStamp + "_-_LPA_Original_Permission"; - break; - case "000011": - return pdfFileNameDateStamp + "_-_LPA's_Registration_Letter"; - break; - case "000012": - return pdfFileNameDateStamp + "_-_Environmental_Statement"; - break; - case "000013": - return pdfFileNameDateStamp + "_-_Cost_of_Application"; - break; - case "000014": - return pdfFileNameDateStamp + "_-_Other_Relevant_Material"; - break; - case "000015": - return ( - pdfFileNameDateStamp + - "_-_S106_Agreement_or_Unilateral_Undertaking" - ); - break; - - default: - return pdfFileNameDateStamp + "_-_000000_-_"; - } - }; - const filelistObj = field.fileList || {}; const blobList = jsonpath({ @@ -2187,43 +2070,6 @@ const RenderFileUpload = (field) => { setRejectedFiles([]); // Clear any previously rejected file errorsss }; - const FILENAME_ALLOWED = /^[A-Za-z0-9 ._\-:()—']+$/; - - const validateFilename = (file, t) => { - const name = file.name; - - // block # - if (name.includes("#")) { - return { - code: "filename-invalid-chars", - message: `${name} ${t( - "newappeal:new-appeal-fileupload-file-error-invalid-filename-label" - )}` - }; - } - - if (!FILENAME_ALLOWED.test(name)) { - return { - code: "filename-invalid-chars", - message: `${name} ${t( - "newappeal:new-appeal-fileupload-file-error-invalid-filename-label" - )}` - }; - } - - // block characters < > " / \ | ? * - if (/[<>"/\\|?*]/.test(name)) { - return { - code: "filename-invalid-chars", - message: `${name} ${t( - "newappeal:new-appeal-fileupload-file-error-invalid-filename-label" - )}` - }; - } - - return null; // valid - }; - return ( <> { "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"] }} - validator={(file) => validateFilename(file, t)} + validator={(file) => validateUploadFilename(file, t)} onDrop={(acceptedFiles, fileRejections, e) => { // build + display rejected messages (ALWAYS, even if some accepted) if (fileRejections?.length) { @@ -2266,7 +2112,7 @@ const RenderFileUpload = (field) => { (file) => new File( [file], - `${getDocumentType(field.documentTypeCode)}_-_${ + `${getDocumentTypePrefix(field.documentTypeCode)}_-_${ file.name }`, { type: file.type } @@ -2434,8 +2280,7 @@ const RenderFileUpload = (field) => { − */} {file.name} pass + +Follow-ups: + +- Phase 2: extract low-risk leaf field renderer components from `components/elements/index.js` in bounded slices. +- Perform manual EN/CY + a11y smoke matrix on new appeal/myportal form journeys before merge. + ### CL-001: TASK22211 endpoint search-document contract consistency slice date: 2026-03-23 diff --git a/memory-bank/refactor-backlog.md b/memory-bank/refactor-backlog.md index cabb30c9..7ac4c5da 100644 --- a/memory-bank/refactor-backlog.md +++ b/memory-bank/refactor-backlog.md @@ -65,6 +65,59 @@ Last updated: 2026-03-12 3. hash utility behavior 4. locale rewrite mapping sanity checks +## Priority 6 — Decompose `components/elements/index.js` monolith (phased) + +- **Problem:** `components/elements/index.js` has grown into a high-coupling UI monolith (~2700+ LOC) combining field primitives, validation/conditional logic, translation helpers, file-upload orchestration, and field-array behavior. +- **Why it matters:** Very high regression surface for new appeal/my portal forms, slower change velocity, and poor testability/isolation. +- **Target outcome:** `components/elements/index.js` reduced to a thin barrel export with responsibility split into focused modules. +- **Context alignment:** Matches architecture direction in `context/architecture.md` (endpoint/component sprawl reduction, bounded slices) and file boundary guidance in `context/coding-conventions.md` (`pages` thin, reusable logic/components split by concern). +- **Guardrail constraints:** + - Preserve public-service reliability for core flows (`search`, `case`, `myportal`, `newappeal`). + - No auth/session/security header behavior changes (out of scope). + - Maintain EN/CY parity for user-facing behavior. + - Keep accessibility behavior unchanged (labels, focus, keyboard flow, errors). + +### Priority 6 — Phase 1 (start here): extract pure helpers only + +- **Scope (Phase 1 only):** + - Move pure/helper logic from `components/elements/index.js` into focused helper modules under `components/elements/` (or `components/elements/helpers/`) without behavior change. + - Candidate helper extraction set: + - translation helpers (`FieldsTranslations`, picklist translation helper) + - file upload helper utilities (icon/doc type naming/pure format helpers) + - other deterministic pure functions used by field renderers + - Keep all field renderers/components in place for Phase 1. +- **Non-goals (Phase 1):** + - No JSX component relocation yet. + - No upload flow logic rewrites. + - No validation rule behavior changes. + - No prop contract changes for existing consumers. + +- **Acceptance criteria (Phase 1):** + - `components/elements/index.js` imports extracted helpers from new helper modules and behavior remains equivalent. + - No route/API changes. + - Existing new appeal + myportal form journeys continue to function in EN and CY. + - Accessibility smoke unchanged for touched form controls (label association, keyboard reachability, inline error visibility). + - Lint passes for touched files. + +- **Validation matrix (minimum):** + 1. `npm run lint` + 2. Manual smoke: + - new appeal form step rendering + validation messages + - myportal representation/new appeal editing flow controls + - file upload field icon/name behavior unchanged + 3. Locale parity checks (EN/CY) for touched user-facing labels/routes. + 4. A11y smoke checks on touched fields (focus, labels, errors). + +- **Rollback plan (Phase 1):** + - Revert helper module extraction commit(s) to restore single-file implementation. + - No migration/data rollback required. + +- **Next phases (for tracking):** + - **Phase 2:** extract low-risk leaf field renderer components. + - **Phase 3:** extract `RenderFileUpload` and upload container. + - **Phase 4:** extract field-array/repeater components. + - **Phase 5:** finalize `components/elements/index.js` as barrel-only export. + ## Sequencing recommendation 1. Priorities 2 + 4 (security/integrity foundation) From 5b9498b3ef54fcb7d962a0cb280cc65402fe1425 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 09:40:25 +0100 Subject: [PATCH 02/22] 22500 Phase 2: extract rich multiline leaf renderer --- .../elements/fields/renderRichMultiline.js | 81 ++++++++++++++++++ components/elements/index.js | 85 +------------------ memory-bank/change-log.md | 24 ++++++ 3 files changed, 107 insertions(+), 83 deletions(-) create mode 100644 components/elements/fields/renderRichMultiline.js diff --git a/components/elements/fields/renderRichMultiline.js b/components/elements/fields/renderRichMultiline.js new file mode 100644 index 00000000..f09b77e8 --- /dev/null +++ b/components/elements/fields/renderRichMultiline.js @@ -0,0 +1,81 @@ +import React, { useState } from "react"; +import dynamic from "next/dynamic"; +import { updateLinks } from "../../utils"; + +const ReactQuill = dynamic(() => import("react-quill-new"), { ssr: false }); + +export const RenderRichMultiline = ({ + id, + className, + rows, + datafieldname, + name, + label, + input, + meta: { touched, error }, + ...custom +}) => { + const [editValue, setEditValue] = useState( + input.value != null ? input.value : "" + ); + + const changeEdit = (valStr) => { + setEditValue(valStr); + input.onChange(valStr); + }; + + const handleBlur = () => { + const updated = updateLinks(editValue); + setEditValue(updated); + input.onChange(updated); + }; + + var modules = { + toolbar: [ + [{ "header": [1, 2, false] }], + ["bold", "italic", "underline", "blockquote"], + [{ "list": "ordered" }, { "list": "bullet" }], + ["clean"] + ] + }; + + return ( + <> +
+ + + {error && ( + + Error:{" "} + {error} + + )} + + + +
+ + ); +}; diff --git a/components/elements/index.js b/components/elements/index.js index 9748dea7..338da931 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -19,12 +19,7 @@ import { import fieldLookup from "../../data/crmfieldlookuptranslations.json"; import pickListLookup from "../../data/picklistLookups.json"; -import dynamic from "next/dynamic"; -import { - bytesToSize, - getThumbnailIconByExtension, - updateLinks -} from "../utils"; +import { bytesToSize, getThumbnailIconByExtension } from "../utils"; import { setFileCount } from "../../store/appealType/action"; import { @@ -37,7 +32,6 @@ import { subYears } from "date-fns"; import "react-quill-new/dist/quill.snow.css"; -const ReactQuill = dynamic(() => import("react-quill-new"), { ssr: false }); import { useStore as store, useSelector } from "react-redux"; import { useDispatch } from "react-redux"; @@ -51,6 +45,7 @@ import { getFieldTranslation, getPickListTranslation } from "./helpers/translationHelpers"; +import { RenderRichMultiline } from "./fields/renderRichMultiline"; const RenderTextfield = ({ id, @@ -472,82 +467,6 @@ export function MultiLinefield(props) { ); } -export const RenderRichMultiline = ({ - id, - className, - rows, - datafieldname, - name, - label, - input, - meta: { touched, error }, - ...custom -}) => { - const [editValue, setEditValue] = useState( - input.value != null ? input.value : "" - ); - - const changeEdit = (valStr) => { - setEditValue(valStr); - input.onChange(valStr); - }; - - const handleBlur = () => { - const updated = updateLinks(editValue); - setEditValue(updated); - input.onChange(updated); - }; - - var modules = { - toolbar: [ - [{ "header": [1, 2, false] }], - ["bold", "italic", "underline", "blockquote"], - [{ "list": "ordered" }, { "list": "bullet" }], - ["clean"] - ] - }; - - return ( - <> -
- - - {error && ( - - Error:{" "} - {error} - - )} - - - -
- - ); -}; - export function RichMultiLinefield(props) { const { name, diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index a88f61e9..d15ea182 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -52,6 +52,30 @@ Follow-ups: - Phase 2: extract low-risk leaf field renderer components from `components/elements/index.js` in bounded slices. - Perform manual EN/CY + a11y smoke matrix on new appeal/myportal form journeys before merge. +### CL-00Y: 22500 `components/elements/index.js` Phase 2 leaf renderer extraction (Rich multiline) + +date: 2026-04-07 +author: Cline +scope: `components/elements/index.js`, `components/elements/fields/renderRichMultiline.js` +type: change +rationale: Continue the approved phased decomposition by extracting one low-risk leaf renderer (`RenderRichMultiline`) from the elements monolith while keeping existing field wiring and behavior intact. +impact: Refactor-only move of rich multiline renderer implementation; no route/API/auth/security changes and no intended EN/CY behavior change. +status: completed + +Summary: + +- Added `components/elements/fields/renderRichMultiline.js` containing the extracted `RenderRichMultiline` renderer. +- Updated `components/elements/index.js` to import the extracted renderer and removed the inline duplicate implementation. +- Kept `RichMultiLinefield` usage and props unchanged (same Redux Field component wiring and validation flow). + +Validation: + +- `npx eslint components/elements/index.js components/elements/fields/renderRichMultiline.js` -> pass + +Follow-ups: + +- Continue Phase 2 in bounded slices by extracting additional low-risk leaf renderers (e.g., `RenderMultiline` / `RenderTextfield`) with no behavior change. + ### CL-001: TASK22211 endpoint search-document contract consistency slice date: 2026-03-23 From aa4d1fad1ece4000614926669967f9ada79f0fbc Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 09:45:19 +0100 Subject: [PATCH 03/22] 22500 Phase 2: extract text and multiline leaf renderers --- components/elements/fields/renderMultiline.js | 51 +++++++ components/elements/fields/renderTextfield.js | 91 ++++++++++++ components/elements/index.js | 131 +----------------- memory-bank/change-log.md | 24 ++++ 4 files changed, 168 insertions(+), 129 deletions(-) create mode 100644 components/elements/fields/renderMultiline.js create mode 100644 components/elements/fields/renderTextfield.js diff --git a/components/elements/fields/renderMultiline.js b/components/elements/fields/renderMultiline.js new file mode 100644 index 00000000..49918fd0 --- /dev/null +++ b/components/elements/fields/renderMultiline.js @@ -0,0 +1,51 @@ +import React from "react"; + +export const RenderMultiline = ({ + id, + className, + rows, + datafieldname, + name, + label, + input, + errorMsg, + meta: { touched, error }, + ...custom +}) => { + return ( + <> +
+ {touched && error && ( + + Error:{" "} + {touched && + ((error && ( + {errorMsg ? errorMsg : error} + )) || + (warning && {warning}))} + + )} + +
+ + ); +}; diff --git a/components/elements/fields/renderTextfield.js b/components/elements/fields/renderTextfield.js new file mode 100644 index 00000000..60960aa6 --- /dev/null +++ b/components/elements/fields/renderTextfield.js @@ -0,0 +1,91 @@ +import React from "react"; +import { useRouter } from "next/router"; +import fieldLookup from "../../../data/crmfieldlookuptranslations.json"; +import { getFieldTranslation } from "../helpers/translationHelpers"; + +export const RenderTextfield = ({ + id, + className, + rows, + datafieldname, + name, + label, + input, + errorMsg, + meta: { touched, error }, + ...custom +}) => { + const router = useRouter(); + const { locale } = router; + const { appealtypes } = router.query; + + const translatedLabel = getFieldTranslation({ + label, + locale, + appealtypes, + fieldLookup + }); + + return ( + <> +
+ + {!custom.hasOwnProperty("showErrorBottom") + ? touched && + error && ( + + + Error: + {" "} + {error} + + ) + : ""} + + {custom.hasOwnProperty("showErrorBottom") + ? touched && + error && ( + + + Error: + {" "} + {error} + + ) + : ""} +
+ + ); +}; diff --git a/components/elements/index.js b/components/elements/index.js index 338da931..bd84bb6d 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -46,82 +46,8 @@ import { getPickListTranslation } from "./helpers/translationHelpers"; import { RenderRichMultiline } from "./fields/renderRichMultiline"; - -const RenderTextfield = ({ - id, - className, - rows, - datafieldname, - name, - label, - input, - errorMsg, - meta: { touched, error }, - ...custom -}) => { - return ( - <> -
- - {!custom.hasOwnProperty("showErrorBottom") - ? touched && - error && ( - - - Error: - {" "} - {error} - - ) - : ""} - - {custom.hasOwnProperty("showErrorBottom") - ? touched && - error && ( - - - Error: - {" "} - {error} - - ) - : ""} -
- - ); -}; +import { RenderTextfield } from "./fields/renderTextfield"; +import { RenderMultiline } from "./fields/renderMultiline"; export function Textfield(props) { const { @@ -284,59 +210,6 @@ export function Textfield(props) { } } -const RenderMultiline = ({ - id, - className, - rows, - datafieldname, - name, - label, - input, - errorMsg, - meta: { touched, error }, - ...custom -}) => { - return ( - <> -
- {/* */} - {touched && error && ( - - Error:{" "} - {touched && - ((error && ( - {errorMsg ? errorMsg : error} - )) || - (warning && {warning}))} - - )} - -
- - ); -}; - export function MultiLinefield(props) { const { name, diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index d15ea182..b36ec9f2 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -76,6 +76,30 @@ Follow-ups: - Continue Phase 2 in bounded slices by extracting additional low-risk leaf renderers (e.g., `RenderMultiline` / `RenderTextfield`) with no behavior change. +### CL-00Z: 22500 `components/elements/index.js` Phase 2 leaf renderer extraction (Text + Multiline) + +date: 2026-04-07 +author: Cline +scope: `components/elements/index.js`, `components/elements/fields/renderTextfield.js`, `components/elements/fields/renderMultiline.js` +type: change +rationale: Complete the requested next bounded phase by extracting the additional low-risk leaf renderers (`RenderTextfield`, `RenderMultiline`) from the elements monolith into dedicated field modules while preserving existing wiring and behavior. +impact: Refactor-only move of two renderer components; no intended changes to auth/API/security and no intended EN/CY behavior change. +status: completed + +Summary: + +- Added `components/elements/fields/renderTextfield.js` and `components/elements/fields/renderMultiline.js`. +- Updated `components/elements/index.js` to import the extracted renderers. +- Removed inline `RenderTextfield` and `RenderMultiline` implementations from `index.js`. + +Validation: + +- `npx eslint components/elements/index.js components/elements/fields/renderTextfield.js components/elements/fields/renderMultiline.js` -> pass + +Follow-ups: + +- Continue Phase 2 by selecting the next lowest-risk leaf renderer extraction in a separate commit. + ### CL-001: TASK22211 endpoint search-document contract consistency slice date: 2026-03-23 From 5b5a91b33bb124b4710450f00f3df9ae7dde7f9f Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 11:24:17 +0100 Subject: [PATCH 04/22] Make uploadsinglefile batch size env-configurable with fallback of 5 --- pages/api/file/uploadsinglefile.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pages/api/file/uploadsinglefile.js b/pages/api/file/uploadsinglefile.js index 42827762..a89d1961 100644 --- a/pages/api/file/uploadsinglefile.js +++ b/pages/api/file/uploadsinglefile.js @@ -10,7 +10,10 @@ import fs from "fs"; import path from "path"; const FILENAME_ALLOWED = /^[A-Za-z0-9 ._\-:()—']+$/; -const MAX_FILES_PER_UPLOAD_BATCH = process.env.UPLOAD_BATCH_COUNT || 5; +const MAX_FILES_PER_UPLOAD_BATCH = Math.max( + 1, + Number.parseInt(process.env.UPLOAD_BATCH_COUNT || "5", 10) || 5 +); function validateFilenameServer(originalFilename) { const name = path.basename(originalFilename || ""); From b022c0a0d9d2b3f135afae4d44222b25ce83339b Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 11:25:48 +0100 Subject: [PATCH 05/22] Refactor elements renderers into dedicated field components --- .../elements/fields/renderDatePicker.js | 162 ++++++++ components/elements/fields/renderRadio.js | 129 ++++++ components/elements/fields/renderYesNo.js | 135 ++++++ components/elements/index.js | 392 +----------------- 4 files changed, 429 insertions(+), 389 deletions(-) create mode 100644 components/elements/fields/renderDatePicker.js create mode 100644 components/elements/fields/renderRadio.js create mode 100644 components/elements/fields/renderYesNo.js diff --git a/components/elements/fields/renderDatePicker.js b/components/elements/fields/renderDatePicker.js new file mode 100644 index 00000000..bb512802 --- /dev/null +++ b/components/elements/fields/renderDatePicker.js @@ -0,0 +1,162 @@ +import React from "react"; +import DatePicker from "react-datepicker"; +import { useRouter } from "next/router"; +import useTranslation from "next-translate/useTranslation"; +import { + addDays, + addMonths, + addYears, + parseISO, + subDays, + subMonths, + subYears +} from "date-fns"; +import fieldLookup from "../../../data/crmfieldlookuptranslations.json"; +import { getFieldTranslation } from "../helpers/translationHelpers"; + +export const RenderDatePicker = ({ + datafieldname, + name, + label, + id, + errorMsg, + input: { onChange, value }, + meta: { touched, error }, + ...custom +}) => { + const router = useRouter(); + let { t } = useTranslation(); + const { locale } = router; + const { appealtypes } = router.query; + + const translatedLabel = getFieldTranslation({ + label, + locale, + appealtypes, + fieldLookup + }); + + function dateFromOffset(offsetStr) { + const today = new Date(); + + if (!offsetStr || offsetStr === "0") { + return today; + } + + const match = offsetStr.match(/^([+-])(\d+)([dmy])$/i); + + if (!match) { + console.warn("Invalid offset format:", offsetStr); + return today; + } + + const [, sign, numberStr, unitRaw] = match; + const amount = parseInt(numberStr, 10); + const unit = unitRaw.toLowerCase(); + const isNegative = sign === "-"; + + switch (unit) { + case "y": + return isNegative + ? subYears(today, amount) + : addYears(today, amount); + + case "m": + return isNegative + ? subMonths(today, amount) + : addMonths(today, amount); + + case "d": + return isNegative + ? subDays(today, amount) + : addDays(today, amount); + + default: + return today; + } + } + + const minDate = dateFromOffset(custom.dateStart); + const maxDate = dateFromOffset(custom.dateEnd); + + return ( + <> +
+
+ + + {custom.hasOwnProperty("hint") && ( +
+ {t(custom.hint)} +
+ )} +
+ {!custom.hasOwnProperty("showErrorBottom") + ? touched && + error && ( + + + Error: + {" "} + {errorMsg} + + ) + : ""} + + + {custom.hasOwnProperty("showErrorBottom") + ? touched && + error && ( + + + Error: + {" "} + {error} + + ) + : ""} +
+
+ + ); +}; diff --git a/components/elements/fields/renderRadio.js b/components/elements/fields/renderRadio.js new file mode 100644 index 00000000..22aef6b2 --- /dev/null +++ b/components/elements/fields/renderRadio.js @@ -0,0 +1,129 @@ +import React from "react"; +import useTranslation from "next-translate/useTranslation"; +import { useRouter } from "next/router"; +import { Field } from "redux-form"; +import fieldLookup from "../../../data/crmfieldlookuptranslations.json"; +import { getFieldTranslation } from "../helpers/translationHelpers"; + +export const RenderRadio = ({ + datafieldname, + name, + label, + id, + errorMsg, + options, + input: { onChange, value }, + meta: { touched, error }, + ...custom +}) => { + let { t } = useTranslation(); + const router = useRouter(); + const { locale } = router; + const { appealtypes } = router.query; + + const translatedLabel = getFieldTranslation({ + label, + locale, + appealtypes, + fieldLookup + }); + + return ( + <> +
+
+ + + + {touched && error && ( + + + Error: + {" "} + {errorMsg} + + )} +
+ {" "} + {custom.hint != false && ( +
{t(custom.hint)}
+ )} + {Object.keys(options).map((key, index) => ( +
+ { + let docListObj = custom.documentList; + if ( + custom.requiredDocumentValue == + "Yes" + ) { + e.target.value != "846040000" + ? (docListObj = Object.assign( + docListObj, + { + ["documentCheckList_" + + id]: + custom.requiredDocumentLabel + } + )) + : delete docListObj[ + "documentCheckList_" + id + ]; + custom.setDocumentsList(docListObj); + } + }} + /> + +
+ ))} +
+
+
+ + ); +}; diff --git a/components/elements/fields/renderYesNo.js b/components/elements/fields/renderYesNo.js new file mode 100644 index 00000000..70fd5b58 --- /dev/null +++ b/components/elements/fields/renderYesNo.js @@ -0,0 +1,135 @@ +import React from "react"; +import { useRouter } from "next/router"; +import { Field } from "redux-form"; +import fieldLookup from "../../../data/crmfieldlookuptranslations.json"; +import { getFieldTranslation } from "../helpers/translationHelpers"; + +export const RenderYesNo = ({ + datafieldname, + name, + label, + id, + errorMsg, + options, + input: { onChange, value }, + meta: { touched, error }, + ...custom +}) => { + const router = useRouter(); + const { locale } = router; + const { appealtypes } = router.query; + + const translatedLabel = getFieldTranslation({ + label, + locale, + appealtypes, + fieldLookup + }); + + return ( + <> +
+
+ + + + {touched && error && ( + + + Error: + {" "} + {errorMsg} + + )} +
+ {Object.keys(options).map((key, index) => ( +
+ { + let docListObj = custom.documentList; + if ( + custom.requiredDocumentValue == + "Yes" || + custom.requiredDocumentValue == + "Ydw" || + custom.requiredDocumentValue == + "true" + ) { + e.target.value == "Ydw" || + e.target.value == "Yes" || + e.target.value == "true" + ? (docListObj = Object.assign( + docListObj, + { + ["documentCheckList_" + + id]: + custom.requiredDocumentLabel + } + )) + : delete docListObj[ + "documentCheckList_" + id + ]; + custom.setDocumentsList(docListObj); + } + }} + /> + +
+ ))} +
+
+
+ + ); +}; diff --git a/components/elements/index.js b/components/elements/index.js index bd84bb6d..05fba24f 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -6,7 +6,6 @@ import useTranslation from "next-translate/useTranslation"; import Link from "next/link"; import { useRouter } from "next/router"; import React, { useMemo, useState } from "react"; -import DatePicker from "react-datepicker"; import "react-datepicker/dist/react-datepicker.css"; import Dropzone from "react-dropzone"; import { Field, FieldArray, change } from "redux-form"; @@ -22,15 +21,6 @@ import pickListLookup from "../../data/picklistLookups.json"; import { bytesToSize, getThumbnailIconByExtension } from "../utils"; import { setFileCount } from "../../store/appealType/action"; -import { - addDays, - addMonths, - addYears, - parseISO, - subDays, - subMonths, - subYears -} from "date-fns"; import "react-quill-new/dist/quill.snow.css"; import { useStore as store, useSelector } from "react-redux"; @@ -48,6 +38,9 @@ import { import { RenderRichMultiline } from "./fields/renderRichMultiline"; import { RenderTextfield } from "./fields/renderTextfield"; import { RenderMultiline } from "./fields/renderMultiline"; +import { RenderDatePicker } from "./fields/renderDatePicker"; +import { RenderYesNo } from "./fields/renderYesNo"; +import { RenderRadio } from "./fields/renderRadio"; export function Textfield(props) { const { @@ -450,152 +443,6 @@ export function RichMultiLinefield(props) { ); } -const RenderDatePicker = ({ - datafieldname, - name, - label, - id, - errorMsg, - input: { onChange, value }, - meta: { touched, error }, - ...custom -}) => { - const router = useRouter(); - - let { t } = useTranslation(); - - function dateFromOffset(offsetStr) { - const today = new Date(); - - // "0" means today - if (!offsetStr || offsetStr === "0") { - return today; - } - - // Expect formats like: -5d, +18m, -10y - const match = offsetStr.match(/^([+-])(\d+)([dmy])$/i); - - if (!match) { - console.warn("Invalid offset format:", offsetStr); - return today; // fallback - } - - const [, sign, numberStr, unitRaw] = match; - const amount = parseInt(numberStr, 10); - const unit = unitRaw.toLowerCase(); - const isNegative = sign === "-"; - - switch (unit) { - case "y": - return isNegative - ? subYears(today, amount) - : addYears(today, amount); - - case "m": - return isNegative - ? subMonths(today, amount) - : addMonths(today, amount); - - case "d": - return isNegative - ? subDays(today, amount) - : addDays(today, amount); - - default: - return today; - } - } - const minDate = dateFromOffset(custom.dateStart); - const maxDate = dateFromOffset(custom.dateEnd); - - return ( - <> -
-
- - - {custom.hasOwnProperty("hint") && ( -
- {t(custom.hint)} -
- )} -
- {!custom.hasOwnProperty("showErrorBottom") - ? touched && - error && ( - - - Error: - {" "} - {errorMsg} - - ) - : ""} - - {/* {value} -
- {moment(value).format("DD/MM/YY")} -
*/} - - - {custom.hasOwnProperty("showErrorBottom") - ? touched && - error && ( - - - Error: - {" "} - {error} - - ) - : ""} -
-
- - ); -}; - export function DateFieldPicker(props) { const { name, @@ -769,127 +616,6 @@ export function DateField(props) { ); } -const RenderYesNo = ({ - datafieldname, - name, - label, - id, - errorMsg, - options, - input: { onChange, value }, - meta: { touched, error }, - ...custom -}) => { - return ( - <> -
-
- - - - {touched && error && ( - - - Error: - {" "} - {errorMsg} - - )} -
- {Object.keys(options).map((key, index) => ( -
- { - let docListObj = custom.documentList; - if ( - custom.requiredDocumentValue == - "Yes" || - custom.requiredDocumentValue == - "Ydw" || - custom.requiredDocumentValue == - "true" - ) { - e.target.value == "Ydw" || - e.target.value == "Yes" || - e.target.value == "true" - ? (docListObj = Object.assign( - docListObj, - { - ["documentCheckList_" + - id]: - custom.requiredDocumentLabel - } - )) - : delete docListObj[ - "documentCheckList_" + id - ]; - custom.setDocumentsList(docListObj); - } - }} - /> - -
- ))} -
-
-
- - ); -}; - export function YesNofield(props) { const router = useRouter(); let { t } = useTranslation(); @@ -961,118 +687,6 @@ export function YesNofield(props) { ); } -const RenderRadio = ({ - datafieldname, - name, - label, - id, - errorMsg, - options, - input: { onChange, value }, - meta: { touched, error }, - ...custom -}) => { - let { t } = useTranslation(); - return ( - <> -
-
- - - - {touched && error && ( - - - Error: - {" "} - {errorMsg} - - )} -
- {" "} - {custom.hint != false && ( -
{t(custom.hint)}
- )} - {Object.keys(options).map((key, index) => ( -
- { - let docListObj = custom.documentList; - if ( - custom.requiredDocumentValue == - "Yes" - ) { - e.target.value != "846040000" - ? (docListObj = Object.assign( - docListObj, - { - ["documentCheckList_" + - id]: - custom.requiredDocumentLabel - } - )) - : delete docListObj[ - "documentCheckList_" + id - ]; - custom.setDocumentsList(docListObj); - } - }} - /> - -
- ))} -
-
-
- - ); -}; - export function Radiofield(props) { const { name, From 7ae5f80f26b3adc629f55cbc5360aecebf722437 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 11:42:11 +0100 Subject: [PATCH 06/22] Refactor checkbox, picklist, and decimal renderers into field components --- components/elements/fields/renderCheckBox.js | 89 ++++++++ .../elements/fields/renderDecimalField.js | 62 ++++++ components/elements/fields/renderPickList.js | 82 +++++++ components/elements/index.js | 207 +----------------- 4 files changed, 237 insertions(+), 203 deletions(-) create mode 100644 components/elements/fields/renderCheckBox.js create mode 100644 components/elements/fields/renderDecimalField.js create mode 100644 components/elements/fields/renderPickList.js diff --git a/components/elements/fields/renderCheckBox.js b/components/elements/fields/renderCheckBox.js new file mode 100644 index 00000000..9bd9b17f --- /dev/null +++ b/components/elements/fields/renderCheckBox.js @@ -0,0 +1,89 @@ +import React from "react"; +import { Field } from "redux-form"; +import { useRouter } from "next/router"; +import fieldLookup from "../../../data/crmfieldlookuptranslations.json"; +import { getFieldTranslation } from "../helpers/translationHelpers"; + +export const RenderCheckBox = ({ + datafieldname, + name, + label, + id, + errorMsg, + options, + input: { onChange, value }, + meta: { touched, error }, + ...custom +}) => { + const router = useRouter(); + const { locale } = router; + const { appealtypes } = router.query; + + const translatedLabel = getFieldTranslation({ + label, + locale, + appealtypes, + fieldLookup + }); + + return ( + <> +
+
+ + + + {touched && error && ( + + + Error: + {" "} + {errorMsg} + + )} +
+ {Object.keys(options).map((key, index) => ( +
+ + +
+ ))} +
+
+
+ + ); +}; diff --git a/components/elements/fields/renderDecimalField.js b/components/elements/fields/renderDecimalField.js new file mode 100644 index 00000000..3711d2ed --- /dev/null +++ b/components/elements/fields/renderDecimalField.js @@ -0,0 +1,62 @@ +import React from "react"; +import { useRouter } from "next/router"; +import fieldLookup from "../../../data/crmfieldlookuptranslations.json"; +import { getFieldTranslation } from "../helpers/translationHelpers"; + +export const RenderDecimalField = ({ + name, + id, + label, + datafieldname, + className, + input, + errorMsg, + meta: { touched, error }, + ...custom +}) => { + const router = useRouter(); + const { locale } = router; + const { appealtypes } = router.query; + + const translatedLabel = getFieldTranslation({ + label, + locale, + appealtypes, + fieldLookup + }); + + return ( + <> +
+ + {touched && error && ( + + Error:{" "} + {error} + + )} + +
+ + ); +}; diff --git a/components/elements/fields/renderPickList.js b/components/elements/fields/renderPickList.js new file mode 100644 index 00000000..521b82d9 --- /dev/null +++ b/components/elements/fields/renderPickList.js @@ -0,0 +1,82 @@ +import React from "react"; +import { JSONPath as jsonpath } from "jsonpath-plus"; +import { useRouter } from "next/router"; +import useTranslation from "next-translate/useTranslation"; +import fieldLookup from "../../../data/crmfieldlookuptranslations.json"; +import pickListLookup from "../../../data/picklistLookups.json"; +import { + getFieldTranslation, + getPickListTranslation +} from "../helpers/translationHelpers"; + +export const RenderPickList = ({ + datafieldname, + picklistData, + id, + name, + label, + input, + errorMsg, + meta: { touched, error } +}) => { + let { t } = useTranslation(); + const router = useRouter(); + const { locale } = router; + const { appealtypes } = router.query; + + var dropdownObj = jsonpath({ + path: + "$..value[?(@ && @.LogicalName=='" + datafieldname + "')]..Options", + json: picklistData, + eval: true + }); + dropdownObj = dropdownObj[0]; + + const translatedLabel = getFieldTranslation({ + label, + locale, + appealtypes, + fieldLookup + }); + + return ( + <> +
+ + {touched && error && ( + + Error:{" "} + {error} + + )} + +
+ + ); +}; diff --git a/components/elements/index.js b/components/elements/index.js index 05fba24f..de495dba 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -31,16 +31,16 @@ import { getThumbnailIconByMimeType, validateUploadFilename } from "./helpers/fileUploadHelpers"; -import { - getFieldTranslation, - getPickListTranslation -} from "./helpers/translationHelpers"; +import { getFieldTranslation } from "./helpers/translationHelpers"; import { RenderRichMultiline } from "./fields/renderRichMultiline"; import { RenderTextfield } from "./fields/renderTextfield"; import { RenderMultiline } from "./fields/renderMultiline"; import { RenderDatePicker } from "./fields/renderDatePicker"; import { RenderYesNo } from "./fields/renderYesNo"; import { RenderRadio } from "./fields/renderRadio"; +import { RenderCheckBox } from "./fields/renderCheckBox"; +import { RenderPickList } from "./fields/renderPickList"; +import { RenderDecimalField } from "./fields/renderDecimalField"; export function Textfield(props) { const { @@ -784,79 +784,6 @@ export function Radiofield(props) { ); } -const RenderCheckBox = ({ - datafieldname, - name, - label, - id, - errorMsg, - options, - input: { onChange, value }, - meta: { touched, error }, - ...custom -}) => { - return ( - <> -
-
- - - - {touched && error && ( - - - Error: - {" "} - {errorMsg} - - )} -
- {Object.keys(options).map((key, index) => ( -
- - -
- ))} -
-
-
- - ); -}; - export function CheckBoxfield(props) { const router = useRouter(); @@ -879,70 +806,6 @@ export function CheckBoxfield(props) { ); } -const RenderPickList = ({ - datafieldname, - picklistData, - id, - name, - label, - input, - errorMsg, - meta: { touched, error } -}) => { - let { t } = useTranslation(); - - var dropdownObj = jsonpath({ - path: - "$..value[?(@ && @.LogicalName=='" + datafieldname + "')]..Options", - json: picklistData, - eval: true - }); - dropdownObj = dropdownObj[0]; - - const required = (value) => { - return value || value == 0 - ? undefined - : t("newappeal:is-required-label"); - }; - - return ( - <> -
- - {touched && error && ( - - Error:{" "} - {error} - - )} - -
- - ); -}; - export function PickList(props) { const { name, label, datafieldname, hint } = props; let { t } = useTranslation(); @@ -1207,60 +1070,6 @@ export function DecimalField(props) { ); } -const RenderDecimalField = ({ - name, - id, - label, - datafieldname, - className, - input, - errorMsg, - meta: { touched, error }, - ...custom -}) => { - //
- // - // - // {touched && error && {error}} - //
; - - return ( - <> -
- - {touched && error && ( - - Error:{" "} - {error} - - )} - -
- - ); -}; - const RenderCaseID = {}; export function ReadOnlyfield(props) { @@ -1299,14 +1108,6 @@ export const FieldsTranslations = (label) => { }); }; -const PickListTranslations = (optionValue) => { - const router = useRouter(); - const { locale } = router; - const { appealtypes } = router.query; - - return getPickListTranslation({ optionValue, locale, pickListLookup }); -}; - export function FileUploadField(props) { const uploadCount = typeof props.uploadCount === "number" ? props.uploadCount : 0; From f8665ee6b7440309051cbea4e13b89cfee8e1aad Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 12:17:15 +0100 Subject: [PATCH 07/22] Refactor field array subfields renderer into dedicated component --- components/elements/fields/renderSubFields.js | 146 ++++++++++++++++++ components/elements/index.js | 138 +---------------- 2 files changed, 147 insertions(+), 137 deletions(-) create mode 100644 components/elements/fields/renderSubFields.js diff --git a/components/elements/fields/renderSubFields.js b/components/elements/fields/renderSubFields.js new file mode 100644 index 00000000..5e5665d9 --- /dev/null +++ b/components/elements/fields/renderSubFields.js @@ -0,0 +1,146 @@ +import React from "react"; +import { Field } from "redux-form"; +import useTranslation from "next-translate/useTranslation"; +import { RenderTextfield } from "./renderTextfield"; +import { RenderDatePicker } from "./renderDatePicker"; + +export const RenderSubFields = ({ + fields, + meta: { touched, error }, + ...custom +}) => { + let { t } = useTranslation(); + + let subTitle = fields.name.split("pinswg_")[1]; + + const checkValue = (value) => { + let errors; + + if (!value) { + errors = t("newappeal:is-required-label"); + } else { + const emojiRegex = + /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{2300}-\u{23FF}\u{2B50}\u{1F004}-\u{1F0CF}\u{1F0A0}-\u{1F0A5}\u{1F170}-\u{1F251}]/gu; + + if (emojiRegex.test(value)) { + errors = "Emojis are not allowed"; + } + } + + return errors; + }; + + const required = (value) => + value ? undefined : t("newappeal:is-required-label"); + + const email = (value) => { + let errors; + + const emailRegex = + /(?:[a-z0-9!#$%&'*+\/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+\/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/gi; + + if (!emailRegex.test(value)) { + errors = t("newappeal:invalid-email-address-errormsg"); + } + return errors; + }; + + fields.length == 0 && fields.push({}); + + return ( +
    + {fields.map((member, index) => ( +
    +

    + {subTitle == "owners" ? "Owner" : "Tenant"} #{index + 1} +

    + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + +
    +
    +
    + ))} + +
    + {fields.length < custom.maxSubField && ( + + )} +
+ ); +}; diff --git a/components/elements/index.js b/components/elements/index.js index de495dba..751a9950 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -41,6 +41,7 @@ import { RenderRadio } from "./fields/renderRadio"; import { RenderCheckBox } from "./fields/renderCheckBox"; import { RenderPickList } from "./fields/renderPickList"; import { RenderDecimalField } from "./fields/renderDecimalField"; +import { RenderSubFields } from "./fields/renderSubFields"; export function Textfield(props) { const { @@ -1584,143 +1585,6 @@ const renderField = ({ input, label, type, meta: { touched, error } }) => ( ); -const RenderSubFields = ({ fields, meta: { touched, error }, ...custom }) => { - let { t } = useTranslation(); - - let subTitle = fields.name.split("pinswg_")[1]; - - const checkValue = (value) => { - let errors; - - if (!value) { - errors = t("newappeal:is-required-label"); - } else { - // Check for emojis using a regular expression - const emojiRegex = - /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{2300}-\u{23FF}\u{2B50}\u{1F004}-\u{1F0CF}\u{1F0A0}-\u{1F0A5}\u{1F170}-\u{1F251}]/gu; - - if (emojiRegex.test(value)) { - errors = "Emojis are not allowed"; - } - } - - return errors; - }; - const required = (value) => - value ? undefined : t("newappeal:is-required-label"); - - const email = (value) => { - let errors; - - const emailRegex = - /(?:[a-z0-9!#$%&'*+\/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+\/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/gi; - - if (!emailRegex.test(value)) { - errors = t("newappeal:invalid-email-address-errormsg"); - } - return errors; - }; - - //fields.push({}); - fields.length == 0 && fields.push({}); - return ( -
    - {fields.map((member, index) => ( -
    -

    - {subTitle == "owners" ? "Owner" : "Tenant"} #{index + 1} -

    - -
    -
    -
    - - -
    -
    - - -
    -
    -
    - -
    -
    -
    - ))} - -
    - {fields.length < custom.maxSubField && ( - - )} -
- ); -}; - export const FieldArrayForm = (props) => { const { name, From f886fb8f2559264578aa636ad6ad3bb7293154d5 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 12:27:36 +0100 Subject: [PATCH 08/22] Remove unused renderField from elements index --- components/elements/index.js | 10 ---------- memory-bank/change-log.md | 26 ++++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/components/elements/index.js b/components/elements/index.js index 751a9950..8c18a759 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -1575,16 +1575,6 @@ const RenderFileUpload = (field) => { ); }; -const renderField = ({ input, label, type, meta: { touched, error } }) => ( -
- -
- - {touched && error && {error}} -
-
-); - export const FieldArrayForm = (props) => { const { name, diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index b36ec9f2..8983ed36 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -3395,3 +3395,29 @@ Validation: Follow-ups: - Optional: localize a dedicated `upload-progress-x-of-y` translation key if copy needs stronger grammatical control per locale. + +--- + +### CL-094: 22500 `components/elements/index.js` Phase 2 bounded cleanup (remove dead `renderField`) + +date: 2026-04-07 +author: Cline +scope: `components/elements/index.js` +type: change +rationale: Execute the next smallest low-risk Phase 2 slice by removing the local `renderField` utility after confirming it is unused in the repo. +impact: No behavior change intended; dead code removal only. No auth/security/middleware/API changes. EN/CY and accessibility behavior remain unchanged. +status: completed + +Summary: + +- Confirmed `renderField` had no usages outside its declaration. +- Removed the unused local `renderField` function from `components/elements/index.js`. +- Kept all field component exports, routing, and existing render paths unchanged. + +Validation: + +- `npx eslint components/elements/index.js` -> pass + +Follow-ups: + +- Continue Phase 2 with one bounded no-behavior-change slice, likely next lowest-risk renderer extraction from `components/elements/index.js`. From 52071058d6560e0a6aea23a85706e42122b6d1aa Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 12:30:35 +0100 Subject: [PATCH 09/22] Fix MultiLinefield hook order warning --- components/elements/index.js | 5 +++-- memory-bank/change-log.md | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/components/elements/index.js b/components/elements/index.js index 8c18a759..1e161433 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -249,6 +249,7 @@ export function MultiLinefield(props) { //console.log(showIfHasParentShowValue, formProps[form].values[parentField]); let { t } = useTranslation(); + const translatedLabel = FieldsTranslations(props.label); const requiredMessage = t("newappeal:is-required-label"); const emojiNotAllowedMessage = t("newappeal:emojis-not-allowed-label"); @@ -259,7 +260,7 @@ export function MultiLinefield(props) { showIfHasParentShowValue && (
pass + +Follow-ups: + +- Continue bounded Phase 2 slices; when touching field components, prefer top-level computed hook-backed values reused across conditional branches. From 503a0b5b9ab988f04bb8ab1431db6b70f8dc51a4 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 12:34:36 +0100 Subject: [PATCH 10/22] Fix render-phase update in RenderSubFields --- components/elements/fields/renderSubFields.js | 8 ++++-- memory-bank/change-log.md | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/components/elements/fields/renderSubFields.js b/components/elements/fields/renderSubFields.js index 5e5665d9..d6d087f1 100644 --- a/components/elements/fields/renderSubFields.js +++ b/components/elements/fields/renderSubFields.js @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import { Field } from "redux-form"; import useTranslation from "next-translate/useTranslation"; import { RenderTextfield } from "./renderTextfield"; @@ -45,7 +45,11 @@ export const RenderSubFields = ({ return errors; }; - fields.length == 0 && fields.push({}); + useEffect(() => { + if (fields.length === 0) { + fields.push({}); + } + }, [fields, fields.length]); return (
    diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index 0a94ddb4..df628206 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -3447,3 +3447,29 @@ Validation: Follow-ups: - Continue bounded Phase 2 slices; when touching field components, prefer top-level computed hook-backed values reused across conditional branches. + +--- + +### CL-096: 22500 `RenderSubFields` render-phase update warning hotfix + +date: 2026-04-07 +author: Cline +scope: `components/elements/fields/renderSubFields.js` +type: change +rationale: Fix React warning about updating parent-connected state during `RenderSubFields` render. +impact: No intended behavior change; initial empty FieldArray row initialization moved out of render phase to effect phase to satisfy React rendering constraints. +status: completed + +Summary: + +- Root cause: `fields.length == 0 && fields.push({})` executed inside render, triggering state updates while rendering `RenderSubFields`. +- Fix: moved initial row insertion into `useEffect`, guarded by `fields.length === 0`. +- Preserved existing UX intent: ensure at least one subfield row appears when array starts empty. + +Validation: + +- `npx eslint components/elements/fields/renderSubFields.js` -> pass + +Follow-ups: + +- Keep redux-form `fields.push/remove` calls event/effect-driven (not render-driven) in future slices. From dcdec08f6d59217d9c83ea45e632e45557c86726 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 12:37:29 +0100 Subject: [PATCH 11/22] Fix render-phase dispatch in FieldArrayForm --- components/elements/index.js | 8 ++++++-- memory-bank/change-log.md | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/components/elements/index.js b/components/elements/index.js index 1e161433..77fa9ad1 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -5,7 +5,7 @@ import _ from "lodash"; import useTranslation from "next-translate/useTranslation"; import Link from "next/link"; import { useRouter } from "next/router"; -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import "react-datepicker/dist/react-datepicker.css"; import Dropzone from "react-dropzone"; import { Field, FieldArray, change } from "redux-form"; @@ -1602,7 +1602,11 @@ export const FieldArrayForm = (props) => { (showIfHasParentShowValue == parentField) != false && showIfHasParentShowValue; - !showIfHasParentShowValue && dispatch(change("appealForm", name, null)); + useEffect(() => { + if (!showIfHasParentShowValue) { + dispatch(change("appealForm", name, null)); + } + }, [dispatch, name, showIfHasParentShowValue]); parentFieldShowOnValue; const { handleSubmit, pristine, reset, submitting } = props; diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index df628206..9cd17cf3 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -3473,3 +3473,29 @@ Validation: Follow-ups: - Keep redux-form `fields.push/remove` calls event/effect-driven (not render-driven) in future slices. + +--- + +### CL-097: 22500 `FieldArrayForm` render-phase dispatch warning hotfix + +date: 2026-04-07 +author: Cline +scope: `components/elements/index.js` +type: change +rationale: Fix React warning caused by dispatching redux-form state updates during `FieldArrayForm` render. +impact: No intended behavior change; clearing hidden FieldArray values remains intact but now executes in effect phase instead of render phase. +status: completed + +Summary: + +- Root cause: `dispatch(change("appealForm", name, null))` was called inline in render when parent condition was false. +- Fix: moved that dispatch into `useEffect` guarded by `!showIfHasParentShowValue`. +- Added `useEffect` import in `components/elements/index.js`. + +Validation: + +- `npx eslint components/elements/index.js` -> pass + +Follow-ups: + +- Continue avoiding dispatch/state mutations inside render for field visibility toggles. From 5e992507de29a0dc19ce029ecec401a4fe1c10c8 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 14:27:31 +0100 Subject: [PATCH 12/22] Clean dead code in FieldArrayForm slice --- components/elements/index.js | 9 --------- memory-bank/change-log.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/components/elements/index.js b/components/elements/index.js index 77fa9ad1..89475e27 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -1579,13 +1579,10 @@ const RenderFileUpload = (field) => { export const FieldArrayForm = (props) => { const { name, - label, - validation, form, formProps, parentFieldShowOnValue, parentField, - maxFieldLength, maxSubField } = props; @@ -1599,18 +1596,12 @@ export const FieldArrayForm = (props) => { formProps[form].values[parentField].toString() ) > -1; - (showIfHasParentShowValue == parentField) != false && - showIfHasParentShowValue; - useEffect(() => { if (!showIfHasParentShowValue) { dispatch(change("appealForm", name, null)); } }, [dispatch, name, showIfHasParentShowValue]); - parentFieldShowOnValue; - const { handleSubmit, pristine, reset, submitting } = props; - return ( <> {" "} diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index 9cd17cf3..bad50fa8 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -3499,3 +3499,32 @@ Validation: Follow-ups: - Continue avoiding dispatch/state mutations inside render for field visibility toggles. + +--- + +### CL-098: 22500 `FieldArrayForm` bounded dead-code cleanup + +date: 2026-04-07 +author: Cline +scope: `components/elements/index.js` +type: change +rationale: Continue bounded Phase 2 cleanup with a lowest-risk slice by removing unused locals/destructured props in `FieldArrayForm`. +impact: No intended behavior change; purely removes unused values left from legacy implementation. +status: completed + +Summary: + +- Removed unused destructured props from `FieldArrayForm`: `label`, `validation`, `maxFieldLength`. +- Removed no-op/dead lines in `FieldArrayForm`: + - redundant boolean expression line + - unused `parentFieldShowOnValue;` expression + - unused `handleSubmit/pristine/reset/submitting` destructure +- Kept visibility logic, effect-driven clearing behavior, and `FieldArray` rendering path unchanged. + +Validation: + +- `npx eslint components/elements/index.js` -> pass + +Follow-ups: + +- Continue Phase 2 with one bounded slice at a time; next low-risk target can be similar dead-code/no-op cleanup in another isolated renderer block. From 497bb4c79900b3e86f114bbaccbfd76a5742f95d Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 15:32:45 +0100 Subject: [PATCH 13/22] Extract RenderFileUpload and FieldArrayForm --- components/elements/fields/fieldArrayForm.js | 45 ++ .../elements/fields/renderFileUpload.js | 448 ++++++++++++++++ components/elements/index.js | 493 +----------------- memory-bank/change-log.md | 27 + 4 files changed, 524 insertions(+), 489 deletions(-) create mode 100644 components/elements/fields/fieldArrayForm.js create mode 100644 components/elements/fields/renderFileUpload.js diff --git a/components/elements/fields/fieldArrayForm.js b/components/elements/fields/fieldArrayForm.js new file mode 100644 index 00000000..0728bcf7 --- /dev/null +++ b/components/elements/fields/fieldArrayForm.js @@ -0,0 +1,45 @@ +import React, { useEffect } from "react"; +import _ from "lodash"; +import { FieldArray, change } from "redux-form"; +import { useDispatch } from "react-redux"; +import { RenderSubFields } from "./renderSubFields"; + +export const FieldArrayForm = (props) => { + const { + name, + form, + formProps, + parentFieldShowOnValue, + parentField, + maxSubField + } = props; + + const dispatch = useDispatch(); + + var showIfHasParentShowValue = + parentField != false && + !_.isEmpty(formProps[form]) && + _.has(formProps[form].values, parentField) && + parentFieldShowOnValue.indexOf( + formProps[form].values[parentField].toString() + ) > -1; + + useEffect(() => { + if (!showIfHasParentShowValue) { + dispatch(change("appealForm", name, null)); + } + }, [dispatch, name, showIfHasParentShowValue]); + + return ( + <> + {" "} + {showIfHasParentShowValue && ( + + )} + + ); +}; diff --git a/components/elements/fields/renderFileUpload.js b/components/elements/fields/renderFileUpload.js new file mode 100644 index 00000000..5164cc59 --- /dev/null +++ b/components/elements/fields/renderFileUpload.js @@ -0,0 +1,448 @@ +import { JSONPath as jsonpath } from "jsonpath-plus"; +import React, { useMemo, useState } from "react"; +import useTranslation from "next-translate/useTranslation"; +import Link from "next/link"; +import Dropzone from "react-dropzone"; +import { + deleteBlob, + getFilesFromBlobHashed, + uploadSingleFile, + getFilesFromBlobproxy +} from "../../../actions/services/documentService"; +import { bytesToSize, getThumbnailIconByExtension } from "../../utils"; +import { + getDocumentTypePrefix, + getThumbnailIconByMimeType, + validateUploadFilename +} from "../helpers/fileUploadHelpers"; + +const baseStyle = { + display: "flex", + flexDirection: "column", + alignItems: "center", + padding: "20px", + borderWidth: 2, + borderRadius: 2, + borderColor: "#eeeeee", + borderStyle: "dashed", + backgroundColor: "#fafafa", + color: "#bdbdbd", + transition: "border .3s ease-in-out" +}; + +const activeStyle = { + borderColor: "#2196f3" +}; + +const acceptStyle = { + borderColor: "#00e676" +}; + +const rejectStyle = { + borderColor: "#ff1744" +}; + +export const RenderFileUpload = (field) => { + let files = field.input.value; + let { t } = useTranslation(); + + const style = useMemo( + () => ({ + ...baseStyle + // ...(isDragActive ? activeStyle : {}), + // ...(isDragAccept ? acceptStyle : {}), + // ...(isDragReject ? rejectStyle : {}), + }), + [] + // [isDragActive, isDragReject, isDragAccept] + ); + const [filesList, setFilesList] = useState([]); + + const thumbs = filesList.map((file) => ( +
    + {file.name} -{" "} + + {file.name} ({file.size / 1024}kb) + +
    + )); + + const filelistObj = field.fileList || {}; + + const blobList = jsonpath({ + path: "$..[?(@ && @.documentType=='" + field.documentTypeCode + "')]", + json: filelistObj, + eval: true + }); + //const blobList = filelistObj; + + const deleteThisBlob = async ( + containerName, + blobName, + deleteblobhash, + getblobshash, + casefolderID + ) => { + //console.log(containerName, blobName, deleteblobhash); + deleteBlob(containerName, blobName, deleteblobhash, casefolderID) + .then((data) => data) + .then(() => { + getFilesFromBlobHashed( + containerName, + getblobshash, + casefolderID + ).then((newfilelist) => field.setFilesForAppeal(newfilelist)); + }); + }; + + const removeFile = (file) => { + const newFilesArr = files.filter((user) => user.name != file.name); + files == newFilesArr; + field.input.onChange(newFilesArr); + }; + + function removeEmptyObjects(arr) { + return arr.filter((obj) => Object.keys(obj).length > 0); + } + + const [errorMessage, setErrorMessage] = useState(null); + const [rejectedFiles, setRejectedFiles] = useState([]); // Store rejected files + + const [completedUploadFiles, setCompletedUploadFiles] = useState(false); + const [uploadCountMessage, setUploadCountMessage] = useState(""); + const [totalUploadFiles, setTotalUploadFiles] = useState(0); + + const handleDropRejected = (fileRejections) => { + const errors = fileRejections + .map(({ file, errors }) => { + return errors.map((error) => { + if (error.code === "file-too-large") { + return `${file.name} ${t( + "newappeal:new-appeal-fileupload-file-error-filesize-label" + )}`; + } + if (error.code === "file-invalid-type") { + return `${file.name} ${t( + "newappeal:new-appeal-fileupload-file-error-invalid-type-label" + )}`; + } + if (error.code === "filename-invalid-chars") { + return ( + error.message || + `${file.name} ${t( + "newappeal:new-appeal-fileupload-file-error-invalid-filename-label" + )}` + ); + } + return `${file.name} ${t( + "newappeal:new-appeal-fileupload-file-error-invalid-label" + )}`; + }); + }) + .flat(); // Flatten the errors array + + setRejectedFiles(errors); // Store the error messages for rejected files + }; + + // Handle accepted files (clear error message when files are accepted) + const handleDropAccepted = () => { + setErrorMessage(null); // Clear any previous error messages when files are accepted + setRejectedFiles([]); // Clear any previously rejected file errorsss + }; + + return ( + <> + validateUploadFilename(file, t)} + onDrop={(acceptedFiles, fileRejections, e) => { + // build + display rejected messages (ALWAYS, even if some accepted) + if (fileRejections?.length) { + handleDropRejected(fileRejections); + } else { + // only clear rejections when nothing was rejected on this drop + setRejectedFiles([]); + setErrorMessage(null); + } + + // if nothing accepted, stop (prevents showing only "uploading" when all invalid) + if (!acceptedFiles || acceptedFiles.length === 0) { + setCompletedUploadFiles(true); // optional: depends on your UX + setUploadCountMessage(0); + setTotalUploadFiles(0); + return; + } + + // continue with your existing upload logic for accepted files + setCompletedUploadFiles(false); + + const renamedAcceptedFiles = acceptedFiles.map( + (file) => + new File( + [file], + `${getDocumentTypePrefix(field.documentTypeCode)}_-_${ + file.name + }`, + { type: file.type } + ) + ); + + field.input.onChange(renamedAcceptedFiles); + + if (typeof field.setFileCount === "function") { + field.setFileCount( + (field.uploadCount || 0) + + renamedAcceptedFiles.length + ); + } + + setUploadCountMessage(0); + setTotalUploadFiles(renamedAcceptedFiles.length); + + uploadSingleFile( + renamedAcceptedFiles, + field.containerID, + field.ticketnumber, + { + onChunkComplete: ({ cumulativeUploaded }) => { + setUploadCountMessage(cumulativeUploaded); + } + } + ) + .then((data) => { + let buildAppealFilesArray = []; + let values = field.form.appealForm.values; + let filesUploadObj = renamedAcceptedFiles; + + for (let key in filesUploadObj) { + typeof filesUploadObj[key].name !== + "undefined" && + buildAppealFilesArray.push({ + name: filesUploadObj[key].name, + size: filesUploadObj[key].size + }); + } + + function removeDuplicates(arr, key) { + const seen = new Set(); + return arr.filter((item) => { + const value = item[key]; + if (seen.has(value)) return false; + seen.add(value); + return true; + }); + } + + buildAppealFilesArray = values.hasOwnProperty( + "filesList" + ) + ? buildAppealFilesArray.concat(values.filesList) + : buildAppealFilesArray; + + buildAppealFilesArray = removeDuplicates( + buildAppealFilesArray, + "name" + ); + + Object.assign(values, { + filesList: buildAppealFilesArray + }); + + // keep your existing server-side invalid handling + if (data.invalidFiles?.length > 0) { + setRejectedFiles((prev) => [ + ...prev, + ...data.invalidFiles + ]); + setUploadCountMessage( + renamedAcceptedFiles.length - + data.invalidFiles.length + ); + } + + return getFilesFromBlobproxy( + field.containerID, + field.ticketnumber + ); + }) + .then((data) => { + field.setFilesForAppeal(data); + setCompletedUploadFiles(true); + }); + }} + > + {({ getRootProps, getInputProps }) => ( + <> +
    +
    + {" "} + +
    + {t( + "newappeal:new-appeal-fileupload-drop-label" + )}{" "} + (Max 50 MB) +
    + + ( + {t( + "newappeal:new-appeal-fileupload-file-list-label" + )} + ) + +
    + {/* */} +
    + + )} +
    + {rejectedFiles.length > 0 && ( +
    +
      + {rejectedFiles.map((error, index) => ( +
    • {error}
    • + ))} +
    +
    + )} + {totalUploadFiles > 0 && completedUploadFiles == false && ( +
    + {t("home:uploading-files-label", { + number: + totalUploadFiles > 0 + ? `${uploadCountMessage} of ${totalUploadFiles}` + : uploadCountMessage + })} +
    + )} + {completedUploadFiles > 0 && uploadCountMessage >= 0 && ( +
    + {t("home:completed-uploading-files-label", { + number: uploadCountMessage + })} +
    + )} + {field.meta.touched && field.meta.error && ( + {field.meta.error} + )} + {files && Array.isArray(files) && ( + <> + {/* {(files = removeEmptyObjects(files))} */} + + {!completedUploadFiles && + files.map( + (file, i) => + typeof file.name != "undefined" && ( +
    + {" "} + {/* removeFile(file)} + > + − + */} + {file.name} + + {file.name} ( + {bytesToSize(file.size)})
    +
    + {completedUploadFiles ? ( +

    + Upload complete +

    + ) : ( +

    + {t( + "home:uploading-files-progress-label" + )}{" "} +

    + )} +
    +
    +
    + ) + )} + + )} + {blobList.length > 0 && ( +

    {t("newappeal:previously-added-files")}

    + )} + {blobList.map( + (blob, i) => + blob.documentType == field.documentTypeCode && ( +
    +
    + { + setUploadCountMessage( + uploadCountMessage - 1 + ); + deleteThisBlob( + field.containerID, + blob.name, + blob.hasheddeletepath, + blob.hashgetblobs, + field.ticketnumber + ); + }} + title={t("home:remove-this-file-label")} + > + − + +
    + {blob.name} + + + {blob.name} ({bytesToSize(blob.contentLength)}) + +
    + ) + )}{" "} + + ); +}; diff --git a/components/elements/index.js b/components/elements/index.js index 89475e27..339866e1 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -1,36 +1,20 @@ -import { JSONPath as jsonpath } from "jsonpath-plus"; import axios from "axios"; import _ from "lodash"; import useTranslation from "next-translate/useTranslation"; -import Link from "next/link"; import { useRouter } from "next/router"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect } from "react"; import "react-datepicker/dist/react-datepicker.css"; -import Dropzone from "react-dropzone"; -import { Field, FieldArray, change } from "redux-form"; -import { - deleteBlob, - getFilesFromBlobHashed, - uploadSingleFile, - getFilesFromBlobproxy -} from "../../actions/services/documentService"; +import { Field } from "redux-form"; import fieldLookup from "../../data/crmfieldlookuptranslations.json"; import pickListLookup from "../../data/picklistLookups.json"; -import { bytesToSize, getThumbnailIconByExtension } from "../utils"; import { setFileCount } from "../../store/appealType/action"; import "react-quill-new/dist/quill.snow.css"; import { useStore as store, useSelector } from "react-redux"; -import { useDispatch } from "react-redux"; import { validateField } from "./validationUtils"; // Import the validation function -import { - getDocumentTypePrefix, - getThumbnailIconByMimeType, - validateUploadFilename -} from "./helpers/fileUploadHelpers"; import { getFieldTranslation } from "./helpers/translationHelpers"; import { RenderRichMultiline } from "./fields/renderRichMultiline"; import { RenderTextfield } from "./fields/renderTextfield"; @@ -41,7 +25,8 @@ import { RenderRadio } from "./fields/renderRadio"; import { RenderCheckBox } from "./fields/renderCheckBox"; import { RenderPickList } from "./fields/renderPickList"; import { RenderDecimalField } from "./fields/renderDecimalField"; -import { RenderSubFields } from "./fields/renderSubFields"; +import { RenderFileUpload } from "./fields/renderFileUpload"; +import { FieldArrayForm } from "./fields/fieldArrayForm"; export function Textfield(props) { const { @@ -1145,473 +1130,3 @@ export function FileUploadField(props) { ); } -const baseStyle = { - display: "flex", - flexDirection: "column", - alignItems: "center", - padding: "20px", - borderWidth: 2, - borderRadius: 2, - borderColor: "#eeeeee", - borderStyle: "dashed", - backgroundColor: "#fafafa", - color: "#bdbdbd", - transition: "border .3s ease-in-out" -}; - -const activeStyle = { - borderColor: "#2196f3" -}; - -const acceptStyle = { - borderColor: "#00e676" -}; - -const rejectStyle = { - borderColor: "#ff1744" -}; - -const RenderFileUpload = (field) => { - let files = field.input.value; - let { t } = useTranslation(); - - const style = useMemo( - () => ({ - ...baseStyle - // ...(isDragActive ? activeStyle : {}), - // ...(isDragAccept ? acceptStyle : {}), - // ...(isDragReject ? rejectStyle : {}), - }), - [] - // [isDragActive, isDragReject, isDragAccept] - ); - const [filesList, setFilesList] = useState([]); - - const thumbs = filesList.map((file) => ( -
    - {file.name} -{" "} - - {file.name} ({file.size / 1024}kb) - -
    - )); - - const filelistObj = field.fileList || {}; - - const blobList = jsonpath({ - path: "$..[?(@ && @.documentType=='" + field.documentTypeCode + "')]", - json: filelistObj, - eval: true - }); - //const blobList = filelistObj; - - const deleteThisBlob = async ( - containerName, - blobName, - deleteblobhash, - getblobshash, - casefolderID - ) => { - //console.log(containerName, blobName, deleteblobhash); - deleteBlob(containerName, blobName, deleteblobhash, casefolderID) - .then((data) => data) - .then(() => { - getFilesFromBlobHashed( - containerName, - getblobshash, - casefolderID - ).then((newfilelist) => field.setFilesForAppeal(newfilelist)); - }); - }; - - const removeFile = (file) => { - const newFilesArr = files.filter((user) => user.name != file.name); - files == newFilesArr; - field.input.onChange(newFilesArr); - }; - - function removeEmptyObjects(arr) { - return arr.filter((obj) => Object.keys(obj).length > 0); - } - - const [errorMessage, setErrorMessage] = useState(null); - const [rejectedFiles, setRejectedFiles] = useState([]); // Store rejected files - - const [completedUploadFiles, setCompletedUploadFiles] = useState(false); - const [uploadCountMessage, setUploadCountMessage] = useState(""); - const [totalUploadFiles, setTotalUploadFiles] = useState(0); - - const handleDropRejected = (fileRejections) => { - const errors = fileRejections - .map(({ file, errors }) => { - return errors.map((error) => { - if (error.code === "file-too-large") { - return `${file.name} ${t( - "newappeal:new-appeal-fileupload-file-error-filesize-label" - )}`; - } - if (error.code === "file-invalid-type") { - return `${file.name} ${t( - "newappeal:new-appeal-fileupload-file-error-invalid-type-label" - )}`; - } - if (error.code === "filename-invalid-chars") { - return ( - error.message || - `${file.name} ${t( - "newappeal:new-appeal-fileupload-file-error-invalid-filename-label" - )}` - ); - } - return `${file.name} ${t( - "newappeal:new-appeal-fileupload-file-error-invalid-label" - )}`; - }); - }) - .flat(); // Flatten the errors array - - setRejectedFiles(errors); // Store the error messages for rejected files - }; - - // Handle accepted files (clear error message when files are accepted) - const handleDropAccepted = () => { - setErrorMessage(null); // Clear any previous error messages when files are accepted - setRejectedFiles([]); // Clear any previously rejected file errorsss - }; - - return ( - <> - validateUploadFilename(file, t)} - onDrop={(acceptedFiles, fileRejections, e) => { - // build + display rejected messages (ALWAYS, even if some accepted) - if (fileRejections?.length) { - handleDropRejected(fileRejections); - } else { - // only clear rejections when nothing was rejected on this drop - setRejectedFiles([]); - setErrorMessage(null); - } - - // if nothing accepted, stop (prevents showing only "uploading" when all invalid) - if (!acceptedFiles || acceptedFiles.length === 0) { - setCompletedUploadFiles(true); // optional: depends on your UX - setUploadCountMessage(0); - setTotalUploadFiles(0); - return; - } - - // continue with your existing upload logic for accepted files - setCompletedUploadFiles(false); - - const renamedAcceptedFiles = acceptedFiles.map( - (file) => - new File( - [file], - `${getDocumentTypePrefix(field.documentTypeCode)}_-_${ - file.name - }`, - { type: file.type } - ) - ); - - field.input.onChange(renamedAcceptedFiles); - - if (typeof field.setFileCount === "function") { - field.setFileCount( - (field.uploadCount || 0) + - renamedAcceptedFiles.length - ); - } - - setUploadCountMessage(0); - setTotalUploadFiles(renamedAcceptedFiles.length); - - uploadSingleFile( - renamedAcceptedFiles, - field.containerID, - field.ticketnumber, - { - onChunkComplete: ({ cumulativeUploaded }) => { - setUploadCountMessage(cumulativeUploaded); - } - } - ) - .then((data) => { - let buildAppealFilesArray = []; - let values = field.form.appealForm.values; - let filesUploadObj = renamedAcceptedFiles; - - for (let key in filesUploadObj) { - typeof filesUploadObj[key].name !== - "undefined" && - buildAppealFilesArray.push({ - name: filesUploadObj[key].name, - size: filesUploadObj[key].size - }); - } - - function removeDuplicates(arr, key) { - const seen = new Set(); - return arr.filter((item) => { - const value = item[key]; - if (seen.has(value)) return false; - seen.add(value); - return true; - }); - } - - buildAppealFilesArray = values.hasOwnProperty( - "filesList" - ) - ? buildAppealFilesArray.concat(values.filesList) - : buildAppealFilesArray; - - buildAppealFilesArray = removeDuplicates( - buildAppealFilesArray, - "name" - ); - - Object.assign(values, { - filesList: buildAppealFilesArray - }); - - // keep your existing server-side invalid handling - if (data.invalidFiles?.length > 0) { - setRejectedFiles((prev) => [ - ...prev, - ...data.invalidFiles - ]); - setUploadCountMessage( - renamedAcceptedFiles.length - - data.invalidFiles.length - ); - } - - return getFilesFromBlobproxy( - field.containerID, - field.ticketnumber - ); - }) - .then((data) => { - field.setFilesForAppeal(data); - setCompletedUploadFiles(true); - }); - }} - > - {({ getRootProps, getInputProps }) => ( - <> -
    -
    - {" "} - -
    - {t( - "newappeal:new-appeal-fileupload-drop-label" - )}{" "} - (Max 50 MB) -
    - - ( - {t( - "newappeal:new-appeal-fileupload-file-list-label" - )} - ) - -
    - {/* */} -
    - - )} -
    - {rejectedFiles.length > 0 && ( -
    -
      - {rejectedFiles.map((error, index) => ( -
    • {error}
    • - ))} -
    -
    - )} - {totalUploadFiles > 0 && completedUploadFiles == false && ( -
    - {t("home:uploading-files-label", { - number: - totalUploadFiles > 0 - ? `${uploadCountMessage} of ${totalUploadFiles}` - : uploadCountMessage - })} -
    - )} - {completedUploadFiles > 0 && uploadCountMessage >= 0 && ( -
    - {t("home:completed-uploading-files-label", { - number: uploadCountMessage - })} -
    - )} - {field.meta.touched && field.meta.error && ( - {field.meta.error} - )} - {files && Array.isArray(files) && ( - <> - {/* {(files = removeEmptyObjects(files))} */} - - {!completedUploadFiles && - files.map( - (file, i) => - typeof file.name != "undefined" && ( -
    - {" "} - {/* removeFile(file)} - > - − - */} - {file.name} - - {file.name} ( - {bytesToSize(file.size)})
    -
    - {completedUploadFiles ? ( -

    - Upload complete -

    - ) : ( -

    - {t( - "home:uploading-files-progress-label" - )}{" "} -

    - )} -
    -
    -
    - ) - )} - - )} - {blobList.length > 0 && ( -

    {t("newappeal:previously-added-files")}

    - )} - {blobList.map( - (blob, i) => - blob.documentType == field.documentTypeCode && ( -
    -
    - { - setUploadCountMessage( - uploadCountMessage - 1 - ); - deleteThisBlob( - field.containerID, - blob.name, - blob.hasheddeletepath, - blob.hashgetblobs, - field.ticketnumber - ); - }} - title={t("home:remove-this-file-label")} - > - − - -
    - {blob.name} - - - {blob.name} ({bytesToSize(blob.contentLength)}) - -
    - ) - )}{" "} - - ); -}; - -export const FieldArrayForm = (props) => { - const { - name, - form, - formProps, - parentFieldShowOnValue, - parentField, - maxSubField - } = props; - - const dispatch = useDispatch(); // Get dispatch using useDispatch hook - - var showIfHasParentShowValue = - parentField != false && - !_.isEmpty(formProps[form]) && - _.has(formProps[form].values, parentField) && - parentFieldShowOnValue.indexOf( - formProps[form].values[parentField].toString() - ) > -1; - - useEffect(() => { - if (!showIfHasParentShowValue) { - dispatch(change("appealForm", name, null)); - } - }, [dispatch, name, showIfHasParentShowValue]); - - return ( - <> - {" "} - {showIfHasParentShowValue && ( - - )} - - ); -}; diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index bad50fa8..b3d55e9f 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -3528,3 +3528,30 @@ Validation: Follow-ups: - Continue Phase 2 with one bounded slice at a time; next low-risk target can be similar dead-code/no-op cleanup in another isolated renderer block. + +--- + +### CL-099: 22500 `components/elements/index.js` bounded extraction bundle (`RenderFileUpload` + `FieldArrayForm`) + +date: 2026-04-07 +author: Cline +scope: `components/elements/index.js`, `components/elements/fields/renderFileUpload.js`, `components/elements/fields/fieldArrayForm.js` +type: change +rationale: Execute a slightly larger but still bounded Phase 2 slice by extracting two self-contained blocks from the monolith (`RenderFileUpload` and `FieldArrayForm`) into dedicated field modules. +impact: No intended behavior change; preserves EN/CY output, upload flow, and accessibility semantics while reducing `index.js` size/coupling. +status: completed + +Summary: + +- Added `components/elements/fields/renderFileUpload.js` and moved the full existing `RenderFileUpload` implementation unchanged. +- Added `components/elements/fields/fieldArrayForm.js` and moved the full existing `FieldArrayForm` implementation unchanged. +- Updated `components/elements/index.js` imports to consume extracted modules. +- Removed inline `RenderFileUpload`/`FieldArrayForm` implementations and related now-unused imports/constants from `index.js`. + +Validation: + +- `npx eslint components/elements/index.js components/elements/fields/renderFileUpload.js components/elements/fields/fieldArrayForm.js` -> pass + +Follow-ups: + +- Continue Phase 2 with bounded renderer/module extractions from `components/elements/index.js` (one cohesive bundle per commit). From a3b848cfc10e7775b32002a3def06675ad14730f Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 15:35:19 +0100 Subject: [PATCH 14/22] Re-export RenderSubFields from elements index --- components/elements/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/elements/index.js b/components/elements/index.js index 339866e1..138c26a7 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -27,6 +27,7 @@ import { RenderPickList } from "./fields/renderPickList"; import { RenderDecimalField } from "./fields/renderDecimalField"; import { RenderFileUpload } from "./fields/renderFileUpload"; import { FieldArrayForm } from "./fields/fieldArrayForm"; +export { RenderSubFields } from "./fields/renderSubFields"; export function Textfield(props) { const { @@ -1129,4 +1130,3 @@ export function FileUploadField(props) { ); } - From eccb87e4a10b21a79ee57201b158c76aa58701e7 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 15:36:34 +0100 Subject: [PATCH 15/22] Export FieldArrayForm from elements index barrel --- components/elements/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/elements/index.js b/components/elements/index.js index 138c26a7..76031fe3 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -26,8 +26,8 @@ import { RenderCheckBox } from "./fields/renderCheckBox"; import { RenderPickList } from "./fields/renderPickList"; import { RenderDecimalField } from "./fields/renderDecimalField"; import { RenderFileUpload } from "./fields/renderFileUpload"; -import { FieldArrayForm } from "./fields/fieldArrayForm"; export { RenderSubFields } from "./fields/renderSubFields"; +export { FieldArrayForm } from "./fields/fieldArrayForm"; export function Textfield(props) { const { From 30bd8c49da2d05f3e3b640f1c8670de4cc54df10 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 15:44:02 +0100 Subject: [PATCH 16/22] Extract visibility and validation helpers in elements index --- components/elements/index.js | 169 ++++++++++++++++++++--------------- memory-bank/change-log.md | 30 +++++++ 2 files changed, 126 insertions(+), 73 deletions(-) diff --git a/components/elements/index.js b/components/elements/index.js index 76031fe3..3134fee3 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -29,6 +29,37 @@ import { RenderFileUpload } from "./fields/renderFileUpload"; export { RenderSubFields } from "./fields/renderSubFields"; export { FieldArrayForm } from "./fields/fieldArrayForm"; +const getValidationMessages = (t) => ({ + requiredMessage: t("newappeal:is-required-label"), + emojiNotAllowedMessage: t("newappeal:emojis-not-allowed-label"), + invalidPostcodeMessage: t("newappeal:invalid-postcode-label") +}); + +const hasParentFieldValue = ({ parentField, form, formProps }) => + parentField != false && + !_.isEmpty(formProps[form]) && + _.has(formProps[form].values, parentField); + +const isVisibleByEquality = ({ + parentField, + form, + formProps, + parentFieldShowOnValue +}) => + (hasParentFieldValue({ parentField, form, formProps }) && + formProps[form].values[parentField]) == parentFieldShowOnValue; + +const isVisibleByInclusion = ({ + parentField, + form, + formProps, + parentFieldShowOnValue +}) => + hasParentFieldValue({ parentField, form, formProps }) && + parentFieldShowOnValue.indexOf( + formProps[form].values[parentField].toString() + ) > -1; + export function Textfield(props) { const { name, @@ -128,19 +159,22 @@ export function Textfield(props) { ); } else { - var showIfHasParentShowValue = - (parentField != false && - !_.isEmpty(formProps[form]) && - _.has(formProps[form].values, parentField) && - formProps[form].values[parentField]) == parentFieldShowOnValue; + var showIfHasParentShowValue = isVisibleByEquality({ + parentField, + form, + formProps, + parentFieldShowOnValue + }); // console.log(parentField, showIfHasParentShowValue); // console.log(parentField, formProps, form); //console.log("validation props:", validation); - const requiredMessage = t("newappeal:is-required-label"); - const emojiNotAllowedMessage = t("newappeal:emojis-not-allowed-label"); - const invalidPostcodeMessage = t("newappeal:invalid-postcode-label"); + const { + requiredMessage, + emojiNotAllowedMessage, + invalidPostcodeMessage + } = getValidationMessages(t); return (
    {parentField != false ? ( @@ -222,13 +256,12 @@ export function MultiLinefield(props) { return errors; }; - var showIfHasParentShowValue = - parentField != false && - !_.isEmpty(formProps[form]) && - _.has(formProps[form].values, parentField) && - parentFieldShowOnValue.indexOf( - formProps[form].values[parentField].toString() - ) > -1; + var showIfHasParentShowValue = isVisibleByInclusion({ + parentField, + form, + formProps, + parentFieldShowOnValue + }); (showIfHasParentShowValue == parentField) != false && showIfHasParentShowValue; @@ -237,9 +270,8 @@ export function MultiLinefield(props) { let { t } = useTranslation(); const translatedLabel = FieldsTranslations(props.label); - const requiredMessage = t("newappeal:is-required-label"); - const emojiNotAllowedMessage = t("newappeal:emojis-not-allowed-label"); - const invalidPostcodeMessage = t("newappeal:invalid-postcode-label"); + const { requiredMessage, emojiNotAllowedMessage, invalidPostcodeMessage } = + getValidationMessages(t); return ( <> {parentField != false ? ( @@ -335,22 +367,20 @@ export function RichMultiLinefield(props) { const required = (value) => value ? undefined : t("newappeal:is-required-label"); - var showIfHasParentShowValue = - parentField != false && - !_.isEmpty(formProps[form]) && - _.has(formProps[form].values, parentField) && - parentFieldShowOnValue.indexOf( - formProps[form].values[parentField].toString() - ) > -1; + var showIfHasParentShowValue = isVisibleByInclusion({ + parentField, + form, + formProps, + parentFieldShowOnValue + }); (showIfHasParentShowValue == parentField) != false && showIfHasParentShowValue; //console.log(showIfHasParentShowValue, formProps[form].values[parentField]); let { t } = useTranslation(); - const requiredMessage = t("newappeal:is-required-label"); - const emojiNotAllowedMessage = t("newappeal:emojis-not-allowed-label"); - const invalidPostcodeMessage = t("newappeal:invalid-postcode-label"); + const { requiredMessage, emojiNotAllowedMessage, invalidPostcodeMessage } = + getValidationMessages(t); return ( <> {parentField != false ? ( @@ -445,15 +475,15 @@ export function DateFieldPicker(props) { let dateEnd = props.dateEnd; let { t } = useTranslation(); const required = (value) => (value ? undefined : "Required"); - var showIfHasParentShowValue = - (parentField != false && - !_.isEmpty(formProps[form]) && - _.has(formProps[form].values, parentField) && - formProps[form].values[parentField]) == parentFieldShowOnValue; + var showIfHasParentShowValue = isVisibleByEquality({ + parentField, + form, + formProps, + parentFieldShowOnValue + }); - const requiredMessage = t("newappeal:is-required-label"); - const emojiNotAllowedMessage = t("newappeal:emojis-not-allowed-label"); - const invalidPostcodeMessage = t("newappeal:invalid-postcode-label"); + const { requiredMessage, emojiNotAllowedMessage, invalidPostcodeMessage } = + getValidationMessages(t); // a and b are javascript Date objects function dateDiffInDays(a, b) { @@ -621,13 +651,12 @@ export function YesNofield(props) { const yesNo = router.locale == "cy" ? ["Ydw", "Na"] : ["Yes", "No"]; const required = (value) => (value ? undefined : "Required"); - var showIfHasParentShowValue = - parentField != false && - !_.isEmpty(formProps[form]) && - _.has(formProps[form].values, parentField) && - parentFieldShowOnValue.indexOf( - formProps[form].values[parentField].toString() - ) > -1; + var showIfHasParentShowValue = isVisibleByInclusion({ + parentField, + form, + formProps, + parentFieldShowOnValue + }); (showIfHasParentShowValue == parentField) != false && showIfHasParentShowValue; @@ -697,20 +726,18 @@ export function Radiofield(props) { .split(","); //parentFieldShowOnValue - var showIfHasParentShowValue = - parentField != false && - !_.isEmpty(formProps[form]) && - _.has(formProps[form].values, parentField) && - parentFieldShowOnValue.indexOf( - formProps[form].values[parentField].toString() - ) > -1; + var showIfHasParentShowValue = isVisibleByInclusion({ + parentField, + form, + formProps, + parentFieldShowOnValue + }); (showIfHasParentShowValue == parentField) != false && showIfHasParentShowValue; - const requiredMessage = t("newappeal:is-required-label"); - const emojiNotAllowedMessage = t("newappeal:emojis-not-allowed-label"); - const invalidPostcodeMessage = t("newappeal:invalid-postcode-label"); + const { requiredMessage, emojiNotAllowedMessage, invalidPostcodeMessage } = + getValidationMessages(t); return ( <> {parentField != false ? ( @@ -849,21 +876,19 @@ export function NumericField(props) { : undefined; //parentFieldShowOnValue - var showIfHasParentShowValue = - parentField != false && - !_.isEmpty(formProps[form]) && - _.has(formProps[form].values, parentField) && - parentFieldShowOnValue.indexOf( - formProps[form].values[parentField].toString() - ) > -1; + var showIfHasParentShowValue = isVisibleByInclusion({ + parentField, + form, + formProps, + parentFieldShowOnValue + }); (showIfHasParentShowValue == parentField) != false && showIfHasParentShowValue; //console.log("parentField:", parentField, showIfHasParentShowValue); - const requiredMessage = t("newappeal:is-required-label"); - const emojiNotAllowedMessage = t("newappeal:emojis-not-allowed-label"); - const invalidPostcodeMessage = t("newappeal:invalid-postcode-label"); + const { requiredMessage, emojiNotAllowedMessage, invalidPostcodeMessage } = + getValidationMessages(t); return ( <> {parentField != false ? ( @@ -968,13 +993,12 @@ export function DecimalField(props) { : undefined; //parentFieldShowOnValue - var showIfHasParentShowValue = - parentField != false && - !_.isEmpty(formProps[form]) && - _.has(formProps[form].values, parentField) && - parentFieldShowOnValue.indexOf( - formProps[form].values[parentField].toString() - ) > -1; + var showIfHasParentShowValue = isVisibleByInclusion({ + parentField, + form, + formProps, + parentFieldShowOnValue + }); (showIfHasParentShowValue == parentField) != false && showIfHasParentShowValue; @@ -999,9 +1023,8 @@ export function DecimalField(props) { return undefined; }; - const requiredMessage = t("newappeal:is-required-label"); - const emojiNotAllowedMessage = t("newappeal:emojis-not-allowed-label"); - const invalidPostcodeMessage = t("newappeal:invalid-postcode-label"); + const { requiredMessage, emojiNotAllowedMessage, invalidPostcodeMessage } = + getValidationMessages(t); return ( <> {parentField != false ? ( diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index b3d55e9f..d0f4f422 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -3555,3 +3555,33 @@ Validation: Follow-ups: - Continue Phase 2 with bounded renderer/module extractions from `components/elements/index.js` (one cohesive bundle per commit). + +--- + +### CL-100: 22500 `components/elements/index.js` helper normalization (validation messages + visibility checks) + +date: 2026-04-07 +author: Cline +scope: `components/elements/index.js` +type: change +rationale: Apply the requested next bounded refactor slice by consolidating repeated validation message setup and parent-field visibility logic into shared local helpers. +impact: No intended behavior change; EN/CY and accessibility behavior preserved while reducing repeated logic and future drift risk. +status: completed + +Summary: + +- Added `getValidationMessages(t)` helper for repeated `required/emoji/postcode` message retrieval. +- Added visibility helpers: + - `hasParentFieldValue(...)` + - `isVisibleByEquality(...)` + - `isVisibleByInclusion(...)` +- Replaced repeated inline visibility and validation-message setup across field wrappers with helper usage (Textfield, MultiLinefield, RichMultiLinefield, DateFieldPicker, YesNofield, Radiofield, NumericField, DecimalField). +- Kept existing field render paths, conditions, and validation calls intact. + +Validation: + +- `npx eslint components/elements/index.js` -> pass + +Follow-ups: + +- Continue bounded no-behavior-change slices by removing dead locals/comments and extracting one additional low-risk field wrapper at a time. From 6c387fa6fb63c25eb6c7ffe1d27cc9cd9e6be484 Mon Sep 17 00:00:00 2001 From: robbond Date: Wed, 8 Apr 2026 08:12:40 +0100 Subject: [PATCH 17/22] Extract PickList wrapper from elements index --- components/elements/fields/pickListField.js | 29 +++++++++++++++++++++ components/elements/index.js | 26 ++---------------- memory-bank/change-log.md | 27 +++++++++++++++++++ 3 files changed, 58 insertions(+), 24 deletions(-) create mode 100644 components/elements/fields/pickListField.js diff --git a/components/elements/fields/pickListField.js b/components/elements/fields/pickListField.js new file mode 100644 index 00000000..9e9374ff --- /dev/null +++ b/components/elements/fields/pickListField.js @@ -0,0 +1,29 @@ +import React from "react"; +import useTranslation from "next-translate/useTranslation"; +import { Field } from "redux-form"; +import { RenderPickList } from "./renderPickList"; + +export function PickList(props) { + const { datafieldname } = props; + let { t } = useTranslation(); + + const required = (value) => { + return value || value == 0 + ? undefined + : t("newappeal:is-required-label"); + }; + + return ( + + ); +} diff --git a/components/elements/index.js b/components/elements/index.js index 3134fee3..cdc5f20d 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -23,11 +23,12 @@ import { RenderDatePicker } from "./fields/renderDatePicker"; import { RenderYesNo } from "./fields/renderYesNo"; import { RenderRadio } from "./fields/renderRadio"; import { RenderCheckBox } from "./fields/renderCheckBox"; -import { RenderPickList } from "./fields/renderPickList"; +import { PickList } from "./fields/pickListField"; import { RenderDecimalField } from "./fields/renderDecimalField"; import { RenderFileUpload } from "./fields/renderFileUpload"; export { RenderSubFields } from "./fields/renderSubFields"; export { FieldArrayForm } from "./fields/fieldArrayForm"; +export { PickList }; const getValidationMessages = (t) => ({ requiredMessage: t("newappeal:is-required-label"), @@ -821,29 +822,6 @@ export function CheckBoxfield(props) { ); } -export function PickList(props) { - const { name, label, datafieldname, hint } = props; - let { t } = useTranslation(); - const required = (value) => { - return value || value == 0 - ? undefined - : t("newappeal:is-required-label"); - }; - return ( - - ); -} - export function NumericField(props) { const { name, diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index d0f4f422..f23e2728 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -3585,3 +3585,30 @@ Validation: Follow-ups: - Continue bounded no-behavior-change slices by removing dead locals/comments and extracting one additional low-risk field wrapper at a time. + +--- + +### CL-101: 22500 `PickList` wrapper extraction from `components/elements/index.js` + +date: 2026-04-08 +author: Cline +scope: `components/elements/index.js`, `components/elements/fields/pickListField.js` +type: change +rationale: Execute one bounded Phase 2 renderer/wrapper extraction slice by moving the `PickList` wrapper out of the elements monolith into a dedicated field module. +impact: No intended behavior change; preserves existing EN/CY translation behavior and validation wiring while reducing `index.js` size/coupling. +status: completed + +Summary: + +- Added `components/elements/fields/pickListField.js` and moved the existing `PickList` wrapper implementation. +- Updated `components/elements/index.js` to import/export `PickList` from the new field module. +- Removed inline `PickList` wrapper implementation from `index.js`. +- Removed now-unused `RenderPickList` import from `index.js` after extraction. + +Validation: + +- `npx eslint components/elements/index.js components/elements/fields/pickListField.js` -> pass + +Follow-ups: + +- Continue bounded no-behavior-change slices by extracting one additional low-risk wrapper (e.g., `CheckBoxfield`) or removing dead locals/debug logging in place. From 8350e42d0225612dac92502272b1a392fb4c9d45 Mon Sep 17 00:00:00 2001 From: robbond Date: Wed, 8 Apr 2026 08:23:51 +0100 Subject: [PATCH 18/22] Remove dead and debug-only code from elements index --- components/elements/index.js | 89 +----------------------------------- memory-bank/change-log.md | 30 ++++++++++++ 2 files changed, 31 insertions(+), 88 deletions(-) diff --git a/components/elements/index.js b/components/elements/index.js index cdc5f20d..dd56b3d1 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -1,5 +1,3 @@ -import axios from "axios"; - import _ from "lodash"; import useTranslation from "next-translate/useTranslation"; import { useRouter } from "next/router"; @@ -7,13 +5,9 @@ import React, { useEffect } from "react"; import "react-datepicker/dist/react-datepicker.css"; import { Field } from "redux-form"; import fieldLookup from "../../data/crmfieldlookuptranslations.json"; -import pickListLookup from "../../data/picklistLookups.json"; - -import { setFileCount } from "../../store/appealType/action"; import "react-quill-new/dist/quill.snow.css"; -import { useStore as store, useSelector } from "react-redux"; import { validateField } from "./validationUtils"; // Import the validation function import { getFieldTranslation } from "./helpers/translationHelpers"; import { RenderRichMultiline } from "./fields/renderRichMultiline"; @@ -75,33 +69,6 @@ export function Textfield(props) { hint } = props; - const required = (value) => { - let errors; - - // Check for the required field - if (!value) { - errors = t("newappeal:is-required-label"); - } else { - // Check for emojis using a regular expression - const emojiRegex = - /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{2300}-\u{23FF}\u{2B50}\u{1F004}-\u{1F0CF}\u{1F0A0}-\u{1F0A5}\u{1F170}-\u{1F251}]/gu; - - if (emojiRegex.test(value)) { - errors = "Emojis are not allowed"; - } - } - - return errors; - }; - - const postcode = (value) => - value && - !/^([A-Z][A-HJ-Y]?[0-9][A-Z0-9]? ?[0-9][A-Z]{2}|GIR ?0A{2})$/i.test( - value - ) - ? t("newappeal:invalid-postcode-label") - : undefined; - let { t } = useTranslation(); if (name == "pinswg_name") { @@ -238,25 +205,6 @@ export function MultiLinefield(props) { hint } = props; - const required = (value) => { - let errors; - - // Check for the required field - if (!value) { - errors = t("newappeal:is-required-label"); - } else { - // Check for emojis using a regular expression - const emojiRegex = - /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{2300}-\u{23FF}\u{2B50}\u{1F004}-\u{1F0CF}\u{1F0A0}-\u{1F0A5}\u{1F170}-\u{1F251}]/gu; - - if (emojiRegex.test(value)) { - errors = t("newappeal:emojis-not-allowed-label"); - } - } - - return errors; - }; - var showIfHasParentShowValue = isVisibleByInclusion({ parentField, form, @@ -365,9 +313,6 @@ export function RichMultiLinefield(props) { validation, maxFieldLength } = props; - const required = (value) => - value ? undefined : t("newappeal:is-required-label"); - var showIfHasParentShowValue = isVisibleByInclusion({ parentField, form, @@ -475,7 +420,6 @@ export function DateFieldPicker(props) { let dateStart = props.dateStart; let dateEnd = props.dateEnd; let { t } = useTranslation(); - const required = (value) => (value ? undefined : "Required"); var showIfHasParentShowValue = isVisibleByEquality({ parentField, form, @@ -651,7 +595,6 @@ export function YesNofield(props) { } = props; const yesNo = router.locale == "cy" ? ["Ydw", "Na"] : ["Yes", "No"]; const required = (value) => (value ? undefined : "Required"); - var showIfHasParentShowValue = isVisibleByInclusion({ parentField, form, @@ -718,7 +661,6 @@ export function Radiofield(props) { } = props; const router = useRouter(); - const required = (value) => (value ? undefined : "Required"); // console.log(props.options); let { t } = useTranslation(); @@ -803,8 +745,7 @@ export function Radiofield(props) { export function CheckBoxfield(props) { const router = useRouter(); - console.log(props); - const required = (value) => (value ? undefined : "Required"); + let { t } = useTranslation(); const fieldOptions = router.locale == "cy" ? ["Ydw", "Na", "2323"] : ["Yes", "No", "23232"]; return ( @@ -955,21 +896,6 @@ export function DecimalField(props) { let { t } = useTranslation(); - const required = (value) => - value ? undefined : t("newappeal:is-required-label"); - - const isNumber = (value) => { - const regex = /^\d*\.?\d{0,1}$/; - return regex.test(value) - ? undefined - : t("newappeal:invalid-number-label"); - }; - - const maxLength = (max) => (value) => - value && value.length > max - ? `Must be ${max} characters or less` - : undefined; - //parentFieldShowOnValue var showIfHasParentShowValue = isVisibleByInclusion({ parentField, @@ -983,10 +909,6 @@ export function DecimalField(props) { //console.log("parentField:", parentField, showIfHasParentShowValue); - const normalizeDecimal = (value) => { - return value ? parseFloat(value) : value; - }; - const validateDecimal = (value) => { if (!value) return t("newappeal:is-required-label"); @@ -1059,8 +981,6 @@ export function DecimalField(props) { ); } -const RenderCaseID = {}; - export function ReadOnlyfield(props) { const { name, label, value } = props; //console.log(props.value.refno); @@ -1098,13 +1018,6 @@ export const FieldsTranslations = (label) => { }; export function FileUploadField(props) { - const uploadCount = - typeof props.uploadCount === "number" ? props.uploadCount : 0; - const setFileCount = - typeof props.setFileCount === "function" - ? props.setFileCount - : () => {}; - return ( <>

    diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index f23e2728..e3bc410b 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -3612,3 +3612,33 @@ Validation: Follow-ups: - Continue bounded no-behavior-change slices by extracting one additional low-risk wrapper (e.g., `CheckBoxfield`) or removing dead locals/debug logging in place. + +--- + +### CL-102: 22500 `components/elements/index.js` dead/debug-only cleanup slice + +date: 2026-04-08 +author: Cline +scope: `components/elements/index.js` +type: change +rationale: Execute requested bounded cleanup slice by removing dead code and debug-only artifacts from the elements monolith without changing behavior. +impact: No intended behavior change; EN/CY and accessibility behavior preserved while reducing noise and unused code paths. +status: completed + +Summary: + +- Removed debug-only runtime log in `CheckBoxfield` (`console.log(props)`). +- Removed unused/dead locals and helpers inside `components/elements/index.js`, including: + - top-level unused imports (`axios`, `pickListLookup`, `setFileCount` action import, redux hooks import) + - unused local validators and helpers in wrappers (e.g., unused `required`/`postcode`/`normalizeDecimal` variants) + - unused placeholder constant `RenderCaseID` + - unused local fallbacks in `FileUploadField` (`uploadCount`, local `setFileCount`) +- Kept functional field wiring, labels/translations, and validation behavior in active render paths unchanged. + +Validation: + +- `npx eslint components/elements/index.js` -> pass + +Follow-ups: + +- Continue bounded no-behavior-change slices only (e.g., extract one additional low-risk wrapper such as `CheckBoxfield`). From dbebd03cacbad30e257cbd989e4b64da1a5b3a39 Mon Sep 17 00:00:00 2001 From: robbond Date: Wed, 8 Apr 2026 08:33:06 +0100 Subject: [PATCH 19/22] Extract CheckBox, DateFieldPicker and YesNo wrappers --- components/elements/fields/checkBoxField.js | 26 +++ components/elements/fields/dateFieldPicker.js | 99 +++++++++ components/elements/fields/yesNoField.js | 66 ++++++ components/elements/index.js | 192 +----------------- memory-bank/change-log.md | 28 +++ 5 files changed, 225 insertions(+), 186 deletions(-) create mode 100644 components/elements/fields/checkBoxField.js create mode 100644 components/elements/fields/dateFieldPicker.js create mode 100644 components/elements/fields/yesNoField.js diff --git a/components/elements/fields/checkBoxField.js b/components/elements/fields/checkBoxField.js new file mode 100644 index 00000000..9b399f22 --- /dev/null +++ b/components/elements/fields/checkBoxField.js @@ -0,0 +1,26 @@ +import React from "react"; +import useTranslation from "next-translate/useTranslation"; +import { useRouter } from "next/router"; +import { Field } from "redux-form"; +import { RenderCheckBox } from "./renderCheckBox"; + +export function CheckBoxfield(props) { + const router = useRouter(); + let { t } = useTranslation(); + + const fieldOptions = + router.locale == "cy" ? ["Ydw", "Na", "2323"] : ["Yes", "No", "23232"]; + + return ( + + ); +} diff --git a/components/elements/fields/dateFieldPicker.js b/components/elements/fields/dateFieldPicker.js new file mode 100644 index 00000000..cbfeba11 --- /dev/null +++ b/components/elements/fields/dateFieldPicker.js @@ -0,0 +1,99 @@ +import React from "react"; +import _ from "lodash"; +import useTranslation from "next-translate/useTranslation"; +import { Field } from "redux-form"; +import { RenderDatePicker } from "./renderDatePicker"; +import { validateField } from "../validationUtils"; + +const dateDiffInDays = (a, b) => { + const _MS_PER_DAY = 1000 * 60 * 60 * 24; + const utc1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); + const utc2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); + + return Math.floor((utc2 - utc1) / _MS_PER_DAY); +}; + +export function DateFieldPicker(props) { + const { + name, + label, + parentField, + form, + formProps, + parentFieldShowOnValue, + validation + } = props; + + let dateStart = props.dateStart; + let dateEnd = props.dateEnd; + let { t } = useTranslation(); + + const showIfHasParentShowValue = + parentField != false && + !_.isEmpty(formProps[form]) && + _.has(formProps[form].values, parentField) && + formProps[form].values[parentField] == parentFieldShowOnValue; + + const requiredMessage = t("newappeal:is-required-label"); + const emojiNotAllowedMessage = t("newappeal:emojis-not-allowed-label"); + const invalidPostcodeMessage = t("newappeal:invalid-postcode-label"); + + dateStart = + showIfHasParentShowValue && name == "pinswg_dateoflpadecision" + ? "-" + + (dateDiffInDays( + new Date(formProps[form].values["pinswg_dateofapplication"]), + new Date() + ) - + 1) + + "d" + : dateStart; + + return ( +
    + {parentField != false ? ( + showIfHasParentShowValue && ( + + validateField( + value, + validation, + requiredMessage, + emojiNotAllowedMessage, + invalidPostcodeMessage + ) + } + errorMsg={t("newappeal:select-a-date-label")} + dateStart={dateStart} + dateEnd={dateEnd} + hint={props.hint} + /> + ) + ) : ( + + validateField( + value, + validation, + requiredMessage, + emojiNotAllowedMessage, + invalidPostcodeMessage + ) + } + errorMsg={t("newappeal:select-a-date-label")} + dateStart={dateStart} + dateEnd={dateEnd} + hint={props.hint} + /> + )} +
    + ); +} diff --git a/components/elements/fields/yesNoField.js b/components/elements/fields/yesNoField.js new file mode 100644 index 00000000..d768979b --- /dev/null +++ b/components/elements/fields/yesNoField.js @@ -0,0 +1,66 @@ +import React from "react"; +import _ from "lodash"; +import useTranslation from "next-translate/useTranslation"; +import { useRouter } from "next/router"; +import { Field } from "redux-form"; +import { RenderYesNo } from "./renderYesNo"; + +export function YesNofield(props) { + const router = useRouter(); + let { t } = useTranslation(); + + const { parentField, form, formProps, parentFieldShowOnValue } = props; + + const yesNo = router.locale == "cy" ? ["Ydw", "Na"] : ["Yes", "No"]; + const required = (value) => (value ? undefined : "Required"); + + const showIfHasParentShowValue = + parentField != false && + !_.isEmpty(formProps[form]) && + _.has(formProps[form].values, parentField) && + parentFieldShowOnValue.indexOf( + formProps[form].values[parentField].toString() + ) > -1; + + return ( + <> + {parentField != false ? ( + showIfHasParentShowValue && ( + + ) + ) : ( + + )} + + ); +} diff --git a/components/elements/index.js b/components/elements/index.js index dd56b3d1..da43b0a3 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -13,16 +13,19 @@ import { getFieldTranslation } from "./helpers/translationHelpers"; import { RenderRichMultiline } from "./fields/renderRichMultiline"; import { RenderTextfield } from "./fields/renderTextfield"; import { RenderMultiline } from "./fields/renderMultiline"; -import { RenderDatePicker } from "./fields/renderDatePicker"; -import { RenderYesNo } from "./fields/renderYesNo"; import { RenderRadio } from "./fields/renderRadio"; -import { RenderCheckBox } from "./fields/renderCheckBox"; import { PickList } from "./fields/pickListField"; +import { CheckBoxfield } from "./fields/checkBoxField"; +import { DateFieldPicker } from "./fields/dateFieldPicker"; +import { YesNofield } from "./fields/yesNoField"; import { RenderDecimalField } from "./fields/renderDecimalField"; import { RenderFileUpload } from "./fields/renderFileUpload"; export { RenderSubFields } from "./fields/renderSubFields"; export { FieldArrayForm } from "./fields/fieldArrayForm"; export { PickList }; +export { CheckBoxfield }; +export { DateFieldPicker }; +export { YesNofield }; const getValidationMessages = (t) => ({ requiredMessage: t("newappeal:is-required-label"), @@ -407,99 +410,6 @@ export function RichMultiLinefield(props) { ); } -export function DateFieldPicker(props) { - const { - name, - label, - parentField, - form, - formProps, - parentFieldShowOnValue, - validation - } = props; - let dateStart = props.dateStart; - let dateEnd = props.dateEnd; - let { t } = useTranslation(); - var showIfHasParentShowValue = isVisibleByEquality({ - parentField, - form, - formProps, - parentFieldShowOnValue - }); - - const { requiredMessage, emojiNotAllowedMessage, invalidPostcodeMessage } = - getValidationMessages(t); - - // a and b are javascript Date objects - function dateDiffInDays(a, b) { - const _MS_PER_DAY = 1000 * 60 * 60 * 24; - // Discard the time and time-zone information. - const utc1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); - const utc2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); - - return Math.floor((utc2 - utc1) / _MS_PER_DAY); - } - - dateStart = - showIfHasParentShowValue && name == "pinswg_dateoflpadecision" - ? "-" + - (dateDiffInDays( - new Date(formProps[form].values["pinswg_dateofapplication"]), - new Date() - ) - - 1) + - "d" - : dateStart; - - return ( -
    - {parentField != false ? ( - showIfHasParentShowValue && ( - - validateField( - value, - validation, - requiredMessage, - emojiNotAllowedMessage, - invalidPostcodeMessage - ) - } // Use the external validate function - errorMsg={t("newappeal:select-a-date-label")} - dateStart={dateStart} - dateEnd={dateEnd} - hint={props.hint} - /> - ) - ) : ( - - validateField( - value, - validation, - requiredMessage, - emojiNotAllowedMessage, - invalidPostcodeMessage - ) - } // Use the external validate function - errorMsg={t("newappeal:select-a-date-label")} - dateStart={dateStart} - dateEnd={dateEnd} - hint={props.hint} - /> - )} -
    - ); -} - export function DateField(props) { const { name, label } = props; @@ -579,75 +489,6 @@ export function DateField(props) { ); } -export function YesNofield(props) { - const router = useRouter(); - let { t } = useTranslation(); - - const { - name, - label, - inline, - form, - formProps, - validation, - parentFieldShowOnValue, - parentField - } = props; - const yesNo = router.locale == "cy" ? ["Ydw", "Na"] : ["Yes", "No"]; - const required = (value) => (value ? undefined : "Required"); - var showIfHasParentShowValue = isVisibleByInclusion({ - parentField, - form, - formProps, - parentFieldShowOnValue - }); - - (showIfHasParentShowValue == parentField) != false && - showIfHasParentShowValue; - - return ( - <> - {parentField != false ? ( - showIfHasParentShowValue && ( - - ) - ) : ( - - )} - - ); -} - export function Radiofield(props) { const { name, @@ -742,27 +583,6 @@ export function Radiofield(props) { ); } -export function CheckBoxfield(props) { - const router = useRouter(); - - let { t } = useTranslation(); - const fieldOptions = - router.locale == "cy" ? ["Ydw", "Na", "2323"] : ["Yes", "No", "23232"]; - return ( - - ); -} - export function NumericField(props) { const { name, diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index e3bc410b..7449ea75 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -3642,3 +3642,31 @@ Validation: Follow-ups: - Continue bounded no-behavior-change slices only (e.g., extract one additional low-risk wrapper such as `CheckBoxfield`). + +--- + +### CL-103: 22500 wrapper extraction bundle (`CheckBoxfield`, `DateFieldPicker`, `YesNofield`) + +date: 2026-04-08 +author: Cline +scope: `components/elements/index.js`, `components/elements/fields/{checkBoxField,dateFieldPicker,yesNoField}.js` +type: change +rationale: Execute the requested bundled wrapper slice by extracting wrappers 1/2/3 in one commit while keeping behavior unchanged. +impact: No intended behavior change; keeps EN/CY output, visibility logic, and accessibility structure intact while reducing `components/elements/index.js` size. +status: completed + +Summary: + +- Added `components/elements/fields/checkBoxField.js` and moved `CheckBoxfield` wrapper. +- Added `components/elements/fields/dateFieldPicker.js` and moved `DateFieldPicker` wrapper logic. +- Added `components/elements/fields/yesNoField.js` and moved `YesNofield` wrapper logic. +- Updated `components/elements/index.js` to import/export these wrappers from field modules. +- Removed inline implementations of `CheckBoxfield`, `DateFieldPicker`, and `YesNofield` from `index.js`. + +Validation: + +- `npx eslint components/elements/index.js components/elements/fields/checkBoxField.js components/elements/fields/dateFieldPicker.js components/elements/fields/yesNoField.js` -> pass + +Follow-ups: + +- Remaining wrappers can continue as bounded slices (`Radiofield`, `NumericField`, `DecimalField`) if required. From 5d40fab5c4e5f3eb0b281826a3cec25a684e7a3d Mon Sep 17 00:00:00 2001 From: robbond Date: Wed, 8 Apr 2026 10:00:01 +0100 Subject: [PATCH 20/22] Extract Radio, Numeric and Decimal wrappers --- components/elements/fields/decimalField.js | 106 +++++++ components/elements/fields/numericField.js | 114 ++++++++ components/elements/fields/radioField.js | 107 +++++++ components/elements/index.js | 320 +-------------------- memory-bank/change-log.md | 28 ++ 5 files changed, 361 insertions(+), 314 deletions(-) create mode 100644 components/elements/fields/decimalField.js create mode 100644 components/elements/fields/numericField.js create mode 100644 components/elements/fields/radioField.js diff --git a/components/elements/fields/decimalField.js b/components/elements/fields/decimalField.js new file mode 100644 index 00000000..772e9a5a --- /dev/null +++ b/components/elements/fields/decimalField.js @@ -0,0 +1,106 @@ +import React from "react"; +import _ from "lodash"; +import useTranslation from "next-translate/useTranslation"; +import { Field } from "redux-form"; +import { RenderTextfield } from "./renderTextfield"; +import { RenderDecimalField } from "./renderDecimalField"; +import { validateField } from "../validationUtils"; + +const isVisibleByInclusion = ({ + parentField, + form, + formProps, + parentFieldShowOnValue +}) => + parentField != false && + !_.isEmpty(formProps[form]) && + _.has(formProps[form].values, parentField) && + parentFieldShowOnValue.indexOf( + formProps[form].values[parentField].toString() + ) > -1; + +export function DecimalField(props) { + const { + name, + validation, + form, + formProps, + parentFieldShowOnValue, + parentField + } = props; + + let { t } = useTranslation(); + + const showIfHasParentShowValue = isVisibleByInclusion({ + parentField, + form, + formProps, + parentFieldShowOnValue + }); + + const validateDecimal = (value) => { + if (!value) return t("newappeal:is-required-label"); + + const regex = /^\d{1,6}(\.\d{1,2})?$/; + + if (!regex.test(value)) { + return t("newappeal:invalid-decimal-label"); + } + + return undefined; + }; + + const requiredMessage = t("newappeal:is-required-label"); + const emojiNotAllowedMessage = t("newappeal:emojis-not-allowed-label"); + const invalidPostcodeMessage = t("newappeal:invalid-postcode-label"); + + return ( + <> + {parentField != false ? ( + showIfHasParentShowValue && ( +
    + + validateField( + value, + validation, + requiredMessage, + emojiNotAllowedMessage, + invalidPostcodeMessage + ) + } + component={RenderTextfield} + label={props.label} + maxFieldLength={props.maxFieldLength} + /> +
    + ) + ) : ( +
    + +
    + )} + + ); +} diff --git a/components/elements/fields/numericField.js b/components/elements/fields/numericField.js new file mode 100644 index 00000000..6bd17454 --- /dev/null +++ b/components/elements/fields/numericField.js @@ -0,0 +1,114 @@ +import React from "react"; +import _ from "lodash"; +import useTranslation from "next-translate/useTranslation"; +import { Field } from "redux-form"; +import { RenderTextfield } from "./renderTextfield"; +import { validateField } from "../validationUtils"; + +const isVisibleByInclusion = ({ + parentField, + form, + formProps, + parentFieldShowOnValue +}) => + parentField != false && + !_.isEmpty(formProps[form]) && + _.has(formProps[form].values, parentField) && + parentFieldShowOnValue.indexOf( + formProps[form].values[parentField].toString() + ) > -1; + +export function NumericField(props) { + const { + name, + label, + validation, + form, + formProps, + parentFieldShowOnValue, + parentField + } = props; + + let { t } = useTranslation(); + + const required = (value) => { + return value || value == 0 + ? undefined + : t("newappeal:is-required-label"); + }; + const isNumber = (value) => { + const regex = /^\d+$/; + return regex.test(value) + ? undefined + : t("newappeal:invalid-number-label"); + }; + + const maxLength = (max) => (value) => + value && value.length > max + ? `Must be ${max} characters or less` + : undefined; + + const showIfHasParentShowValue = isVisibleByInclusion({ + parentField, + form, + formProps, + parentFieldShowOnValue + }); + + const requiredMessage = t("newappeal:is-required-label"); + const emojiNotAllowedMessage = t("newappeal:emojis-not-allowed-label"); + const invalidPostcodeMessage = t("newappeal:invalid-postcode-label"); + + return ( + <> + {parentField != false ? ( + showIfHasParentShowValue && ( +
    + + validateField( + value, + validation, + requiredMessage, + emojiNotAllowedMessage, + invalidPostcodeMessage + ) + } + component={RenderTextfield} + label={label} + maxFieldLength={props.maxFieldLength} + /> +
    + ) + ) : ( +
    + +
    + )} + + ); +} diff --git a/components/elements/fields/radioField.js b/components/elements/fields/radioField.js new file mode 100644 index 00000000..80eae158 --- /dev/null +++ b/components/elements/fields/radioField.js @@ -0,0 +1,107 @@ +import React from "react"; +import _ from "lodash"; +import useTranslation from "next-translate/useTranslation"; +import { Field } from "redux-form"; +import { RenderRadio } from "./renderRadio"; +import { validateField } from "../validationUtils"; + +const isVisibleByInclusion = ({ + parentField, + form, + formProps, + parentFieldShowOnValue +}) => + parentField != false && + !_.isEmpty(formProps[form]) && + _.has(formProps[form].values, parentField) && + parentFieldShowOnValue.indexOf( + formProps[form].values[parentField].toString() + ) > -1; + +export function Radiofield(props) { + const { + name, + label, + datafieldname, + form, + formProps, + parentFieldShowOnValue, + parentField, + validation + } = props; + + let { t } = useTranslation(); + + const fieldOptions = props.options + .slice(1, props.options.length - 1) + .split(","); + + const showIfHasParentShowValue = isVisibleByInclusion({ + parentField, + form, + formProps, + parentFieldShowOnValue + }); + + const requiredMessage = t("newappeal:is-required-label"); + const emojiNotAllowedMessage = t("newappeal:emojis-not-allowed-label"); + const invalidPostcodeMessage = t("newappeal:invalid-postcode-label"); + + return ( + <> + {parentField != false ? ( + showIfHasParentShowValue && ( + + validateField( + value, + validation, + requiredMessage, + emojiNotAllowedMessage, + invalidPostcodeMessage + ) + } + requiredDocumentLabel={props.requiredDocumentLabel} + requiredDocumentValue={props.requiredDocumentValue} + setDocumentsList={props.setDocumentsList} + documentList={props.documentList} + /> + ) + ) : ( + + validateField( + value, + validation, + requiredMessage, + emojiNotAllowedMessage, + invalidPostcodeMessage + ) + } + requiredDocumentLabel={props.requiredDocumentLabel} + requiredDocumentValue={props.requiredDocumentValue} + setDocumentsList={props.setDocumentsList} + documentList={props.documentList} + /> + )} + + ); +} diff --git a/components/elements/index.js b/components/elements/index.js index da43b0a3..550f8d17 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -13,12 +13,13 @@ import { getFieldTranslation } from "./helpers/translationHelpers"; import { RenderRichMultiline } from "./fields/renderRichMultiline"; import { RenderTextfield } from "./fields/renderTextfield"; import { RenderMultiline } from "./fields/renderMultiline"; -import { RenderRadio } from "./fields/renderRadio"; import { PickList } from "./fields/pickListField"; import { CheckBoxfield } from "./fields/checkBoxField"; import { DateFieldPicker } from "./fields/dateFieldPicker"; import { YesNofield } from "./fields/yesNoField"; -import { RenderDecimalField } from "./fields/renderDecimalField"; +import { Radiofield } from "./fields/radioField"; +import { NumericField } from "./fields/numericField"; +import { DecimalField } from "./fields/decimalField"; import { RenderFileUpload } from "./fields/renderFileUpload"; export { RenderSubFields } from "./fields/renderSubFields"; export { FieldArrayForm } from "./fields/fieldArrayForm"; @@ -26,6 +27,9 @@ export { PickList }; export { CheckBoxfield }; export { DateFieldPicker }; export { YesNofield }; +export { Radiofield }; +export { NumericField }; +export { DecimalField }; const getValidationMessages = (t) => ({ requiredMessage: t("newappeal:is-required-label"), @@ -489,318 +493,6 @@ export function DateField(props) { ); } -export function Radiofield(props) { - const { - name, - label, - datafieldname, - form, - formProps, - parentFieldShowOnValue, - parentField, - validation - } = props; - - const router = useRouter(); - // console.log(props.options); - let { t } = useTranslation(); - - const fieldOptions = props.options - .slice(1, props.options.length - 1) - .split(","); - - //parentFieldShowOnValue - var showIfHasParentShowValue = isVisibleByInclusion({ - parentField, - form, - formProps, - parentFieldShowOnValue - }); - - (showIfHasParentShowValue == parentField) != false && - showIfHasParentShowValue; - - const { requiredMessage, emojiNotAllowedMessage, invalidPostcodeMessage } = - getValidationMessages(t); - return ( - <> - {parentField != false ? ( - showIfHasParentShowValue && ( - - validateField( - value, - validation, - requiredMessage, - emojiNotAllowedMessage, - invalidPostcodeMessage - ) - } // Use the external validate function - requiredDocumentLabel={props.requiredDocumentLabel} - requiredDocumentValue={props.requiredDocumentValue} - setDocumentsList={props.setDocumentsList} - documentList={props.documentList} - /> - ) - ) : ( - - validateField( - value, - validation, - requiredMessage, - emojiNotAllowedMessage, - invalidPostcodeMessage - ) - } // Use the external validate function - requiredDocumentLabel={props.requiredDocumentLabel} - requiredDocumentValue={props.requiredDocumentValue} - setDocumentsList={props.setDocumentsList} - documentList={props.documentList} - /> - )} - - ); -} - -export function NumericField(props) { - const { - name, - label, - validation, - form, - formProps, - parentFieldShowOnValue, - parentField, - maxFieldLength - } = props; - - let { t } = useTranslation(); - - const required = (value) => { - return value || value == 0 - ? undefined - : t("newappeal:is-required-label"); - }; - const isNumber = (value) => { - const regex = /^\d+$/; - return regex.test(value) - ? undefined - : t("newappeal:invalid-number-label"); - }; - - const maxLength = (max) => (value) => - value && value.length > max - ? `Must be ${max} characters or less` - : undefined; - - //parentFieldShowOnValue - var showIfHasParentShowValue = isVisibleByInclusion({ - parentField, - form, - formProps, - parentFieldShowOnValue - }); - - (showIfHasParentShowValue == parentField) != false && - showIfHasParentShowValue; - - //console.log("parentField:", parentField, showIfHasParentShowValue); - const { requiredMessage, emojiNotAllowedMessage, invalidPostcodeMessage } = - getValidationMessages(t); - return ( - <> - {parentField != false ? ( - showIfHasParentShowValue && ( -
    - - validateField( - value, - validation, - requiredMessage, - emojiNotAllowedMessage, - invalidPostcodeMessage - ) - } // Use the external validate function - component={RenderTextfield} - label={label} - maxFieldLength={props.maxFieldLength} - /> -
    - ) - ) : ( -
    - parseInt(value)} - // maxFieldLength={props.maxFieldLength} - - name={props.name} - id={props.name} - type="text" - className="govuk-input govuk-input--width-10" - aria-describedby={name} - //pattern="^\d*\.?\d+$" - pattern="[0-9]*" - validate={[ - required, - isNumber, - maxLength(props.maxFieldLength) - ]} - component={RenderTextfield} - label={props.label} - maxFieldLength={props.maxFieldLength} - /> -
    - )} - - ); -} - -export function DecimalField(props) { - const { - name, - label, - validation, - form, - formProps, - parentFieldShowOnValue, - parentField, - maxFieldLength - } = props; - - let { t } = useTranslation(); - - //parentFieldShowOnValue - var showIfHasParentShowValue = isVisibleByInclusion({ - parentField, - form, - formProps, - parentFieldShowOnValue - }); - - (showIfHasParentShowValue == parentField) != false && - showIfHasParentShowValue; - - //console.log("parentField:", parentField, showIfHasParentShowValue); - - const validateDecimal = (value) => { - if (!value) return t("newappeal:is-required-label"); - - // Regex to match whole numbers or decimal numbers (up to 7 characters including decimal) - const regex = /^\d{1,6}(\.\d{1,2})?$/; // Up to 6 digits before the decimal, 2 after - - // Check if the value matches the regex pattern - if (!regex.test(value)) { - return t("newappeal:invalid-decimal-label"); - } - - return undefined; - }; - - const { requiredMessage, emojiNotAllowedMessage, invalidPostcodeMessage } = - getValidationMessages(t); - return ( - <> - {parentField != false ? ( - showIfHasParentShowValue && ( -
    - - validateField( - value, - validation, - requiredMessage, - emojiNotAllowedMessage, - invalidPostcodeMessage - ) - } // Use the external validate function - component={RenderTextfield} - label={props.label} - maxFieldLength={props.maxFieldLength} - /> -
    - ) - ) : ( -
    - -
    - )} - - ); -} - export function ReadOnlyfield(props) { const { name, label, value } = props; //console.log(props.value.refno); diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index 7449ea75..28f12758 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -3670,3 +3670,31 @@ Validation: Follow-ups: - Remaining wrappers can continue as bounded slices (`Radiofield`, `NumericField`, `DecimalField`) if required. + +--- + +### CL-104: 22500 wrapper extraction bundle (`Radiofield`, `NumericField`, `DecimalField`) + +date: 2026-04-08 +author: Cline +scope: `components/elements/index.js`, `components/elements/fields/{radioField,numericField,decimalField}.js` +type: change +rationale: Continue Phase 2 with the next bounded wrapper bundle by extracting wrappers 4/5/6 from `components/elements/index.js` into dedicated field modules without behavior change. +impact: No intended behavior change; preserves EN/CY behavior, validation wiring, and accessibility semantics while reducing monolith size. +status: completed + +Summary: + +- Added `components/elements/fields/radioField.js` for `Radiofield`. +- Added `components/elements/fields/numericField.js` for `NumericField`. +- Added `components/elements/fields/decimalField.js` for `DecimalField`. +- Updated `components/elements/index.js` to import/export these wrappers from field modules. +- Removed inline `Radiofield`, `NumericField`, and `DecimalField` implementations from `index.js`. + +Validation: + +- `npx eslint components/elements/index.js components/elements/fields/radioField.js components/elements/fields/numericField.js components/elements/fields/decimalField.js` -> pass + +Follow-ups: + +- Next bounded wrappers (if needed): `ReadOnlyfield`/remaining small wrappers or additional dead-code cleanup slices. From 56e854ca2b7cfb821e4e5f5bcfaa4b784b7ebb2d Mon Sep 17 00:00:00 2001 From: robbond Date: Wed, 8 Apr 2026 11:18:41 +0100 Subject: [PATCH 21/22] defect fix for download local questionnaire --- actions/services/documentDirectService.js | 1 + 1 file changed, 1 insertion(+) diff --git a/actions/services/documentDirectService.js b/actions/services/documentDirectService.js index b63b8cd3..436ee123 100644 --- a/actions/services/documentDirectService.js +++ b/actions/services/documentDirectService.js @@ -230,6 +230,7 @@ export const generateRepPDF = async ( var queryUrl = "/api/file/generatepdf" + (options.download ? "?download=true" : ""); + formValues.containerID = containerID; try { return await postSignedFileJson(queryUrl, formValues, { ...(options.download ? { responseType: "blob" } : {}) From 1b2f8e29c6ede33fa5730682ac60bf729ed3e2b1 Mon Sep 17 00:00:00 2001 From: robbond Date: Wed, 8 Apr 2026 12:17:28 +0100 Subject: [PATCH 22/22] defect fix for language and login - crm precedence then current session --- components/account/personaldetailsComplete.js | 3 +- components/header.js | 11 +- pages/api/auth/[...nextauth].js | 123 +++++++++++------- pages/api/auth/resolve-locale.js | 43 ++++++ pages/api/endpoint/getportallogin_api.js | 2 +- pages/auth/signin.js | 49 +++++-- 6 files changed, 169 insertions(+), 62 deletions(-) create mode 100644 pages/api/auth/resolve-locale.js diff --git a/components/account/personaldetailsComplete.js b/components/account/personaldetailsComplete.js index 6a50ef86..ff2199c0 100644 --- a/components/account/personaldetailsComplete.js +++ b/components/account/personaldetailsComplete.js @@ -40,8 +40,7 @@ const PersonalDetailsComplete = (props) => { console.log("=============== pref lang", prefLang); setCookie(null, "pedw_locale", userLang, { - path: "/", - maxAge: 60 * 60 * 24 * 365 + path: "/" }); console.log("Set cookie to:", userLang); diff --git a/components/header.js b/components/header.js index d7ae3032..80e158fa 100644 --- a/components/header.js +++ b/components/header.js @@ -64,7 +64,7 @@ const Header = (props) => { "/myportal/contactus", "/unsubscribe/[watchlistid]", "/unsubscribeall/[watchlistid]", - "/status", + "/status" ]; const hasContactLink = [ @@ -72,7 +72,7 @@ const Header = (props) => { "/dns/help", "/dns/contact-us", "/dns/applications", - "/dns/application-view", + "/dns/application-view" ]; let domainSwitch; @@ -91,7 +91,7 @@ const Header = (props) => { destroyCookie(null, "pedw_locale", { path: "/" }), destroyCookie(null, "pinsUser", { path: "/" }), signOut({ - callbackUrl: locale == "cy" ? "/cy/allgofnodi" : "/logout", + callbackUrl: locale == "cy" ? "/cy/allgofnodi" : "/logout" })); }; @@ -136,8 +136,7 @@ const Header = (props) => { // Set cookie first setCookie(null, "pedw_locale", newLocale, { - path: "/", - maxAge: 60 * 60 * 24 * 365, // 1 year + path: "/" }); // Then navigate with the new locale @@ -270,7 +269,7 @@ const mapDispatchToProps = (dispatch) => { return { setLogout: () => { dispatch(setLogout()); - }, + } }; }; diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js index 7a713e47..9e017930 100644 --- a/pages/api/auth/[...nextauth].js +++ b/pages/api/auth/[...nextauth].js @@ -15,9 +15,12 @@ import { PrismaClient } from "@prisma/client"; import NextAuth from "next-auth"; import EmailProvider from "next-auth/providers/email"; import { consoleLogger } from "../../../actions/core/logger"; +import { getPortalLogin } from "../../../actions/services/accountService"; const prisma = new PrismaClient(); +const WELSH_LANGUAGE_CODE = 846040000; + const appendParamsAndPathToNewUrl = (fromUrl, toUrl) => { const fromUrlObj = new URL(fromUrl); const params = fromUrlObj.searchParams; @@ -34,14 +37,64 @@ const appendParamsAndPathToNewUrl = (fromUrl, toUrl) => { return toUrlObj.toString(); }; -const resolveLocale = (req) => +const resolveRequestLocale = (req) => req?.query?.locale || req?.body?.locale || req?.cookies?.pedw_locale || "en"; +const resolveCrmLocale = async (email) => { + if (!email) return null; + + try { + const portalUserObj = await getPortalLogin(email); + const preferredLanguage = + portalUserObj?.value?.[0]?.pinswg_preferredlanguage; + + if (preferredLanguage === WELSH_LANGUAGE_CODE) { + return "cy"; + } + + if (preferredLanguage != null) { + return "en"; + } + + return null; + } catch (error) { + consoleLogger(error); + return null; + } +}; + +const resolveEffectiveLocale = async (req, email) => { + const crmLocale = await resolveCrmLocale(email); + if (crmLocale) return crmLocale; + + return resolveRequestLocale(req); +}; + +const buildLocalizedVerificationUrl = ({ url, email, effectiveLocale }) => { + const { host, protocol, searchParams } = new URL(url); + const baseDomain = `${protocol}//${host}`; + + const newURL = + effectiveLocale === "cy" + ? baseDomain + + "/api/auth/callback/email?callbackUrl=" + + encodeURIComponent(baseDomain + "/cy") + + "&token=" + + searchParams.get("token") + + "&email=" + + encodeURIComponent(email) + + "&locale=" + + effectiveLocale + : url; + + return appendParamsAndPathToNewUrl(url, newURL); +}; + const authOptions = (req, res) => { - const locale = resolveLocale(req); + const requestLocale = resolveRequestLocale(req); return { providers: [ @@ -50,7 +103,10 @@ const authOptions = (req, res) => { name: "emailAPI", type: "email", async sendVerificationRequest({ identifier: email, url }) { - const { host, protocol, searchParams } = new URL(url); + const effectiveLocale = await resolveEffectiveLocale( + req, + email + ); console.log( "============================================================\n", @@ -58,23 +114,11 @@ const authOptions = (req, res) => { "============================================================\n" ); - const baseDomain = `${protocol}//${host}`; - const effectiveLocale = resolveLocale(req); - - const newURL = - effectiveLocale === "cy" - ? baseDomain + - "/api/auth/callback/email?callbackUrl=" + - encodeURIComponent(baseDomain + "/cy") + - "&token=" + - searchParams.get("token") + - "&email=" + - encodeURIComponent(email) + - "&locale=" + - effectiveLocale - : url; - - const formURL = appendParamsAndPathToNewUrl(url, newURL); + const formURL = buildLocalizedVerificationUrl({ + url, + email, + effectiveLocale + }); console.log( "============================================================\n", @@ -90,12 +134,13 @@ const authOptions = (req, res) => { EmailProvider({ maxAge: 2 * 60 * 60, async sendVerificationRequest({ identifier: email, url }) { - const { host, protocol, searchParams } = new URL(url); - - const baseDomain = `${protocol}//${host}`; const templateId = "b1b5704b-9bb8-4deb-a75c-d887ca902661"; const templateIdcy = "0614ce53-cd5f-421f-a1a5-8c8486a9113a"; - const effectiveLocale = resolveLocale(req); + + const effectiveLocale = await resolveEffectiveLocale( + req, + email + ); console.log( "============================================================\n", @@ -103,20 +148,11 @@ const authOptions = (req, res) => { "============================================================\n" ); - const newURL = - effectiveLocale === "cy" - ? baseDomain + - "/api/auth/callback/email?callbackUrl=" + - encodeURIComponent(baseDomain + "/cy") + - "&token=" + - searchParams.get("token") + - "&email=" + - encodeURIComponent(email) + - "&locale=" + - effectiveLocale - : url; - - const formURL = appendParamsAndPathToNewUrl(url, newURL); + const formURL = buildLocalizedVerificationUrl({ + url, + email, + effectiveLocale + }); console.log( "============================================================\n", @@ -178,11 +214,11 @@ const authOptions = (req, res) => { } }, pages: { - signIn: (locale === "cy" ? "/cy" : "") + "/auth/signin", - error: (locale === "cy" ? "/cy" : "") + "/auth/error", + signIn: (requestLocale === "cy" ? "/cy" : "") + "/auth/signin", + error: (requestLocale === "cy" ? "/cy" : "") + "/auth/error", verifyRequest: - (locale === "cy" ? "/cy" : "") + "/auth/verify-request", - newUser: (locale === "cy" ? "/cy" : "") + "/account/register" + (requestLocale === "cy" ? "/cy" : "") + "/auth/verify-request", + newUser: (requestLocale === "cy" ? "/cy" : "") + "/account/register" }, callbacks: { session: async (session, user) => { @@ -196,9 +232,8 @@ const authOptions = (req, res) => { if (url.startsWith("/")) return `${baseUrl}${url}`; if (new URL(url).origin === baseUrl) return url; - const effectiveLocale = resolveLocale(req); const newUrl = - effectiveLocale === "cy" + requestLocale === "cy" ? process.env.CY_API_ROOT : process.env.NEXTAUTH_URL; diff --git a/pages/api/auth/resolve-locale.js b/pages/api/auth/resolve-locale.js new file mode 100644 index 00000000..f89bf9df --- /dev/null +++ b/pages/api/auth/resolve-locale.js @@ -0,0 +1,43 @@ +import { getPortalLogin } from "../../../actions/services/accountService"; +import { consoleLogger } from "../../../actions/core/logger"; + +const WELSH_LANGUAGE_CODE = 846040000; + +const resolveRequestLocale = (req) => + req?.query?.locale || + req?.body?.locale || + req?.cookies?.pedw_locale || + "en"; + +export default async function handler(req, res) { + if (req.method !== "POST") { + return res.status(405).json({ message: "Method not allowed" }); + } + + const email = String(req.body?.email || "") + .trim() + .toLowerCase(); + const sessionLocale = resolveRequestLocale(req); + + if (!email) { + return res.status(200).json({ locale: sessionLocale }); + } + + try { + const portalUserObj = await getPortalLogin(email); + const preferredLanguage = + portalUserObj?.value?.[0]?.pinswg_preferredlanguage; + + const locale = + preferredLanguage === WELSH_LANGUAGE_CODE + ? "cy" + : preferredLanguage != null + ? "en" + : sessionLocale; + + return res.status(200).json({ locale }); + } catch (error) { + consoleLogger(error); + return res.status(200).json({ locale: sessionLocale }); + } +} diff --git a/pages/api/endpoint/getportallogin_api.js b/pages/api/endpoint/getportallogin_api.js index 3e7e3a97..00871021 100644 --- a/pages/api/endpoint/getportallogin_api.js +++ b/pages/api/endpoint/getportallogin_api.js @@ -64,7 +64,7 @@ export default async function ApiProxy(req, res) { const queryUrl = "contacts?$filter=emailaddress1 eq '" + emailAddress + - "' and statuscode eq 1&$count=true&$select=emailaddress1,contactid,yomifullname,firstname,lastname"; + "' and statuscode eq 1&$count=true&$select=pinswg_preferredlanguage,emailaddress1,contactid,yomifullname,firstname,lastname"; const relayPolicy = RELAY_POLICY_STRICT_LOGIN; diff --git a/pages/auth/signin.js b/pages/auth/signin.js index b0113660..2de256c8 100644 --- a/pages/auth/signin.js +++ b/pages/auth/signin.js @@ -19,17 +19,48 @@ const SignIn = (props) => { event.preventDefault(); setButtonDisabled(true); - const currentLocale = lang || "en"; - const url = new URL(event.target.callbackUrl.value); - url.searchParams.set("locale", currentLocale); + try { + const email = String(event.target.email.value || "").trim(); + const currentLocale = lang || "en"; - setCookie(null, "pedw_locale", currentLocale, { - path: "/", - maxAge: 60 * 60 * 24 * 365 - }); + const response = await fetch("/api/auth/resolve-locale", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + email, + locale: currentLocale + }) + }); - event.target.callbackUrl.value = url.toString(); - event.target.submit(); + const data = await response.json(); + const resolvedLocale = + data?.locale === "cy" || data?.locale === "en" + ? data.locale + : currentLocale; + + const url = new URL(event.target.callbackUrl.value); + url.searchParams.set("locale", resolvedLocale); + + setCookie(null, "pedw_locale", resolvedLocale, { + path: "/" + }); + + event.target.callbackUrl.value = url.toString(); + event.target.submit(); + } catch (error) { + const fallbackLocale = lang || "en"; + const url = new URL(event.target.callbackUrl.value); + url.searchParams.set("locale", fallbackLocale); + + setCookie(null, "pedw_locale", fallbackLocale, { + path: "/" + }); + + event.target.callbackUrl.value = url.toString(); + event.target.submit(); + } }; return (