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" } : {}) 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/fields/renderSubFields.js b/components/elements/fields/renderSubFields.js new file mode 100644 index 00000000..d6d087f1 --- /dev/null +++ b/components/elements/fields/renderSubFields.js @@ -0,0 +1,150 @@ +import React, { useEffect } 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; + }; + + useEffect(() => { + if (fields.length === 0) { + fields.push({}); + } + }, [fields, fields.length]); + + return ( +
    + {fields.map((member, index) => ( +
    +

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

    + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + +
    +
    +
    + ))} + +
    + {fields.length < custom.maxSubField && ( + + )} +
+ ); +}; 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/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/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/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..550f8d17 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -1,123 +1,66 @@ -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, { useMemo, useState } from "react"; -import DatePicker from "react-datepicker"; +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 dynamic from "next/dynamic"; -import { - bytesToSize, - getThumbnailIconByExtension, - updateLinks -} 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"; -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 { getFieldTranslation } from "./helpers/translationHelpers"; +import { RenderRichMultiline } from "./fields/renderRichMultiline"; +import { RenderTextfield } from "./fields/renderTextfield"; +import { RenderMultiline } from "./fields/renderMultiline"; +import { PickList } from "./fields/pickListField"; +import { CheckBoxfield } from "./fields/checkBoxField"; +import { DateFieldPicker } from "./fields/dateFieldPicker"; +import { YesNofield } from "./fields/yesNoField"; +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"; +export { PickList }; +export { CheckBoxfield }; +export { DateFieldPicker }; +export { YesNofield }; +export { Radiofield }; +export { NumericField }; +export { DecimalField }; -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} - - ) - : ""} -
- - ); -}; +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 { @@ -133,33 +76,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") { @@ -218,19 +134,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 ? ( @@ -280,59 +199,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, @@ -346,49 +212,29 @@ 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 = - 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 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 ? ( showIfHasParentShowValue && (
{ - 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, @@ -550,25 +320,20 @@ export function RichMultiLinefield(props) { validation, maxFieldLength } = 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 ? ( @@ -649,246 +414,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, - label, - parentField, - form, - formProps, - parentFieldShowOnValue, - validation - } = props; - let dateStart = props.dateStart; - 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; - - const requiredMessage = t("newappeal:is-required-label"); - const emojiNotAllowedMessage = t("newappeal:emojis-not-allowed-label"); - const invalidPostcodeMessage = t("newappeal:invalid-postcode-label"); - - // 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; @@ -968,886 +493,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(); - - 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 = - parentField != false && - !_.isEmpty(formProps[form]) && - _.has(formProps[form].values, parentField) && - parentFieldShowOnValue.indexOf( - formProps[form].values[parentField].toString() - ) > -1; - - (showIfHasParentShowValue == parentField) != false && - showIfHasParentShowValue; - - return ( - <> - {parentField != false ? ( - showIfHasParentShowValue && ( - - ) - ) : ( - - )} - - ); -} - -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, - label, - datafieldname, - form, - formProps, - parentFieldShowOnValue, - parentField, - validation - } = props; - - const router = useRouter(); - const required = (value) => (value ? undefined : "Required"); - // console.log(props.options); - let { t } = useTranslation(); - - const fieldOptions = props.options - .slice(1, props.options.length - 1) - .split(","); - - //parentFieldShowOnValue - var showIfHasParentShowValue = - parentField != false && - !_.isEmpty(formProps[form]) && - _.has(formProps[form].values, parentField) && - parentFieldShowOnValue.indexOf( - formProps[form].values[parentField].toString() - ) > -1; - - (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"); - 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} - /> - )} - - ); -} - -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(); - - console.log(props); - const required = (value) => (value ? undefined : "Required"); - const fieldOptions = - router.locale == "cy" ? ["Ydw", "Na", "2323"] : ["Yes", "No", "23232"]; - return ( - - ); -} - -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(); - const required = (value) => { - return value || value == 0 - ? undefined - : t("newappeal:is-required-label"); - }; - return ( - - ); -} - -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 = - parentField != false && - !_.isEmpty(formProps[form]) && - _.has(formProps[form].values, parentField) && - parentFieldShowOnValue.indexOf( - formProps[form].values[parentField].toString() - ) > -1; - - (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"); - 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(); - - 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 = - parentField != false && - !_.isEmpty(formProps[form]) && - _.has(formProps[form].values, parentField) && - parentFieldShowOnValue.indexOf( - formProps[form].values[parentField].toString() - ) > -1; - - (showIfHasParentShowValue == parentField) != false && - showIfHasParentShowValue; - - //console.log("parentField:", parentField, showIfHasParentShowValue); - - const normalizeDecimal = (value) => { - return value ? parseFloat(value) : value; - }; - - 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 = 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 - ) - } // Use the external validate function - component={RenderTextfield} - label={props.label} - maxFieldLength={props.maxFieldLength} - /> -
- ) - ) : ( -
- -
- )} - - ); -} - -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) { const { name, label, value } = props; //console.log(props.value.refno); @@ -1876,51 +521,15 @@ 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; }; export function FileUploadField(props) { - const uploadCount = - typeof props.uploadCount === "number" ? props.uploadCount : 0; - const setFileCount = - typeof props.setFileCount === "function" - ? props.setFileCount - : () => {}; - return ( <>

@@ -1947,769 +556,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 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({ - 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 - }; - - 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 ( - <> - validateFilename(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], - `${getDocumentType(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)}) - -
- ) - )}{" "} - - ); -}; - -const renderField = ({ input, label, type, meta: { touched, error } }) => ( -
- -
- - {touched && error && {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, - label, - validation, - form, - formProps, - parentFieldShowOnValue, - parentField, - maxFieldLength, - 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; - - (showIfHasParentShowValue == parentField) != false && - showIfHasParentShowValue; - - !showIfHasParentShowValue && dispatch(change("appealForm", name, null)); - - parentFieldShowOnValue; - const { handleSubmit, pristine, reset, submitting } = props; - - return ( - <> - {" "} - {showIfHasParentShowValue && ( - - )} - - ); -}; 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/memory-bank/change-log.md b/memory-bank/change-log.md index 54b008da..28f12758 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -18,6 +18,88 @@ Follow-ups: --- +### CL-00X: 22500 `components/elements/index.js` Phase 1 helper extraction + +date: 2026-04-07 +author: Cline +scope: `components/elements/index.js`, `components/elements/helpers/fileUploadHelpers.js`, `components/elements/helpers/translationHelpers.js`, `memory-bank/refactor-backlog.md` +type: change +rationale: Execute Phase 1 of the approved `components/elements/index.js` decomposition plan by extracting pure helper logic only, reducing monolith coupling while preserving UI/component behavior. +impact: No route/API contract changes; refactor-only extraction of translation/file-upload helper functions with expected behavior parity for EN/CY field labels, file naming, thumbnail icon mapping, and filename validation. +status: completed + +Summary: + +- Created work branch from `origin/SIPS-Development`: `22500-elements-index-phase1`. +- Added helper modules: + - `components/elements/helpers/fileUploadHelpers.js` + - `getThumbnailIconByMimeType` + - `getDocumentTypePrefix` + - `validateUploadFilename` + - `components/elements/helpers/translationHelpers.js` + - `getFieldTranslation` + - `getPickListTranslation` +- Updated `components/elements/index.js` to consume these helpers and removed duplicated inline helper implementations. +- Kept field renderer/component placement and external prop contracts unchanged (Phase 1 non-goals respected). +- Updated `memory-bank/refactor-backlog.md` with a phased Priority 6 track and Phase 1 guardrail-aligned acceptance criteria. + +Validation: + +- `npx eslint components/elements/index.js components/elements/helpers/fileUploadHelpers.js components/elements/helpers/translationHelpers.js` -> 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-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-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 @@ -3313,3 +3395,306 @@ 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`. + +--- + +### CL-095: 22500 `components/elements/index.js` hook-order hotfix (`MultiLinefield`) + +date: 2026-04-07 +author: Cline +scope: `components/elements/index.js` +type: change +rationale: Fix runtime React warning caused by conditional hook execution path in `MultiLinefield` after dead-code cleanup. +impact: No functional behavior change intended; resolves Rules of Hooks ordering warning by making label translation hook usage unconditional per render. +status: completed + +Summary: + +- Root cause: `FieldsTranslations(props.label)` (which internally uses `useRouter`) was invoked within conditional render branches in `MultiLinefield`, causing hook order mismatch when branch conditions changed. +- Fix: precomputed `translatedLabel` once in `MultiLinefield` render body and reused in both branches. +- Kept EN/CY text resolution logic and rendered output unchanged. + +Validation: + +- `npx eslint components/elements/index.js` -> pass + +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. + +--- + +### 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. + +--- + +### 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. + +--- + +### 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). + +--- + +### 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. + +--- + +### 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. + +--- + +### 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`). + +--- + +### 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. + +--- + +### 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. 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) 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/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 || ""); 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 (