From 06b78558263c6e5cbde3345d48b406be95dd3a9f Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 7 Apr 2026 09:34:50 +0100 Subject: [PATCH] 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)