diff --git a/.eslintrc.json b/.eslintrc.json index 5eb43a7..8f54005 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -38,6 +38,8 @@ "@typescript-eslint/no-use-before-define": 0, "class-methods-use-this": 0, "no-useless-constructor": 0, + "import/prefer-default-export": 0, + "max-classes-per-file": "off", "@typescript-eslint/no-unused-vars": [ 1, { diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 2bb37ec..b9c7290 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -27,18 +27,26 @@ jobs: name: node build uses: ministryofjustice/hmpps-github-actions/.github/workflows/node_build.yml@v1 # WORKFLOW_VERSION secrets: inherit + with: + node_version: 22 # generic node unit tests - feel free to override with local tests if required node_unit_tests: name: node unit tests uses: ministryofjustice/hmpps-github-actions/.github/workflows/node_unit_tests.yml@v1 # WORKFLOW_VERSION needs: [node_build] secrets: inherit + with: + node_version: 22 # generic node integration tests using wiremock - feel free to override with local tests if required node_integration_tests: name: node integration tests + # skip integration tests until testing strategy is established + if: false uses: ministryofjustice/hmpps-github-actions/.github/workflows/node_integration_tests.yml@v1 # WORKFLOW_VERSION needs: [node_build] secrets: inherit + with: + node_version: 22 helm_lint: strategy: matrix: diff --git a/.gitignore b/.gitignore index 75d78b0..8261ad1 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,4 @@ integration_tests/screenshots/ */*.iml **/Chart.lock **/.DS_Store +tmp diff --git a/integration_tests/fixtures/applicationData.json b/integration_tests/fixtures/applicationData.json new file mode 100644 index 0000000..681731a --- /dev/null +++ b/integration_tests/fixtures/applicationData.json @@ -0,0 +1,358 @@ +{ + "confirm-eligibility": { + "confirm-eligibility": { + "isEligible": "yes" + } + }, + "confirm-consent": { + "confirm-consent": { + "hasGivenConsent": "yes", + "consentDate": "2023-01-01", + "consentDate-year": "2023", + "consentDate-month": "1", + "consentDate-day": "1" + } + }, + "hdc-licence-dates": { + "hdc-licence-dates": { + "hdcEligibilityDate": "2026-02-22", + "hdcEligibilityDate-year": "2026", + "hdcEligibilityDate-month": "2", + "hdcEligibilityDate-day": "22", + "conditionalReleaseDate": "2026-03-28", + "conditionalReleaseDate-year": "2026", + "conditionalReleaseDate-month": "3", + "conditionalReleaseDate-day": "28" + }, + "hdc-warning": {}, + "hdc-ineligible": {} + }, + "referrer-details": { + "confirm-details": { "name": "Eric Dier", "email": "eric.dier@moj.gov.uk" }, + "job-title": { "jobTitle": "POM" }, + "contact-number": { "telephone": "1234567" } + }, + "information-needed-from-applicant": { + "information-needed-from-applicant": { + "hasInformationNeeded": "yes" + } + }, + "area-information": { + "first-preferred-area": { + "preferredArea": "London", + "preferenceReason": "They have family there" + }, + "second-preferred-area": { + "preferredArea": "Birmingham", + "preferenceReason": "They have a job there" + }, + "exclusion-zones": { + "hasExclusionZones": "yes", + "exclusionZonesDetail": "Avoid Liverpool" + }, + "gang-affiliations": { + "hasGangAffiliations": "yes", + "gangName": "Gang name", + "gangOperationArea": "Derby", + "rivalGangDetail": "Rival gang detail" + }, + "family-accommodation": { + "familyProperty": "yes" + } + }, + "funding-information": { + "funding-source": { + "fundingSource": "benefits" + }, + "identification": { + "idDocuments": "passport" + }, + "national-insurance": { + "nationalInsuranceNumber": "12345" + } + }, + "personal-information": { + "working-mobile-phone": { + "hasWorkingMobilePhone": "yes", + "mobilePhoneNumber": "11111111111", + "isSmartPhone": "yes" + }, + "immigration-status": { + "immigrationStatus": "UK citizen" + }, + "pregnancy-information": { + "isPregnant": "yes", + "dueDate-day": "5", + "dueDate-month": "6", + "dueDate-year": "2024" + }, + "support-worker-preference": { + "hasSupportWorkerPreference": "yes", + "supportWorkerPreference": "female" + } + }, + "address-history": { + "previous-address": { + "hasPreviousAddress": "yes", + "previousAddressLine1": "1 Example Road", + "previousAddressLine2": "Pretend Close", + "previousTownOrCity": "Aberdeen", + "previousCounty": "Gloucestershire", + "previousPostcode": "AB1 2CD" + } + }, + "equality-and-diversity-monitoring": { + "will-answer-equality-questions": { + "willAnswer": "yes" + }, + "disability": { + "hasDisability": "no" + }, + "sex-and-gender": { + "sex": "female", + "gender": "yes" + }, + "sexual-orientation": { + "orientation": "gay" + }, + "ethnic-group": { + "ethnicGroup": "white" + }, + "white-background": { + "whiteBackground": "english" + }, + "religion": { + "religion": "atheist" + }, + "military-veteran": { + "isVeteran": "yes" + }, + "care-leaver": { + "isCareLeaver": "no" + }, + "parental-carer-responsibilities": { + "hasParentalOrCarerResponsibilities": "yes" + }, + "marital-status": { + "maritalStatus": "widowed" + } + }, + "health-needs": { + "guidance": {}, + "substance-misuse": { + "usesIllegalSubstances": "no", + "substanceMisuse": "", + "pastSubstanceMisuse": "no", + "pastSubstanceMisuseDetail": "", + "engagedWithDrugAndAlcoholService": "no", + "intentToReferToServiceOnRelease": "no", + "drugAndAlcoholServiceDetail": "", + "requiresSubstituteMedication": "no", + "substituteMedicationDetail": "", + "releasedWithNaloxone": "no" + }, + "physical-health": { + "hasPhyHealthNeeds": "no", + "needsDetail": "", + "isReceivingMedicationOrTreatment": "no", + "medicationOrTreatmentDetail": "", + "canLiveIndependently": "yes", + "indyLivingDetail": "", + "requiresAdditionalSupport": "no", + "addSupportDetail": "" + }, + "mental-health": { + "hasMentalHealthNeeds": "no", + "needsDetail": "", + "needsPresentation": "", + "isEngagedWithCommunity": "no", + "servicesDetail": "", + "isEngagedWithServicesInCustody": "no", + "areIntendingToEngageWithServicesAfterCustody": "no", + "canManageMedication": "notPrescribedMedication", + "canManageMedicationNotes": "", + "medicationIssues": "", + "cantManageMedicationNotes": "" + }, + "communication-and-language": { + "requiresInterpreter": "no", + "interpretationDetail": "", + "hasSupportNeeds": "no", + "supportDetail": "", + "oldQuestion": "this answer should not appear" + }, + "learning-difficulties": { + "hasLearningNeeds": "no", + "needsDetail": "", + "isVulnerable": "no", + "vulnerabilityDetail": "", + "hasDifficultyInteracting": "no", + "interactionDetail": "", + "requiresAdditionalSupport": "no", + "addSupportDetail": "" + }, + "brain-injury": { + "hasBrainInjury": "no", + "injuryDetail": "", + "isVulnerable": "no", + "vulnerabilityDetail": "", + "hasDifficultyInteracting": "no", + "interactionDetail": "", + "requiresAdditionalSupport": "no", + "addSupportDetail": "" + }, + "other-health": { + "hasLongTermHealthCondition": "no", + "healthConditionDetail": "", + "hasSeizures": "no", + "seizuresDetail": "", + "beingTreatedForCancer": "no" + } + }, + "risk-to-self": { + "oasys-import": { + "oasysImportedDate": "2023-09-21T15:47:51.430Z", + "oasysStartedDate": "2023-09-10", + "oasysCompletedDate": "2023-09-11" + }, + "current-risk": { + "currentRiskDetail": "[R8.1.1] Review 06.10.21:\r\n\r\n There have been numerous ACCTs opened since 2013 and every subsequent year he has been in custody. In 2021...", + "confirmation": "confirmed" + }, + "vulnerability": { + "vulnerabilityDetail": "example answer vulnerability", + "confirmation": "confirmed" + }, + "historical-risk": { + "historicalRiskDetail": "example answer historical risk", + "confirmation": "confirmed" + }, + "acct": {}, + "acct-data": [ + { + "createdDate-day": "1", + "createdDate-month": "2", + "createdDate-year": "2012", + "isOngoing": "no", + "closedDate-day": "10", + "closedDate-month": "10", + "closedDate-year": "2013", + "referringInstitution": "HMPPS prison", + "acctDetails": "ACCT details\nsome more details on another line" + }, + { + "createdDate-day": "2", + "createdDate-month": "3", + "createdDate-year": "2013", + "isOngoing": "yes", + "referringInstitution": "HMPPS prison 2", + "acctDetails": "ACCT details 2" + } + ], + "additional-information": { + "hasAdditionalInformation": "yes", + "additionalInformationDetail": "some information" + } + }, + "risk-of-serious-harm": { + "oasys-import": { "oasysImportedDate": "2023-09-21T15:47:51.430Z" }, + "summary-data": { + "oasysImportedDate": "2023-09-21T15:47:51.430Z", + "oasysStartedDate": "2023-09-10", + "oasysCompletedDate": "2023-09-11", + "status": "retrieved", + "value": { + "overallRisk": "High", + "riskToChildren": "High", + "riskToPublic": "Very High", + "riskToKnownAdult": "Medium", + "riskToStaff": "Low" + } + }, + "summary": { + "additionalComments": "some rosh comments" + }, + "risk-to-others": { + "whoIsAtRisk": "a person", + "natureOfRisk": "a nature", + "confirmation": "confirmed" + }, + "risk-management-arrangements": { + "arrangements": ["mappa", "marac", "iom"], + "mappaDetails": "mappa details", + "maracDetails": "marac details", + "iomDetails": "iom details" + }, + "cell-share-information": { + "hasCellShareComments": "yes", + "cellShareInformationDetail": "some information" + }, + "additional-risk-information": { + "hasAdditionalInformation": "yes", + "additionalInformationDetail": "some information" + } + }, + "current-offences": { + "current-offence-data": [ + { + "titleAndNumber": "Arson", + "offenceCategory": "Arson", + "offenceDate-day": "5", + "offenceDate-month": "6", + "offenceDate-year": "1940", + "sentenceLength": "3 years", + "summary": "summary detail", + "outstandingCharges": "yes", + "outstandingChargesDetail": "outstanding charges detail" + }, + { + "titleAndNumber": "Stalking", + "offenceCategory": "Stalking", + "offenceDate-day": "6", + "offenceDate-month": "7", + "offenceDate-year": "2023", + "sentenceLength": "2 months", + "summary": "more summary detail", + "outstandingCharges": "no" + } + ], + "current-offences": {} + }, + "offending-history": { + "any-previous-convictions": { "hasAnyPreviousConvictions": "yesRelevantRisk" }, + "offence-history-data": [ + { + "offenceGroupName": "Arson", + "offenceCategory": "arson", + "numberOfOffences": "3", + "sentenceTypes": "1 custodial", + "summary": "summary detail" + }, + { + "offenceGroupName": "Stalking", + "offenceCategory": "stalkingOrHarassment", + "numberOfOffences": "3", + "sentenceTypes": "1 suspended", + "summary": "more summary detail" + } + ], + "offence-history": {} + }, + "cpp-details-and-hdc-licence-conditions": { + "cpp-details": { + "name": "A. CPP", + "probationRegion": "some region", + "email": "cpp@moj.gov.uk", + "telephone": "012345" + }, + "non-standard-licence-conditions": { + "nonStandardLicenceConditions": "yes", + "nonStandardLicenceConditionsDetail": "some detail" + } + }, + "check-your-answers": { + "check-your-answers": { + "checkYourAnswers": "confirmed" + } + } +} diff --git a/integration_tests/fixtures/applicationDocument.json b/integration_tests/fixtures/applicationDocument.json new file mode 100644 index 0000000..2fe9fd4 --- /dev/null +++ b/integration_tests/fixtures/applicationDocument.json @@ -0,0 +1,322 @@ +{ + "sections": [ + { + "title": "Before you apply", + "tasks": [ + { + "title": "Confirm eligibility", + "questionsAndAnswers": [ + { + "question": "Is Aadland Bertrand eligible for Short-Term Accommodation (CAS-2)?", + "answer": "Yes, I confirm Aadland Bertrand is eligible" + } + ] + } + ] + }, + { + "title": "Area, funding and ID", + "tasks": [ + { + "title": "Confirm funding and ID", + "questionsAndAnswers": [ + { + "question": "How will Aadland Bertrand pay for their accommodation and service charge?", + "answer": "Housing Benefit and personal money or wages" + } + ] + }, + { + "title": "Add exclusion zones and preferred areas", + "questionsAndAnswers": [ + { + "question": " First preferred area", + "answer": "London" + }, + { + "question": "Reason for first preference", + "answer": "The person has family there" + }, + { + "question": "Second preferred area", + "answer": "Birmingham" + }, + { + "question": "Reason for second preference", + "answer": "The person has a job there" + }, + { + "question": "Does Aadland Bertrand have any exclusion zones?", + "answer": "Yes" + }, + { + "question": "Provide details about the exclusion zone", + "answer": "Avoid Liverpool" + }, + { + "question": "Does Aadland Bertrand have any gang affiliations?", + "answer": "Yes" + }, + { + "question": "What is the name of the gang?", + "answer": "Gang name" + }, + { + "question": "Where do they operate?", + "answer": "Derby" + }, + { + "question": "Name any known rival gangs and where they operate (optional)", + "answer": "Rival gang detail" + }, + { + "question": "Do they want to apply to live with their children in a family property?", + "answer": "Yes" + } + ] + } + ] + }, + { + "title": "About the applicant", + "tasks": [ + { + "title": "Add personal information", + "questionsAndAnswers": [ + { + "question": "Will Aadland Bertrand have a working mobile phone when they are released?", + "answer": "Yes" + }, + { + "question": "What is their mobile number? (Optional)", + "answer": "11111111111" + }, + { + "question": "Is their mobile a smart phone?", + "answer": "Yes" + }, + { + "question": "What is Aadland Bertrand immigration status?", + "answer": "UK citizen" + }, + { + "question": "Is Aadland Bertrand pregnant?", + "answer": "Yes" + }, + { + "question": "When is their due date?", + "answer": "5 June 2024" + }, + { + "question": "Does Aadland Bertrand have a gender preference for their support worker?", + "answer": "Yes" + }, + { + "question": "What is their preference?", + "answer": "Female" + } + ] + }, + { + "title": "Add equality and diversity monitoring information", + "questionsAndAnswers": [ + { + "question": "Equality questions for Aadland Bertrand", + "answer": "Yes, answer the equality questions (takes 2 minutes)" + }, + { "question": "Does Aadland Bertrand have a disability?", "answer": "Yes" }, + { "question": "What type of disability?", "answer": "Sensory impairment" }, + { "question": "What is Aadland Bertrand's sex?", "answer": "Male" }, + { + "question": "Is the gender Aadland Bertrand identifies with the same as the sex registered at birth?", + "answer": "Yes" + }, + { + "question": "Which of the following best describes Aadland Bertrand's sexual orientation?", + "answer": "Other" + }, + { + "question": "How would they describe their sexual orientation? (optional)", + "answer": "other sexual orientation" + }, + { "question": "What is Aadland Bertrand's ethnic group?", "answer": "Other ethnic group" }, + { + "question": "Which of the following best describes Aadland Bertrand's background?", + "answer": "Any other ethnic group" + }, + { "question": "How would they describe their background? (optional)", "answer": "any other ethnic group" }, + { "question": "What is Aadland Bertrand's religion?", "answer": "Any other religion" }, + { "question": "Is Aadland Bertrand a military veteran?", "answer": "Yes" }, + { "question": "Is Aadland Bertrand a care leaver?", "answer": "Yes" }, + { "question": "Does Aadland Bertrand have parental or carer responsibilities?", "answer": "Yes" }, + { + "question": "What is Aadland Bertrand's legal marital or registered civil partnership status?", + "answer": "In a registered civil partnership" + } + ] + } + ] + }, + { + "title": "Risks and needs", + "tasks": [ + { + "title": "Add health needs", + "questionsAndAnswers": [ + { "question": "Do they take any illegal substances in custody?", "answer": "Yes" }, + { "question": "What substances do they take?", "answer": "substances taken illegally" }, + { "question": "Did they have any past issues with substance misuse before custody?", "answer": "Yes" }, + { "question": "Describe their previous substance misuse", "answer": "misuse of various substances" }, + { "question": "Are they engaged with a drug and alcohol service in custody?", "answer": "Yes" }, + { + "question": "Is there an intention to refer them to a drug and alcohol service when they are released?", + "answer": "Yes" + }, + { "question": "Name the drug and alcohol service", "answer": "Local drug and alcohol service" }, + { "question": "Do they require any substitute medication for misused substances?", "answer": "Yes" }, + { "question": "What substitute medication do they take?", "answer": "substitute meds" }, + { "question": "Are they being released with naloxone?", "answer": "Yes" }, + { "question": "Do they have any physical health needs?", "answer": "Yes" }, + { "question": "Please describe their needs.", "answer": "some physical needs" }, + { "question": "Can they climb stairs?", "answer": "no" }, + { + "question": "Are they currently receiving any medication or treatment for their physical health?", + "answer": "Yes" + }, + { + "question": "Describe the medication or treatment", + "answer": "medication for physical needs" + }, + { "question": "Can they live independently?", "answer": "No" }, + { + "question": "Describe why they are unable to live independently", + "answer": "they are unable to live independently" + }, + { "question": "Do they require any additional support?", "answer": "Yes" }, + { "question": "Please describe the types of support.", "answer": "more additional support" }, + { "question": "Do they have any mental health needs?", "answer": "Yes" }, + { "question": "Please describe their mental health needs.", "answer": "some mental health needs" }, + { "question": "How are they presenting?", "answer": "some presentation details" }, + { "question": "Were they engaged in mental health services before custody?", "answer": "Yes" }, + { "question": "Please state which services.", "answer": "some community mental health services" }, + { + "question": "Are they engaged with any mental health services in custody?", + "answer": "Yes" + }, + { + "question": "Can they manage their own mental health medication on release?", + "answer": "They are not prescribed medication for their mental health" + }, + { "question": "Do they have any additional communication needs?", "answer": "Yes" }, + { "question": "Please describe their communication needs.", "answer": "additional needs" }, + { "question": "Do they need an interpreter?", "answer": "Yes" }, + { "question": "What language do they need an interpreter for?", "answer": "French" }, + { "question": "Do they need any support to see, hear, speak, or understand?", "answer": "Yes" }, + { "question": "Please describe their support needs.", "answer": "more support needs" }, + { + "question": "Do they have any additional needs relating to learning difficulties or neurodiversity?", + "answer": "Yes" + }, + { "question": "Please describe their additional needs.", "answer": "more additional needs " }, + { "question": "Are they vulnerable as a result of this condition?", "answer": "Yes" }, + { "question": "Please describe their level of vulnerability.", "answer": "vulnerability" }, + { + "question": "Do they have difficulties interacting with other people as a result of this condition?", + "answer": "Yes" + }, + { "question": "Please describe these difficulties.", "answer": "more difficulties" }, + { "question": "Is additional support required?", "answer": "Yes" }, + { "question": "Please describe the type of support.", "answer": "more additional support" }, + { "question": "Do they have a brain injury?", "answer": "Yes" }, + { "question": "Please describe their brain injury and needs.", "answer": "brain injury" }, + { "question": "Are they vulnerable as a result of this injury?", "answer": "Yes" }, + { "question": "Please describe their level of vulnerability.", "answer": "vulnerability" }, + { + "question": "Do they have difficulties interacting with other people as a result of this injury?", + "answer": "Yes" + }, + { "question": "Please describe these difficulties.", "answer": "more difficulties" }, + { "question": "Is additional support required?", "answer": "Yes" }, + { "question": "Please describe the type of support.", "answer": "more additional support" }, + { "question": "Are they managing any long term health conditions?", "answer": "Yes" }, + { "question": "Please describe the long term health conditions.", "answer": "long term conditions" }, + { "question": "Have they experienced a stroke?", "answer": "No" }, + { "question": "Do they experience seizures?", "answer": "Yes" }, + { "question": "Please describe the type and any treatment.", "answer": "seizure treatment and type" }, + { "question": "Are they currently receiving regular treatment for cancer?", "answer": "Yes" } + ] + }, + { + "title": "Add risk to self information", + "questionsAndAnswers": [ + { + "question": "Describe Aadland Bertrand's current issues and needs related to self harm and suicide", + "answer": "[R8.1.1] Review 06.10.21:\r\n\r\n There have been numerous ACCTs opened since 2013 and every subsequent year he has been in custody. In 2021..." + }, + { "question": "I confirm this information is relevant and up to date.", "answer": "Confirmed" }, + { + "question": "Describe Aadland Bertrand's current circumstances, issues and needs related to vulnerability", + "answer": "[R8.3.1] Review 06.10.21:\r\n\r\n A previous assessor opined that Mr Smith was displaying all the characteristics of someone..." + }, + { "question": "I confirm this information is relevant and up to date.", "answer": "Confirmed" }, + { + "question": "Describe Aadland Bertrand's historical issues and needs related to self harm and suicide", + "answer": "[R8.1.4] Review 06.10.21:\r\n\r\n There have been numerous ACCTs opened since 2013 and every subsequent year he has been in custody. In 2021 there..." + }, + { "question": "I confirm this information is relevant and up to date.", "answer": "Confirmed" }, + { + "question": "ACCT
Created: 1 February 2012
Expiry: 1 March 2013", + "answer": "more details about ACCT\r\nmore details about ACCT\r\nmore details about ACCT\r\n\r\nmore details about ACCT" + }, + { "question": "Is there anything else to include about Aadland Bertrand's risk to self?", "answer": "Yes" }, + { "question": "Additional information", "answer": "more additional info" } + ] + }, + { + "title": "Add risk of serious harm (RoSH) information", + "questionsAndAnswers": [ + { + "question": "Who is at risk?", + "answer": "[R10.1] IN CUSTODY\r\n\r\nKNOWN ADULTS:\r\nSuch as Ms Elaine Underhill and any of the victims of the index offence if they are placed close to Mr Smith cell.\r\n\r\nCHILDREN:\r\nSimon Smith, Roger Smith, Lindy Smith" + }, + { + "question": "What is the nature of the risk?", + "answer": "[R10.2] IN CUSTODY:\r\n\r\nKNOWN ADULTS:\r\nIntimidation, threats of violence, use of weapons or boiling water, physical education and violent assault, long term psychological impact as a result of Mr Smith violent behaviour. This harm may be cause in the course of physical altercation due to seeking revenge or holding grudges..." + }, + { "question": "I confirm this information is relevant and up to date.", "answer": "Confirmed" }, + { + "question": "What factors are likely to reduce risk?", + "answer": "[R10.5] Engage with Mental Health Services in prison and in the community.\r\nMaintain emotional stability.\r\nAbstain from alcohol.\r\nAbstain from illegal drugs.\r\nRegular testing for alcohol and drug use, in prison and on Licence.\r\nMaintain stable accommodation..." + }, + { "question": "I confirm this information is relevant and up to date.", "answer": "Confirmed" }, + { + "question": "Is Aadland Bertrand subject to any of these multi-agency risk management arrangements upon release?", + "answer": "MAPPA,MARAC,IOM" + }, + { "question": "Provide MAPPA details", "answer": "Mapp details" }, + { "question": "Provide MARAC details", "answer": "Marac details" }, + { "question": "Provide IOM details", "answer": "IOM details" }, + { "question": "Are there any comments to add about cell sharing?", "answer": "Yes" }, + { "question": "Cell sharing information", "answer": "cell sharing comments and more" }, + { "question": "Is there any other risk information for Aadland Bertrand?", "answer": "Yes" }, + { "question": "Additional information", "answer": "more additional info" } + ] + } + ] + }, + { + "title": "Offence and licence information", + "tasks": [ + { + "title": "Add offending history", + "questionsAndAnswers": [ + { + "question": "Does Aadland Bertrand have any previous unspent convictions?", + "answer": "No, they do not have any previous unspent convictions" + } + ] + } + ] + } + ] +} diff --git a/integration_tests/fixtures/oasysSections.json b/integration_tests/fixtures/oasysSections.json new file mode 100644 index 0000000..f4acd53 --- /dev/null +++ b/integration_tests/fixtures/oasysSections.json @@ -0,0 +1,19 @@ +{ + "roshSummary": [ + { + "questionNumber": "Q1", + "label": "The first RoSH question", + "answer": "Some answer for the first RoSH question" + }, + { + "questionNumber": "Q2", + "label": "The second RoSH question", + "answer": "Some answer for the second RoSH question" + }, + { + "questionNumber": "Q3", + "label": "The third RoSH question", + "answer": "Some answer for the third RoSH question" + } + ] +} diff --git a/package-lock.json b/package-lock.json index e1f2693..a414622 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@faker-js/faker": "^9.2.0", "@golevelup/ts-jest": "^0.6.1", "@ministryofjustice/frontend": "^3.0.1", + "@types/jsonpath": "^0.2.4", "agentkeepalive": "^4.5.0", "applicationinsights": "^2.9.6", "body-parser": "^1.20.3", @@ -29,6 +30,7 @@ "govuk-frontend": "^5.7.1", "helmet": "^8.0.0", "http-errors": "^2.0.0", + "jsonpath": "^1.1.1", "jwt-decode": "^4.0.0", "nocache": "^4.0.0", "nunjucks": "^3.2.4", @@ -37,6 +39,7 @@ "prom-client": "^15.1.3", "qs": "^6.13.1", "redis": "^4.7.0", + "reflect-metadata": "^0.2.2", "static-path": "^0.0.4", "superagent": "^10.1.1", "url-value-parser": "^2.2.0" @@ -3442,6 +3445,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/jsonpath": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@types/jsonpath/-/jsonpath-0.2.4.tgz", + "integrity": "sha512-K3hxB8Blw0qgW6ExKgMbXQv2UPZBoE2GqLpVY+yr7nMD2Pq86lsuIzyAaiQ7eMqFL5B6di6pxSkogLJEyEHoGA==", + "license": "MIT" + }, "node_modules/@types/jsonwebtoken": { "version": "9.0.7", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz", @@ -5688,8 +5697,7 @@ "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, "node_modules/deepmerge": { "version": "4.3.1", @@ -6348,6 +6356,87 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=4.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/escodegen/node_modules/levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "license": "MIT", + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "license": "MIT", + "dependencies": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "license": "MIT", + "dependencies": { + "prelude-ls": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/eslint": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", @@ -6761,7 +6850,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -6807,7 +6895,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -7140,8 +7227,7 @@ "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, "node_modules/fast-redact": { "version": "3.5.0", @@ -9681,6 +9767,29 @@ "node >= 0.2.0" ] }, + "node_modules/jsonpath": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.1.1.tgz", + "integrity": "sha512-l6Cg7jRpixfbgoWgkrl77dgEj8RPvND0wMH6TwQmi9Qs4TFfS9u5cUFnbeKTwj5ga5Y3BTGGNI28k117LJ009w==", + "license": "MIT", + "dependencies": { + "esprima": "1.2.2", + "static-eval": "2.0.2", + "underscore": "1.12.1" + } + }, + "node_modules/jsonpath/node_modules/esprima": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.2.2.tgz", + "integrity": "sha512-+JpPZam9w5DuJ3Q67SqsMGtiHKENSMRVoxvArfJZK01/BfLEObtZ6orJa/MtoGNR/rfMgp5837T41PAmTwAv/A==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/jsonstream-next": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/jsonstream-next/-/jsonstream-next-3.0.0.tgz", @@ -12244,6 +12353,12 @@ "@redis/time-series": "1.1.0" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", @@ -12957,7 +13072,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -13060,6 +13175,15 @@ "node": ">=8" } }, + "node_modules/static-eval": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz", + "integrity": "sha512-N/D219Hcr2bPjLxPiV+TQE++Tsmrady7TqAJugLy7Xk1EumfDWS/f5dtBbkRCGE7wKKXuYockQoj8Rm2/pVKyg==", + "license": "MIT", + "dependencies": { + "escodegen": "^1.8.1" + } + }, "node_modules/static-path": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/static-path/-/static-path-0.0.4.tgz", @@ -13884,7 +14008,6 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==", - "dev": true, "license": "MIT" }, "node_modules/undici-types": { @@ -14110,7 +14233,6 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, "engines": { "node": ">=0.10.0" } diff --git a/package.json b/package.json index 2034901..c25b9a6 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,9 @@ "testMatch": [ "/(server|job)/**/?(*.)(cy|test).{ts,js,jsx,mjs}" ], + "setupFilesAfterEnv": [ + "/server/testutils/jest.setup.ts" + ], "testEnvironment": "node", "reporters": [ "default", @@ -81,6 +84,7 @@ "@faker-js/faker": "^9.2.0", "@golevelup/ts-jest": "^0.6.1", "@ministryofjustice/frontend": "^3.0.1", + "@types/jsonpath": "^0.2.4", "agentkeepalive": "^4.5.0", "applicationinsights": "^2.9.6", "body-parser": "^1.20.3", @@ -97,6 +101,7 @@ "govuk-frontend": "^5.7.1", "helmet": "^8.0.0", "http-errors": "^2.0.0", + "jsonpath": "^1.1.1", "jwt-decode": "^4.0.0", "nocache": "^4.0.0", "nunjucks": "^3.2.4", @@ -105,6 +110,7 @@ "prom-client": "^15.1.3", "qs": "^6.13.1", "redis": "^4.7.0", + "reflect-metadata": "^0.2.2", "static-path": "^0.0.4", "superagent": "^10.1.1", "url-value-parser": "^2.2.0" diff --git a/server/applicationInfo.ts b/server/applicationInfo.ts index 7b7bd49..29c7209 100644 --- a/server/applicationInfo.ts +++ b/server/applicationInfo.ts @@ -1,5 +1,3 @@ -import fs from 'fs' -import path from 'path' import config from './config' const { buildNumber, gitRef, productId, branchName } = config @@ -14,7 +12,6 @@ export type ApplicationInfo = { } export default (): ApplicationInfo => { - const packageJson = path.join(__dirname, '../../package.json') - const { name: applicationName } = JSON.parse(fs.readFileSync(packageJson).toString()) + const applicationName = 'hmpps-community-accommodation-tier-2-bail-ui' return { applicationName, buildNumber, gitRef, gitShortHash: gitRef.substring(0, 7), productId, branchName } } diff --git a/server/config.ts b/server/config.ts index 13420a8..f80711b 100755 --- a/server/config.ts +++ b/server/config.ts @@ -105,4 +105,7 @@ export default { }, ingressUrl: get('INGRESS_URL', 'http://localhost:3000', requiredInProduction), environmentName: get('ENVIRONMENT_NAME', ''), + flags: { + maintenanceMode: get('IN_MAINTENANCE_MODE', false), + }, } diff --git a/server/data/index.ts b/server/data/index.ts index 5d09df6..480307b 100644 --- a/server/data/index.ts +++ b/server/data/index.ts @@ -5,6 +5,7 @@ */ import { initialiseAppInsights, buildAppInsightsClient } from '../utils/azureAppInsights' import applicationInfoSupplier from '../applicationInfo' +import PersonClient from './personClient' const applicationInfo = applicationInfoSupplier() initialiseAppInsights() @@ -25,8 +26,9 @@ export const dataAccess = () => ({ config.redis.enabled ? new RedisTokenStore(createRedisClient()) : new InMemoryTokenStore(), ), hmppsAuditClient: new HmppsAuditClient(config.sqs.audit), + personClient: ((token: string) => new PersonClient(token)) as RestClientBuilder, }) export type DataAccess = ReturnType -export { HmppsAuthClient, RestClientBuilder, HmppsAuditClient } +export { HmppsAuthClient, RestClientBuilder, HmppsAuditClient, PersonClient } diff --git a/server/form-pages/apply/about-the-person/address-history/index.ts b/server/form-pages/apply/about-the-person/address-history/index.ts new file mode 100644 index 0000000..7865cc2 --- /dev/null +++ b/server/form-pages/apply/about-the-person/address-history/index.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ + +import { Task } from '../../../utils/decorators' +import PreviousAddress from './previousAddress' + +@Task({ + name: 'Add address history', + slug: 'address-history', + pages: [PreviousAddress], +}) +export default class AddressHistory {} diff --git a/server/form-pages/apply/about-the-person/address-history/previousAddress.test.ts b/server/form-pages/apply/about-the-person/address-history/previousAddress.test.ts new file mode 100644 index 0000000..d2f6f2e --- /dev/null +++ b/server/form-pages/apply/about-the-person/address-history/previousAddress.test.ts @@ -0,0 +1,162 @@ +import { Cas2Application } from '@approved-premises/api' +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import PreviousAddress, { PreviousAddressBody } from './previousAddress' + +describe('PreviousAddress', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + const previousAddress = { + hasPreviousAddress: 'yes', + previousAddressLine1: '1 Example Road', + previousAddressLine2: 'Pretend Close', + previousTownOrCity: 'Aberdeen', + previousCounty: 'Gloucestershire', + previousPostcode: 'AB1 2CD', + } as PreviousAddressBody + + const lastKnownAddress = { + hasPreviousAddress: 'no', + howLong: '6 months', + lastKnownAddressLine1: '2 Example Road', + lastKnownAddressLine2: '2 Pretend Close', + lastKnownTownOrCity: 'Bristol', + lastKnownCounty: 'Shropshire', + lastKnownPostcode: 'EF1 GHD', + } as PreviousAddressBody + + const noLastKnownAddress = { + hasPreviousAddress: 'no', + } as PreviousAddressBody + + describe('title', () => { + it('has a page title', () => { + const page = new PreviousAddress({}, application) + + expect(page.title).toEqual('Did Roger Smith have a previous address before entering custody?') + }) + }) + + itShouldHaveNextValue(new PreviousAddress({}, application), '') + itShouldHavePreviousValue(new PreviousAddress({}, application), 'taskList') + + describe('errors', () => { + describe('when the previous address question has not been answered', () => { + it('it includes returns an error', () => { + const page = new PreviousAddress({}, application) + const errors = page.errors() + + expect(errors.hasPreviousAddress).toEqual('Select whether applicant had an address before entering custody') + }) + }) + describe('when there is a previous address', () => { + describe('when there are no errors', () => { + it('returns empty object', () => { + const page = new PreviousAddress(previousAddress, application) + expect(page.errors()).toEqual({}) + }) + }) + const requiredFields = [ + ['previousAddressLine1', 'Enter the first line of the address'], + ['previousTownOrCity', 'Enter a town or city'], + ['previousPostcode', 'Enter a postcode'], + ] + + it.each(requiredFields)('it includes a validation error for %s', (field, message) => { + const page = new PreviousAddress({ hasPreviousAddress: 'yes' }, application) + const errors = page.errors() + + expect(errors[field as keyof typeof errors]).toEqual(message) + }) + }) + + describe('when there is not a previous address', () => { + it('includes a validation error for howLong field', () => { + const page = new PreviousAddress(noLastKnownAddress, application) + expect(page.errors()).toEqual({ howLong: 'Enter how long the person has had no fixed address' }) + }) + }) + }) + + describe('response', () => { + it('returns empty object', () => { + const applicationWithNoData = { ...application, data: undefined } as Cas2Application + const page = new PreviousAddress({}, applicationWithNoData) + expect(page.response()).toEqual({}) + }) + + describe('when there is a previous address', () => { + const applicationWithData = { + ...application, + data: { 'address-history': { 'previous-address': { ...previousAddress } } }, + } + const page = new PreviousAddress({}, applicationWithData) + expect(page.response()).toEqual({ + 'Did Roger Smith have an address before entering custody?': 'Yes', + 'What was the address?': `1 Example Road\r\nPretend Close\r\nAberdeen\r\nGloucestershire\r\nAB1 2CD\r\n`, + }) + }) + + describe('when there is no previous address, but there is a last known address', () => { + const applicationWithData = { + ...application, + data: { 'address-history': { 'previous-address': { ...lastKnownAddress } } }, + } + const page = new PreviousAddress({}, applicationWithData) + expect(page.response()).toEqual({ + 'Did Roger Smith have an address before entering custody?': 'No fixed address', + 'How long did the applicant have no fixed address for?': '6 months', + 'What was their last known address? (Optional)': `2 Example Road\r\n2 Pretend Close\r\nBristol\r\nShropshire\r\nEF1 GHD\r\n`, + }) + }) + + describe('when there is no previous address or last known address', () => { + const applicationWithData = { + ...application, + data: { 'address-history': { 'previous-address': { ...noLastKnownAddress, howLong: '6 months' } } }, + } + const page = new PreviousAddress({}, applicationWithData) + expect(page.response()).toEqual({ + 'Did Roger Smith have an address before entering custody?': 'No fixed address', + 'How long did the applicant have no fixed address for?': '6 months', + 'What was their last known address? (Optional)': 'Not applicable', + }) + }) + + describe('when there is no previous address and a partial last known address', () => { + const applicationWithData = { + ...application, + data: { + 'address-history': { + 'previous-address': { + hasPreviousAddress: 'no', + howLong: '6 months', + lastKnownCounty: 'Shropshire', + lastKnownPostcode: 'EF1 GHD', + }, + }, + }, + } + const page = new PreviousAddress({}, applicationWithData) + expect(page.response()).toEqual({ + 'Did Roger Smith have an address before entering custody?': 'No fixed address', + 'How long did the applicant have no fixed address for?': '6 months', + 'What was their last known address? (Optional)': `Shropshire\r\nEF1 GHD\r\n`, + }) + }) + }) + + describe('items', () => { + it('returns the checkboxs as expected', () => { + const page = new PreviousAddress({}, application) + + const knownAddressHtml = 'known address' + const lastKnownHtml = 'last known' + + expect(page.items(knownAddressHtml, lastKnownHtml)).toEqual([ + { value: 'yes', text: 'Yes', checked: false, conditional: { html: knownAddressHtml } }, + { value: 'no', text: 'No fixed address', checked: false, conditional: { html: lastKnownHtml } }, + ]) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/address-history/previousAddress.ts b/server/form-pages/apply/about-the-person/address-history/previousAddress.ts new file mode 100644 index 0000000..f4ad8eb --- /dev/null +++ b/server/form-pages/apply/about-the-person/address-history/previousAddress.ts @@ -0,0 +1,158 @@ +import type { Radio, TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('') + +const options = applicationQuestions['address-history']['previous-address'].hasPreviousAddress.answers + +export const lastKnownKeys = [ + 'howLong', + 'lastKnownAddressLine1', + 'lastKnownAddressLine2', + 'lastKnownTownOrCity', + 'lastKnownCounty', + 'lastKnownPostcode', +] + +export const previousKeys = [ + 'previousAddressLine1', + 'previousAddressLine2', + 'previousTownOrCity', + 'previousCounty', + 'previousPostcode', +] + +export type PreviousAddressBody = { + hasPreviousAddress: keyof typeof options + previousAddressLine1: string + previousAddressLine2?: string + previousTownOrCity: string + previousCounty?: string + previousPostcode: string + howLong: string + lastKnownAddressLine1: string + lastKnownAddressLine2?: string + lastKnownTownOrCity: string + lastKnownCounty?: string + lastKnownPostcode: string +} + +@Page({ + name: 'previous-address', + bodyProperties: ['hasPreviousAddress', ...previousKeys, ...lastKnownKeys], +}) +export default class PreviousAddress implements TaskListPage { + documentTitle = 'Did the person have a previous address before entering custody?' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Did ${this.personName} have a previous address before entering custody?` + + questions = getQuestions(this.personName)['address-history']['previous-address'] + + addressLabels = { + addressLine1: 'Address line 1', + addressLine2: 'Address line 2', + addressLine2Optional: 'Address line 2 (optional)', + townOrCity: 'Town or city', + county: 'County', + countyOptional: 'County (optional)', + postcode: 'Postcode', + } + + body: PreviousAddressBody + + constructor( + body: Partial, + private readonly application: Application, + ) {} + + previous() { + return 'taskList' + } + + next() { + return '' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.hasPreviousAddress) { + errors.hasPreviousAddress = errorLookups.hasPreviousAddress.empty + } + + if (this.body.hasPreviousAddress === 'yes') { + if (!this.body.previousAddressLine1) { + errors.previousAddressLine1 = errorLookups.addressLine1.empty + } + if (!this.body.previousTownOrCity) { + errors.previousTownOrCity = errorLookups.townOrCity.empty + } + if (!this.body.previousPostcode) { + errors.previousPostcode = errorLookups.postCode.empty + } + } + + if (this.body.hasPreviousAddress === 'no' && !this.body.howLong) { + errors.howLong = 'Enter how long the person has had no fixed address' + } + + return errors + } + + items(knownAddress: string, lastKnownAddress: string) { + const items = convertKeyValuePairToRadioItems(options, this.body.hasPreviousAddress) as [Radio] + + items.forEach(item => { + if (item.value === 'yes') { + item.conditional = { + html: knownAddress, + } + } + if (item.value === 'no') { + item.conditional = { + html: lastKnownAddress, + } + } + }) + + return items + } + + response(): Record { + const response: Record = {} + + const answerData: PreviousAddressBody = this.application.data?.['address-history']?.['previous-address'] + + if (answerData) { + response[this.questions.hasPreviousAddress.question] = + this.questions.hasPreviousAddress.answers[answerData.hasPreviousAddress] + + let address = '' + if (answerData.hasPreviousAddress === 'yes') { + previousKeys.forEach(key => { + address += `${answerData[key as keyof typeof answerData]}\r\n` + }) + response[this.questions.knownAddress.question] = address + } else if (answerData.hasPreviousAddress === 'no') { + response[this.questions.howLong.question] = answerData.howLong + + lastKnownKeys.slice(1).forEach(key => { + if (answerData[key as keyof typeof answerData]) { + address += `${answerData[key as keyof typeof answerData]}\r\n` + } + }) + + response[this.questions.lastKnownAddress.question] = address || 'Not applicable' + } + } + + return response + } +} diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/asianBackground.test.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/asianBackground.test.ts new file mode 100644 index 0000000..3805daf --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/asianBackground.test.ts @@ -0,0 +1,97 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import AsianBackground, { AsianBackgroundBody } from './asianBackground' + +describe('AsianBackground', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new AsianBackground({}, application) + + expect(page.title).toEqual('Equality and diversity questions for Roger Smith') + }) + }) + + itShouldHaveNextValue(new AsianBackground({}, application), 'religion') + itShouldHavePreviousValue(new AsianBackground({}, application), 'ethnic-group') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new AsianBackground({ asianBackground: 'chinese' }, application) + const optionalExample = 'example' + + expect(page.items(optionalExample)).toEqual([ + { + checked: false, + text: 'Indian', + value: 'indian', + }, + { + checked: false, + text: 'Pakistani', + value: 'pakistani', + }, + { + checked: true, + text: 'Chinese', + value: 'chinese', + }, + { + checked: false, + text: 'Bangladeshi', + value: 'bangladeshi', + }, + { + checked: false, + conditional: { + html: 'example', + }, + text: 'Any other Asian background', + value: 'other', + }, + { + divider: 'or', + }, + { + checked: false, + text: 'Prefer not to say', + value: 'preferNotToSay', + }, + ]) + }) + }) + + describe('errors', () => { + it('should return errors when the questions are blank', () => { + const page = new AsianBackground({}, application) + + expect(page.errors()).toEqual({ + asianBackground: "Select a background or 'Prefer not to say'", + }) + }) + + it('should not return an error when the optional question is missing', () => { + const page = new AsianBackground({ asianBackground: 'other', optionalAsianBackground: undefined }, application) + + expect(page.errors()).toEqual({}) + }) + }) + + describe('onSave', () => { + it('removes asian background data when the question is not set to "other"', () => { + const body: Partial = { + asianBackground: 'preferNotToSay', + optionalAsianBackground: 'Asian background', + } + + const page = new AsianBackground(body, application) + + page.onSave() + + expect(page.body).toEqual({ + asianBackground: 'preferNotToSay', + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/asianBackground.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/asianBackground.ts new file mode 100644 index 0000000..20f29cc --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/asianBackground.ts @@ -0,0 +1,74 @@ +import type { Radio, TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('') + +const options = applicationQuestions['equality-and-diversity-monitoring']['asian-background'].asianBackground.answers + +export type AsianBackgroundBody = { + asianBackground: keyof typeof options + optionalAsianBackground: string +} +@Page({ + name: 'asian-background', + bodyProperties: ['asianBackground', 'optionalAsianBackground'], +}) +export default class AsianBackground implements TaskListPage { + documentTitle = "Which of the following best describes the person's Asian or Asian British background?" + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Equality and diversity questions for ${this.personName}` + + questions = getQuestions(this.personName)['equality-and-diversity-monitoring']['asian-background'] + + body: AsianBackgroundBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as AsianBackgroundBody + } + + previous() { + return 'ethnic-group' + } + + next() { + return 'religion' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.asianBackground) { + errors.asianBackground = errorLookups.background.empty + } + return errors + } + + items(optionalAsianBackground: string) { + const items = convertKeyValuePairToRadioItems(options, this.body.asianBackground) as [Radio] + + items.forEach(item => { + if (item.value === 'other') { + item.conditional = { html: optionalAsianBackground } + } + }) + const preferNotToSay = items.pop() + + return [...items, { divider: 'or' }, { ...preferNotToSay }] + } + + onSave(): void { + if (this.body.asianBackground !== 'other') { + delete this.body.optionalAsianBackground + } + } +} diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/blackBackground.test.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/blackBackground.test.ts new file mode 100644 index 0000000..551ba06 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/blackBackground.test.ts @@ -0,0 +1,87 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import BlackBackground, { BlackBackgroundBody } from './blackBackground' + +describe('BlackBackground', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new BlackBackground({}, application) + + expect(page.title).toEqual('Equality and diversity questions for Roger Smith') + }) + }) + + itShouldHaveNextValue(new BlackBackground({}, application), 'religion') + itShouldHavePreviousValue(new BlackBackground({}, application), 'ethnic-group') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new BlackBackground({ blackBackground: 'african' }, application) + const optionalExample = 'example' + + expect(page.items(optionalExample)).toEqual([ + { + checked: true, + text: 'African', + value: 'african', + }, + { + checked: false, + text: 'Caribbean', + value: 'caribbean', + }, + { + checked: false, + conditional: { + html: 'example', + }, + text: 'Any other Black, African or Caribbean background', + value: 'other', + }, + { + divider: 'or', + }, + { + checked: false, + text: 'Prefer not to say', + value: 'preferNotToSay', + }, + ]) + }) + }) + + describe('errors', () => { + it('should return errors when the questions are blank', () => { + const page = new BlackBackground({}, application) + + expect(page.errors()).toEqual({ + blackBackground: "Select a background or 'Prefer not to say'", + }) + }) + + it('should not return an error when the optional question is missing', () => { + const page = new BlackBackground({ blackBackground: 'other', optionalBlackBackground: undefined }, application) + + expect(page.errors()).toEqual({}) + }) + }) + + describe('onSave', () => { + it('removes black background data when the question is not set to "other"', () => { + const body: Partial = { + blackBackground: 'preferNotToSay', + optionalBlackBackground: 'Black background', + } + + const page = new BlackBackground(body, application) + + page.onSave() + + expect(page.body).toEqual({ + blackBackground: 'preferNotToSay', + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/blackBackground.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/blackBackground.ts new file mode 100644 index 0000000..c9bf3d9 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/blackBackground.ts @@ -0,0 +1,77 @@ +import type { Radio, TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('') + +export const blackBackgroundOptions = + applicationQuestions['equality-and-diversity-monitoring']['black-background'].blackBackground.answers + +export type BlackBackgroundBody = { + blackBackground: keyof typeof blackBackgroundOptions + optionalBlackBackground: string +} + +@Page({ + name: 'black-background', + bodyProperties: ['blackBackground', 'optionalBlackBackground'], +}) +export default class BlackBackground implements TaskListPage { + documentTitle = + "Which of the following best describes the person's Black, African, Caribbean or Black British background?" + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Equality and diversity questions for ${this.personName}` + + questions = getQuestions(this.personName)['equality-and-diversity-monitoring']['black-background'] + + body: BlackBackgroundBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as BlackBackgroundBody + } + + previous() { + return 'ethnic-group' + } + + next() { + return 'religion' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.blackBackground) { + errors.blackBackground = errorLookups.background.empty + } + return errors + } + + items(optionalBlackBackground: string) { + const items = convertKeyValuePairToRadioItems(blackBackgroundOptions, this.body.blackBackground) as [Radio] + + items.forEach(item => { + if (item.value === 'other') { + item.conditional = { html: optionalBlackBackground } + } + }) + const preferNotToSay = items.pop() + + return [...items, { divider: 'or' }, { ...preferNotToSay }] + } + + onSave(): void { + if (this.body.blackBackground !== 'other') { + delete this.body.optionalBlackBackground + } + } +} diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/careLeaver.test.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/careLeaver.test.ts new file mode 100644 index 0000000..45643db --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/careLeaver.test.ts @@ -0,0 +1,71 @@ +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import CareLeaver from './careLeaver' +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' + +describe('CareLeaver', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new CareLeaver({}, application) + + expect(page.title).toEqual('Equality and diversity questions for Roger Smith') + }) + }) + + itShouldHaveNextValue(new CareLeaver({ isCareLeaver: 'yes' }, application), 'parental-carer-responsibilities') + itShouldHavePreviousValue(new CareLeaver({ isCareLeaver: 'yes' }, application), 'military-veteran') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new CareLeaver( + { + isCareLeaver: 'yes', + }, + application, + ) + + expect(page.items()).toEqual([ + { + value: 'yes', + text: 'Yes', + checked: true, + }, + { + value: 'no', + text: 'No', + checked: false, + }, + { + divider: 'or', + }, + { + value: 'dontKnow', + text: `I don't know`, + checked: false, + }, + ]) + }) + }) + + describe('errors', () => { + it('does not return an error for valid answers', () => { + const page = new CareLeaver( + { + isCareLeaver: 'no', + }, + application, + ) + + expect(page.errors()).toEqual({}) + }) + + it('should return errors when no answer given', () => { + const page = new CareLeaver({}, application) + + expect(page.errors()).toEqual({ + isCareLeaver: `Choose either Yes, No or I don't know`, + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/careLeaver.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/careLeaver.ts new file mode 100644 index 0000000..11a143c --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/careLeaver.ts @@ -0,0 +1,62 @@ +import type { TaskListErrors, YesNoOrDontKnow } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('') + +export const options = applicationQuestions['equality-and-diversity-monitoring']['care-leaver'].isCareLeaver.answers + +export type CareLeaverBody = { + isCareLeaver: YesNoOrDontKnow +} + +@Page({ + name: 'care-leaver', + bodyProperties: ['isCareLeaver'], +}) +export default class CareLeaver implements TaskListPage { + documentTitle = 'Is the person a care leaver?' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Equality and diversity questions for ${this.personName}` + + questions = getQuestions(this.personName)['equality-and-diversity-monitoring']['care-leaver'] + + body: CareLeaverBody + + constructor( + body: Partial, + private readonly application: Application, + ) {} + + previous() { + return 'military-veteran' + } + + next() { + return 'parental-carer-responsibilities' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.isCareLeaver) { + errors.isCareLeaver = errorLookups.isCareLeaver.empty + } + return errors + } + + items() { + const items = convertKeyValuePairToRadioItems(options, this.body.isCareLeaver) + + const dontKnow = items.pop() + items.push({ divider: 'or' }) + + return [...items, { ...dontKnow }] + } +} diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/disability.test.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/disability.test.ts new file mode 100644 index 0000000..3f8f62f --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/disability.test.ts @@ -0,0 +1,209 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import Disability, { DisabilityBody } from './disability' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' + +describe('Disability', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new Disability({ hasDisability: 'yes' }, application) + + expect(page.title).toEqual('Equality and diversity questions for Roger Smith') + }) + }) + + itShouldHaveNextValue(new Disability({ hasDisability: 'yes' }, application), 'sex-and-gender') + itShouldHavePreviousValue(new Disability({ hasDisability: 'yes' }, application), 'will-answer-equality-questions') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new Disability( + { + hasDisability: 'yes', + typeOfDisability: ['physicalImpairment', 'other'], + otherDisability: 'another disability', + }, + application, + ) + + expect(page.items('conditionalHtml')).toEqual([ + { + value: 'yes', + text: 'Yes', + checked: true, + conditional: { + html: 'conditionalHtml', + }, + }, + { + value: 'no', + text: 'No', + checked: false, + }, + { + divider: 'or', + }, + { + value: 'preferNotToSay', + text: 'Prefer not to say', + checked: false, + }, + ]) + }) + }) + + describe('typeOfDisabilityItems', () => { + it('returns the checkboxes with conditional text input', () => { + const page = new Disability( + { + hasDisability: 'yes', + typeOfDisability: ['physicalImpairment', 'other'], + otherDisability: 'another disability', + }, + application, + ) + + expect(page.typeOfDisabilityItems(`some html`)).toEqual([ + { + value: 'sensoryImpairment', + text: 'Sensory impairment', + checked: false, + }, + { + value: 'physicalImpairment', + text: 'Physical impairment', + checked: true, + }, + { + value: 'learningDisability', + text: 'Learning disability or difficulty', + checked: false, + }, + { + value: 'mentalHealth', + text: 'Mental health condition', + checked: false, + }, + { + value: 'illness', + text: 'Long-standing illness', + checked: false, + }, + { + value: 'other', + text: 'Other', + checked: true, + conditional: { html: `some html` }, + }, + ]) + }) + }) + + describe('errors', () => { + const validAnswers = [ + { + hasDisability: 'no', + }, + { + hasDisability: 'preferNotToSay', + }, + { + hasDisability: 'yes', + typeOfDisability: 'sensoryImpairment', + }, + { + hasDisability: 'yes', + typeOfDisability: 'other', + otherDisability: 'something', + }, + ] + it.each(validAnswers)('it does not return an error for valid answers', validAnswer => { + const page = new Disability(validAnswer as DisabilityBody, application) + + expect(page.errors()).toEqual({}) + }) + + it('should return errors when no answer given', () => { + const page = new Disability({}, application) + + expect(page.errors()).toEqual({ + hasDisability: 'Choose either Yes, No or Prefer not to say', + }) + }) + + it('should return errors when hasDisability is entered but the disability is not specified', () => { + const page = new Disability( + { + hasDisability: 'yes', + }, + application, + ) + + expect(page.errors()).toEqual({ + typeOfDisability: 'Choose a disability type', + }) + }) + + it('should return errors when user selects other disability but does not specify', () => { + const page = new Disability( + { + hasDisability: 'yes', + typeOfDisability: ['other'], + }, + application, + ) + + expect(page.errors()).toEqual({ + otherDisability: 'Enter the other disability', + }) + }) + + it('should not return errors when user has selected other disability but then selects no to hasDisability', () => { + const page = new Disability( + { + hasDisability: 'no', + typeOfDisability: ['other'], + }, + application, + ) + + expect(page.errors()).toEqual({}) + }) + }) + + describe('onSave', () => { + it('removes disability data when the question is not set to "yes"', () => { + const body: Partial = { + hasDisability: 'preferNotToSay', + typeOfDisability: ['sensoryImpairment', 'other'], + otherDisability: 'Other disability', + } + + const page = new Disability(body, application) + + page.onSave() + + expect(page.body).toEqual({ + hasDisability: 'preferNotToSay', + }) + }) + + it('removes other disability data when it is not listed as the type of disability', () => { + const body: Partial = { + hasDisability: 'yes', + typeOfDisability: ['sensoryImpairment'], + otherDisability: 'Other disability', + } + + const page = new Disability(body, application) + + page.onSave() + + expect(page.body).toEqual({ + hasDisability: 'yes', + typeOfDisability: ['sensoryImpairment'], + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/disability.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/disability.ts new file mode 100644 index 0000000..74097f7 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/disability.ts @@ -0,0 +1,103 @@ +import type { TaskListErrors, YesOrNoOrPreferNotToSay } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems, convertKeyValuePairToCheckboxItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('') + +export const hasDisabilityOptions = + applicationQuestions['equality-and-diversity-monitoring'].disability.hasDisability.answers + +export const disabilityTypeOptions = + applicationQuestions['equality-and-diversity-monitoring'].disability.typeOfDisability.answers + +export type DisabilityOptions = keyof typeof disabilityTypeOptions + +export type DisabilityBody = { + hasDisability: YesOrNoOrPreferNotToSay + typeOfDisability: Array + otherDisability: string +} + +@Page({ + name: 'disability', + bodyProperties: ['hasDisability', 'typeOfDisability', 'otherDisability'], +}) +export default class Disability implements TaskListPage { + documentTitle = 'Does the person have a disability?' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Equality and diversity questions for ${this.personName}` + + questions = getQuestions(this.personName)['equality-and-diversity-monitoring'].disability + + body: { + hasDisability: YesOrNoOrPreferNotToSay + typeOfDisability: Array + otherDisability: string + } + + constructor( + body: Partial, + private readonly application: Application, + ) {} + + previous() { + return 'will-answer-equality-questions' + } + + next() { + return 'sex-and-gender' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.hasDisability) { + errors.hasDisability = errorLookups.hasDisability.empty + } + if (this.body.hasDisability === 'yes' && !this.body.typeOfDisability) { + errors.typeOfDisability = errorLookups.hasDisability.typeOfDisability + } + if ( + this.body.hasDisability === 'yes' && + this.body.typeOfDisability?.includes('other') && + !this.body.otherDisability + ) { + errors.otherDisability = errorLookups.hasDisability.otherDisability + } + return errors + } + + items(conditionalHtml: string) { + const items = convertKeyValuePairToRadioItems(hasDisabilityOptions, this.body.hasDisability) + + const yes = items.shift() + const preferNotToSay = items.pop() + items.push({ divider: 'or' }) + + return [{ ...yes, conditional: { html: conditionalHtml } }, ...items, { ...preferNotToSay }] + } + + typeOfDisabilityItems(otherDisabilityHtml: string) { + const items = convertKeyValuePairToCheckboxItems(disabilityTypeOptions, this.body.typeOfDisability) + const other = items.pop() + + return [...items, { ...other, conditional: { html: otherDisabilityHtml } }] + } + + onSave(): void { + if (this.body.hasDisability !== 'yes') { + delete this.body.typeOfDisability + delete this.body.otherDisability + } + + if (this.body.hasDisability === 'yes' && !this.body.typeOfDisability?.includes('other')) { + delete this.body.otherDisability + } + } +} diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/ethnicGroup.test.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/ethnicGroup.test.ts new file mode 100644 index 0000000..9b0cbc0 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/ethnicGroup.test.ts @@ -0,0 +1,85 @@ +import { itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import EthnicGroup, { EthnicGroupBody } from './ethnicGroup' + +describe('EthnicGroup', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new EthnicGroup({}, application) + + expect(page.title).toEqual('Equality and diversity questions for Roger Smith') + }) + }) + + describe('next', () => { + const backgroundPages = [ + [undefined, 'religion'], + ['preferNotToSay', 'religion'], + ['white', 'white-background'], + ['mixed', 'mixed-background'], + ['asian', 'asian-background'], + ['black', 'black-background'], + ['other', 'other-background'], + ] + + it.each(backgroundPages)('it returns the right next page based on answer', (answer, pageName) => { + const page = new EthnicGroup({ ethnicGroup: answer } as EthnicGroupBody, application) + expect(page.next()).toEqual(pageName) + }) + }) + + itShouldHavePreviousValue(new EthnicGroup({}, application), 'sexual-orientation') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new EthnicGroup({ ethnicGroup: 'white' }, application) + expect(page.items()).toEqual([ + { + checked: true, + text: 'White', + value: 'white', + }, + { + checked: false, + text: 'Mixed or multiple ethnic groups', + value: 'mixed', + }, + { + checked: false, + text: 'Asian or Asian British', + value: 'asian', + }, + { + checked: false, + text: 'Black, African, Caribbean or Black British', + value: 'black', + }, + { + checked: false, + text: 'Other ethnic group', + value: 'other', + }, + { + divider: 'or', + }, + { + checked: false, + text: 'Prefer not to say', + value: 'preferNotToSay', + }, + ]) + }) + }) + + describe('errors', () => { + it('should return errors when the questions are blank', () => { + const page = new EthnicGroup({}, application) + + expect(page.errors()).toEqual({ + ethnicGroup: "Select an ethnic group or choose 'Prefer not to say'", + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/ethnicGroup.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/ethnicGroup.ts new file mode 100644 index 0000000..51d0fe0 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/ethnicGroup.ts @@ -0,0 +1,77 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('') + +export const ethnicGroupOptions = + applicationQuestions['equality-and-diversity-monitoring']['ethnic-group'].ethnicGroup.answers + +export type EthnicGroupBody = { + ethnicGroup: keyof typeof ethnicGroupOptions +} + +@Page({ + name: 'ethnic-group', + bodyProperties: ['ethnicGroup'], +}) +export default class EthnicGroup implements TaskListPage { + documentTitle = "What is the person's ethnic group?" + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Equality and diversity questions for ${this.personName}` + + questions = getQuestions(this.personName)['equality-and-diversity-monitoring']['ethnic-group'] + + body: EthnicGroupBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as EthnicGroupBody + } + + previous() { + return 'sexual-orientation' + } + + next() { + const ethnicGroupNext = { + white: 'white-background', + mixed: 'mixed-background', + asian: 'asian-background', + black: 'black-background', + other: 'other-background', + } + const ethnicGroupBody = this.body.ethnicGroup as keyof typeof ethnicGroupNext + + if (ethnicGroupNext[ethnicGroupBody]) { + return ethnicGroupNext[ethnicGroupBody] + } + return 'religion' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.ethnicGroup) { + errors.ethnicGroup = errorLookups.ethnicGroup.empty + } + return errors + } + + items() { + const items = convertKeyValuePairToRadioItems(ethnicGroupOptions, this.body.ethnicGroup) + + const preferNotToSay = items.pop() + items.push({ divider: 'or' }) + + return [...items, { ...preferNotToSay }] + } +} diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/index.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/index.ts new file mode 100644 index 0000000..fa8b9f1 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/index.ts @@ -0,0 +1,41 @@ +/* istanbul ignore file */ + +import { Task } from '../../../utils/decorators' +import WillAnswer from './willAnswer' +import Disability from './disability' +import SexAndGender from './sexAndGender' +import SexualOrientation from './sexualOrientation' +import EthnicGroup from './ethnicGroup' +import WhiteBackground from './whiteBackground' +import MixedBackground from './mixedBackground' +import AsianBackground from './asianBackground' +import BlackBackground from './blackBackground' +import OtherBackground from './otherBackground' +import Religion from './religion' +import MilitaryVeteran from './militaryVeteran' +import CareLeaver from './careLeaver' +import CarerResponsibilities from './parentalCarerResponsibilities' +import MaritalStatus from './maritalStatus' + +@Task({ + name: 'Add equality and diversity monitoring information', + slug: 'equality-and-diversity-monitoring', + pages: [ + WillAnswer, + Disability, + SexAndGender, + SexualOrientation, + EthnicGroup, + WhiteBackground, + MixedBackground, + AsianBackground, + BlackBackground, + OtherBackground, + Religion, + MilitaryVeteran, + CareLeaver, + CarerResponsibilities, + MaritalStatus, + ], +}) +export default class EqualityAndDiversityMonitoring {} diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/maritalStatus.test.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/maritalStatus.test.ts new file mode 100644 index 0000000..7a72602 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/maritalStatus.test.ts @@ -0,0 +1,87 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import MaritalStatus from './maritalStatus' + +describe('Marital status', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new MaritalStatus({}, application) + + expect(page.title).toEqual('Equality and diversity questions for Roger Smith') + }) + }) + + itShouldHaveNextValue(new MaritalStatus({}, application), '') + itShouldHavePreviousValue(new MaritalStatus({}, application), 'parental-carer-responsibilities') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new MaritalStatus({ maritalStatus: 'survivingPartnerFromCivilPartnership' }, application) + expect(page.items()).toEqual([ + { + checked: false, + text: 'Never married and never registered in a civil partnership', + value: 'neverMarried', + }, + { + checked: false, + text: 'Married', + value: 'married', + }, + { + checked: false, + text: 'In a registered civil partnership', + value: 'inCivilPartnership', + }, + { + checked: false, + text: 'Separated, but still legally married', + value: 'marriedButSeparated', + }, + { + checked: false, + text: 'Separated, but still legally in a civil partnership', + value: 'inCivilPartnershipButSeparated', + }, + { + checked: false, + text: 'Divorced', + value: 'divorced', + }, + { + checked: false, + text: 'Formerly in a civil partnership which is now legally dissolved', + value: 'formerlyInCivilPartnershipNowDissolved', + }, + { + checked: false, + text: 'Widowed', + value: 'widowed', + }, + { + checked: true, + text: 'Surviving partner from a registered civil partnership', + value: 'survivingPartnerFromCivilPartnership', + }, + { divider: 'or' }, + { + value: 'preferNotToSay', + text: 'Prefer not to say', + checked: false, + }, + ]) + }) + }) + + describe('errors', () => { + it('should return errors when the questions are blank', () => { + const page = new MaritalStatus({}, application) + + expect(page.errors()).toEqual({ + maritalStatus: "Select a marital status or 'Prefer not to say'", + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/maritalStatus.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/maritalStatus.ts new file mode 100644 index 0000000..fadc0d8 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/maritalStatus.ts @@ -0,0 +1,62 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('') + +export const options = applicationQuestions['equality-and-diversity-monitoring']['marital-status'].maritalStatus.answers + +export type MaritalStatusBody = { + maritalStatus: keyof typeof options +} + +@Page({ + name: 'marital-status', + bodyProperties: ['maritalStatus'], +}) +export default class MaritalStatus implements TaskListPage { + documentTitle = "What is the person's legal marital or registered civil partnership status?" + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Equality and diversity questions for ${this.personName}` + + questions = getQuestions(this.personName)['equality-and-diversity-monitoring']['marital-status'] + + body: MaritalStatusBody + + constructor( + body: Partial, + private readonly application: Application, + ) {} + + previous() { + return 'parental-carer-responsibilities' + } + + next() { + return '' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.maritalStatus) { + errors.maritalStatus = errorLookups.maritalStatus.empty + } + return errors + } + + items() { + const items = convertKeyValuePairToRadioItems(options, this.body.maritalStatus) + + const preferNotToSay = items.pop() + items.push({ divider: 'or' }) + + return [...items, { ...preferNotToSay }] + } +} diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/militaryVeteran.test.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/militaryVeteran.test.ts new file mode 100644 index 0000000..6c0c09d --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/militaryVeteran.test.ts @@ -0,0 +1,71 @@ +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import MilitaryVeteran from './militaryVeteran' +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' + +describe('MilitaryVeteran', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new MilitaryVeteran({}, application) + + expect(page.title).toEqual('Equality and diversity questions for Roger Smith') + }) + }) + + itShouldHaveNextValue(new MilitaryVeteran({ isVeteran: 'yes' }, application), 'care-leaver') + itShouldHavePreviousValue(new MilitaryVeteran({ isVeteran: 'yes' }, application), 'religion') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new MilitaryVeteran( + { + isVeteran: 'yes', + }, + application, + ) + + expect(page.items()).toEqual([ + { + value: 'yes', + text: 'Yes', + checked: true, + }, + { + value: 'no', + text: 'No', + checked: false, + }, + { + divider: 'or', + }, + { + value: 'dontKnow', + text: `I don't know`, + checked: false, + }, + ]) + }) + }) + + describe('errors', () => { + it('does not return an error for valid answers', () => { + const page = new MilitaryVeteran( + { + isVeteran: 'no', + }, + application, + ) + + expect(page.errors()).toEqual({}) + }) + + it('should return errors when no answer given', () => { + const page = new MilitaryVeteran({}, application) + + expect(page.errors()).toEqual({ + isVeteran: `Choose either Yes, No or I don't know`, + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/militaryVeteran.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/militaryVeteran.ts new file mode 100644 index 0000000..b439091 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/militaryVeteran.ts @@ -0,0 +1,62 @@ +import type { TaskListErrors, YesNoOrDontKnow } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('') + +export const options = applicationQuestions['equality-and-diversity-monitoring']['military-veteran'].isVeteran.answers + +export type MilitaryVeteranBody = { + isVeteran: YesNoOrDontKnow +} + +@Page({ + name: 'military-veteran', + bodyProperties: ['isVeteran'], +}) +export default class MilitaryVeteran implements TaskListPage { + documentTitle = 'Is the person a military veteran?' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Equality and diversity questions for ${this.personName}` + + questions = getQuestions(this.personName)['equality-and-diversity-monitoring']['military-veteran'] + + body: MilitaryVeteranBody + + constructor( + body: Partial, + private readonly application: Application, + ) {} + + previous() { + return 'religion' + } + + next() { + return 'care-leaver' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.isVeteran) { + errors.isVeteran = errorLookups.isVeteran.empty + } + return errors + } + + items() { + const items = convertKeyValuePairToRadioItems(options, this.body.isVeteran) + + const dontKnow = items.pop() + items.push({ divider: 'or' }) + + return [...items, { ...dontKnow }] + } +} diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/mixedBackground.test.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/mixedBackground.test.ts new file mode 100644 index 0000000..0ad91ed --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/mixedBackground.test.ts @@ -0,0 +1,92 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import MixedBackground, { MixedBackgroundBody } from './mixedBackground' + +describe('MixedBackground', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new MixedBackground({}, application) + + expect(page.title).toEqual('Equality and diversity questions for Roger Smith') + }) + }) + + itShouldHaveNextValue(new MixedBackground({}, application), 'religion') + itShouldHavePreviousValue(new MixedBackground({}, application), 'ethnic-group') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new MixedBackground({ mixedBackground: 'whiteAndBlackAfrican' }, application) + const optionalExample = 'example' + + expect(page.items(optionalExample)).toEqual([ + { + checked: false, + text: 'White and Black Caribbean', + value: 'whiteAndBlackCaribbean', + }, + { + checked: true, + text: 'White and Black African', + value: 'whiteAndBlackAfrican', + }, + { + checked: false, + text: 'White and Asian', + value: 'whiteAndAsian', + }, + { + checked: false, + conditional: { + html: 'example', + }, + text: 'Any other mixed or multiple ethnic background', + value: 'other', + }, + { + divider: 'or', + }, + { + checked: false, + text: 'Prefer not to say', + value: 'preferNotToSay', + }, + ]) + }) + }) + + describe('errors', () => { + it('should return errors when the questions are blank', () => { + const page = new MixedBackground({}, application) + + expect(page.errors()).toEqual({ + mixedBackground: "Select a background or 'Prefer not to say'", + }) + }) + + it('should not return an error when the optional question is missing', () => { + const page = new MixedBackground({ mixedBackground: 'other', optionalMixedBackground: undefined }, application) + + expect(page.errors()).toEqual({}) + }) + }) + + describe('onSave', () => { + it('removes mixed background data when the question is not set to "other"', () => { + const body: Partial = { + mixedBackground: 'preferNotToSay', + optionalMixedBackground: 'Mixed background', + } + + const page = new MixedBackground(body, application) + + page.onSave() + + expect(page.body).toEqual({ + mixedBackground: 'preferNotToSay', + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/mixedBackground.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/mixedBackground.ts new file mode 100644 index 0000000..d8a9a77 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/mixedBackground.ts @@ -0,0 +1,76 @@ +import type { Radio, TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('') + +export const mixedBackgroundOptions = + applicationQuestions['equality-and-diversity-monitoring']['mixed-background'].mixedBackground.answers + +export type MixedBackgroundBody = { + mixedBackground: keyof typeof mixedBackgroundOptions + optionalMixedBackground: string +} + +@Page({ + name: 'mixed-background', + bodyProperties: ['mixedBackground', 'optionalMixedBackground'], +}) +export default class MixedBackground implements TaskListPage { + documentTitle = "Which of the following best describes the person's mixed or multiple ethnic groups background?" + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Equality and diversity questions for ${this.personName}` + + questions = getQuestions(this.personName)['equality-and-diversity-monitoring']['mixed-background'] + + body: MixedBackgroundBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as MixedBackgroundBody + } + + previous() { + return 'ethnic-group' + } + + next() { + return 'religion' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.mixedBackground) { + errors.mixedBackground = errorLookups.background.empty + } + return errors + } + + items(optionalMixedBackground: string) { + const items = convertKeyValuePairToRadioItems(mixedBackgroundOptions, this.body.mixedBackground) as [Radio] + + items.forEach(item => { + if (item.value === 'other') { + item.conditional = { html: optionalMixedBackground } + } + }) + const preferNotToSay = items.pop() + + return [...items, { divider: 'or' }, { ...preferNotToSay }] + } + + onSave(): void { + if (this.body.mixedBackground !== 'other') { + delete this.body.optionalMixedBackground + } + } +} diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/otherBackground.test.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/otherBackground.test.ts new file mode 100644 index 0000000..3dff059 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/otherBackground.test.ts @@ -0,0 +1,82 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import OtherBackground, { OtherBackgroundBody } from './otherBackground' + +describe('OtherBackground', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new OtherBackground({}, application) + + expect(page.title).toEqual('Equality and diversity questions for Roger Smith') + }) + }) + + itShouldHaveNextValue(new OtherBackground({}, application), 'religion') + itShouldHavePreviousValue(new OtherBackground({}, application), 'ethnic-group') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new OtherBackground({ otherBackground: 'arab' }, application) + const optionalExample = 'example' + + expect(page.items(optionalExample)).toEqual([ + { + checked: true, + text: 'Arab', + value: 'arab', + }, + { + checked: false, + conditional: { + html: 'example', + }, + text: 'Any other ethnic group', + value: 'other', + }, + { + divider: 'or', + }, + { + checked: false, + text: 'Prefer not to say', + value: 'preferNotToSay', + }, + ]) + }) + }) + + describe('errors', () => { + it('should return errors when the questions are blank', () => { + const page = new OtherBackground({}, application) + + expect(page.errors()).toEqual({ + otherBackground: "Select a background or 'Prefer not to say'", + }) + }) + + it('should not return an error when the optional question is missing', () => { + const page = new OtherBackground({ otherBackground: 'other', optionalOtherBackground: undefined }, application) + + expect(page.errors()).toEqual({}) + }) + }) + + describe('onSave', () => { + it('removes background data if question is not set to "other"', () => { + const body: Partial = { + otherBackground: 'preferNotToSay', + optionalOtherBackground: 'Other background', + } + + const page = new OtherBackground(body, application) + + page.onSave() + + expect(page.body).toEqual({ + otherBackground: 'preferNotToSay', + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/otherBackground.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/otherBackground.ts new file mode 100644 index 0000000..e0b4ee2 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/otherBackground.ts @@ -0,0 +1,76 @@ +import type { Radio, TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('') + +export const otherBackgroundOptions = + applicationQuestions['equality-and-diversity-monitoring']['other-background'].otherBackground.answers + +export type OtherBackgroundBody = { + otherBackground: keyof typeof otherBackgroundOptions + optionalOtherBackground: string +} + +@Page({ + name: 'other-background', + bodyProperties: ['otherBackground', 'optionalOtherBackground'], +}) +export default class OtherBackground implements TaskListPage { + documentTitle = "Which of the following best describes the person's background?" + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Equality and diversity questions for ${this.personName}` + + questions = getQuestions(this.personName)['equality-and-diversity-monitoring']['other-background'] + + body: OtherBackgroundBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as OtherBackgroundBody + } + + previous() { + return 'ethnic-group' + } + + next() { + return 'religion' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.otherBackground) { + errors.otherBackground = errorLookups.background.empty + } + return errors + } + + items(optionalOtherBackground: string) { + const items = convertKeyValuePairToRadioItems(otherBackgroundOptions, this.body.otherBackground) as [Radio] + + items.forEach(item => { + if (item.value === 'other') { + item.conditional = { html: optionalOtherBackground } + } + }) + const preferNotToSay = items.pop() + + return [...items, { divider: 'or' }, { ...preferNotToSay }] + } + + onSave(): void { + if (this.body.otherBackground !== 'other') { + delete this.body.optionalOtherBackground + } + } +} diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/parentalCarerResponsibilities.test.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/parentalCarerResponsibilities.test.ts new file mode 100644 index 0000000..4e1ac57 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/parentalCarerResponsibilities.test.ts @@ -0,0 +1,77 @@ +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import ParentalCarerResponsibilities from './parentalCarerResponsibilities' +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' + +describe('CarerResponsibilities', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new ParentalCarerResponsibilities({}, application) + + expect(page.title).toEqual('Equality and diversity questions for Roger Smith') + }) + }) + + itShouldHaveNextValue( + new ParentalCarerResponsibilities({ hasParentalOrCarerResponsibilities: 'yes' }, application), + 'marital-status', + ) + itShouldHavePreviousValue( + new ParentalCarerResponsibilities({ hasParentalOrCarerResponsibilities: 'yes' }, application), + 'care-leaver', + ) + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new ParentalCarerResponsibilities( + { + hasParentalOrCarerResponsibilities: 'yes', + }, + application, + ) + + expect(page.items()).toEqual([ + { + value: 'yes', + text: 'Yes', + checked: true, + }, + { + value: 'no', + text: 'No', + checked: false, + }, + { + divider: 'or', + }, + { + value: 'dontKnow', + text: `I don't know`, + checked: false, + }, + ]) + }) + }) + + describe('errors', () => { + it('does not return an error for valid answers', () => { + const page = new ParentalCarerResponsibilities( + { + hasParentalOrCarerResponsibilities: 'no', + }, + application, + ) + + expect(page.errors()).toEqual({}) + }) + + it('should return errors when no answer given', () => { + const page = new ParentalCarerResponsibilities({}, application) + + expect(page.errors()).toEqual({ + hasParentalOrCarerResponsibilities: `Choose either Yes, No or I don't know`, + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/parentalCarerResponsibilities.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/parentalCarerResponsibilities.ts new file mode 100644 index 0000000..7396b0a --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/parentalCarerResponsibilities.ts @@ -0,0 +1,64 @@ +import type { TaskListErrors, YesNoOrDontKnow } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('') + +export const options = + applicationQuestions['equality-and-diversity-monitoring']['parental-carer-responsibilities'] + .hasParentalOrCarerResponsibilities.answers + +export type ParentalCarerResponsibilitiesBody = { + hasParentalOrCarerResponsibilities: YesNoOrDontKnow +} + +@Page({ + name: 'parental-carer-responsibilities', + bodyProperties: ['hasParentalOrCarerResponsibilities'], +}) +export default class ParentalCarerResponsibilities implements TaskListPage { + documentTitle = 'Does the person have parental or carer responsibilities?' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Equality and diversity questions for ${this.personName}` + + questions = getQuestions(this.personName)['equality-and-diversity-monitoring']['parental-carer-responsibilities'] + + body: ParentalCarerResponsibilitiesBody + + constructor( + body: Partial, + private readonly application: Application, + ) {} + + previous() { + return 'care-leaver' + } + + next() { + return 'marital-status' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.hasParentalOrCarerResponsibilities) { + errors.hasParentalOrCarerResponsibilities = errorLookups.hasParentalOrCarerResponsibilities.empty + } + return errors + } + + items() { + const items = convertKeyValuePairToRadioItems(options, this.body.hasParentalOrCarerResponsibilities) + + const dontKnow = items.pop() + items.push({ divider: 'or' }) + + return [...items, { ...dontKnow }] + } +} diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/religion.test.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/religion.test.ts new file mode 100644 index 0000000..271f842 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/religion.test.ts @@ -0,0 +1,145 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import Religion, { ReligionBody } from './religion' + +describe('Religion', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new Religion({}, application) + + expect(page.title).toEqual('Equality and diversity questions for Roger Smith') + }) + }) + + itShouldHaveNextValue(new Religion({}, application), 'military-veteran') + itShouldHavePreviousValue( + new Religion( + {}, + { ...application, data: { 'equality-and-diversity-monitoring': { 'ethnic-group': { ethnicGroup: 'white' } } } }, + ), + 'white-background', + ) + itShouldHavePreviousValue( + new Religion( + {}, + { ...application, data: { 'equality-and-diversity-monitoring': { 'ethnic-group': { ethnicGroup: 'asian' } } } }, + ), + 'asian-background', + ) + itShouldHavePreviousValue( + new Religion( + {}, + { + ...application, + data: { 'equality-and-diversity-monitoring': { 'ethnic-group': { ethnicGroup: 'preferNotToSay' } } }, + }, + ), + 'ethnic-group', + ) + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new Religion({ religion: 'agnostic' }, application) + const conditional = 'example' + expect(page.items(conditional)).toEqual([ + { + checked: false, + text: 'No religion', + value: 'noReligion', + }, + { + checked: false, + text: 'Atheist or Humanist', + value: 'atheist', + }, + { + checked: true, + text: 'Agnostic', + value: 'agnostic', + }, + { + checked: false, + text: 'Christian', + value: 'christian', + hint: { + text: 'Including Church of England, Catholic, Protestant and all other Christian denominations.', + }, + }, + { + checked: false, + text: 'Buddhist', + value: 'buddhist', + }, + { + checked: false, + text: 'Hindu', + value: 'hindu', + }, + { + checked: false, + text: 'Jewish', + value: 'jewish', + }, + { + checked: false, + text: 'Muslim', + value: 'muslim', + }, + { + checked: false, + text: 'Sikh', + value: 'sikh', + }, + { + checked: false, + conditional: { + html: 'example', + }, + text: 'Any other religion', + value: 'other', + }, + { divider: 'or' }, + { + value: 'preferNotToSay', + text: 'Prefer not to say', + checked: false, + }, + ]) + }) + }) + + describe('errors', () => { + it('should return errors when the questions are blank', () => { + const page = new Religion({}, application) + + expect(page.errors()).toEqual({ + religion: "Select a religion or 'Prefer not to say'", + }) + }) + + it('should not return an error when the optional question is missing', () => { + const page = new Religion({ religion: 'other' }, application) + + expect(page.errors()).toEqual({}) + }) + }) + + describe('onSave', () => { + it('removes religion data when the question is not set to "other"', () => { + const body: Partial = { + religion: 'preferNotToSay', + otherReligion: 'Other religion', + } + + const page = new Religion(body, application) + + page.onSave() + + expect(page.body).toEqual({ + religion: 'preferNotToSay', + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/religion.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/religion.ts new file mode 100644 index 0000000..0270d18 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/religion.ts @@ -0,0 +1,91 @@ +import type { Radio, TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('') + +export const religionOptions = applicationQuestions['equality-and-diversity-monitoring'].religion.religion.answers + +export type ReligionBody = { + religion: keyof typeof religionOptions + otherReligion: string +} + +@Page({ + name: 'religion', + bodyProperties: ['religion', 'otherReligion'], +}) +export default class Religion implements TaskListPage { + documentTitle = "What is the person's religion?" + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Equality and diversity questions for ${this.personName}` + + questions = getQuestions(this.personName)['equality-and-diversity-monitoring'].religion + + body: ReligionBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as ReligionBody + } + + previous() { + switch (this.application.data?.['equality-and-diversity-monitoring']?.['ethnic-group']?.ethnicGroup) { + case 'white': + return 'white-background' + case 'mixed': + return 'mixed-background' + case 'asian': + return 'asian-background' + case 'black': + return 'black-background' + case 'other': + return 'other-background' + default: + return 'ethnic-group' + } + } + + next() { + return 'military-veteran' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.religion) { + errors.religion = errorLookups.religion.empty + } + return errors + } + + items(otherReligionHtml: string) { + const items = convertKeyValuePairToRadioItems(religionOptions, this.body.religion) as [Radio] + + items.forEach(item => { + if (item.value === 'other') { + item.conditional = { html: otherReligionHtml } + } + if (item.value === 'christian') { + item.hint = { text: 'Including Church of England, Catholic, Protestant and all other Christian denominations.' } + } + }) + const preferNotToSay = items.pop() + + return [...items, { divider: 'or' }, { ...preferNotToSay }] + } + + onSave(): void { + if (this.body.religion !== 'other') { + delete this.body.otherReligion + } + } +} diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/sexAndGender.test.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/sexAndGender.test.ts new file mode 100644 index 0000000..af16673 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/sexAndGender.test.ts @@ -0,0 +1,104 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import SexAndGender, { SexAndGenderBody } from './sexAndGender' + +describe('SexAndGender', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new SexAndGender({ sex: 'female' }, application) + + expect(page.title).toEqual('Equality and diversity questions for Roger Smith') + }) + }) + + itShouldHaveNextValue(new SexAndGender({ sex: 'female' }, application), 'sexual-orientation') + itShouldHavePreviousValue(new SexAndGender({}, application), 'disability') + + describe('sexItems', () => { + it('returns the radio with the expected label text', () => { + const page = new SexAndGender({ sex: 'female' }, application) + + expect(page.sexItems()).toEqual([ + { + value: 'female', + text: 'Female', + checked: true, + }, + { + value: 'male', + text: 'Male', + checked: false, + }, + { + value: 'preferNotToSay', + text: 'Prefer not to say', + checked: false, + }, + ]) + }) + }) + + describe('genderItems', () => { + it('returns the radio with the expected label text', () => { + const page = new SexAndGender({ gender: 'yes' }, application) + const optionalGenderIdentity = 'example' + + expect(page.genderItems(optionalGenderIdentity)).toEqual([ + { + value: 'yes', + text: 'Yes', + checked: true, + }, + { + value: 'no', + text: 'No', + checked: false, + conditional: { + html: 'example', + }, + }, + { + value: 'preferNotToSay', + text: 'Prefer not to say', + checked: false, + }, + ]) + }) + }) + + describe('errors', () => { + it('should return errors when the questions are blank', () => { + const page = new SexAndGender({}, application) + + expect(page.errors()).toEqual({ + sex: 'Choose either Female, Male or Prefer not to say', + gender: 'Choose either Yes, No or Prefer not to say', + }) + }) + + it('should not return an error when the optional question is missing', () => { + const page = new SexAndGender({ sex: 'female', gender: 'no' }, application) + + expect(page.errors()).toEqual({}) + }) + }) + + describe('onSave', () => { + it('removes gender identity data when the question is not set to "no"', () => { + const body: Partial = { + gender: 'preferNotToSay', + optionalGenderIdentity: 'Gender identity', + } + + const page = new SexAndGender(body, application) + + page.onSave() + + expect(page.body).toEqual({ + gender: 'preferNotToSay', + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/sexAndGender.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/sexAndGender.ts new file mode 100644 index 0000000..65b8510 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/sexAndGender.ts @@ -0,0 +1,84 @@ +import type { Radio, TaskListErrors, YesOrNoOrPreferNotToSay } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('') + +export const sexOptions = applicationQuestions['equality-and-diversity-monitoring']['sex-and-gender'].sex.answers + +const genderOptions = applicationQuestions['equality-and-diversity-monitoring']['sex-and-gender'].gender.answers + +export type SexAndGenderBody = { + sex: keyof typeof sexOptions + gender: YesOrNoOrPreferNotToSay + optionalGenderIdentity: string +} + +@Page({ + name: 'sex-and-gender', + bodyProperties: ['sex', 'gender', 'optionalGenderIdentity'], +}) +export default class SexAndGender implements TaskListPage { + documentTitle = "What is the person's sex?" + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Equality and diversity questions for ${this.personName}` + + heading = 'Sex and gender identity' + + questions = getQuestions(this.personName)['equality-and-diversity-monitoring']['sex-and-gender'] + + body: SexAndGenderBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as SexAndGenderBody + } + + previous() { + return 'disability' + } + + next() { + return 'sexual-orientation' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.sex) { + errors.sex = errorLookups.sexAndGender.sex + } + if (!this.body.gender) { + errors.gender = errorLookups.sexAndGender.gender + } + return errors + } + + sexItems() { + return convertKeyValuePairToRadioItems(sexOptions, this.body.sex) + } + + genderItems(optionalGenderIdentity: string) { + const radioItems = convertKeyValuePairToRadioItems(genderOptions, this.body.gender) as [Radio] + radioItems.forEach(item => { + if (item.value === 'no') { + item.conditional = { html: optionalGenderIdentity } + } + }) + return radioItems + } + + onSave(): void { + if (this.body.gender !== 'no') { + delete this.body.optionalGenderIdentity + } + } +} diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/sexualOrientation.test.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/sexualOrientation.test.ts new file mode 100644 index 0000000..b6898e6 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/sexualOrientation.test.ts @@ -0,0 +1,93 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import SexualOrientation, { SexualOrientationBody } from './sexualOrientation' + +describe('SexualOrientation', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new SexualOrientation({}, application) + + expect(page.title).toEqual('Equality and diversity questions for Roger Smith') + }) + }) + + itShouldHaveNextValue(new SexualOrientation({}, application), 'ethnic-group') + itShouldHavePreviousValue(new SexualOrientation({}, application), 'sex-and-gender') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new SexualOrientation({ orientation: 'gay' }, application) + const conditional = 'example' + expect(page.items(conditional)).toEqual([ + { + checked: false, + text: 'Heterosexual or straight', + value: 'heterosexual', + }, + { + text: 'Gay', + value: 'gay', + checked: true, + }, + { + text: 'Lesbian', + value: 'lesbian', + checked: false, + }, + { + checked: false, + text: 'Bisexual', + value: 'bisexual', + }, + { + checked: false, + conditional: { + html: 'example', + }, + text: 'Other', + value: 'other', + }, + { + value: 'preferNotToSay', + text: 'Prefer not to say', + checked: false, + }, + ]) + }) + }) + + describe('errors', () => { + it('should return errors when the questions are blank', () => { + const page = new SexualOrientation({}, application) + + expect(page.errors()).toEqual({ + orientation: "Select an orientation or choose 'Prefer not to say'", + }) + }) + + it('should not return an error when the optional question is missing', () => { + const page = new SexualOrientation({ orientation: 'other' }, application) + + expect(page.errors()).toEqual({}) + }) + }) + + describe('onSave', () => { + it('removes sexual orientation data if question is not set to "other"', () => { + const body: SexualOrientationBody = { + orientation: 'preferNotToSay', + otherOrientation: 'Orientation', + } + + const page = new SexualOrientation(body, application) + + page.onSave() + + expect(page.body).toEqual({ + orientation: 'preferNotToSay', + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/sexualOrientation.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/sexualOrientation.ts new file mode 100644 index 0000000..71ca3d8 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/sexualOrientation.ts @@ -0,0 +1,74 @@ +import type { Radio, TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('') + +export const orientationOptions = + applicationQuestions['equality-and-diversity-monitoring']['sexual-orientation'].orientation.answers + +export type SexualOrientationBody = { + orientation: keyof typeof orientationOptions + otherOrientation: string +} + +@Page({ + name: 'sexual-orientation', + bodyProperties: ['orientation', 'otherOrientation'], +}) +export default class SexualOrientation implements TaskListPage { + documentTitle = "Which of the following best describes the person's sexual orientation?" + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Equality and diversity questions for ${this.personName}` + + questions = getQuestions(this.personName)['equality-and-diversity-monitoring']['sexual-orientation'] + + body: SexualOrientationBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as SexualOrientationBody + } + + previous() { + return 'sex-and-gender' + } + + next() { + return 'ethnic-group' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.orientation) { + errors.orientation = errorLookups.sexualOrientation.orientation + } + return errors + } + + items(otherOrientationHtml: string) { + const items = convertKeyValuePairToRadioItems(orientationOptions, this.body.orientation) as [Radio] + items.forEach(item => { + if (item.value === 'other') { + item.conditional = { html: otherOrientationHtml } + } + }) + + return items + } + + onSave(): void { + if (this.body.orientation !== 'other') { + delete this.body.otherOrientation + } + } +} diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/whiteBackground.test.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/whiteBackground.test.ts new file mode 100644 index 0000000..2be4f55 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/whiteBackground.test.ts @@ -0,0 +1,92 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import WhiteBackground, { WhiteBackgroundBody } from './whiteBackground' + +describe('WhiteBackground', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new WhiteBackground({}, application) + + expect(page.title).toEqual('Equality and diversity questions for Roger Smith') + }) + }) + + itShouldHaveNextValue(new WhiteBackground({}, application), 'religion') + itShouldHavePreviousValue(new WhiteBackground({}, application), 'ethnic-group') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new WhiteBackground({ whiteBackground: 'english' }, application) + const optionalExample = 'example' + + expect(page.items(optionalExample)).toEqual([ + { + checked: true, + text: 'English, Welsh, Scottish, Northern Irish or British', + value: 'english', + }, + { + checked: false, + text: 'Irish', + value: 'irish', + }, + { + checked: false, + text: 'Gypsy or Irish Traveller', + value: 'gypsy', + }, + { + checked: false, + conditional: { + html: 'example', + }, + text: 'Any other White background', + value: 'other', + }, + { + divider: 'or', + }, + { + checked: false, + text: 'Prefer not to say', + value: 'preferNotToSay', + }, + ]) + }) + }) + + describe('errors', () => { + it('should return errors when the questions are blank', () => { + const page = new WhiteBackground({}, application) + + expect(page.errors()).toEqual({ + whiteBackground: "Select a background or 'Prefer not to say'", + }) + }) + + it('should not return an error when the optional question is missing', () => { + const page = new WhiteBackground({ whiteBackground: 'other', optionalWhiteBackground: undefined }, application) + + expect(page.errors()).toEqual({}) + }) + }) + + describe('onSave', () => { + it('removes white background data when the question is not set to "other"', () => { + const body: Partial = { + whiteBackground: 'preferNotToSay', + optionalWhiteBackground: 'White background', + } + + const page = new WhiteBackground(body, application) + + page.onSave() + + expect(page.body).toEqual({ + whiteBackground: 'preferNotToSay', + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/whiteBackground.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/whiteBackground.ts new file mode 100644 index 0000000..c735790 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/whiteBackground.ts @@ -0,0 +1,76 @@ +import type { Radio, TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('') + +export const whiteBackgroundOptions = + applicationQuestions['equality-and-diversity-monitoring']['white-background'].whiteBackground.answers + +export type WhiteBackgroundBody = { + whiteBackground: keyof typeof whiteBackgroundOptions + optionalWhiteBackground: string +} + +@Page({ + name: 'white-background', + bodyProperties: ['whiteBackground', 'optionalWhiteBackground'], +}) +export default class WhiteBackground implements TaskListPage { + documentTitle = "Which of the following best describes the person's White background?" + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Equality and diversity questions for ${this.personName}` + + questions = getQuestions(this.personName)['equality-and-diversity-monitoring']['white-background'] + + body: WhiteBackgroundBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as WhiteBackgroundBody + } + + previous() { + return 'ethnic-group' + } + + next() { + return 'religion' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.whiteBackground) { + errors.whiteBackground = errorLookups.background.empty + } + return errors + } + + items(optionalWhiteBackground: string) { + const items = convertKeyValuePairToRadioItems(whiteBackgroundOptions, this.body.whiteBackground) as [Radio] + + items.forEach(item => { + if (item.value === 'other') { + item.conditional = { html: optionalWhiteBackground } + } + }) + const preferNotToSay = items.pop() + + return [...items, { divider: 'or' }, { ...preferNotToSay }] + } + + onSave(): void { + if (this.body.whiteBackground !== 'other') { + delete this.body.optionalWhiteBackground + } + } +} diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/willAnswer.test.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/willAnswer.test.ts new file mode 100644 index 0000000..c36e16e --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/willAnswer.test.ts @@ -0,0 +1,58 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import EqualityAndDiversity from './willAnswer' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' + +describe('EqualityAndDiversity', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('question', () => { + it('personalises the question', () => { + const page = new EqualityAndDiversity({ willAnswer: 'yes' }, application) + + expect(page.questions).toEqual({ + willAnswer: 'Equality questions for Roger Smith', + }) + }) + }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new EqualityAndDiversity({ willAnswer: 'yes' }, application) + + expect(page.title).toEqual('Equality questions for Roger Smith') + }) + }) + + itShouldHaveNextValue(new EqualityAndDiversity({ willAnswer: 'no' }, application), '') + itShouldHaveNextValue(new EqualityAndDiversity({ willAnswer: 'yes' }, application), 'disability') + itShouldHavePreviousValue(new EqualityAndDiversity({ willAnswer: 'yes' }, application), 'taskList') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new EqualityAndDiversity({ willAnswer: 'yes' }, application) + + expect(page.items()).toEqual([ + { + value: 'yes', + text: 'Yes, answer the equality questions (takes 2 minutes)', + checked: true, + }, + { + value: 'no', + text: 'No, skip the equality questions', + checked: false, + }, + ]) + }) + }) + + describe('errors', () => { + it('should return errors when yes/no questions are blank', () => { + const page = new EqualityAndDiversity({}, application) + + expect(page.errors()).toEqual({ + willAnswer: 'Choose either Yes or No', + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/equality-diversity-monitoring/willAnswer.ts b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/willAnswer.ts new file mode 100644 index 0000000..5f84481 --- /dev/null +++ b/server/form-pages/apply/about-the-person/equality-diversity-monitoring/willAnswer.ts @@ -0,0 +1,66 @@ +import type { TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +type EqualityAndDiversityBody = { + willAnswer: YesOrNo +} + +@Page({ + name: 'will-answer-equality-questions', + bodyProperties: ['willAnswer'], +}) +export default class EqualityAndDiversity implements TaskListPage { + documentTitle = 'Equality questions' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Equality questions for ${this.personName}` + + questions: Record + + body: EqualityAndDiversityBody + + options: Record + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as EqualityAndDiversityBody + const applicationQuestions = getQuestions(this.personName) + this.questions = { + willAnswer: + applicationQuestions['equality-and-diversity-monitoring']['will-answer-equality-questions'].willAnswer.question, + } + this.options = + applicationQuestions['equality-and-diversity-monitoring']['will-answer-equality-questions'].willAnswer.answers + } + + previous() { + return 'taskList' + } + + next() { + if (this.body.willAnswer === 'no') { + return '' + } + return 'disability' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.willAnswer) { + errors.willAnswer = 'Choose either Yes or No' + } + return errors + } + + items() { + return convertKeyValuePairToRadioItems(this.options, this.body.willAnswer) + } +} diff --git a/server/form-pages/apply/about-the-person/index.ts b/server/form-pages/apply/about-the-person/index.ts new file mode 100644 index 0000000..fc7b9d3 --- /dev/null +++ b/server/form-pages/apply/about-the-person/index.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ + +import { Section } from '../../utils/decorators' +import PersonalInformation from './personal-information' +import AddressHistory from './address-history' +import EqualityAndDiversityMonitoring from './equality-diversity-monitoring' + +@Section({ + title: 'About the applicant', + tasks: [PersonalInformation, AddressHistory, EqualityAndDiversityMonitoring], +}) +export default class AboutThePerson {} diff --git a/server/form-pages/apply/about-the-person/personal-information/immigrationStatus.test.ts b/server/form-pages/apply/about-the-person/personal-information/immigrationStatus.test.ts new file mode 100644 index 0000000..5741164 --- /dev/null +++ b/server/form-pages/apply/about-the-person/personal-information/immigrationStatus.test.ts @@ -0,0 +1,64 @@ +import { itShouldHavePreviousValue } from '../../../shared-examples' +import ImmigrationStatus, { ImmigrationStatusBody } from './immigrationStatus' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import { isPersonMale } from '../../../../utils/personUtils' + +jest.mock('../../../../utils/personUtils') + +describe('ImmigrationStatus', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Sue Smith' }) }) + + const body: ImmigrationStatusBody = { + immigrationStatus: 'British', + } + + it('sets the question as the page title', () => { + const page = new ImmigrationStatus(body, application) + + expect(page.title).toEqual("What is Sue Smith's immigration status?") + }) + + it('sets the body', () => { + const page = new ImmigrationStatus(body, application) + + expect(page.body).toEqual(body) + }) + + describe('errors', () => { + it('returns an error if immigrationStatus is not selected', () => { + const page = new ImmigrationStatus({ immigrationStatus: 'choose' }, application) + + expect(page.errors()).toEqual({ + immigrationStatus: 'Select their immigration status', + }) + }) + }) + + describe('next', () => { + beforeEach(() => { + ;(isPersonMale as jest.Mock).mockReset() + }) + + describe('when the applicant is male', () => { + it('should not return a page name', () => { + ;(isPersonMale as jest.Mock).mockImplementation(() => true) + + const page = new ImmigrationStatus(body, application) + + expect(page.next()).toEqual('') + }) + }) + + describe('when the applicant is not male', () => { + it('should not return pregnancy information page name', () => { + ;(isPersonMale as jest.Mock).mockImplementation(() => false) + + const page = new ImmigrationStatus(body, application) + + expect(page.next()).toEqual('pregnancy-information') + }) + }) + }) + + itShouldHavePreviousValue(new ImmigrationStatus(body, application), 'working-mobile-phone') +}) diff --git a/server/form-pages/apply/about-the-person/personal-information/immigrationStatus.ts b/server/form-pages/apply/about-the-person/personal-information/immigrationStatus.ts new file mode 100644 index 0000000..5983b24 --- /dev/null +++ b/server/form-pages/apply/about-the-person/personal-information/immigrationStatus.ts @@ -0,0 +1,81 @@ +import { Cas2Application as Application } from '@approved-premises/api' +import { SelectItem, TaskListErrors } from '@approved-premises/ui' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' +import { isPersonMale } from '../../../../utils/personUtils' + +export type ImmigrationStatusBody = { + immigrationStatus: string +} + +@Page({ + name: 'immigration-status', + bodyProperties: ['immigrationStatus'], +}) +export default class ImmigrationStatus implements TaskListPage { + documentTitle = "What is the person's immigration status?" + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `What is ${nameOrPlaceholderCopy(this.application.person)}'s immigration status?` + + questions = getQuestions(this.personName)['personal-information']['immigration-status'] + + body: ImmigrationStatusBody + + immigrationStatusSelectItems: Array + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as ImmigrationStatusBody + this.immigrationStatusSelectItems = this.getImmigrationStatusAsItemsForSelect(this.body.immigrationStatus) + } + + private getImmigrationStatusAsItemsForSelect(selectedItem: string): Array { + const items = [ + { + value: 'choose', + text: 'Select immigration status', + selected: selectedItem === '', + }, + ] + + Object.keys(this.questions.immigrationStatus.answers).forEach(value => { + items.push({ + value, + text: this.questions.immigrationStatus.answers[ + value as keyof typeof this.questions.immigrationStatus.answers + ] as string, + selected: selectedItem === value, + }) + }) + + return items + } + + previous() { + return 'working-mobile-phone' + } + + next() { + if (isPersonMale(this.application.person)) { + return '' + } + + return 'pregnancy-information' + } + + errors() { + const errors: TaskListErrors = {} + + if (this.body.immigrationStatus === 'choose') { + errors.immigrationStatus = 'Select their immigration status' + } + + return errors + } +} diff --git a/server/form-pages/apply/about-the-person/personal-information/index.ts b/server/form-pages/apply/about-the-person/personal-information/index.ts new file mode 100644 index 0000000..2465a68 --- /dev/null +++ b/server/form-pages/apply/about-the-person/personal-information/index.ts @@ -0,0 +1,14 @@ +/* istanbul ignore file */ + +import { Task } from '../../../utils/decorators' +import WorkingMobilePhone from './workingMobilePhone' +import ImmigrationStatus from './immigrationStatus' +import PregnancyInformation from './pregnancyInformation' +import SupportWorkerPreference from './supportWorkerPreference' + +@Task({ + name: 'Add personal information', + slug: 'personal-information', + pages: [WorkingMobilePhone, ImmigrationStatus, PregnancyInformation, SupportWorkerPreference], +}) +export default class PersonalInformation {} diff --git a/server/form-pages/apply/about-the-person/personal-information/pregnancyInformation.test.ts b/server/form-pages/apply/about-the-person/personal-information/pregnancyInformation.test.ts new file mode 100644 index 0000000..7bbc409 --- /dev/null +++ b/server/form-pages/apply/about-the-person/personal-information/pregnancyInformation.test.ts @@ -0,0 +1,98 @@ +import { applicationFactory, personFactory } from '../../../../testutils/factories/index' +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import PregnancyInformation, { PregnancyInformationBody } from './pregnancyInformation' + +describe('PregnancyInformation', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Sue Smith' }) }) + + const body: PregnancyInformationBody = { + isPregnant: 'yes', + dueDate: '2024-03-27', + 'dueDate-month': '10', + 'dueDate-year': '2023', + 'dueDate-day': '01', + } + + it('sets the question as the page title', () => { + const page = new PregnancyInformation(body, application) + + expect(page.title).toEqual('Is Sue Smith pregnant?') + }) + + it('sets the body', () => { + const page = new PregnancyInformation(body, application) + + expect(page.body).toEqual(body) + }) + + describe('errors', () => { + it('returns an error if isPregnant is not set', () => { + const page = new PregnancyInformation({ isPregnant: null }, application) + + expect(page.errors()).toEqual({ + isPregnant: `Choose either Yes, No or I don't know`, + }) + }) + + describe('when isPregnant is yes', () => { + it('returns an error if dueDate is not set', () => { + const page = new PregnancyInformation({ ...body, 'dueDate-year': null }, application) + + expect(page.errors()).toEqual({ + dueDate: 'Enter the due date', + }) + }) + }) + }) + + itShouldHaveNextValue(new PregnancyInformation(body, application), 'support-worker-preference') + itShouldHavePreviousValue(new PregnancyInformation(body, application), 'immigration-status') + + describe('response', () => { + it('returns the pregnancy information', () => { + const page = new PregnancyInformation(body, application) + expect(page.response()).toEqual({ + 'Is Sue Smith pregnant?': 'Yes', + 'When is their due date?': '1 October 2023', + }) + }) + + describe('and they are not pregnant', () => { + const bodyWithoutDueDate: PregnancyInformationBody = { + isPregnant: 'no', + dueDate: '', + 'dueDate-month': '', + 'dueDate-year': '', + 'dueDate-day': '', + } + + it("doesn't return the due date", () => { + const page = new PregnancyInformation(bodyWithoutDueDate, application) + + expect(page.response()).toEqual({ + 'Is Sue Smith pregnant?': 'No', + }) + }) + }) + }) + + describe('onSave', () => { + it('removes due date data if question is not set to "yes"', () => { + const pageBody: PregnancyInformationBody = { + isPregnant: 'dontKnow', + dueDate: '2024-03-27', + 'dueDate-month': '10', + 'dueDate-year': '2023', + 'dueDate-day': '01', + } + + const page = new PregnancyInformation(pageBody, application) + + page.onSave() + + expect(page.body).toEqual({ + isPregnant: 'dontKnow', + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/personal-information/pregnancyInformation.ts b/server/form-pages/apply/about-the-person/personal-information/pregnancyInformation.ts new file mode 100644 index 0000000..cfdfb32 --- /dev/null +++ b/server/form-pages/apply/about-the-person/personal-information/pregnancyInformation.ts @@ -0,0 +1,81 @@ +import { Cas2Application as Application } from '@approved-premises/api' +import { TaskListErrors, YesNoOrDontKnow } from '@approved-premises/ui' +import { DateFormats, dateAndTimeInputsAreValidDates } from '../../../../utils/dateUtils' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import TaskListPage from '../../../taskListPage' +import { Page } from '../../../utils/decorators' +import { getQuestions } from '../../../utils/questions' + +export type PregnancyInformationBody = { + isPregnant: YesNoOrDontKnow + dueDate: string + 'dueDate-month': string + 'dueDate-year': string + 'dueDate-day': string +} + +@Page({ + name: 'pregnancy-information', + bodyProperties: ['isPregnant', 'dueDate-month', 'dueDate-year', 'dueDate-day', 'dueDate'], +}) +export default class PregnancyInformation implements TaskListPage { + documentTitle = 'Is the person pregnant?' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Is ${nameOrPlaceholderCopy(this.application.person)} pregnant?` + + questions = getQuestions(this.personName)['personal-information']['pregnancy-information'] + + body: PregnancyInformationBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as PregnancyInformationBody + } + + previous() { + return 'immigration-status' + } + + next() { + return 'support-worker-preference' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.isPregnant) { + errors.isPregnant = `Choose either Yes, No or I don't know` + } + + if (this.body.isPregnant === 'yes' && !dateAndTimeInputsAreValidDates(this.body, 'dueDate')) { + errors.dueDate = 'Enter the due date' + } + + return errors + } + + response() { + const response: Record = {} + + response[`${this.questions.isPregnant.question}`] = this.questions.isPregnant.answers[this.body.isPregnant] + + if (this.body.dueDate) { + response[`${this.questions.dueDate.question}`] = DateFormats.dateAndTimeInputsToUiDate(this.body, 'dueDate') + } + + return response + } + + onSave(): void { + if (this.body.isPregnant !== 'yes') { + delete this.body.dueDate + delete this.body['dueDate-day'] + delete this.body['dueDate-month'] + delete this.body['dueDate-year'] + } + } +} diff --git a/server/form-pages/apply/about-the-person/personal-information/supportWorkerPreference.test.ts b/server/form-pages/apply/about-the-person/personal-information/supportWorkerPreference.test.ts new file mode 100644 index 0000000..780673c --- /dev/null +++ b/server/form-pages/apply/about-the-person/personal-information/supportWorkerPreference.test.ts @@ -0,0 +1,64 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import SupportWorkerPreference, { SupportWorkerPreferenceBody } from './supportWorkerPreference' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' + +describe('SupportWorkerPreference', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Sue Smith' }) }) + + const body: SupportWorkerPreferenceBody = { + hasSupportWorkerPreference: 'yes', + supportWorkerPreference: 'female', + } + + it('sets the question as the page title', () => { + const page = new SupportWorkerPreference(body, application) + + expect(page.title).toEqual('Does Sue Smith have a gender preference for their support worker?') + }) + + it('sets the body', () => { + const page = new SupportWorkerPreference(body, application) + + expect(page.body).toEqual(body) + }) + + describe('errors', () => { + it('returns an error if hasSupportWorkerPreference is not set', () => { + const page = new SupportWorkerPreference({ hasSupportWorkerPreference: null }, application) + + expect(page.errors()).toEqual({ + hasSupportWorkerPreference: `Choose either Yes, No or I don't know`, + }) + }) + + describe('when hasSupportWorkerPreference is yes', () => { + it('returns an error if supportWorkerPreference is not set', () => { + const page = new SupportWorkerPreference({ ...body, supportWorkerPreference: null }, application) + + expect(page.errors()).toEqual({ + supportWorkerPreference: 'Choose either Male or Female', + }) + }) + }) + }) + + itShouldHaveNextValue(new SupportWorkerPreference(body, application), '') + itShouldHavePreviousValue(new SupportWorkerPreference(body, application), 'pregnancy-information') + + describe('onSave', () => { + it('removes support worker data if question is not set to "yes"', () => { + const pageBody: SupportWorkerPreferenceBody = { + hasSupportWorkerPreference: 'dontKnow', + supportWorkerPreference: 'female', + } + + const page = new SupportWorkerPreference(pageBody, application) + + page.onSave() + + expect(page.body).toEqual({ + hasSupportWorkerPreference: 'dontKnow', + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/personal-information/supportWorkerPreference.ts b/server/form-pages/apply/about-the-person/personal-information/supportWorkerPreference.ts new file mode 100644 index 0000000..d37c2be --- /dev/null +++ b/server/form-pages/apply/about-the-person/personal-information/supportWorkerPreference.ts @@ -0,0 +1,62 @@ +import { Cas2Application as Application } from '@approved-premises/api' +import { TaskListErrors, YesNoOrDontKnow } from '@approved-premises/ui' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' + +export type SupportWorkerPreferenceBody = { + hasSupportWorkerPreference: YesNoOrDontKnow + supportWorkerPreference: 'male' | 'female' +} + +@Page({ + name: 'support-worker-preference', + bodyProperties: ['hasSupportWorkerPreference', 'supportWorkerPreference'], +}) +export default class SupportWorkerPreference implements TaskListPage { + documentTitle = 'Does the person have a gender preference for their support worker?' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Does ${this.personName} have a gender preference for their support worker?` + + questions = getQuestions(this.personName)['personal-information']['support-worker-preference'] + + body: SupportWorkerPreferenceBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as SupportWorkerPreferenceBody + } + + previous() { + return 'pregnancy-information' + } + + next() { + return '' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.hasSupportWorkerPreference) { + errors.hasSupportWorkerPreference = `Choose either Yes, No or I don't know` + } + + if (this.body.hasSupportWorkerPreference === 'yes' && !this.body.supportWorkerPreference) { + errors.supportWorkerPreference = 'Choose either Male or Female' + } + + return errors + } + + onSave(): void { + if (this.body.hasSupportWorkerPreference !== 'yes') { + delete this.body.supportWorkerPreference + } + } +} diff --git a/server/form-pages/apply/about-the-person/personal-information/workingMobilePhone.test.ts b/server/form-pages/apply/about-the-person/personal-information/workingMobilePhone.test.ts new file mode 100644 index 0000000..a51dac6 --- /dev/null +++ b/server/form-pages/apply/about-the-person/personal-information/workingMobilePhone.test.ts @@ -0,0 +1,66 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import WorkingMobilePhone, { WorkingMobilePhoneBody } from './workingMobilePhone' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' + +describe('WorkingMobilePhone', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Sue Smith' }) }) + + const body: WorkingMobilePhoneBody = { + hasWorkingMobilePhone: 'yes', + mobilePhoneNumber: '11111111111', + isSmartPhone: 'yes', + } + + it('sets the question as the page title', () => { + const page = new WorkingMobilePhone(body, application) + + expect(page.title).toEqual('Will Sue Smith have a working mobile phone when they are released?') + }) + + it('sets the body', () => { + const page = new WorkingMobilePhone(body, application) + + expect(page.body).toEqual(body) + }) + + describe('errors', () => { + it('returns an error if hasWorkingMobilePhone is not set', () => { + const page = new WorkingMobilePhone({ hasWorkingMobilePhone: null }, application) + + expect(page.errors()).toEqual({ + hasWorkingMobilePhone: `Choose either Yes, No or I don't know`, + }) + }) + + describe('when hasWorkingMobilePhone is yes', () => { + it('returns an error if isSmartPhone is not set', () => { + const page = new WorkingMobilePhone({ ...body, isSmartPhone: null }, application) + + expect(page.errors()).toEqual({ + isSmartPhone: 'Choose either Yes or No', + }) + }) + }) + }) + + itShouldHaveNextValue(new WorkingMobilePhone(body, application), 'immigration-status') + itShouldHavePreviousValue(new WorkingMobilePhone(body, application), 'taskList') + + describe('onSave', () => { + it('removes mobile phone data if question is not set to "yes"', () => { + const pageBody: WorkingMobilePhoneBody = { + hasWorkingMobilePhone: 'dontKnow', + mobilePhoneNumber: '07111111111', + isSmartPhone: 'yes', + } + + const page = new WorkingMobilePhone(pageBody, application) + + page.onSave() + + expect(page.body).toEqual({ + hasWorkingMobilePhone: 'dontKnow', + }) + }) + }) +}) diff --git a/server/form-pages/apply/about-the-person/personal-information/workingMobilePhone.ts b/server/form-pages/apply/about-the-person/personal-information/workingMobilePhone.ts new file mode 100644 index 0000000..44df551 --- /dev/null +++ b/server/form-pages/apply/about-the-person/personal-information/workingMobilePhone.ts @@ -0,0 +1,64 @@ +import { Cas2Application as Application } from '@approved-premises/api' +import { TaskListErrors, YesNoOrDontKnow, YesOrNo } from '@approved-premises/ui' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' + +export type WorkingMobilePhoneBody = { + hasWorkingMobilePhone: YesNoOrDontKnow + mobilePhoneNumber: string + isSmartPhone: YesOrNo +} + +@Page({ + name: 'working-mobile-phone', + bodyProperties: ['hasWorkingMobilePhone', 'mobilePhoneNumber', 'isSmartPhone'], +}) +export default class WorkingMobilePhone implements TaskListPage { + documentTitle = 'Will the person have a working mobile phone when they are released?' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Will ${nameOrPlaceholderCopy(this.application.person)} have a working mobile phone when they are released?` + + questions = getQuestions(this.personName)['personal-information']['working-mobile-phone'] + + body: WorkingMobilePhoneBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as WorkingMobilePhoneBody + } + + previous() { + return 'taskList' + } + + next() { + return 'immigration-status' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.hasWorkingMobilePhone) { + errors.hasWorkingMobilePhone = `Choose either Yes, No or I don't know` + } + + if (this.body.hasWorkingMobilePhone === 'yes' && !this.body.isSmartPhone) { + errors.isSmartPhone = 'Choose either Yes or No' + } + + return errors + } + + onSave(): void { + if (this.body.hasWorkingMobilePhone !== 'yes') { + delete this.body.mobilePhoneNumber + delete this.body.isSmartPhone + } + } +} diff --git a/server/form-pages/apply/area-and-funding/area-information/exclusionZones.test.ts b/server/form-pages/apply/area-and-funding/area-information/exclusionZones.test.ts new file mode 100644 index 0000000..e147fa5 --- /dev/null +++ b/server/form-pages/apply/area-and-funding/area-information/exclusionZones.test.ts @@ -0,0 +1,64 @@ +import { YesOrNo } from '@approved-premises/ui' +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import ExclusionZones, { ExclusionZonesBody } from './exclusionZones' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' + +describe('ExclusionZones', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Sue Smith' }) }) + const body = { + hasExclusionZones: 'yes' as YesOrNo, + exclusionZonesDetail: 'Liverpool', + } + + it('sets the question as the page title', () => { + const page = new ExclusionZones(body, application) + + expect(page.title).toEqual('Exclusion zones for Sue Smith') + }) + + it('sets the body', () => { + const page = new ExclusionZones(body, application) + + expect(page.body).toEqual(body) + }) + + describe('errors', () => { + it('returns an error if hasExclusionZones is not set', () => { + const page = new ExclusionZones({ ...body, hasExclusionZones: null }, application) + + expect(page.errors()).toEqual({ + hasExclusionZones: 'Confirm whether they have any exclusion zones', + }) + }) + + describe('when hasExclusionZones is set to yes', () => { + it('returns an error if exclusionZonesDetail is not set', () => { + const page = new ExclusionZones({ hasExclusionZones: 'yes', exclusionZonesDetail: null }, application) + + expect(page.errors()).toEqual({ + exclusionZonesDetail: 'Provide details about the exclusion zone', + }) + }) + }) + }) + + itShouldHaveNextValue(new ExclusionZones(body, application), 'gang-affiliations') + itShouldHavePreviousValue(new ExclusionZones(body, application), 'second-preferred-area') + + describe('onSave', () => { + it('removes exclusion zones data if question is set to "no"', () => { + const pageBody: ExclusionZonesBody = { + hasExclusionZones: 'no', + exclusionZonesDetail: 'Exclusion zones detail', + } + + const page = new ExclusionZones(pageBody, application) + + page.onSave() + + expect(page.body).toEqual({ + hasExclusionZones: 'no', + }) + }) + }) +}) diff --git a/server/form-pages/apply/area-and-funding/area-information/exclusionZones.ts b/server/form-pages/apply/area-and-funding/area-information/exclusionZones.ts new file mode 100644 index 0000000..bb3f8ed --- /dev/null +++ b/server/form-pages/apply/area-and-funding/area-information/exclusionZones.ts @@ -0,0 +1,62 @@ +import { Cas2Application as Application } from '@approved-premises/api' +import { TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' + +export type ExclusionZonesBody = { + hasExclusionZones: YesOrNo + exclusionZonesDetail: string +} + +@Page({ + name: 'exclusion-zones', + bodyProperties: ['hasExclusionZones', 'exclusionZonesDetail'], +}) +export default class ExclusionZones implements TaskListPage { + documentTitle = 'Exclusion zones for the person' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Exclusion zones for ${nameOrPlaceholderCopy(this.application.person)}` + + questions = getQuestions(this.personName)['area-information']['exclusion-zones'] + + body: ExclusionZonesBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as ExclusionZonesBody + } + + previous() { + return 'second-preferred-area' + } + + next() { + return 'gang-affiliations' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.hasExclusionZones) { + errors.hasExclusionZones = 'Confirm whether they have any exclusion zones' + } + + if (this.body.hasExclusionZones === 'yes' && !this.body.exclusionZonesDetail) { + errors.exclusionZonesDetail = 'Provide details about the exclusion zone' + } + + return errors + } + + onSave(): void { + if (this.body.hasExclusionZones !== 'yes') { + delete this.body.exclusionZonesDetail + } + } +} diff --git a/server/form-pages/apply/area-and-funding/area-information/familyAccommodation.test.ts b/server/form-pages/apply/area-and-funding/area-information/familyAccommodation.test.ts new file mode 100644 index 0000000..eea83b2 --- /dev/null +++ b/server/form-pages/apply/area-and-funding/area-information/familyAccommodation.test.ts @@ -0,0 +1,36 @@ +import { YesOrNo } from '@approved-premises/ui' +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import FamilyAccommodation from './familyAccommodation' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' + +describe('FamilyAccommodation', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Sue Smith' }) }) + const body = { + familyProperty: 'yes' as YesOrNo, + } + + it('sets the question as the page title', () => { + const page = new FamilyAccommodation(body, application) + + expect(page.title).toEqual('Family accommodation for Sue Smith') + }) + + it('sets the body', () => { + const page = new FamilyAccommodation(body, application) + + expect(page.body).toEqual(body) + }) + + describe('errors', () => { + it('returns an error if familyProperty is not set', () => { + const page = new FamilyAccommodation({ familyProperty: null }, application) + + expect(page.errors()).toEqual({ + familyProperty: 'Select yes if they want to apply to live with their children in a family property', + }) + }) + }) + + itShouldHaveNextValue(new FamilyAccommodation(body, application), '') + itShouldHavePreviousValue(new FamilyAccommodation(body, application), 'gang-affiliations') +}) diff --git a/server/form-pages/apply/area-and-funding/area-information/familyAccommodation.ts b/server/form-pages/apply/area-and-funding/area-information/familyAccommodation.ts new file mode 100644 index 0000000..1af00d9 --- /dev/null +++ b/server/form-pages/apply/area-and-funding/area-information/familyAccommodation.ts @@ -0,0 +1,51 @@ +import { Cas2Application as Application } from '@approved-premises/api' +import { TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' + +type FamilyAccommodationBody = { + familyProperty: YesOrNo +} + +@Page({ + name: 'family-accommodation', + bodyProperties: ['familyProperty'], +}) +export default class FamilyAccommodation implements TaskListPage { + documentTitle = 'Family accommodation for the person' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Family accommodation for ${nameOrPlaceholderCopy(this.application.person)}` + + questions = getQuestions(this.personName)['area-information']['family-accommodation'] + + body: FamilyAccommodationBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as FamilyAccommodationBody + } + + previous() { + return 'gang-affiliations' + } + + next() { + return '' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.familyProperty) { + errors.familyProperty = 'Select yes if they want to apply to live with their children in a family property' + } + + return errors + } +} diff --git a/server/form-pages/apply/area-and-funding/area-information/firstPreferredArea.test.ts b/server/form-pages/apply/area-and-funding/area-information/firstPreferredArea.test.ts new file mode 100644 index 0000000..91f647d --- /dev/null +++ b/server/form-pages/apply/area-and-funding/area-information/firstPreferredArea.test.ts @@ -0,0 +1,44 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import FirstPreferredArea from './firstPreferredArea' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' + +describe('FirstPreferredArea', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Sue Smith' }) }) + const body = { + preferredArea: 'London', + preferenceReason: 'They have family in the area', + } + + it('sets the question as the page title', () => { + const page = new FirstPreferredArea(body, application) + + expect(page.title).toEqual('First preferred area for Sue Smith') + }) + + it('sets the body', () => { + const page = new FirstPreferredArea(body, application) + + expect(page.body).toEqual(body) + }) + + describe('errors', () => { + it('returns an error if preferredArea is not set', () => { + const page = new FirstPreferredArea({ ...body, preferredArea: null }, application) + + expect(page.errors()).toEqual({ + preferredArea: 'Provide a town, city or region for the first preferred area', + }) + }) + + it('returns an error if preferenceReason is not set', () => { + const page = new FirstPreferredArea({ ...body, preferenceReason: null }, application) + + expect(page.errors()).toEqual({ + preferenceReason: "Provide the reason for the applicant's first preferred area", + }) + }) + }) + + itShouldHaveNextValue(new FirstPreferredArea(body, application), 'second-preferred-area') + itShouldHavePreviousValue(new FirstPreferredArea(body, application), 'taskList') +}) diff --git a/server/form-pages/apply/area-and-funding/area-information/firstPreferredArea.ts b/server/form-pages/apply/area-and-funding/area-information/firstPreferredArea.ts new file mode 100644 index 0000000..0bae703 --- /dev/null +++ b/server/form-pages/apply/area-and-funding/area-information/firstPreferredArea.ts @@ -0,0 +1,56 @@ +import { Cas2Application as Application } from '@approved-premises/api' +import { TaskListErrors } from '@approved-premises/ui' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' + +type FirstPreferredAreaBody = { + preferredArea: string + preferenceReason: string +} + +@Page({ + name: 'first-preferred-area', + bodyProperties: ['preferredArea', 'preferenceReason'], +}) +export default class FirstPreferredArea implements TaskListPage { + documentTitle = 'First preferred area for the person' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `First preferred area for ${nameOrPlaceholderCopy(this.application.person)}` + + questions = getQuestions(this.personName)['area-information']['first-preferred-area'] + + body: FirstPreferredAreaBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as FirstPreferredAreaBody + } + + previous() { + return 'taskList' + } + + next() { + return 'second-preferred-area' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.preferredArea) { + errors.preferredArea = 'Provide a town, city or region for the first preferred area' + } + + if (!this.body.preferenceReason) { + errors.preferenceReason = "Provide the reason for the applicant's first preferred area" + } + + return errors + } +} diff --git a/server/form-pages/apply/area-and-funding/area-information/gangAffiliations.test.ts b/server/form-pages/apply/area-and-funding/area-information/gangAffiliations.test.ts new file mode 100644 index 0000000..b3f0827 --- /dev/null +++ b/server/form-pages/apply/area-and-funding/area-information/gangAffiliations.test.ts @@ -0,0 +1,76 @@ +import { YesOrNo } from '@approved-premises/ui' +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import GangAffiliations, { GangAffiliationsBody } from './gangAffiliations' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' + +describe('GangAffiliations', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Sue Smith' }) }) + const body = { + hasGangAffiliations: 'yes' as YesOrNo, + gangName: 'Gang name', + gangOperationArea: 'Derby', + rivalGangDetail: 'Rival gang name and detail', + } + + it('sets the question as the page title', () => { + const page = new GangAffiliations(body, application) + + expect(page.title).toEqual('Does Sue Smith have any gang affiliations?') + }) + + it('sets the body', () => { + const page = new GangAffiliations(body, application) + + expect(page.body).toEqual(body) + }) + + describe('errors', () => { + it('returns an error if hasGangAffiliations is not set', () => { + const page = new GangAffiliations({ ...body, hasGangAffiliations: null }, application) + + expect(page.errors()).toEqual({ + hasGangAffiliations: 'Select yes if they have gang affiliations', + }) + }) + + describe('when hasGangAffiliations is set to yes', () => { + it('returns an error if gangName is not set', () => { + const page = new GangAffiliations({ ...body, hasGangAffiliations: 'yes', gangName: null }, application) + + expect(page.errors()).toEqual({ + gangName: `Enter the gang's name`, + }) + }) + + it('returns an error if gangOperationArea is not set', () => { + const page = new GangAffiliations({ ...body, hasGangAffiliations: 'yes', gangOperationArea: null }, application) + + expect(page.errors()).toEqual({ + gangOperationArea: 'Describe the area the gang operates in', + }) + }) + }) + }) + + itShouldHaveNextValue(new GangAffiliations(body, application), 'family-accommodation') + itShouldHavePreviousValue(new GangAffiliations(body, application), 'exclusion-zones') + + describe('onSave', () => { + it('removes gang affiliation data if question is set to "no"', () => { + const pageBody: GangAffiliationsBody = { + hasGangAffiliations: 'no', + gangName: 'Gang name', + gangOperationArea: 'Gang operation area', + rivalGangDetail: 'Rival gang detail', + } + + const page = new GangAffiliations(pageBody, application) + + page.onSave() + + expect(page.body).toEqual({ + hasGangAffiliations: 'no', + }) + }) + }) +}) diff --git a/server/form-pages/apply/area-and-funding/area-information/gangAffiliations.ts b/server/form-pages/apply/area-and-funding/area-information/gangAffiliations.ts new file mode 100644 index 0000000..9e189b1 --- /dev/null +++ b/server/form-pages/apply/area-and-funding/area-information/gangAffiliations.ts @@ -0,0 +1,70 @@ +import { Cas2Application as Application } from '@approved-premises/api' +import { TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' + +export type GangAffiliationsBody = { + hasGangAffiliations: YesOrNo + gangName: string + gangOperationArea: string + rivalGangDetail: string +} + +@Page({ + name: 'gang-affiliations', + bodyProperties: ['hasGangAffiliations', 'gangName', 'gangOperationArea', 'rivalGangDetail'], +}) +export default class GangAffiliations implements TaskListPage { + documentTitle = 'Does the person have any gang affiliations?' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Does ${nameOrPlaceholderCopy(this.application.person)} have any gang affiliations?` + + questions = getQuestions(this.personName)['area-information']['gang-affiliations'] + + body: GangAffiliationsBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as GangAffiliationsBody + } + + previous() { + return 'exclusion-zones' + } + + next() { + return 'family-accommodation' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.hasGangAffiliations) { + errors.hasGangAffiliations = 'Select yes if they have gang affiliations' + } + + if (this.body.hasGangAffiliations === 'yes' && !this.body.gangName) { + errors.gangName = `Enter the gang's name` + } + + if (this.body.hasGangAffiliations === 'yes' && !this.body.gangOperationArea) { + errors.gangOperationArea = 'Describe the area the gang operates in' + } + + return errors + } + + onSave(): void { + if (this.body.hasGangAffiliations !== 'yes') { + delete this.body.gangName + delete this.body.gangOperationArea + delete this.body.rivalGangDetail + } + } +} diff --git a/server/form-pages/apply/area-and-funding/area-information/index.ts b/server/form-pages/apply/area-and-funding/area-information/index.ts new file mode 100644 index 0000000..27ac94f --- /dev/null +++ b/server/form-pages/apply/area-and-funding/area-information/index.ts @@ -0,0 +1,15 @@ +/* istanbul ignore file */ + +import { Task } from '../../../utils/decorators' +import FirstPreferredArea from './firstPreferredArea' +import SecondPreferredArea from './secondPreferredArea' +import ExclusionZones from './exclusionZones' +import GangAffiliations from './gangAffiliations' +import FamilyAccommodation from './familyAccommodation' + +@Task({ + name: 'Add exclusion zones and preferred areas', + slug: 'area-information', + pages: [FirstPreferredArea, SecondPreferredArea, ExclusionZones, GangAffiliations, FamilyAccommodation], +}) +export default class AreaInformation {} diff --git a/server/form-pages/apply/area-and-funding/area-information/secondPreferredArea.test.ts b/server/form-pages/apply/area-and-funding/area-information/secondPreferredArea.test.ts new file mode 100644 index 0000000..c60010a --- /dev/null +++ b/server/form-pages/apply/area-and-funding/area-information/secondPreferredArea.test.ts @@ -0,0 +1,44 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import SecondPreferredArea from './secondPreferredArea' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' + +describe('SecondPreferredArea', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Sue Smith' }) }) + const body = { + preferredArea: 'London', + preferenceReason: 'They have family in the area', + } + + it('sets the question as the page title', () => { + const page = new SecondPreferredArea(body, application) + + expect(page.title).toEqual('Second preferred area for Sue Smith') + }) + + it('sets the body', () => { + const page = new SecondPreferredArea(body, application) + + expect(page.body).toEqual(body) + }) + + describe('errors', () => { + it('returns an error if preferredArea is not set', () => { + const page = new SecondPreferredArea({ ...body, preferredArea: null }, application) + + expect(page.errors()).toEqual({ + preferredArea: 'Provide a town, city or region for the second preferred area', + }) + }) + + it('returns an error if preferenceReason is not set', () => { + const page = new SecondPreferredArea({ ...body, preferenceReason: null }, application) + + expect(page.errors()).toEqual({ + preferenceReason: "Provide the reason for the applicant's second preferred area", + }) + }) + }) + + itShouldHaveNextValue(new SecondPreferredArea(body, application), 'exclusion-zones') + itShouldHavePreviousValue(new SecondPreferredArea(body, application), 'first-preferred-area') +}) diff --git a/server/form-pages/apply/area-and-funding/area-information/secondPreferredArea.ts b/server/form-pages/apply/area-and-funding/area-information/secondPreferredArea.ts new file mode 100644 index 0000000..552d6dd --- /dev/null +++ b/server/form-pages/apply/area-and-funding/area-information/secondPreferredArea.ts @@ -0,0 +1,56 @@ +import { Cas2Application as Application } from '@approved-premises/api' +import { TaskListErrors } from '@approved-premises/ui' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' + +type SecondPreferredAreaBody = { + preferredArea: string + preferenceReason: string +} + +@Page({ + name: 'second-preferred-area', + bodyProperties: ['preferredArea', 'preferenceReason'], +}) +export default class SecondPreferredArea implements TaskListPage { + documentTitle = 'Second preferred area for the person' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Second preferred area for ${nameOrPlaceholderCopy(this.application.person)}` + + questions = getQuestions(this.personName)['area-information']['second-preferred-area'] + + body: SecondPreferredAreaBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as SecondPreferredAreaBody + } + + previous() { + return 'first-preferred-area' + } + + next() { + return 'exclusion-zones' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.preferredArea) { + errors.preferredArea = 'Provide a town, city or region for the second preferred area' + } + + if (!this.body.preferenceReason) { + errors.preferenceReason = "Provide the reason for the applicant's second preferred area" + } + + return errors + } +} diff --git a/server/form-pages/apply/area-and-funding/funding-information/alternativeID.test.ts b/server/form-pages/apply/area-and-funding/funding-information/alternativeID.test.ts new file mode 100644 index 0000000..dbfd33b --- /dev/null +++ b/server/form-pages/apply/area-and-funding/funding-information/alternativeID.test.ts @@ -0,0 +1,111 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { applicationFactory } from '../../../../testutils/factories/index' +import AlternativeIdentification, { AlternativeIdentificationBody } from './alternativeID' + +describe('AlternativeIdentification', () => { + const application = applicationFactory.build({}) + + itShouldHaveNextValue(new AlternativeIdentification({}, application), 'national-insurance') + itShouldHavePreviousValue(new AlternativeIdentification({}, application), 'identification') + + describe('errors', () => { + it('returns error if no document is selected', () => { + const page = new AlternativeIdentification({}, application) + + expect(page.errors()).toEqual({ + alternativeIDDocuments: "Select an ID document or 'Other type of identification'", + }) + }) + + it('returns an error if other is selected but no other document is entered', () => { + const page = new AlternativeIdentification({ alternativeIDDocuments: ['other'] }, application) + + expect(page.errors()).toEqual({ other: 'Enter the other type of ID' }) + }) + }) + + describe('items', () => { + it('returns items as expected', () => { + const page = new AlternativeIdentification({}, application) + + const conditional = 'some html' + + const expected = [ + { divider: 'Work and employment' }, + { checked: false, text: 'Employer letter/contract of employment', value: 'contract' }, + { checked: false, text: 'Trade union membership card', value: 'tradeUnion' }, + { checked: false, text: 'Invoices (self-employed)', value: 'invoice' }, + { checked: false, text: 'HMRC correspondence', value: 'hmrc' }, + { divider: 'Citizenship and nationality' }, + { checked: false, text: 'CitizenCard', value: 'citizenCard' }, + { checked: false, text: 'Foreign birth certificate', value: 'foreignBirthCertificate' }, + { + checked: false, + text: 'British citizen registration/naturalisation certificate', + value: 'citizenCertificate', + }, + { checked: false, text: 'Permanent residence card', value: 'residenceCard' }, + { checked: false, text: 'Residence permit', value: 'residencePermit' }, + { checked: false, text: 'Biometric Residence Permit', value: 'biometricResidencePermit' }, + { checked: false, text: 'Local authority rent card', value: 'laRentCard' }, + { divider: 'Marriage and civil partnership' }, + { checked: false, text: 'Original marriage/civil partnership certificate', value: 'marriageCertificate' }, + { checked: false, text: 'Divorce or annulment papers', value: 'divorcePapers' }, + { checked: false, text: 'Dissolution of marriage/civil partnership papers', value: 'dissolutionPapers' }, + { divider: 'Financial' }, + { checked: false, text: 'Building society passbook', value: 'buildingSociety' }, + { checked: false, text: 'Council tax documents', value: 'councilTax' }, + { checked: false, text: 'Life assurance or insurance policies', value: 'insurance' }, + { checked: false, text: 'Personal cheque book', value: 'chequeBook' }, + { checked: false, text: 'Mortgage repayment policies', value: 'mortgage' }, + { checked: false, text: 'Saving account book', value: 'savingAccount' }, + { divider: 'Young people and students' }, + { checked: false, text: 'Student ID card', value: 'studentID' }, + { checked: false, text: 'Educational institution letter (student)', value: 'educationalInstitution' }, + { checked: false, text: 'Young Scot card', value: 'youngScot' }, + { divider: 'Other' }, + { checked: false, text: 'Deed poll certificate', value: 'deedPoll' }, + { checked: false, text: 'Vehicle registration/motor insurance documents', value: 'vehicleRegistration' }, + { checked: false, text: 'NHS medical card', value: 'nhsCard' }, + { checked: false, text: 'Other type of identification', value: 'other', conditional: { html: conditional } }, + { divider: 'or' }, + { checked: false, text: 'No ID available', value: 'none' }, + ] + + expect(page.items(conditional)).toEqual(expected) + }) + }) + + describe('onSave', () => { + it('removes "other" alternative ID data if "other" is not selected', () => { + const body: Partial = { + alternativeIDDocuments: ['citizenCard', 'insurance'], + other: 'Other ID document', + } + + const page = new AlternativeIdentification(body, application) + + page.onSave() + + expect(page.body).toEqual({ + alternativeIDDocuments: ['citizenCard', 'insurance'], + }) + }) + + it('does not remove "other" alternative ID data if "other" is selected', () => { + const body: Partial = { + alternativeIDDocuments: ['citizenCard', 'other'], + other: 'Other ID document', + } + + const page = new AlternativeIdentification(body, application) + + page.onSave() + + expect(page.body).toEqual({ + alternativeIDDocuments: ['citizenCard', 'other'], + other: 'Other ID document', + }) + }) + }) +}) diff --git a/server/form-pages/apply/area-and-funding/funding-information/alternativeID.ts b/server/form-pages/apply/area-and-funding/funding-information/alternativeID.ts new file mode 100644 index 0000000..80b94cd --- /dev/null +++ b/server/form-pages/apply/area-and-funding/funding-information/alternativeID.ts @@ -0,0 +1,152 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToCheckboxItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('')['funding-information']['alternative-identification'] + +const alternativeIDOptions = applicationQuestions.alternativeIDDocuments.answers + +export type AlternativeIdentificationBody = { + alternativeIDDocuments: Array + other: string +} + +@Page({ + name: 'alternative-identification', + bodyProperties: ['alternativeIDDocuments', 'other'], +}) +export default class AlternativeIdentification implements TaskListPage { + documentTitle = 'What alternative identification documentation (ID) does the person have?' + + title + + personName = nameOrPlaceholderCopy(this.application.person) + + questions + + body: AlternativeIdentificationBody + + guidanceHtml = `The applicant needs ID if they are applying for Universal Credit for financial support, and Housing Benefit to cover their rent.

If they want to receive an advance payment of Universal Credit on the day of release, they will need a bank account and photo ID.` + + hintHtml = `
+ ${applicationQuestions.alternativeIDDocuments.hint} +
` + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.questions = getQuestions(this.personName)['funding-information']['alternative-identification'] + this.title = this.questions.alternativeIDDocuments.question + } + + previous() { + return 'identification' + } + + next() { + return 'national-insurance' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.alternativeIDDocuments) { + errors.alternativeIDDocuments = errorLookups.alternativeIDDocuments.empty + } else if (this.body.alternativeIDDocuments.includes('other') && !this.body.other) { + errors.other = errorLookups.alternativeIDDocuments.other + } + return errors + } + + items(conditionalHtml: string) { + const workAndEmploymentOptions = (({ contract, tradeUnion, invoice, hmrc }) => ({ + contract, + tradeUnion, + invoice, + hmrc, + }))(alternativeIDOptions) + + const citizenshipOptions = (({ + citizenCard, + foreignBirthCertificate, + citizenCertificate, + residenceCard, + residencePermit, + biometricResidencePermit, + laRentCard, + }) => ({ + citizenCard, + foreignBirthCertificate, + citizenCertificate, + residenceCard, + residencePermit, + biometricResidencePermit, + laRentCard, + }))(alternativeIDOptions) + + const marriageOptions = (({ marriageCertificate, divorcePapers, dissolutionPapers }) => ({ + marriageCertificate, + divorcePapers, + dissolutionPapers, + }))(alternativeIDOptions) + + const financialOptions = (({ buildingSociety, councilTax, insurance, chequeBook, mortgage, savingAccount }) => ({ + buildingSociety, + councilTax, + insurance, + chequeBook, + mortgage, + savingAccount, + }))(alternativeIDOptions) + + const studentOptions = (({ studentID, educationalInstitution, youngScot }) => ({ + studentID, + educationalInstitution, + youngScot, + }))(alternativeIDOptions) + + const otherOptions = (({ deedPoll, vehicleRegistration, nhsCard, other }) => ({ + deedPoll, + vehicleRegistration, + nhsCard, + other, + }))(alternativeIDOptions) + + const noneOption = (({ none }) => ({ + none, + }))(alternativeIDOptions) + + const otherItems = convertKeyValuePairToCheckboxItems(otherOptions, this.body.alternativeIDDocuments) + + const miscellaneousOther = otherItems.pop() + + return [ + { divider: 'Work and employment' }, + ...convertKeyValuePairToCheckboxItems(workAndEmploymentOptions, this.body.alternativeIDDocuments), + { divider: 'Citizenship and nationality' }, + ...convertKeyValuePairToCheckboxItems(citizenshipOptions, this.body.alternativeIDDocuments), + { divider: 'Marriage and civil partnership' }, + ...convertKeyValuePairToCheckboxItems(marriageOptions, this.body.alternativeIDDocuments), + { divider: 'Financial' }, + ...convertKeyValuePairToCheckboxItems(financialOptions, this.body.alternativeIDDocuments), + { divider: 'Young people and students' }, + ...convertKeyValuePairToCheckboxItems(studentOptions, this.body.alternativeIDDocuments), + { divider: 'Other' }, + ...otherItems, + { ...miscellaneousOther, conditional: { html: conditionalHtml } }, + { divider: 'or' }, + ...convertKeyValuePairToCheckboxItems(noneOption, this.body.alternativeIDDocuments), + ] + } + + onSave(): void { + if (!this.body.alternativeIDDocuments.includes('other')) { + delete this.body.other + } + } +} diff --git a/server/form-pages/apply/area-and-funding/funding-information/fundingInformation.test.ts b/server/form-pages/apply/area-and-funding/funding-information/fundingInformation.test.ts new file mode 100644 index 0000000..fbedb0b --- /dev/null +++ b/server/form-pages/apply/area-and-funding/funding-information/fundingInformation.test.ts @@ -0,0 +1,55 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import FundingInformation, { FundingSources } from './fundingInformation' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' + +describe('FundingInformation', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('sets the title', () => { + const page = new FundingInformation({ fundingSource: 'personalSavings' }, application) + + expect(page.title).toEqual('Funding information for Roger Smith') + }) + }) + + it('should set the body', () => { + const page = new FundingInformation( + { + fundingSource: 'personalSavings', + }, + application, + ) + + expect(page.body).toEqual({ + fundingSource: 'personalSavings', + }) + }) + + itShouldHaveNextValue(new FundingInformation({ fundingSource: 'personalSavings' }, application), 'national-insurance') + itShouldHaveNextValue(new FundingInformation({ fundingSource: 'benefits' }, application), 'identification') + itShouldHavePreviousValue(new FundingInformation({ fundingSource: 'personalSavings' }, application), 'taskList') + + describe('errors', () => { + it('should return errors when yes/no questions are blank', () => { + const page = new FundingInformation({}, application) + + expect(page.errors()).toEqual({ + fundingSource: 'Select a funding source', + }) + }) + + it('should return an error when the answer is both', () => { + const page = new FundingInformation( + { + fundingSource: 'both' as FundingSources, + }, + application, + ) + + expect(page.errors()).toEqual({ + fundingSource: 'Select a funding source', + }) + }) + }) +}) diff --git a/server/form-pages/apply/area-and-funding/funding-information/fundingInformation.ts b/server/form-pages/apply/area-and-funding/funding-information/fundingInformation.ts new file mode 100644 index 0000000..36a3d89 --- /dev/null +++ b/server/form-pages/apply/area-and-funding/funding-information/fundingInformation.ts @@ -0,0 +1,59 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +export type FundingSources = 'personalSavings' | 'benefits' + +type FundingSourceBody = { + fundingSource: FundingSources +} + +@Page({ + name: 'funding-source', + bodyProperties: ['fundingSource'], +}) +export default class FundingSource implements TaskListPage { + documentTitle = 'How will the person pay for their accommodation and service charge?' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Funding information for ${this.personName}` + + questions + + options: Record + + body: FundingSourceBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as FundingSourceBody + + const applicationQuestions = getQuestions(this.personName) + this.questions = applicationQuestions['funding-information']['funding-source'] + } + + previous() { + return 'taskList' + } + + next() { + if (this.body.fundingSource === 'personalSavings') { + return 'national-insurance' + } + return 'identification' + } + + errors() { + const errors: TaskListErrors = {} + if (this.body.fundingSource !== 'personalSavings' && this.body.fundingSource !== 'benefits') { + errors.fundingSource = 'Select a funding source' + } + return errors + } +} diff --git a/server/form-pages/apply/area-and-funding/funding-information/identification.test.ts b/server/form-pages/apply/area-and-funding/funding-information/identification.test.ts new file mode 100644 index 0000000..94a9e7b --- /dev/null +++ b/server/form-pages/apply/area-and-funding/funding-information/identification.test.ts @@ -0,0 +1,96 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { applicationFactory } from '../../../../testutils/factories/index' +import Identification from './identification' + +describe('Identification', () => { + const application = applicationFactory.build({}) + + itShouldHaveNextValue(new Identification({ idDocuments: ['none'] }, application), 'alternative-identification') + itShouldHaveNextValue(new Identification({ idDocuments: ['travelPass'] }, application), 'national-insurance') + + itShouldHavePreviousValue(new Identification({}, application), 'funding-source') + + describe('errors', () => { + it('returns error if no document is selected', () => { + const page = new Identification({}, application) + + expect(page.errors()).toEqual({ idDocuments: "Select an ID document or 'None of these options'" }) + }) + }) + + describe('items', () => { + it('returns items as expected', () => { + const page = new Identification({}, application) + + const expected = [ + { + attributes: { + 'data-selector': 'documents', + }, + checked: false, + text: 'Passport', + value: 'passport', + }, + { + attributes: { + 'data-selector': 'documents', + }, + checked: false, + text: 'Travel pass with photograph', + value: 'travelPass', + }, + { + attributes: { + 'data-selector': 'documents', + }, + checked: false, + text: 'Birth certificate', + value: 'birthCertificate', + }, + { + attributes: { + 'data-selector': 'documents', + }, + checked: false, + text: 'Bank account or debit card', + value: 'bankOrDebitCard', + }, + { + attributes: { + 'data-selector': 'documents', + }, + checked: false, + text: 'Bank, building society or Post Office card account statements', + value: 'bankStatements', + }, + { + attributes: { + 'data-selector': 'documents', + }, + checked: false, + text: 'UK photo driving licence', + hint: { text: 'Can be provisional' }, + value: 'drivingLicence', + }, + { + attributes: { + 'data-selector': 'documents', + }, + checked: false, + text: 'Recent wage slip', + hint: { text: 'With payee name and NI number' }, + value: 'wageSlip', + }, + { + divider: 'or', + }, + { + checked: false, + text: 'None of these options', + value: 'none', + }, + ] + expect(page.items()).toEqual(expected) + }) + }) +}) diff --git a/server/form-pages/apply/area-and-funding/funding-information/identification.ts b/server/form-pages/apply/area-and-funding/funding-information/identification.ts new file mode 100644 index 0000000..13ccd05 --- /dev/null +++ b/server/form-pages/apply/area-and-funding/funding-information/identification.ts @@ -0,0 +1,80 @@ +import type { Radio, TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToCheckboxItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +const applicationQuestions = getQuestions('') + +export const idDocumentOptions = applicationQuestions['funding-information'].identification.idDocuments.answers + +export type IDDocumentOptions = keyof typeof idDocumentOptions + +export type IdentificationBody = { + idDocuments: Array +} + +@Page({ + name: 'identification', + bodyProperties: ['idDocuments'], +}) +export default class Identification implements TaskListPage { + documentTitle = 'What identification documentation (ID) does the person have?' + + title + + personName = nameOrPlaceholderCopy(this.application.person) + + questions + + body: IdentificationBody + + guidanceHtml = `The applicant needs ID if they are applying for Universal Credit for financial support, and Housing Benefit to cover their rent.

If they want to receive an advance payment of Universal Credit on the day of release, they will need a bank account and photo ID.` + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.questions = getQuestions(this.personName)['funding-information'].identification.idDocuments + this.title = this.questions.question + } + + previous() { + return 'funding-source' + } + + next() { + if (this.body.idDocuments.includes('none')) { + return 'alternative-identification' + } + return 'national-insurance' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.idDocuments) { + errors.idDocuments = errorLookups.idDocuments.empty + } + return errors + } + + items() { + const items = convertKeyValuePairToCheckboxItems(idDocumentOptions, this.body.idDocuments) as [Radio] + + const none = items.pop() + + items.forEach(item => { + item.attributes = { 'data-selector': 'documents' } + if (item.value === 'wageSlip') { + item.hint = { text: 'With payee name and NI number' } + } else if (item.value === 'drivingLicence') { + item.hint = { text: 'Can be provisional' } + } + }) + + return [...items, { divider: 'or' }, { ...none }] + } +} diff --git a/server/form-pages/apply/area-and-funding/funding-information/index.ts b/server/form-pages/apply/area-and-funding/funding-information/index.ts new file mode 100644 index 0000000..772c711 --- /dev/null +++ b/server/form-pages/apply/area-and-funding/funding-information/index.ts @@ -0,0 +1,14 @@ +/* istanbul ignore file */ + +import { Task } from '../../../utils/decorators' +import FundingInformationPage from './fundingInformation' +import NationalInsurance from './nationalInsurance' +import Identification from './identification' +import AlternativeIdentification from './alternativeID' + +@Task({ + name: 'Confirm funding and ID', + slug: 'funding-information', + pages: [FundingInformationPage, NationalInsurance, Identification, AlternativeIdentification], +}) +export default class FundingInformation {} diff --git a/server/form-pages/apply/area-and-funding/funding-information/nationalInsurance.test.ts b/server/form-pages/apply/area-and-funding/funding-information/nationalInsurance.test.ts new file mode 100644 index 0000000..43a353c --- /dev/null +++ b/server/form-pages/apply/area-and-funding/funding-information/nationalInsurance.test.ts @@ -0,0 +1,51 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { applicationFactory } from '../../../../testutils/factories/index' +import NationalInsurance from './nationalInsurance' + +describe('NationalInsurance', () => { + const applicationWithPersonalSavings = applicationFactory.build({ + data: { + 'funding-information': { + 'funding-source': { + fundingSource: 'personalSavings', + }, + }, + }, + }) + + const applicationWithAlternativeID = applicationFactory.build({ + data: { + 'funding-information': { + identification: { + idDocuments: 'none', + }, + }, + }, + }) + + const applicationWithNoPreviousData = applicationFactory.build({ + data: null, + }) + const applicationWithNoFundingSource = applicationFactory.build({ + data: { + 'funding-information': {}, + }, + }) + + const application = applicationFactory.build({}) + + itShouldHaveNextValue(new NationalInsurance({}, applicationWithPersonalSavings), '') + itShouldHavePreviousValue(new NationalInsurance({}, applicationWithNoPreviousData), 'identification') + itShouldHavePreviousValue(new NationalInsurance({}, applicationWithNoFundingSource), 'identification') + itShouldHavePreviousValue(new NationalInsurance({}, applicationWithPersonalSavings), 'funding-source') + itShouldHavePreviousValue(new NationalInsurance({}, applicationWithAlternativeID), 'alternative-identification') + itShouldHavePreviousValue(new NationalInsurance({}, application), 'identification') + + describe('errors', () => { + it('not implemented', () => { + const page = new NationalInsurance({}, applicationWithPersonalSavings) + + expect(page.errors()).toEqual({}) + }) + }) +}) diff --git a/server/form-pages/apply/area-and-funding/funding-information/nationalInsurance.ts b/server/form-pages/apply/area-and-funding/funding-information/nationalInsurance.ts new file mode 100644 index 0000000..3b56fce --- /dev/null +++ b/server/form-pages/apply/area-and-funding/funding-information/nationalInsurance.ts @@ -0,0 +1,53 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' + +type NationalInsuranceBody = { nationalInsuranceNumber: string } + +@Page({ + name: 'national-insurance', + bodyProperties: ['nationalInsuranceNumber'], +}) +export default class NationalInsurance implements TaskListPage { + title + + documentTitle = "What is the person's National Insurance number?" + + questions + + body: NationalInsuranceBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as NationalInsuranceBody + this.questions = getQuestions(nameOrPlaceholderCopy(this.application.person))['funding-information'][ + 'national-insurance' + ] + this.title = this.questions.nationalInsuranceNumber.question + } + + previous() { + if (this.application.data?.['funding-information']?.['funding-source']?.fundingSource === 'personalSavings') { + return 'funding-source' + } + if (this.application.data?.['funding-information']?.identification?.idDocuments === 'none') { + return 'alternative-identification' + } + return 'identification' + } + + next() { + return '' + } + + errors() { + const errors: TaskListErrors = {} + + return errors + } +} diff --git a/server/form-pages/apply/area-and-funding/index.ts b/server/form-pages/apply/area-and-funding/index.ts new file mode 100644 index 0000000..5c3a691 --- /dev/null +++ b/server/form-pages/apply/area-and-funding/index.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ + +import { Section } from '../../utils/decorators' +import FundingInformation from './funding-information' +import AreaInformation from './area-information' + +@Section({ + title: 'Area, funding and ID', + tasks: [AreaInformation, FundingInformation], +}) +export default class AreaAndFunding {} diff --git a/server/form-pages/apply/before-you-start/confirm-consent/confirmConsent.test.ts b/server/form-pages/apply/before-you-start/confirm-consent/confirmConsent.test.ts new file mode 100644 index 0000000..4e9f325 --- /dev/null +++ b/server/form-pages/apply/before-you-start/confirm-consent/confirmConsent.test.ts @@ -0,0 +1,88 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import ConfirmConsent from './confirmConsent' +import { applicationFactory, personFactory } from '../../../../testutils/factories/index' +import { getQuestions } from '../../../utils/questions' + +describe('ConfirmConsent', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + const questions = getQuestions('Roger Smith') + + describe('title', () => { + it('personalises the page title', () => { + const page = new ConfirmConsent({}, application) + + expect(page.title).toEqual("Confirm Roger Smith's consent to apply for Short-Term Accommodation (CAS-2)") + }) + }) + + itShouldHaveNextValue(new ConfirmConsent({}, application), '') + itShouldHavePreviousValue(new ConfirmConsent({}, application), 'taskList') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new ConfirmConsent({ hasGivenConsent: 'no' }, application) + + expect(page.items('dateHtml', 'refusalDetailHtml')).toEqual([ + { + value: 'yes', + text: questions['confirm-consent']['confirm-consent'].hasGivenConsent.answers.yes, + conditional: { html: 'dateHtml' }, + checked: false, + }, + { + value: 'no', + text: questions['confirm-consent']['confirm-consent'].hasGivenConsent.answers.no, + conditional: { html: 'refusalDetailHtml' }, + checked: true, + }, + ]) + }) + }) + + describe('errors', () => { + it('should return errors when yes/no questions are blank', () => { + const page = new ConfirmConsent({}, application) + + expect(page.errors()).toEqual({ + hasGivenConsent: 'Confirm whether the applicant gave their consent', + }) + }) + + it('should return an error when yes is selected but no date is provided', () => { + const page = new ConfirmConsent({ hasGivenConsent: 'yes' }, application) + + expect(page.errors()).toEqual({ + consentDate: 'Enter date applicant gave their consent', + }) + }) + + it('should return an error when no is selected but no detail is provided', () => { + const page = new ConfirmConsent({ hasGivenConsent: 'no' }, application) + + expect(page.errors()).toEqual({ + consentRefusalDetail: 'Enter the applicant’s reason for refusing consent', + }) + }) + }) + + describe('response', () => { + it('should return the consent date if consent has been given', () => { + const page = new ConfirmConsent({ hasGivenConsent: 'yes', consentDate: '2023-11-01' }, application) + + expect(page.response()).toEqual({ + 'Has Roger Smith given their consent to apply for CAS-2?': 'Yes, Roger Smith has given their consent', + 'When did they give consent?': '1 November 2023', + }) + }) + }) + + it('should return the consent refusal detail if consent has been refused', () => { + const page = new ConfirmConsent({ hasGivenConsent: 'no', consentRefusalDetail: 'some reasons' }, application) + + expect(page.response()).toEqual({ + 'Has Roger Smith given their consent to apply for CAS-2?': 'No, Roger Smith has not given their consent', + 'Why was consent refused?': 'some reasons', + }) + }) +}) diff --git a/server/form-pages/apply/before-you-start/confirm-consent/confirmConsent.ts b/server/form-pages/apply/before-you-start/confirm-consent/confirmConsent.ts new file mode 100644 index 0000000..bba09e2 --- /dev/null +++ b/server/form-pages/apply/before-you-start/confirm-consent/confirmConsent.ts @@ -0,0 +1,101 @@ +import type { Radio, TaskListErrors, YesOrNo, ObjectWithDateParts } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' +import { dateBodyProperties } from '../../../utils' +import { DateFormats, dateAndTimeInputsAreValidDates } from '../../../../utils/dateUtils' + +type ConfirmConsentBody = { + hasGivenConsent: YesOrNo + consentRefusalDetail: string +} & ObjectWithDateParts<'consentDate'> + +@Page({ + name: 'confirm-consent', + bodyProperties: ['hasGivenConsent', ...dateBodyProperties('consentDate'), 'consentRefusalDetail'], +}) +export default class ConfirmConsent implements TaskListPage { + documentTitle = "Confirm the person's consent to apply for Short-Term Accommodation (CAS-2)" + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Confirm ${this.personName}'s consent to apply for Short-Term Accommodation (CAS-2)` + + questions + + body: ConfirmConsentBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + if (body.hasGivenConsent === 'yes') { + body.consentRefusalDetail = '' + } + if (body.hasGivenConsent === 'no') { + body.consentDate = '' + body['consentDate-day'] = '' + body['consentDate-month'] = '' + body['consentDate-year'] = '' + } + this.body = body as ConfirmConsentBody + + const applicationQuestions = getQuestions(this.personName) + this.questions = applicationQuestions['confirm-consent']['confirm-consent'] + } + + previous() { + return 'taskList' + } + + next() { + return '' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.hasGivenConsent) { + errors.hasGivenConsent = 'Confirm whether the applicant gave their consent' + } + if (this.body.hasGivenConsent === 'yes' && !dateAndTimeInputsAreValidDates(this.body, 'consentDate')) { + errors.consentDate = 'Enter date applicant gave their consent' + } + if (this.body.hasGivenConsent === 'no' && !this.body.consentRefusalDetail) { + errors.consentRefusalDetail = 'Enter the applicant’s reason for refusing consent' + } + return errors + } + + items(dateHtml: string, refusalDetailHtml: string) { + const items = convertKeyValuePairToRadioItems( + this.questions.hasGivenConsent.answers, + this.body.hasGivenConsent, + ) as Array + + items.forEach(item => { + if (item.value === 'yes') { + item.conditional = { html: dateHtml } + } + if (item.value === 'no') { + item.conditional = { html: refusalDetailHtml } + } + }) + + return items + } + + response() { + return { + [this.questions.hasGivenConsent.question]: this.questions.hasGivenConsent.answers[this.body.hasGivenConsent], + ...(this.body.hasGivenConsent === 'yes' && { + [this.questions.consentDate.question]: DateFormats.isoDateToUIDate(this.body.consentDate, { format: 'medium' }), + }), + ...(this.body.hasGivenConsent === 'no' && { + [this.questions.consentRefusalDetail.question]: this.body.consentRefusalDetail, + }), + } + } +} diff --git a/server/form-pages/apply/before-you-start/confirm-consent/index.ts b/server/form-pages/apply/before-you-start/confirm-consent/index.ts new file mode 100644 index 0000000..06cbf86 --- /dev/null +++ b/server/form-pages/apply/before-you-start/confirm-consent/index.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ + +import { Task } from '../../../utils/decorators' +import ConfirmConsentPage from './confirmConsent' + +@Task({ + name: 'Confirm consent to share information', + slug: 'confirm-consent', + pages: [ConfirmConsentPage], +}) +export default class ConfirmConsent {} diff --git a/server/form-pages/apply/before-you-start/confirm-eligibility/confirmEligibility.test.ts b/server/form-pages/apply/before-you-start/confirm-eligibility/confirmEligibility.test.ts new file mode 100644 index 0000000..91d815d --- /dev/null +++ b/server/form-pages/apply/before-you-start/confirm-eligibility/confirmEligibility.test.ts @@ -0,0 +1,60 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import ConfirmEligibility from './confirmEligibility' +import { applicationFactory, personFactory } from '../../../../testutils/factories/index' +import { getQuestions } from '../../../utils/questions' + +describe('ConfirmEligibility', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + const questions = getQuestions('Roger Smith') + + describe('question', () => { + it('personalises the question', () => { + const page = new ConfirmEligibility({ isEligible: 'yes' }, application) + + expect(page.questions).toEqual({ + isEligible: questions['confirm-eligibility']['confirm-eligibility'].isEligible.question, + }) + }) + }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new ConfirmEligibility({ isEligible: 'yes' }, application) + + expect(page.title).toEqual('Check Roger Smith is eligible for Short-Term Accommodation (CAS-2)') + }) + }) + + itShouldHaveNextValue(new ConfirmEligibility({ isEligible: 'yes' }, application), '') + itShouldHavePreviousValue(new ConfirmEligibility({ isEligible: 'yes' }, application), 'taskList') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new ConfirmEligibility({ isEligible: 'yes' }, application) + + expect(page.items()).toEqual([ + { + value: 'yes', + text: questions['confirm-eligibility']['confirm-eligibility'].isEligible.answers.yes, + checked: true, + }, + { + value: 'no', + text: questions['confirm-eligibility']['confirm-eligibility'].isEligible.answers.no, + checked: false, + }, + ]) + }) + }) + + describe('errors', () => { + it('should return errors when yes/no questions are blank', () => { + const page = new ConfirmEligibility({}, application) + + expect(page.errors()).toEqual({ + isEligible: 'Confirm whether the applicant is eligible or not eligible', + }) + }) + }) +}) diff --git a/server/form-pages/apply/before-you-start/confirm-eligibility/confirmEligibility.ts b/server/form-pages/apply/before-you-start/confirm-eligibility/confirmEligibility.ts new file mode 100644 index 0000000..2bc02be --- /dev/null +++ b/server/form-pages/apply/before-you-start/confirm-eligibility/confirmEligibility.ts @@ -0,0 +1,61 @@ +import type { TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +type ConfirmEligibilityBody = { + isEligible: YesOrNo +} +@Page({ + name: 'confirm-eligibility', + bodyProperties: ['isEligible'], +}) +export default class ConfirmEligibility implements TaskListPage { + documentTitle = 'Check the person is eligible for Short-Term Accommodation (CAS-2)' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Check ${this.personName} is eligible for Short-Term Accommodation (CAS-2)` + + questions: Record + + options: Record + + body: ConfirmEligibilityBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as ConfirmEligibilityBody + + const applicationQuestions = getQuestions(this.personName) + this.questions = { + isEligible: applicationQuestions['confirm-eligibility']['confirm-eligibility'].isEligible.question, + } + this.options = applicationQuestions['confirm-eligibility']['confirm-eligibility'].isEligible.answers + } + + previous() { + return 'taskList' + } + + next() { + return '' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.isEligible) { + errors.isEligible = 'Confirm whether the applicant is eligible or not eligible' + } + return errors + } + + items() { + return convertKeyValuePairToRadioItems(this.options, this.body.isEligible) + } +} diff --git a/server/form-pages/apply/before-you-start/confirm-eligibility/index.ts b/server/form-pages/apply/before-you-start/confirm-eligibility/index.ts new file mode 100644 index 0000000..7ac186f --- /dev/null +++ b/server/form-pages/apply/before-you-start/confirm-eligibility/index.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ + +import { Task } from '../../../utils/decorators' +import ConfirmEligibilityPage from './confirmEligibility' + +@Task({ + name: 'Confirm eligibility', + slug: 'confirm-eligibility', + pages: [ConfirmEligibilityPage], +}) +export default class ConfirmEligibility {} diff --git a/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcIneligible.test.ts b/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcIneligible.test.ts new file mode 100644 index 0000000..8bb45a4 --- /dev/null +++ b/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcIneligible.test.ts @@ -0,0 +1,26 @@ +import { itShouldHavePreviousValue, itShouldHaveNextValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import HDCIneligible from './hdcIneligible' + +describe('HDCIneligible', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('returns the page title', () => { + const page = new HDCIneligible({}, application) + + expect(page.title).toEqual('It is too late to submit a CAS-2 application') + }) + }) + + itShouldHavePreviousValue(new HDCIneligible({}, application), 'hdc-licence-dates') + itShouldHaveNextValue(new HDCIneligible({}, application), '') + + describe('errors', () => { + it('returns no errors as this page has no questions/answers', () => { + const page = new HDCIneligible({}, application) + + expect(page.errors()).toEqual({}) + }) + }) +}) diff --git a/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcIneligible.ts b/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcIneligible.ts new file mode 100644 index 0000000..f060d4f --- /dev/null +++ b/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcIneligible.ts @@ -0,0 +1,39 @@ +import { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' + +type HDCIneligibleBody = Record + +@Page({ + name: 'hdc-ineligible', + bodyProperties: [], +}) +export default class HDCIneligible implements TaskListPage { + documentTitle = 'It is too late to submit a CAS-2 application' + + title = 'It is too late to submit a CAS-2 application' + + body: HDCIneligibleBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as HDCIneligibleBody + } + + previous() { + return 'hdc-licence-dates' + } + + next() { + return '' + } + + errors() { + const errors: TaskListErrors = {} + + return errors + } +} diff --git a/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcLicenceDates.test.ts b/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcLicenceDates.test.ts new file mode 100644 index 0000000..58090f9 --- /dev/null +++ b/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcLicenceDates.test.ts @@ -0,0 +1,173 @@ +import { itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import HDCLicenceDates from './hdcLicenceDates' +import { + dateAndTimeInputsAreValidDates, + dateIsTodayOrInTheFuture, + isBeforeDate, + isMoreThanMonthsBetweenDates, + differenceInDaysFromToday, + dateIsComplete, +} from '../../../../utils/dateUtils' + +jest.mock('../../../../utils/dateUtils', () => { + const actual = jest.requireActual('../../../../utils/dateUtils') + return { + ...actual, + dateAndTimeInputsAreValidDates: jest.fn(), + dateIsTodayOrInTheFuture: jest.fn(), + isMoreThanMonthsBetweenDates: jest.fn(), + isBeforeDate: jest.fn(), + differenceInDaysFromToday: jest.fn(), + dateIsComplete: jest.fn(), + } +}) + +describe('HDCLicenceDates', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new HDCLicenceDates({}, application) + + expect(page.title).toEqual("Roger Smith's Home Detention Curfew (HDC) licence dates") + }) + }) + + itShouldHavePreviousValue(new HDCLicenceDates({}, application), 'taskList') + + describe('next', () => { + it('returns an empty string', () => { + ;(differenceInDaysFromToday as jest.Mock).mockImplementation(() => 25) + + const page = new HDCLicenceDates({}, application) + expect(page.next()).toEqual('') + }) + + describe('when the current date is within 20 days of the CRD', () => { + it("returns 'hdc-warning'", () => { + ;(differenceInDaysFromToday as jest.Mock).mockImplementation(() => 20) + + const page = new HDCLicenceDates({}, application) + expect(page.next()).toEqual('hdc-warning') + }) + }) + + describe('when the current date is within 10 days of the CRD', () => { + it("returns 'hdc-ineligible'", () => { + ;(differenceInDaysFromToday as jest.Mock).mockImplementation(() => 10) + + const page = new HDCLicenceDates({}, application) + expect(page.next()).toEqual('hdc-ineligible') + }) + }) + }) + + describe('errors', () => { + describe('when no dates are provided', () => { + beforeEach(() => { + jest.resetAllMocks() + ;(dateIsComplete as jest.Mock).mockImplementation(() => false) + }) + + it('returns errors', () => { + const page = new HDCLicenceDates({}, application) + expect(page.errors()).toEqual({ + hdcEligibilityDate: "Enter the applicant's HDC eligibility date", + conditionalReleaseDate: "Enter the applicant's conditional release date", + }) + }) + }) + + describe('when false dates are provided', () => { + beforeEach(() => { + jest.resetAllMocks() + ;(dateIsComplete as jest.Mock).mockImplementation(() => true) + ;(dateAndTimeInputsAreValidDates as jest.Mock).mockImplementation(() => false) + }) + + it('returns errors', () => { + const page = new HDCLicenceDates({}, application) + expect(page.errors()).toEqual({ + hdcEligibilityDate: 'Eligibility date must be a real date', + conditionalReleaseDate: 'Conditional release date must be a real date', + }) + }) + }) + + describe('when the conditional release date (CRD) is in the past', () => { + beforeEach(() => { + jest.resetAllMocks() + ;(dateIsComplete as jest.Mock).mockImplementation(() => true) + ;(dateAndTimeInputsAreValidDates as jest.Mock).mockImplementation(() => true) + ;(dateIsTodayOrInTheFuture as jest.Mock).mockImplementation(() => false) + ;(isMoreThanMonthsBetweenDates as jest.Mock).mockImplementation(() => false) + ;(isBeforeDate as jest.Mock).mockImplementation(() => true) + }) + + it('returns errors', () => { + const page = new HDCLicenceDates({}, application) + expect(page.errors()).toEqual({ + conditionalReleaseDate: 'Conditional release date cannot be in the past', + }) + }) + }) + + describe('when the HDC date is more than 6 months before the CRD date', () => { + beforeEach(() => { + jest.resetAllMocks() + ;(dateIsComplete as jest.Mock).mockImplementation(() => true) + ;(dateAndTimeInputsAreValidDates as jest.Mock).mockImplementation(() => true) + ;(dateIsTodayOrInTheFuture as jest.Mock).mockImplementation(() => true) + ;(isMoreThanMonthsBetweenDates as jest.Mock).mockImplementation(() => true) + ;(isBeforeDate as jest.Mock).mockImplementation(() => true) + }) + + it('returns errors', () => { + const page = new HDCLicenceDates({}, application) + expect(page.errors()).toEqual({ + hdcEligibilityDate: 'HDC eligibility date cannot be more than 6 months before the conditional release date', + }) + }) + }) + + describe('when the HDC date is after the CRD date', () => { + beforeEach(() => { + jest.resetAllMocks() + ;(dateIsComplete as jest.Mock).mockImplementation(() => true) + ;(dateAndTimeInputsAreValidDates as jest.Mock).mockImplementation(() => true) + ;(dateIsTodayOrInTheFuture as jest.Mock).mockImplementation(() => true) + ;(isMoreThanMonthsBetweenDates as jest.Mock).mockImplementation(() => false) + ;(isBeforeDate as jest.Mock).mockImplementation(() => false) + }) + + it('returns errors', () => { + const page = new HDCLicenceDates({}, application) + expect(page.errors()).toEqual({ + hdcEligibilityDate: 'HDC eligibility date must be before the conditional release date', + }) + }) + }) + }) + + describe('response', () => { + it('returns data in expected format', () => { + const page = new HDCLicenceDates( + { + 'hdcEligibilityDate-year': '2023', + 'hdcEligibilityDate-month': '11', + 'hdcEligibilityDate-day': '11', + 'conditionalReleaseDate-year': '2023', + 'conditionalReleaseDate-month': '11', + 'conditionalReleaseDate-day': '12', + }, + application, + ) + + expect(page.response()).toEqual({ + 'HDC eligibility date': '11 November 2023', + 'Conditional release date': '12 November 2023', + }) + }) + }) +}) diff --git a/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcLicenceDates.ts b/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcLicenceDates.ts new file mode 100644 index 0000000..5c554af --- /dev/null +++ b/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcLicenceDates.ts @@ -0,0 +1,115 @@ +import { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import type { ObjectWithDateParts } from '@approved-premises/ui' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' +import { + dateAndTimeInputsAreValidDates, + DateFormats, + dateIsTodayOrInTheFuture, + isBeforeDate, + differenceInDaysFromToday, + isMoreThanMonthsBetweenDates, + dateIsComplete, +} from '../../../../utils/dateUtils' +import { dateBodyProperties } from '../../../utils' + +type HDCLicenceDatesBody = ObjectWithDateParts<'hdcEligibilityDate'> & ObjectWithDateParts<'conditionalReleaseDate'> + +const MAX_MONTHS_BETWEEN_HDC_AND_CRD = 6 +const MAX_DAYS_BETWEEN_TODAY_AND_CRD_FOR_WARNING = 21 +const MAX_DAYS_BETWEEN_TODAY_AND_CRD_FOR_INELIGIBILITY = 10 +@Page({ + name: 'hdc-licence-dates', + bodyProperties: [...dateBodyProperties('hdcEligibilityDate'), ...dateBodyProperties('conditionalReleaseDate')], +}) +export default class HDCLicenceDates implements TaskListPage { + documentTitle = `The person's Home Detention Curfew (HDC) licence dates` + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `${this.personName}'s Home Detention Curfew (HDC) licence dates` + + questions = getQuestions(this.personName)['hdc-licence-dates']['hdc-licence-dates'] + + options: Record + + body: HDCLicenceDatesBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as HDCLicenceDatesBody + } + + previous() { + return 'taskList' + } + + next() { + const differenceBetweenCrdAndTodaysDate = differenceInDaysFromToday(this.body, 'conditionalReleaseDate') + const isWithinWarningRange = + differenceBetweenCrdAndTodaysDate < MAX_DAYS_BETWEEN_TODAY_AND_CRD_FOR_WARNING && + differenceBetweenCrdAndTodaysDate > MAX_DAYS_BETWEEN_TODAY_AND_CRD_FOR_INELIGIBILITY + const isWithinIneligibleRange = + differenceBetweenCrdAndTodaysDate <= MAX_DAYS_BETWEEN_TODAY_AND_CRD_FOR_INELIGIBILITY + + if (isWithinWarningRange) { + return 'hdc-warning' + } + + if (isWithinIneligibleRange) { + return 'hdc-ineligible' + } + + return '' + } + + response() { + return { + 'HDC eligibility date': DateFormats.dateAndTimeInputsToUiDate(this.body, 'hdcEligibilityDate'), + 'Conditional release date': DateFormats.dateAndTimeInputsToUiDate(this.body, 'conditionalReleaseDate'), + } + } + + errors() { + const errors: TaskListErrors = {} + + if (!dateIsComplete(this.body, 'hdcEligibilityDate')) { + errors.hdcEligibilityDate = "Enter the applicant's HDC eligibility date" + } else if (!dateAndTimeInputsAreValidDates(this.body, 'hdcEligibilityDate')) { + errors.hdcEligibilityDate = 'Eligibility date must be a real date' + } + + if (!dateIsComplete(this.body, 'conditionalReleaseDate')) { + errors.conditionalReleaseDate = "Enter the applicant's conditional release date" + } else if (!dateAndTimeInputsAreValidDates(this.body, 'conditionalReleaseDate')) { + errors.conditionalReleaseDate = 'Conditional release date must be a real date' + } else if (!dateIsTodayOrInTheFuture(this.body, 'conditionalReleaseDate')) { + errors.conditionalReleaseDate = 'Conditional release date cannot be in the past' + } + + if (errors.hdcEligibilityDate || errors.conditionalReleaseDate) { + return errors + } + + if ( + isMoreThanMonthsBetweenDates( + this.body, + 'conditionalReleaseDate', + 'hdcEligibilityDate', + MAX_MONTHS_BETWEEN_HDC_AND_CRD, + ) + ) { + errors.hdcEligibilityDate = + 'HDC eligibility date cannot be more than 6 months before the conditional release date' + } + if (!isBeforeDate(this.body, 'hdcEligibilityDate', 'conditionalReleaseDate')) { + errors.hdcEligibilityDate = 'HDC eligibility date must be before the conditional release date' + } + return errors + } +} diff --git a/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcWarning.test.ts b/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcWarning.test.ts new file mode 100644 index 0000000..5e58360 --- /dev/null +++ b/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcWarning.test.ts @@ -0,0 +1,26 @@ +import { itShouldHavePreviousValue, itShouldHaveNextValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import HDCWarning from './hdcWarning' + +describe('HDCWarning', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('returns the page title', () => { + const page = new HDCWarning({}, application) + + expect(page.title).toEqual('It may be too late to offer this applicant a CAS-2 placement') + }) + }) + + itShouldHavePreviousValue(new HDCWarning({}, application), 'hdc-licence-dates') + itShouldHaveNextValue(new HDCWarning({}, application), '') + + describe('errors', () => { + it('returns no errors as this page has no questions/answers', () => { + const page = new HDCWarning({}, application) + + expect(page.errors()).toEqual({}) + }) + }) +}) diff --git a/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcWarning.ts b/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcWarning.ts new file mode 100644 index 0000000..377b607 --- /dev/null +++ b/server/form-pages/apply/before-you-start/hdc-licence-dates/hdcWarning.ts @@ -0,0 +1,39 @@ +import { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' + +type HDCWarningBody = Record + +@Page({ + name: 'hdc-warning', + bodyProperties: [], +}) +export default class HDCWarning implements TaskListPage { + documentTitle = 'It may be too late to offer this applicant a CAS-2 placement' + + title = 'It may be too late to offer this applicant a CAS-2 placement' + + body: HDCWarningBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as HDCWarningBody + } + + previous() { + return 'hdc-licence-dates' + } + + next() { + return '' + } + + errors() { + const errors: TaskListErrors = {} + + return errors + } +} diff --git a/server/form-pages/apply/before-you-start/hdc-licence-dates/index.ts b/server/form-pages/apply/before-you-start/hdc-licence-dates/index.ts new file mode 100644 index 0000000..ff641c8 --- /dev/null +++ b/server/form-pages/apply/before-you-start/hdc-licence-dates/index.ts @@ -0,0 +1,13 @@ +/* istanbul ignore file */ + +import { Task } from '../../../utils/decorators' +import HDCLicenceDatesPage from './hdcLicenceDates' +import HDCWarning from './hdcWarning' +import HDCIneligible from './hdcIneligible' + +@Task({ + name: 'Add HDC licence dates', + slug: 'hdc-licence-dates', + pages: [HDCLicenceDatesPage, HDCWarning, HDCIneligible], +}) +export default class HDCLicenceDates {} diff --git a/server/form-pages/apply/before-you-start/index.ts b/server/form-pages/apply/before-you-start/index.ts new file mode 100644 index 0000000..70fadd7 --- /dev/null +++ b/server/form-pages/apply/before-you-start/index.ts @@ -0,0 +1,14 @@ +/* istanbul ignore file */ + +import { Section } from '../../utils/decorators' +import ConfirmEligibility from './confirm-eligibility' +import ConfirmConsent from './confirm-consent' +import ReferrerDetails from './referrer-details' +import CheckInformationNeeded from './information-needed-from-applicant' +import HDCLicenceDates from './hdc-licence-dates' + +@Section({ + title: 'Before you apply', + tasks: [ConfirmEligibility, ConfirmConsent, HDCLicenceDates, ReferrerDetails, CheckInformationNeeded], +}) +export default class BeforeYouStart {} diff --git a/server/form-pages/apply/before-you-start/information-needed-from-applicant/checkInformationNeeded.test.ts b/server/form-pages/apply/before-you-start/information-needed-from-applicant/checkInformationNeeded.test.ts new file mode 100644 index 0000000..e54819e --- /dev/null +++ b/server/form-pages/apply/before-you-start/information-needed-from-applicant/checkInformationNeeded.test.ts @@ -0,0 +1,52 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import CheckInformationNeeded from './checkInformationNeeded' +import { applicationFactory, personFactory } from '../../../../testutils/factories/index' +import { getQuestions } from '../../../utils/questions' + +describe('CheckInformationNeeded', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + const questions = getQuestions('Roger Smith') + + describe('title', () => { + it('personalises the page title', () => { + const page = new CheckInformationNeeded({ hasInformationNeeded: 'yes' }, application) + + expect(page.title).toEqual('Check information needed from Roger Smith') + }) + }) + + itShouldHaveNextValue(new CheckInformationNeeded({ hasInformationNeeded: 'yes' }, application), '') + itShouldHavePreviousValue(new CheckInformationNeeded({ hasInformationNeeded: 'yes' }, application), 'taskList') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new CheckInformationNeeded({ hasInformationNeeded: 'yes' }, application) + + expect(page.items()).toEqual([ + { + value: 'yes', + text: questions['information-needed-from-applicant']['information-needed-from-applicant'].hasInformationNeeded + .answers.yes, + checked: true, + }, + { + value: 'no', + text: questions['information-needed-from-applicant']['information-needed-from-applicant'].hasInformationNeeded + .answers.no, + checked: false, + }, + ]) + }) + }) + + describe('errors', () => { + it('should return errors when yes/no questions are blank', () => { + const page = new CheckInformationNeeded({}, application) + + expect(page.errors()).toEqual({ + hasInformationNeeded: 'Confirm whether you have all the information you need from the applicant', + }) + }) + }) +}) diff --git a/server/form-pages/apply/before-you-start/information-needed-from-applicant/checkInformationNeeded.ts b/server/form-pages/apply/before-you-start/information-needed-from-applicant/checkInformationNeeded.ts new file mode 100644 index 0000000..69b4fc9 --- /dev/null +++ b/server/form-pages/apply/before-you-start/information-needed-from-applicant/checkInformationNeeded.ts @@ -0,0 +1,66 @@ +import type { TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +type InformationNeededBody = { + hasInformationNeeded: YesOrNo +} +@Page({ + name: 'information-needed-from-applicant', + bodyProperties: ['hasInformationNeeded'], +}) +export default class InformationNeeded implements TaskListPage { + documentTitle = 'Check information needed from the applicant' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Check information needed from ${this.personName}` + + questions: Record + + options: Record + + body: InformationNeededBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as InformationNeededBody + + const applicationQuestions = getQuestions(this.personName) + this.questions = { + hasInformationNeeded: + applicationQuestions['information-needed-from-applicant']['information-needed-from-applicant'] + .hasInformationNeeded.question, + } + this.options = + applicationQuestions['information-needed-from-applicant'][ + 'information-needed-from-applicant' + ].hasInformationNeeded.answers + } + + previous() { + return 'taskList' + } + + next() { + return '' + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.hasInformationNeeded) { + errors.hasInformationNeeded = 'Confirm whether you have all the information you need from the applicant' + } + return errors + } + + items() { + return convertKeyValuePairToRadioItems(this.options, this.body.hasInformationNeeded) + } +} diff --git a/server/form-pages/apply/before-you-start/information-needed-from-applicant/index.ts b/server/form-pages/apply/before-you-start/information-needed-from-applicant/index.ts new file mode 100644 index 0000000..b81ba07 --- /dev/null +++ b/server/form-pages/apply/before-you-start/information-needed-from-applicant/index.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ + +import { Task } from '../../../utils/decorators' +import CheckInformationNeededPage from './checkInformationNeeded' + +@Task({ + name: 'Check information needed from the applicant', + slug: 'information-needed-from-applicant', + pages: [CheckInformationNeededPage], +}) +export default class CheckInformationNeeded {} diff --git a/server/form-pages/apply/before-you-start/referrer-details/confirmDetails.test.ts b/server/form-pages/apply/before-you-start/referrer-details/confirmDetails.test.ts new file mode 100644 index 0000000..e4c5e48 --- /dev/null +++ b/server/form-pages/apply/before-you-start/referrer-details/confirmDetails.test.ts @@ -0,0 +1,24 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import ConfirmDetails from './confirmDetails' +import { applicationFactory, personFactory } from '../../../../testutils/factories/index' + +describe('ConfirmDetails', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + itShouldHaveNextValue(new ConfirmDetails({}, application), 'job-title') + itShouldHavePreviousValue(new ConfirmDetails({}, application), 'taskList') + + it('writes the user name and email to the referrerDetails property', () => { + const page = new ConfirmDetails({}, application) + + expect(page.referrerDetails).toEqual({ name: application.createdBy.name, email: application.createdBy.email }) + }) + + describe('errors', () => { + it('not implemented', () => { + const page = new ConfirmDetails({}, application) + + expect(page.errors()).toEqual({}) + }) + }) +}) diff --git a/server/form-pages/apply/before-you-start/referrer-details/confirmDetails.ts b/server/form-pages/apply/before-you-start/referrer-details/confirmDetails.ts new file mode 100644 index 0000000..7bfed53 --- /dev/null +++ b/server/form-pages/apply/before-you-start/referrer-details/confirmDetails.ts @@ -0,0 +1,53 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +type ConfirmReferrerDetailsBody = { + name: string + email: string +} + +@Page({ + name: 'confirm-details', + bodyProperties: ['name', 'email'], +}) +export default class ConfirmReferrerDetails implements TaskListPage { + documentTitle = 'Confirm your details' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Confirm your details` + + questions + + body: ConfirmReferrerDetailsBody + + referrerDetails: ConfirmReferrerDetailsBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.referrerDetails = { name: application.createdBy.name, email: application.createdBy.email } + + const applicationQuestions = getQuestions(this.personName) + this.questions = applicationQuestions['referrer-details']['confirm-details'] + } + + previous() { + return 'taskList' + } + + next() { + return 'job-title' + } + + errors() { + const errors: TaskListErrors = {} + + return errors + } +} diff --git a/server/form-pages/apply/before-you-start/referrer-details/contactNumber.test.ts b/server/form-pages/apply/before-you-start/referrer-details/contactNumber.test.ts new file mode 100644 index 0000000..441ae65 --- /dev/null +++ b/server/form-pages/apply/before-you-start/referrer-details/contactNumber.test.ts @@ -0,0 +1,18 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import ContactNumber from './contactNumber' +import { applicationFactory, personFactory } from '../../../../testutils/factories/index' + +describe('ContactNumber', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + itShouldHaveNextValue(new ContactNumber({}, application), '') + itShouldHavePreviousValue(new ContactNumber({}, application), 'job-title') + + describe('errors', () => { + it('returns an error if contact number is missing', () => { + const page = new ContactNumber({}, application) + + expect(page.errors()).toEqual({ telephone: 'Enter your contact telephone number' }) + }) + }) +}) diff --git a/server/form-pages/apply/before-you-start/referrer-details/contactNumber.ts b/server/form-pages/apply/before-you-start/referrer-details/contactNumber.ts new file mode 100644 index 0000000..03fe5a2 --- /dev/null +++ b/server/form-pages/apply/before-you-start/referrer-details/contactNumber.ts @@ -0,0 +1,56 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +type ContactNumberBody = { + telephone: string +} + +@Page({ + name: 'contact-number', + bodyProperties: ['telephone'], +}) +export default class ContactNumber implements TaskListPage { + documentTitle: string + + personName = nameOrPlaceholderCopy(this.application.person) + + title: string + + questions + + body: ContactNumberBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as ContactNumberBody + + const applicationQuestions = getQuestions(this.personName) + this.questions = applicationQuestions['referrer-details']['contact-number'] + this.documentTitle = this.questions.telephone.question + this.title = this.questions.telephone.question + } + + previous() { + return 'job-title' + } + + next() { + return '' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.telephone) { + errors.telephone = 'Enter your contact telephone number' + } + + return errors + } +} diff --git a/server/form-pages/apply/before-you-start/referrer-details/index.ts b/server/form-pages/apply/before-you-start/referrer-details/index.ts new file mode 100644 index 0000000..8a6f45f --- /dev/null +++ b/server/form-pages/apply/before-you-start/referrer-details/index.ts @@ -0,0 +1,13 @@ +/* istanbul ignore file */ + +import { Task } from '../../../utils/decorators' +import ConfirmDetailsPage from './confirmDetails' +import ContactNumberPage from './contactNumber' +import JobTitlePage from './jobTitle' + +@Task({ + name: 'Add referrer details', + slug: 'referrer-details', + pages: [ConfirmDetailsPage, JobTitlePage, ContactNumberPage], +}) +export default class ReferrerDetails {} diff --git a/server/form-pages/apply/before-you-start/referrer-details/jobTitle.test.ts b/server/form-pages/apply/before-you-start/referrer-details/jobTitle.test.ts new file mode 100644 index 0000000..c421d11 --- /dev/null +++ b/server/form-pages/apply/before-you-start/referrer-details/jobTitle.test.ts @@ -0,0 +1,18 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import JobTitle from './jobTitle' +import { applicationFactory, personFactory } from '../../../../testutils/factories/index' + +describe('JobTitle', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + itShouldHaveNextValue(new JobTitle({}, application), 'contact-number') + itShouldHavePreviousValue(new JobTitle({}, application), 'confirm-details') + + describe('errors', () => { + it('returns an error if job title is missing', () => { + const page = new JobTitle({}, application) + + expect(page.errors()).toEqual({ jobTitle: 'Enter your job title' }) + }) + }) +}) diff --git a/server/form-pages/apply/before-you-start/referrer-details/jobTitle.ts b/server/form-pages/apply/before-you-start/referrer-details/jobTitle.ts new file mode 100644 index 0000000..bc571a1 --- /dev/null +++ b/server/form-pages/apply/before-you-start/referrer-details/jobTitle.ts @@ -0,0 +1,56 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +type JobTitleBody = { + jobTitle: string +} + +@Page({ + name: 'job-title', + bodyProperties: ['jobTitle'], +}) +export default class JobTitle implements TaskListPage { + documentTitle: string + + personName = nameOrPlaceholderCopy(this.application.person) + + title: string + + questions + + body: JobTitleBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as JobTitleBody + + const applicationQuestions = getQuestions(this.personName) + this.questions = applicationQuestions['referrer-details']['job-title'] + this.documentTitle = this.questions.jobTitle.question + this.title = this.questions.jobTitle.question + } + + previous() { + return 'confirm-details' + } + + next() { + return 'contact-number' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.jobTitle) { + errors.jobTitle = 'Enter your job title' + } + + return errors + } +} diff --git a/server/form-pages/apply/check-your-answers/check-your-answers/checkYourAnswers.test.ts b/server/form-pages/apply/check-your-answers/check-your-answers/checkYourAnswers.test.ts new file mode 100644 index 0000000..cc07d48 --- /dev/null +++ b/server/form-pages/apply/check-your-answers/check-your-answers/checkYourAnswers.test.ts @@ -0,0 +1,86 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' + +import { applicationFactory, personFactory, restrictedPersonFactory } from '../../../../testutils/factories' +import CheckYourAnswers from './checkYourAnswers' + +describe('CheckYourAnswers', () => { + let application = applicationFactory.build({}) + + const body = { + checkYourAnswers: 'confirmed', + } + + describe('body', () => { + it('should set the body', () => { + const page = new CheckYourAnswers(body, application) + + expect(page.body).toEqual(body) + }) + }) + + describe('title', () => { + it('should set the title', () => { + const page = new CheckYourAnswers(body, application) + + expect(page.title).toEqual('Check your answers before sending your application') + }) + }) + + describe('applicationSummary', () => { + it('returns data for application summary', () => { + const person = personFactory.build({ name: 'name', nomsNumber: '123', prisonName: 'prison-name' }) + application = applicationFactory.build({ + person, + createdBy: { email: 'createdByEmail', name: 'createdByName' }, + }) + + const page = new CheckYourAnswers(body, application) + + expect(page.applicationSummary()).toEqual({ + id: application.id, + name: person.name, + prisonNumber: person.nomsNumber, + prisonName: person.prisonName, + referrerName: application.createdBy.name, + contactEmail: application.createdBy.email, + view: 'checkYourAnswers', + }) + }) + + describe('when person type is not FullPerson', () => { + it('returns data for the application summary', () => { + const person = restrictedPersonFactory.build({}) + application = applicationFactory.build({ + person, + createdBy: { email: 'createdByEmail', name: 'createdByName' }, + }) + + const page = new CheckYourAnswers(body, application) + + expect(page.applicationSummary()).toEqual({ + id: application.id, + name: null, + prisonNumber: null, + prisonName: null, + referrerName: application.createdBy.name, + contactEmail: application.createdBy.email, + view: 'checkYourAnswers', + }) + }) + }) + }) + + itShouldHaveNextValue(new CheckYourAnswers(body, application), '') + itShouldHavePreviousValue(new CheckYourAnswers(body, application), 'dashboard') + + describe('errors', () => { + it('should return an error when page has not been reviewed', () => { + const page = new CheckYourAnswers({}, application) + + expect(page.errors()).toEqual({ + checkYourAnswers: + 'You must confirm the information provided is accurate and, where required, it has been verified by all relevant prison departments.', + }) + }) + }) +}) diff --git a/server/form-pages/apply/check-your-answers/check-your-answers/checkYourAnswers.ts b/server/form-pages/apply/check-your-answers/check-your-answers/checkYourAnswers.ts new file mode 100644 index 0000000..5cfa604 --- /dev/null +++ b/server/form-pages/apply/check-your-answers/check-your-answers/checkYourAnswers.ts @@ -0,0 +1,63 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import type { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import { isFullPerson } from '../../../../utils/utils' + +import TaskListPage from '../../../taskListPage' + +type CheckYourAnswersBody = { + checkYourAnswers?: string +} + +type ApplicationSummary = { + id: string + name?: string + prisonNumber?: string + prisonName?: string + referrerName: string + contactEmail?: string + view: string +} + +@Page({ name: 'check-your-answers', bodyProperties: ['checkYourAnswers'] }) +export default class CheckYourAnswers implements TaskListPage { + documentTitle = 'Check your answers before sending your application' + + title = 'Check your answers before sending your application' + + constructor( + public body: Partial, + readonly application: Application, + ) {} + + previous() { + return 'dashboard' + } + + next() { + return '' + } + + applicationSummary(): ApplicationSummary { + return { + id: this.application.id, + name: isFullPerson(this.application.person) ? this.application.person.name : null, + prisonNumber: isFullPerson(this.application.person) ? this.application.person.nomsNumber : null, + prisonName: isFullPerson(this.application.person) ? this.application.person.prisonName : null, + referrerName: this.application.createdBy.name, + contactEmail: this.application.createdBy.email, + view: 'checkYourAnswers', + } + } + + errors() { + const errors: TaskListErrors = {} + + if (this.body?.checkYourAnswers !== 'confirmed') { + errors.checkYourAnswers = + 'You must confirm the information provided is accurate and, where required, it has been verified by all relevant prison departments.' + } + + return errors + } +} diff --git a/server/form-pages/apply/check-your-answers/index.ts b/server/form-pages/apply/check-your-answers/index.ts new file mode 100644 index 0000000..3fafbd6 --- /dev/null +++ b/server/form-pages/apply/check-your-answers/index.ts @@ -0,0 +1,15 @@ +/* istanbul ignore file */ + +import CheckYourAnswersPage from './check-your-answers/checkYourAnswers' +import { Section, Task } from '../../utils/decorators' + +@Task({ + name: 'Check application answers', + slug: 'check-your-answers', + pages: [CheckYourAnswersPage], +}) +@Section({ + title: 'Check answers', + tasks: [CheckYourAnswers], +}) +export default class CheckYourAnswers {} diff --git a/server/form-pages/apply/index.ts b/server/form-pages/apply/index.ts new file mode 100644 index 0000000..9d7c6c4 --- /dev/null +++ b/server/form-pages/apply/index.ts @@ -0,0 +1,20 @@ +import { Form } from '../utils/decorators' +import BaseForm from '../baseForm' +import BeforeYouStart from './before-you-start' +import AreaAndFunding from './area-and-funding' +import AboutPerson from './about-the-person' +import RisksAndNeeds from './risks-and-needs' +import CheckYourAnswers from './check-your-answers' +import OffenceAndLicenceInformation from './offence-and-licence-information' + +@Form({ + sections: [ + BeforeYouStart, + AboutPerson, + AreaAndFunding, + RisksAndNeeds, + OffenceAndLicenceInformation, + CheckYourAnswers, + ], +}) +export default class Apply extends BaseForm {} diff --git a/server/form-pages/apply/offence-and-licence-information/cpp-details-and-hdc-licence-conditions/cppDetails.test.ts b/server/form-pages/apply/offence-and-licence-information/cpp-details-and-hdc-licence-conditions/cppDetails.test.ts new file mode 100644 index 0000000..362403b --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/cpp-details-and-hdc-licence-conditions/cppDetails.test.ts @@ -0,0 +1,50 @@ +import { itShouldHavePreviousValue, itShouldHaveNextValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import CPPDetails from './cppDetails' + +describe('CPPDetails', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new CPPDetails({}, application) + + expect(page.title).toEqual("Who is Roger Smith's Community Probation Practitioner (CPP)?") + }) + }) + + itShouldHavePreviousValue(new CPPDetails({}, application), 'taskList') + itShouldHaveNextValue(new CPPDetails({}, application), 'non-standard-licence-conditions') + + describe('errors', () => { + describe('when they have not provided any answer', () => { + it('returns errors', () => { + const page = new CPPDetails({}, application) + expect(page.errors()).toEqual({ + name: "Enter the CPP's full name", + probationRegion: 'Enter the probation region', + email: "Enter the CPP's email address", + telephone: "Enter the CPP's contact number", + }) + }) + }) + }) + + describe('response', () => { + it('returns data in expected format', () => { + const page = new CPPDetails( + { + name: 'a name', + probationRegion: 'a probation region', + email: 'an email address', + telephone: 'a phone number', + }, + application, + ) + + expect(page.response()).toEqual({ + "Who is Roger Smith's Community Probation Practitioner (CPP)?": `a name\r\na probation region\r\nan email address\r\na phone number`, + }) + }) + }) +}) diff --git a/server/form-pages/apply/offence-and-licence-information/cpp-details-and-hdc-licence-conditions/cppDetails.ts b/server/form-pages/apply/offence-and-licence-information/cpp-details-and-hdc-licence-conditions/cppDetails.ts new file mode 100644 index 0000000..28817ec --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/cpp-details-and-hdc-licence-conditions/cppDetails.ts @@ -0,0 +1,70 @@ +import { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +type CPPDetailsBody = { + name: string + probationRegion: string + email: string + telephone: string +} + +@Page({ + name: 'cpp-details', + bodyProperties: ['name', 'probationRegion', 'email', 'telephone'], +}) +export default class CPPDetails implements TaskListPage { + documentTitle = "Who is the person's Community Probation Practitioner (CPP)?" + + personName = nameOrPlaceholderCopy(this.application.person) + + title + + questions = getQuestions(this.personName)['cpp-details-and-hdc-licence-conditions']['cpp-details'] + + options: Record + + body: CPPDetailsBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as CPPDetailsBody + this.title = this.questions.cppDetails.question + } + + previous() { + return 'taskList' + } + + next() { + return 'non-standard-licence-conditions' + } + + response() { + return { + [this.title]: `${this.body.name}\r\n${this.body.probationRegion}\r\n${this.body.email}\r\n${this.body.telephone}`, + } + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.name) { + errors.name = "Enter the CPP's full name" + } + if (!this.body.probationRegion) { + errors.probationRegion = 'Enter the probation region' + } + if (!this.body.email) { + errors.email = "Enter the CPP's email address" + } + if (!this.body.telephone) { + errors.telephone = "Enter the CPP's contact number" + } + return errors + } +} diff --git a/server/form-pages/apply/offence-and-licence-information/cpp-details-and-hdc-licence-conditions/index.ts b/server/form-pages/apply/offence-and-licence-information/cpp-details-and-hdc-licence-conditions/index.ts new file mode 100644 index 0000000..c6944c7 --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/cpp-details-and-hdc-licence-conditions/index.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ + +import { Task } from '../../../utils/decorators' +import CPPDetails from './cppDetails' +import NonStandardLicenceConditions from './nonStandardLicenceConditions' + +@Task({ + name: 'Add CPP details and HDC licence conditions', + slug: 'cpp-details-and-hdc-licence-conditions', + pages: [CPPDetails, NonStandardLicenceConditions], +}) +export default class CPPDetailsAndHDCLicenceConditions {} diff --git a/server/form-pages/apply/offence-and-licence-information/cpp-details-and-hdc-licence-conditions/nonStandardLicenceConditions.test.ts b/server/form-pages/apply/offence-and-licence-information/cpp-details-and-hdc-licence-conditions/nonStandardLicenceConditions.test.ts new file mode 100644 index 0000000..b7e7781 --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/cpp-details-and-hdc-licence-conditions/nonStandardLicenceConditions.test.ts @@ -0,0 +1,87 @@ +import { itShouldHavePreviousValue, itShouldHaveNextValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import NonStandardLicenceConditions, { NonStandardLicenceConditionsBody } from './nonStandardLicenceConditions' + +describe('NonStandardLicenceConditions', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new NonStandardLicenceConditions({}, application) + + expect(page.title).toEqual('Does Roger Smith have any non-standard licence conditions?') + }) + }) + + itShouldHavePreviousValue(new NonStandardLicenceConditions({}, application), 'cpp-details') + itShouldHaveNextValue(new NonStandardLicenceConditions({}, application), '') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new NonStandardLicenceConditions( + { + nonStandardLicenceConditions: 'no', + }, + application, + ) + + expect(page.items('some html')).toEqual([ + { + value: 'yes', + text: 'Yes', + checked: false, + conditional: { html: 'some html' }, + }, + { + value: 'no', + text: 'No', + checked: true, + }, + { + divider: 'or', + }, + { + value: 'dontKnow', + text: `I don't know`, + checked: false, + }, + ]) + }) + }) + + describe('errors', () => { + describe('when they have not provided any answer', () => { + it('returns an error', () => { + const page = new NonStandardLicenceConditions({}, application) + expect(page.errors()).toEqual({ + nonStandardLicenceConditions: "Choose either Yes, No or I don't know", + }) + }) + }) + describe('when they have not provided detail', () => { + it('returns an error', () => { + const page = new NonStandardLicenceConditions({ nonStandardLicenceConditions: 'yes' }, application) + expect(page.errors()).toEqual({ + nonStandardLicenceConditionsDetail: 'Describe their non-standard licence conditions', + }) + }) + }) + }) + + describe('onSave', () => { + it('removes non-standard license conditions data when the question is not set to "yes"', () => { + const body: NonStandardLicenceConditionsBody = { + nonStandardLicenceConditions: 'dontKnow', + nonStandardLicenceConditionsDetail: 'Non-standard license condition detail', + } + + const page = new NonStandardLicenceConditions(body, application) + + page.onSave() + + expect(page.body).toEqual({ + nonStandardLicenceConditions: 'dontKnow', + }) + }) + }) +}) diff --git a/server/form-pages/apply/offence-and-licence-information/cpp-details-and-hdc-licence-conditions/nonStandardLicenceConditions.ts b/server/form-pages/apply/offence-and-licence-information/cpp-details-and-hdc-licence-conditions/nonStandardLicenceConditions.ts new file mode 100644 index 0000000..322e1ea --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/cpp-details-and-hdc-licence-conditions/nonStandardLicenceConditions.ts @@ -0,0 +1,78 @@ +import { Radio, TaskListErrors, YesNoOrDontKnow } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' +import { convertKeyValuePairToRadioItems } from '../../../../utils/formUtils' + +export type NonStandardLicenceConditionsBody = { + nonStandardLicenceConditions: YesNoOrDontKnow + nonStandardLicenceConditionsDetail: string +} + +@Page({ + name: 'non-standard-licence-conditions', + bodyProperties: ['nonStandardLicenceConditions', 'nonStandardLicenceConditionsDetail'], +}) +export default class NonStandardLicenceConditions implements TaskListPage { + documentTitle = 'Does the person have any non-standard licence conditions?' + + personName = nameOrPlaceholderCopy(this.application.person) + + title + + questions = getQuestions(this.personName)['cpp-details-and-hdc-licence-conditions']['non-standard-licence-conditions'] + + body: NonStandardLicenceConditionsBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as NonStandardLicenceConditionsBody + this.title = this.questions.nonStandardLicenceConditions.question + } + + previous() { + return 'cpp-details' + } + + next() { + return '' + } + + items(detailConditionalHtml: string) { + const items = convertKeyValuePairToRadioItems( + this.questions.nonStandardLicenceConditions.answers, + this.body.nonStandardLicenceConditions, + ) as Array + + items.forEach(item => { + if (item.value === 'yes') { + item.conditional = { html: detailConditionalHtml } + } + }) + + const dontKnow = items.pop() + + return [...items, { divider: 'or' }, { ...dontKnow }] + } + + errors() { + const errors: TaskListErrors = {} + if (!this.body.nonStandardLicenceConditions) { + errors.nonStandardLicenceConditions = "Choose either Yes, No or I don't know" + } + if (this.body.nonStandardLicenceConditions === 'yes' && !this.body.nonStandardLicenceConditionsDetail) { + errors.nonStandardLicenceConditionsDetail = 'Describe their non-standard licence conditions' + } + return errors + } + + onSave(): void { + if (this.body.nonStandardLicenceConditions !== 'yes') { + delete this.body.nonStandardLicenceConditionsDetail + } + } +} diff --git a/server/form-pages/apply/offence-and-licence-information/current-offences/currentOffences.test.ts b/server/form-pages/apply/offence-and-licence-information/current-offences/currentOffences.test.ts new file mode 100644 index 0000000..99ca43e --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/current-offences/currentOffences.test.ts @@ -0,0 +1,153 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import CurrentOffences from './currentOffences' +import CurrentOffenceData from './custom-forms/currentOffenceData' + +jest.mock('./custom-forms/currentOffenceData') + +describe('CurrentOffences', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + const applicationWithData = applicationFactory.build({ + person: personFactory.build({ name: 'Roger Smith' }), + data: { + 'current-offences': { + 'current-offence-data': [ + { + titleAndNumber: 'Stalking', + offenceCategory: 'stalkingOrHarassment', + 'offenceDate-day': '1', + 'offenceDate-month': '2', + 'offenceDate-year': '2023', + sentenceLength: '12 months', + summary: 'summary detail', + outstandingCharges: 'yes', + outstandingChargesDetail: 'some detail', + }, + { + titleAndNumber: 'Arson', + offenceCategory: 'arson', + 'offenceDate-day': '5', + 'offenceDate-month': '6', + 'offenceDate-year': '1940', + sentenceLength: '3 years', + summary: 'second summary detail', + outstandingCharges: 'no', + outstandingChargesDetail: '', + }, + ], + }, + }, + }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new CurrentOffences({}, application) + + expect(page.title).toEqual('Current offences for Roger Smith') + }) + }) + + describe('current offence data', () => { + describe('when there is current offence data on the application', () => { + it('assigns them to the offences field on the page', () => { + const page = new CurrentOffences({}, applicationWithData) + + expect(page.offences).toEqual([ + { + titleAndNumber: 'Stalking', + offenceCategoryTag: 'Stalking or Harassment', + offenceCategoryText: 'Stalking or Harassment', + offenceDate: '1 February 2023', + sentenceLength: '12 months', + summary: 'summary detail', + outstandingCharges: 'Yes', + outstandingChargesDetail: 'some detail', + removeLink: `/applications/${applicationWithData.id}/tasks/current-offences/pages/current-offence-data/0/removeFromList?redirectPage=current-offences`, + }, + { + titleAndNumber: 'Arson', + offenceCategoryTag: 'Arson', + offenceCategoryText: 'Arson', + offenceDate: '5 June 1940', + sentenceLength: '3 years', + summary: 'second summary detail', + outstandingCharges: 'No', + outstandingChargesDetail: '', + removeLink: `/applications/${applicationWithData.id}/tasks/current-offences/pages/current-offence-data/1/removeFromList?redirectPage=current-offences`, + }, + ]) + }) + }) + }) + + itShouldHaveNextValue(new CurrentOffences({}, application), '') + itShouldHavePreviousValue(new CurrentOffences({}, application), 'taskList') + + describe('errors', () => { + it('returns an empty object where there is current offence data', () => { + const page = new CurrentOffences({}, applicationWithData) + expect(page.errors()).toEqual({}) + }) + + it('returns an error where there is no current offence data', () => { + const page = new CurrentOffences({}, application) + expect(page.errors()).toEqual({ offenceList: 'Current offences must be added to the application' }) + }) + }) + + describe('response', () => { + it('returns the offence information', () => { + const page = new CurrentOffences({}, applicationWithData) + expect(page.response()).toEqual({ + 'Current offence 1': + 'Stalking\r\nStalking or Harassment\r\n1 February 2023\r\n12 months\r\n\nSummary: summary detail\r\nOutstanding charges: Yes\r\nDetails of outstanding charges: some detail', + 'Current offence 2': + 'Arson\r\nArson\r\n5 June 1940\r\n3 years\r\n\nSummary: second summary detail\r\nOutstanding charges: No', + }) + }) + + it('returns empty object when there are no offences', () => { + const page = new CurrentOffences({}, application) + expect(page.response()).toEqual({}) + }) + }) + + describe('getOffenceTagColour', () => { + const categories = [ + ['stalkingOrHarassment', 'blue'], + ['weaponsOrFirearms', 'red'], + ['arson', 'yellow'], + ['violence', 'pink'], + ['domesticAbuse', 'purple'], + ['hateCrime', 'green'], + ['drugs', 'custom-brown'], + ['other', 'grey'], + ['undefinedCategory', 'grey'], + ] + it.each(categories)('returns correct colour for category %s', (category, colour) => { + const page = new CurrentOffences({}, applicationWithData) + expect(page.getOffenceTagColour(category)).toEqual(colour) + }) + }) + + describe('initialize', () => { + it('returns CurrentOffenceData page if there is no current offences data', () => { + const currentOffenceDataPageConstructor = jest.fn() + + ;(CurrentOffenceData as jest.Mock).mockImplementation(() => { + return currentOffenceDataPageConstructor + }) + + CurrentOffences.initialize({}, application) + + expect(CurrentOffenceData).toHaveBeenCalledWith({}, application) + }) + + it('returns CurrentOffence page if there is current offences data', async () => { + const page = (await CurrentOffences.initialize({}, applicationWithData)) as CurrentOffences + + expect(page.title).toBe('Current offences for Roger Smith') + }) + }) +}) diff --git a/server/form-pages/apply/offence-and-licence-information/current-offences/currentOffences.ts b/server/form-pages/apply/offence-and-licence-information/current-offences/currentOffences.ts new file mode 100644 index 0000000..7e3d7d6 --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/current-offences/currentOffences.ts @@ -0,0 +1,162 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import CurrentOffenceData, { CurrentOffenceDataBody } from './custom-forms/currentOffenceData' +import { DateFormats } from '../../../../utils/dateUtils' +import { createQueryString, nameOrPlaceholderCopy } from '../../../../utils/utils' +import paths from '../../../../paths/apply' +import { getQuestions } from '../../../utils/questions' + +type CurrentOffencesBody = { offenceList: string } + +type CurrentOffencesUI = { + titleAndNumber: string + offenceCategoryTag: string + offenceCategoryText: string + offenceDate: string + sentenceLength: string + summary: string + outstandingCharges: string + outstandingChargesDetail: string + removeLink: string +} + +@Page({ + name: 'current-offences', + bodyProperties: ['offenceList'], +}) +export default class CurrentOffences implements TaskListPage { + personName = nameOrPlaceholderCopy(this.application.person) + + documentTitle = 'Current offences' + + title = `Current offences for ${this.personName}` + + body: CurrentOffencesBody + + offences: CurrentOffencesUI[] + + pageName = 'current-offences' + + dataPageName = 'current-offence-data' + + taskName = 'current-offences' + + currentOffenceQuestions = getQuestions('')['current-offences']['current-offence-data'] + + constructor( + body: Partial, + private readonly application: Application, + ) { + if (application.data[this.taskName]?.[this.dataPageName]) { + const CurrentOffencesData = application.data[this.taskName][this.dataPageName] as [CurrentOffenceDataBody] + + const query = { + redirectPage: this.pageName, + } + + this.offences = CurrentOffencesData.map((offence, index) => { + const offenceDate = DateFormats.dateAndTimeInputsToUiDate(offence, 'offenceDate') + + const offenceCategoryText = + this.currentOffenceQuestions.offenceCategory.answers[ + offence.offenceCategory as keyof typeof this.currentOffenceQuestions.offenceCategory.answers + ] + + return { + titleAndNumber: offence.titleAndNumber, + offenceCategoryTag: this.getOffenceCategoryTag(offence.offenceCategory, offenceCategoryText), + offenceCategoryText, + offenceDate, + sentenceLength: offence.sentenceLength, + summary: offence.summary, + outstandingCharges: this.currentOffenceQuestions.outstandingCharges.answers[offence.outstandingCharges], + outstandingChargesDetail: offence.outstandingChargesDetail, + removeLink: `${paths.applications.removeFromList({ + id: application.id, + task: this.taskName, + page: this.dataPageName, + index: index.toString(), + })}?${createQueryString(query)}`, + } + }) + } + this.body = body as CurrentOffencesBody + } + + static async initialize(body: Partial, application: Application) { + if (!application.data['current-offences']?.['current-offence-data']) { + return new CurrentOffenceData(body, application) + } + return new CurrentOffences({}, application) + } + + previous() { + return 'taskList' + } + + next() { + return '' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.application.data['current-offences']?.['current-offence-data'].length) { + errors.offenceList = 'Current offences must be added to the application' + } + + return errors + } + + response() { + const response: Record = {} + + this.offences?.forEach((offence, index) => { + const { + titleAndNumber, + offenceCategoryText, + offenceDate, + sentenceLength, + summary, + outstandingCharges, + outstandingChargesDetail, + } = offence + let offenceString = `${titleAndNumber}\r\n${offenceCategoryText}\r\n${offenceDate}\r\n${sentenceLength}\r\n\nSummary: ${summary}\r\nOutstanding charges: ${outstandingCharges}` + if (outstandingChargesDetail) { + offenceString += `\r\nDetails of outstanding charges: ${outstandingChargesDetail}` + } + response[`Current offence ${index + 1}`] = offenceString + }) + + return response + } + + getOffenceCategoryTag(offenceCategory: string, offenceCategoryText: string) { + return `${offenceCategoryText}` + } + + getOffenceTagColour(offenceCategory: string) { + switch (offenceCategory) { + case 'stalkingOrHarassment': + return 'blue' + case 'weaponsOrFirearms': + return 'red' + case 'arson': + return 'yellow' + case 'violence': + return 'pink' + case 'domesticAbuse': + return 'purple' + case 'hateCrime': + return 'green' + case 'drugs': + return 'custom-brown' + default: + return 'grey' + } + } +} diff --git a/server/form-pages/apply/offence-and-licence-information/current-offences/custom-forms/currentOffenceData.test.ts b/server/form-pages/apply/offence-and-licence-information/current-offences/custom-forms/currentOffenceData.test.ts new file mode 100644 index 0000000..770feee --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/current-offences/custom-forms/currentOffenceData.test.ts @@ -0,0 +1,86 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../../testutils/factories/index' +import CurrentOffenceData from './currentOffenceData' + +describe('CurrentOffenceData', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + const currentOffenceData = [ + { + titleAndNumber: 'Stalking', + offenceCategory: 'Arson', + 'offenceDate-day': '1', + 'offenceDate-month': '2', + 'offenceDate-year': '2023', + sentenceLength: '12 months', + summary: 'summary detail', + outstandingCharges: 'yes' as const, + outstandingChargesDetail: 'outstanding charges detail', + }, + ] + + describe('title', () => { + it('has a page title', () => { + const page = new CurrentOffenceData({}, application) + + expect(page.title).toEqual(`Add Roger Smith's current offence details`) + }) + }) + + itShouldHaveNextValue(new CurrentOffenceData({}, application), 'current-offences') + itShouldHavePreviousValue(new CurrentOffenceData({}, application), 'current-offences') + + describe('errors', () => { + describe('when there are no errors', () => { + it('returns empty object', () => { + const page = new CurrentOffenceData(currentOffenceData[0], application) + expect(page.errors()).toEqual({}) + }) + }) + + describe('when there are errors', () => { + const requiredFields = [ + ['titleAndNumber', 'Enter the offence title'], + ['offenceCategory', 'Select the offence type'], + ['offenceDate', 'Enter the date the offence was committed'], + ['sentenceLength', 'Enter the sentence length'], + ['summary', 'Enter a summary of the offence'], + ['outstandingCharges', 'Select whether there are any outstanding charges'], + ] + + it.each(requiredFields)('it includes a validation error for %s', (field, message) => { + const page = new CurrentOffenceData({ offenceCategory: 'choose' }, application) + const errors = page.errors() + + expect(errors[field as keyof typeof errors]).toEqual(message) + }) + + it('when outstanding charges are selected but no detail is provided', () => { + const page = new CurrentOffenceData( + { + titleAndNumber: 'Stalking', + offenceCategory: 'Arson', + 'offenceDate-day': '1', + 'offenceDate-month': '2', + 'offenceDate-year': '2023', + sentenceLength: '12 months', + summary: 'summary detail', + outstandingCharges: 'yes' as const, + }, + application, + ) + + const errors = page.errors() + + expect(errors).toEqual({ outstandingChargesDetail: 'Enter the details of the outstanding charges' }) + }) + }) + + describe('response', () => { + it('returns empty object', () => { + const page = new CurrentOffenceData({}, application) + expect(page.response()).toEqual({}) + }) + }) + }) +}) diff --git a/server/form-pages/apply/offence-and-licence-information/current-offences/custom-forms/currentOffenceData.ts b/server/form-pages/apply/offence-and-licence-information/current-offences/custom-forms/currentOffenceData.ts new file mode 100644 index 0000000..7b20ef4 --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/current-offences/custom-forms/currentOffenceData.ts @@ -0,0 +1,126 @@ +import type { SelectItem, TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { Cas2Application } from '@approved-premises/api' +import { Page } from '../../../../utils/decorators' +import TaskListPage from '../../../../taskListPage' +import { dateAndTimeInputsAreValidDates } from '../../../../../utils/dateUtils' +import { getQuestions } from '../../../../utils/questions' +import { nameOrPlaceholderCopy } from '../../../../../utils/utils' + +export type CurrentOffenceDataBody = { + titleAndNumber: string + offenceCategory: string + offenceDate: string + 'offenceDate-day': string + 'offenceDate-month': string + 'offenceDate-year': string + sentenceLength: string + summary: string + outstandingCharges: YesOrNo + outstandingChargesDetail: string +} + +@Page({ + name: 'current-offence-data', + bodyProperties: [ + 'titleAndNumber', + 'offenceCategory', + 'offenceDate-day', + 'offenceDate-month', + 'offenceDate-year', + 'sentenceLength', + 'summary', + 'outstandingCharges', + 'outstandingChargesDetail', + ], +}) +export default class CurrentOffenceData implements TaskListPage { + personName = nameOrPlaceholderCopy(this.application.person) + + documentTitle = 'Add a current offence' + + title = `Add ${this.personName}'s current offence details` + + body: CurrentOffenceDataBody + + taskName = 'current-offences' + + pageName = 'current-offence-data' + + questions = getQuestions('')['current-offences']['current-offence-data'] + + offenceCategories: Array + + hasPreviouslySavedACurrentOffence: boolean + + constructor( + body: Partial, + private readonly application: Cas2Application, + ) { + this.body = body as CurrentOffenceDataBody + this.offenceCategories = this.getCategoriesAsItemsForSelect(this.body.offenceCategory) + this.hasPreviouslySavedACurrentOffence = Boolean(application.data['current-offences']?.['current-offence-data']) + } + + private getCategoriesAsItemsForSelect(selectedItem: string): Array { + const items = [ + { + value: 'choose', + text: 'Choose type', + selected: selectedItem === '', + }, + ] + Object.keys(this.questions.offenceCategory.answers).forEach(value => { + items.push({ + value, + text: this.questions.offenceCategory.answers[ + value as keyof typeof this.questions.offenceCategory.answers + ] as string, + selected: selectedItem === value, + }) + }) + + return items + } + + previous() { + return 'current-offences' + } + + next() { + return 'current-offences' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.titleAndNumber) { + errors.titleAndNumber = 'Enter the offence title' + } + if (this.body.offenceCategory === 'choose') { + errors.offenceCategory = 'Select the offence type' + } + if (!dateAndTimeInputsAreValidDates(this.body, 'offenceDate')) { + errors.offenceDate = 'Enter the date the offence was committed' + } + if (!this.body.sentenceLength) { + errors.sentenceLength = 'Enter the sentence length' + } + if (!this.body.summary) { + errors.summary = 'Enter a summary of the offence' + } + + if (!this.body.outstandingCharges) { + errors.outstandingCharges = 'Select whether there are any outstanding charges' + } + + if (this.body.outstandingCharges === 'yes' && !this.body.outstandingChargesDetail) { + errors.outstandingChargesDetail = 'Enter the details of the outstanding charges' + } + + return errors + } + + response() { + return {} + } +} diff --git a/server/form-pages/apply/offence-and-licence-information/current-offences/index.ts b/server/form-pages/apply/offence-and-licence-information/current-offences/index.ts new file mode 100644 index 0000000..08d5e35 --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/current-offences/index.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ + +import { Task } from '../../../utils/decorators' +import CurrentOffenceData from './custom-forms/currentOffenceData' +import CurrentOffencesIndexPage from './currentOffences' + +@Task({ + name: 'Add current offences', + slug: 'current-offences', + pages: [CurrentOffencesIndexPage, CurrentOffenceData], +}) +export default class CurrentOffences {} diff --git a/server/form-pages/apply/offence-and-licence-information/index.ts b/server/form-pages/apply/offence-and-licence-information/index.ts new file mode 100644 index 0000000..75cb8c6 --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/index.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ + +import { Section } from '../../utils/decorators' +import OffendingHistory from './offending-history' +import CurrentOffences from './current-offences' +import CPPDetailsAndHDCLicenceConditions from './cpp-details-and-hdc-licence-conditions' + +@Section({ + title: 'Offence and licence information', + tasks: [CurrentOffences, OffendingHistory, CPPDetailsAndHDCLicenceConditions], +}) +export default class OffenceAndLicenceInformation {} diff --git a/server/form-pages/apply/offence-and-licence-information/offending-history/anyPreviousConvictions.test.ts b/server/form-pages/apply/offence-and-licence-information/offending-history/anyPreviousConvictions.test.ts new file mode 100644 index 0000000..c9b9e20 --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/offending-history/anyPreviousConvictions.test.ts @@ -0,0 +1,89 @@ +import { itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import AnyPreviousConvictions, { PreviousConvictionsAnswers } from './anyPreviousConvictions' + +describe('hasAnyPreviousConvictions', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new AnyPreviousConvictions({}, application) + + expect(page.title).toEqual('Does Roger Smith have any previous unspent convictions?') + }) + }) + + describe('Questions', () => { + const page = new AnyPreviousConvictions({}, application) + + describe('hasAnyPreviousConvictions', () => { + it('has a question', () => { + expect(page.questions.hasAnyPreviousConvictions.question).toBeDefined() + }) + }) + }) + + itShouldHavePreviousValue(new AnyPreviousConvictions({}, application), 'taskList') + describe('next', () => { + describe('when the applicant has previous unspent convictions with relevant risk', () => { + describe('offence history', () => { + describe('when no offences have been added yet', () => { + it('takes the user to the offence history data page', () => { + const page = new AnyPreviousConvictions( + { hasAnyPreviousConvictions: PreviousConvictionsAnswers.YesRelevantRisk }, + application, + ) + expect(page.next()).toEqual('offence-history-data') + }) + }) + + describe('when some offences have been added', () => { + it('takes the user to the offence history page', () => { + const applicationWithOffences = applicationFactory.build({ + person: personFactory.build({ name: 'Roger Smith' }), + data: { 'offending-history': { 'offence-history-data': [{ offenceGroupName: 'Stalking (08800)' }] } }, + }) + const page = new AnyPreviousConvictions( + { hasAnyPreviousConvictions: PreviousConvictionsAnswers.YesRelevantRisk }, + applicationWithOffences, + ) + expect(page.next()).toEqual('offence-history') + }) + }) + }) + }) + + describe('when the applicant has previous unspent convictions with no relevant risk', () => { + it('takes the user back to the task list', () => { + const page = new AnyPreviousConvictions( + { hasAnyPreviousConvictions: PreviousConvictionsAnswers.YesNoRelevantRisk }, + application, + ) + expect(page.next()).toEqual('') + }) + }) + }) + + describe('errors', () => { + describe('when they have not provided any answer', () => { + it('returns an error', () => { + const page = new AnyPreviousConvictions({}, application) + expect(page.errors()).toEqual({ + hasAnyPreviousConvictions: 'Confirm whether the applicant has any previous unspent convictions', + }) + }) + }) + + describe('when the answer does not match the expected answers', () => { + it('returns an error', () => { + const page = new AnyPreviousConvictions( + { hasAnyPreviousConvictions: 'yes' as PreviousConvictionsAnswers }, + application, + ) + expect(page.errors()).toEqual({ + hasAnyPreviousConvictions: 'Confirm whether the applicant has any previous unspent convictions', + }) + }) + }) + }) +}) diff --git a/server/form-pages/apply/offence-and-licence-information/offending-history/anyPreviousConvictions.ts b/server/form-pages/apply/offence-and-licence-information/offending-history/anyPreviousConvictions.ts new file mode 100644 index 0000000..4d640c7 --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/offending-history/anyPreviousConvictions.ts @@ -0,0 +1,66 @@ +import { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +export enum PreviousConvictionsAnswers { + YesRelevantRisk = 'yesRelevantRisk', + YesNoRelevantRisk = 'yesNoRelevantRisk', + No = 'no', +} + +type AnyPreviousConvictionsBody = { + hasAnyPreviousConvictions: PreviousConvictionsAnswers +} + +@Page({ + name: 'any-previous-convictions', + bodyProperties: ['hasAnyPreviousConvictions'], +}) +export default class AnyPreviousConvictions implements TaskListPage { + documentTitle = 'Does the person have any previous unspent convictions?' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Does ${this.personName} have any previous unspent convictions?` + + questions = getQuestions(this.personName)['offending-history']['any-previous-convictions'] + + options: Record + + body: AnyPreviousConvictionsBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as AnyPreviousConvictionsBody + } + + previous() { + return 'taskList' + } + + next() { + if (this.body.hasAnyPreviousConvictions === 'yesRelevantRisk') { + if (this.application.data['offending-history']?.['offence-history-data']?.length > 0) { + return 'offence-history' + } + return 'offence-history-data' + } + return '' + } + + errors() { + const errors: TaskListErrors = {} + if ( + !this.body.hasAnyPreviousConvictions || + !Object.values(PreviousConvictionsAnswers).includes(this.body.hasAnyPreviousConvictions) + ) { + errors.hasAnyPreviousConvictions = 'Confirm whether the applicant has any previous unspent convictions' + } + return errors + } +} diff --git a/server/form-pages/apply/offence-and-licence-information/offending-history/custom-forms/offenceHistoryData.test.ts b/server/form-pages/apply/offence-and-licence-information/offending-history/custom-forms/offenceHistoryData.test.ts new file mode 100644 index 0000000..4a23859 --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/offending-history/custom-forms/offenceHistoryData.test.ts @@ -0,0 +1,59 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../../testutils/factories/index' +import OffenceHistoryData from './offenceHistoryData' + +describe('OffenceHistoryData', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + const offenceHistoryData = [ + { + offenceGroupName: 'Stalking', + offenceCategory: 'Arson', + numberOfOffences: '3', + sentenceTypes: '1 custodial', + summary: 'summary detail', + }, + ] + + describe('title', () => { + it('has a page title', () => { + const page = new OffenceHistoryData({}, application) + + expect(page.title).toEqual('Add a previous offence for Roger Smith') + }) + }) + + itShouldHaveNextValue(new OffenceHistoryData({}, application), 'offence-history') + itShouldHavePreviousValue(new OffenceHistoryData({}, application), 'offence-history') + + describe('errors', () => { + describe('when there are no errors', () => { + it('returns empty object', () => { + const page = new OffenceHistoryData(offenceHistoryData[0], application) + expect(page.errors()).toEqual({}) + }) + }) + + const requiredFields = [ + ['offenceGroupName', 'Enter the offence group name'], + ['offenceCategory', 'Select the offence type'], + ['numberOfOffences', 'Enter the number of offences'], + ['sentenceTypes', 'Enter the sentence type(s)'], + ['summary', 'Enter the offence details'], + ] + + it.each(requiredFields)('it includes a validation error for %s', (field, message) => { + const page = new OffenceHistoryData({ offenceCategory: 'choose' }, application) + const errors = page.errors() + + expect(errors[field as keyof typeof errors]).toEqual(message) + }) + }) + + describe('response', () => { + it('returns empty object', () => { + const page = new OffenceHistoryData({}, application) + expect(page.response()).toEqual({}) + }) + }) +}) diff --git a/server/form-pages/apply/offence-and-licence-information/offending-history/custom-forms/offenceHistoryData.ts b/server/form-pages/apply/offence-and-licence-information/offending-history/custom-forms/offenceHistoryData.ts new file mode 100644 index 0000000..84f5b5f --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/offending-history/custom-forms/offenceHistoryData.ts @@ -0,0 +1,99 @@ +import type { SelectItem, TaskListErrors } from '@approved-premises/ui' +import { Cas2Application } from '@approved-premises/api' +import { Page } from '../../../../utils/decorators' +import TaskListPage from '../../../../taskListPage' +import { getQuestions } from '../../../../utils/questions' +import { nameOrPlaceholderCopy } from '../../../../../utils/utils' + +export type OffenceHistoryDataBody = { + offenceGroupName: string + offenceCategory: string + numberOfOffences: string + sentenceTypes: string + summary: string +} + +@Page({ + name: 'offence-history-data', + bodyProperties: ['offenceGroupName', 'offenceCategory', 'numberOfOffences', 'sentenceTypes', 'summary'], +}) +export default class OffenceHistoryData implements TaskListPage { + personName = nameOrPlaceholderCopy(this.application.person) + + documentTitle = 'Add a previous offence' + + title = `Add a previous offence for ${this.personName}` + + body: OffenceHistoryDataBody + + taskName = 'offending-history' + + pageName = 'offence-history-data' + + questions = getQuestions('')['offending-history']['offence-history-data'] + + offenceCategories: Array + + constructor( + body: Partial, + private readonly application: Cas2Application, + ) { + this.body = body as OffenceHistoryDataBody + this.offenceCategories = this.getCategoriesAsItemsForSelect(this.body.offenceCategory) + } + + private getCategoriesAsItemsForSelect(selectedItem: string) { + const items = [ + { + value: 'choose', + text: 'Choose type', + selected: selectedItem === '', + }, + ] + Object.keys(this.questions.offenceCategory.answers).forEach(value => { + items.push({ + value, + text: this.questions.offenceCategory.answers[ + value as keyof typeof this.questions.offenceCategory.answers + ] as string, + selected: selectedItem === value, + }) + }) + + return items + } + + previous() { + return 'offence-history' + } + + next() { + return 'offence-history' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.offenceGroupName) { + errors.offenceGroupName = 'Enter the offence group name' + } + if (this.body.offenceCategory === 'choose') { + errors.offenceCategory = 'Select the offence type' + } + if (!this.body.numberOfOffences) { + errors.numberOfOffences = 'Enter the number of offences' + } + if (!this.body.sentenceTypes) { + errors.sentenceTypes = 'Enter the sentence type(s)' + } + if (!this.body.summary) { + errors.summary = 'Enter the offence details' + } + + return errors + } + + response() { + return {} + } +} diff --git a/server/form-pages/apply/offence-and-licence-information/offending-history/index.ts b/server/form-pages/apply/offence-and-licence-information/offending-history/index.ts new file mode 100644 index 0000000..7fbf5c2 --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/offending-history/index.ts @@ -0,0 +1,13 @@ +/* istanbul ignore file */ + +import { Task } from '../../../utils/decorators' +import AnyPreviousConvictions from './anyPreviousConvictions' +import OffenceHistoryData from './custom-forms/offenceHistoryData' +import OffenceHistory from './offenceHistory' + +@Task({ + name: 'Add offending history', + slug: 'offending-history', + pages: [AnyPreviousConvictions, OffenceHistoryData, OffenceHistory], +}) +export default class OffendingHistory {} diff --git a/server/form-pages/apply/offence-and-licence-information/offending-history/offenceHistory.test.ts b/server/form-pages/apply/offence-and-licence-information/offending-history/offenceHistory.test.ts new file mode 100644 index 0000000..c48b71f --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/offending-history/offenceHistory.test.ts @@ -0,0 +1,196 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import OffenceHistory from './offenceHistory' + +describe('OffenceHistory', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + const applicationWithData = applicationFactory.build({ + person: personFactory.build({ name: 'Roger Smith' }), + data: { + 'offending-history': { + 'offence-history-data': [ + { + offenceGroupName: 'Stalking', + offenceCategory: 'stalkingOrHarassment', + numberOfOffences: '3', + sentenceTypes: '1 custodial', + summary: 'summary detail', + }, + { + offenceGroupName: 'Arson', + offenceCategory: 'arson', + numberOfOffences: '2', + sentenceTypes: '2 suspended', + summary: 'second summary detail', + }, + ], + }, + }, + }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new OffenceHistory({}, application) + + expect(page.title).toEqual('Offence history for Roger Smith') + }) + }) + + describe('offence history data', () => { + describe('when there is offence history data on the application', () => { + it('assigns them to the offences field on the page', () => { + const page = new OffenceHistory({}, applicationWithData) + + expect(page.offences).toEqual([ + { + offenceGroupName: 'Stalking', + offenceCategoryTag: 'Stalking or Harassment', + offenceCategoryText: 'Stalking or Harassment', + numberOfOffences: '3', + sentenceTypes: '1 custodial', + summary: 'summary detail', + removeLink: `/applications/${applicationWithData.id}/tasks/offending-history/pages/offence-history-data/0/removeFromList?redirectPage=offence-history`, + }, + { + offenceGroupName: 'Arson', + offenceCategoryTag: 'Arson', + offenceCategoryText: 'Arson', + numberOfOffences: '2', + sentenceTypes: '2 suspended', + summary: 'second summary detail', + removeLink: `/applications/${applicationWithData.id}/tasks/offending-history/pages/offence-history-data/1/removeFromList?redirectPage=offence-history`, + }, + ]) + }) + }) + + describe('when there is offence data using the previous data model', () => { + it('ignores the outdated data', () => { + const applicationWithMixedData = applicationFactory.build({ + person: personFactory.build({ name: 'Roger Smith' }), + data: { + 'offending-history': { + 'offence-history-data': [ + { + offenceGroupName: 'Stalking', + offenceCategory: 'stalkingOrHarassment', + numberOfOffences: '3', + sentenceTypes: '1 custodial', + summary: 'summary detail', + }, + { + offenceGroupName: 'Arson', + offenceCategory: 'Arson', + 'offenceDate-day': '5', + 'offenceDate-month': '6', + 'offenceDate-year': '1940', + sentenceLength: '3 years', + summary: 'summary detail', + }, + ], + }, + }, + }) + + const page = new OffenceHistory({}, applicationWithMixedData) + + expect(page.offences).toEqual([ + { + offenceGroupName: 'Stalking', + numberOfOffences: '3', + sentenceTypes: '1 custodial', + summary: 'summary detail', + offenceCategoryTag: 'Stalking or Harassment', + offenceCategoryText: 'Stalking or Harassment', + removeLink: `/applications/${applicationWithMixedData.id}/tasks/offending-history/pages/offence-history-data/0/removeFromList?redirectPage=offence-history`, + }, + ]) + }) + }) + + itShouldHaveNextValue(new OffenceHistory({}, application), '') + itShouldHavePreviousValue(new OffenceHistory({}, application), 'any-previous-convictions') + + describe('errors', () => { + it('returns empty object', () => { + const page = new OffenceHistory({}, application) + expect(page.errors()).toEqual({}) + }) + }) + + describe('response', () => { + it('returns the offence information', () => { + const page = new OffenceHistory({}, applicationWithData) + expect(page.response()).toEqual({ + 'Stalking or Harassment': + 'Stalking\r\nNumber of offences: 3\r\nSentence types: 1 custodial\r\n\nDetails: summary detail', + 'Arson': + 'Arson\r\nNumber of offences: 2\r\nSentence types: 2 suspended\r\n\nDetails: second summary detail', + }) + }) + + it('returns empty object when there are no offences', () => { + const page = new OffenceHistory({}, application) + expect(page.response()).toEqual({}) + }) + }) + + describe('getOffenceTagColour', () => { + const categories = [ + ['stalkingOrHarassment', 'blue'], + ['weaponsOrFirearms', 'red'], + ['arson', 'yellow'], + ['violence', 'pink'], + ['domesticAbuse', 'purple'], + ['hateCrime', 'green'], + ['drugs', 'custom-brown'], + ['other', 'grey'], + ['undefinedCategory', 'grey'], + ] + it.each(categories)('returns correct colour for category %s', (category, colour) => { + const page = new OffenceHistory({}, applicationWithData) + expect(page.getOffenceTagColour(category)).toEqual(colour) + }) + }) + + describe('tableRows', () => { + it('returns the rows correctly', () => { + const page = new OffenceHistory({}, applicationWithData) + + const expected = [ + [ + { + text: 'Stalking', + }, + { + text: 'Stalking or Harassment', + }, + { + text: '3', + }, + { + html: `Remove`, + }, + ], + [ + { + text: 'Arson', + }, + { + text: 'Arson', + }, + { + text: '2', + }, + { + html: `Remove`, + }, + ], + ] + + expect(page.tableRows()).toEqual(expected) + }) + }) + }) +}) diff --git a/server/form-pages/apply/offence-and-licence-information/offending-history/offenceHistory.ts b/server/form-pages/apply/offence-and-licence-information/offending-history/offenceHistory.ts new file mode 100644 index 0000000..ab66fba --- /dev/null +++ b/server/form-pages/apply/offence-and-licence-information/offending-history/offenceHistory.ts @@ -0,0 +1,156 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { OffenceHistoryDataBody } from './custom-forms/offenceHistoryData' +import { createQueryString, nameOrPlaceholderCopy } from '../../../../utils/utils' +import paths from '../../../../paths/apply' +import { getQuestions } from '../../../utils/questions' + +type OffenceHistoryBody = Record + +type OffenceHistoryUI = { + offenceGroupName: string + offenceCategoryTag: string + offenceCategoryText: string + numberOfOffences: string + sentenceTypes: string + summary: string + removeLink: string +} + +@Page({ + name: 'offence-history', + bodyProperties: [''], +}) +export default class OffenceHistory implements TaskListPage { + personName = nameOrPlaceholderCopy(this.application.person) + + documentTitle = 'Offence history' + + title = `Offence history for ${this.personName}` + + body: OffenceHistoryBody + + offences: OffenceHistoryUI[] + + pageName = 'offence-history' + + dataPageName = 'offence-history-data' + + taskName = 'offending-history' + + offenceCategories = getQuestions('')['offending-history']['offence-history-data'].offenceCategory.answers + + constructor( + body: Partial, + private readonly application: Application, + ) { + if (application.data[this.taskName]?.[this.dataPageName]) { + const offenceHistoryData = application.data[this.taskName][this.dataPageName] as [OffenceHistoryDataBody] + + const query = { + redirectPage: this.pageName, + } + + this.offences = offenceHistoryData + .filter(offence => offence.numberOfOffences) + .map((offence, index) => { + const offenceCategoryText = + this.offenceCategories[offence.offenceCategory as keyof typeof this.offenceCategories] + + return { + offenceGroupName: offence.offenceGroupName, + offenceCategoryTag: this.getOffenceCategoryTag(offence.offenceCategory, offenceCategoryText), + offenceCategoryText, + numberOfOffences: offence.numberOfOffences, + sentenceTypes: offence.sentenceTypes, + summary: offence.summary, + removeLink: `${paths.applications.removeFromList({ + id: application.id, + task: this.taskName, + page: this.dataPageName, + index: index.toString(), + })}?${createQueryString(query)}`, + } + }) + } + this.body = body as OffenceHistoryBody + } + + previous() { + return 'any-previous-convictions' + } + + next() { + return '' + } + + errors() { + const errors: TaskListErrors = {} + + return errors + } + + response() { + const response: Record = {} + + this.offences?.forEach(offence => { + const { offenceCategoryTag, offenceGroupName, numberOfOffences, sentenceTypes, summary } = offence + + const offenceString = `${offenceGroupName}\r\nNumber of offences: ${numberOfOffences}\r\nSentence types: ${sentenceTypes}\r\n\nDetails: ${summary}` + response[offenceCategoryTag] = offenceString + }) + + return response + } + + getOffenceCategoryTag(offenceCategory: string, offenceCategoryText: string) { + return `${offenceCategoryText}` + } + + getOffenceTagColour(offenceCategory: string) { + switch (offenceCategory) { + case 'stalkingOrHarassment': + return 'blue' + case 'weaponsOrFirearms': + return 'red' + case 'arson': + return 'yellow' + case 'violence': + return 'pink' + case 'domesticAbuse': + return 'purple' + case 'hateCrime': + return 'green' + case 'drugs': + return 'custom-brown' + default: + return 'grey' + } + } + + tableRows() { + if (this.offences) { + return this.offences.map(offence => { + return [ + { + text: offence.offenceGroupName, + }, + { + text: offence.offenceCategoryText, + }, + { + text: offence.numberOfOffences, + }, + { + html: `Remove`, + }, + ] + }) + } + return [] + } +} diff --git a/server/form-pages/apply/risks-and-needs/health-needs/brainInjury.test.ts b/server/form-pages/apply/risks-and-needs/health-needs/brainInjury.test.ts new file mode 100644 index 0000000..c7dcd47 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/brainInjury.test.ts @@ -0,0 +1,191 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import BrainInjury, { BrainInjuryBody } from './brainInjury' + +describe('BrainInjury', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new BrainInjury({}, application) + + expect(page.title).toEqual('Brain injury needs for Roger Smith') + }) + }) + + describe('questions', () => { + const page = new BrainInjury({}, application) + + describe('hasBrainInjury', () => { + it('has a question', () => { + expect(page.questions.hasBrainInjury.question).toBeDefined() + }) + it('has one follow-up question', () => { + expect(page.questions.injuryDetail.question).toBeDefined() + }) + }) + + describe('isVulnerable', () => { + it('has a question', () => { + expect(page.questions.isVulnerable.question).toBeDefined() + }) + it('has one follow-up question', () => { + expect(page.questions.vulnerabilityDetail.question).toBeDefined() + }) + }) + + describe('hasDifficultyInteracting', () => { + it('has a question', () => { + expect(page.questions.hasDifficultyInteracting.question).toBeDefined() + }) + it('has one follow-up question', () => { + expect(page.questions.interactionDetail.question).toBeDefined() + }) + }) + + describe('requiresAdditionalSupport', () => { + it('has a question', () => { + expect(page.questions.requiresAdditionalSupport.question).toBeDefined() + }) + it('has one follow-up question', () => { + expect(page.questions.addSupportDetail.question).toBeDefined() + }) + }) + }) + + itShouldHaveNextValue(new BrainInjury({}, application), 'other-health') + itShouldHavePreviousValue(new BrainInjury({}, application), 'learning-difficulties') + + describe('errors', () => { + describe('when top-level questions are unanswered', () => { + const page = new BrainInjury({}, application) + + it('includes a validation error for _hasBrainInjury_', () => { + expect(page.errors()).toHaveProperty('hasBrainInjury', 'Confirm whether they have a brain injury') + }) + + it('includes a validation error for _isVulnerable_', () => { + expect(page.errors()).toHaveProperty('isVulnerable', 'Confirm whether they are vulnerable') + }) + + it('includes a validation error for _hasDifficultyInteracting_', () => { + expect(page.errors()).toHaveProperty( + 'hasDifficultyInteracting', + 'Confirm whether they have difficulties interacting', + ) + }) + + it('includes a validation error for _requiresAdditionalSupport_', () => { + expect(page.errors()).toHaveProperty( + 'requiresAdditionalSupport', + 'Confirm whether additional support is required', + ) + }) + }) + + describe('when _hasBrainInjury_ is YES', () => { + const page = new BrainInjury({ hasBrainInjury: 'yes' }, application) + + describe('and _injuryDetail_ is UNANSWERED', () => { + it('includes a validation error for _injuryDetail_', () => { + expect(page.errors()).toHaveProperty('injuryDetail', 'Describe their brain injury and needs') + }) + }) + }) + + describe('when _isVulnerable_ is YES', () => { + const page = new BrainInjury({ isVulnerable: 'yes' }, application) + + describe('and _vulnerabilityDetail_ is UNANSWERED', () => { + it('includes a validation error for _vulnerabilityDetail_', () => { + expect(page.errors()).toHaveProperty('vulnerabilityDetail', 'Describe their level of vulnerability') + }) + }) + }) + + describe('when _hasDifficultyInteracting_ is YES', () => { + const page = new BrainInjury({ hasDifficultyInteracting: 'yes' }, application) + + describe('and _interactionDetail_ is UNANSWERED', () => { + it('includes a validation error for _interactionDetail_', () => { + expect(page.errors()).toHaveProperty( + 'interactionDetail', + 'Describe their difficulties interacting with other people', + ) + }) + }) + }) + + describe('when _requiresAdditionalSupport_ is YES', () => { + const page = new BrainInjury({ requiresAdditionalSupport: 'yes' }, application) + + describe('and _addSupportDetail_ is UNANSWERED', () => { + it('includes a validation error for _addSupportDetail_', () => { + expect(page.errors()).toHaveProperty('addSupportDetail', 'Describe the additional support required') + }) + }) + }) + }) + + describe('onSave', () => { + it('removes brain injury data when the question is set to "no"', () => { + const body: Partial = { + hasBrainInjury: 'no', + injuryDetail: 'Injury detail', + } + + const page = new BrainInjury(body, application) + + page.onSave() + + expect(page.body).toEqual({ + hasBrainInjury: 'no', + }) + }) + + it('removes vulnerability data when the question is set to "no"', () => { + const body: Partial = { + isVulnerable: 'no', + vulnerabilityDetail: 'Vulnerability detail', + } + + const page = new BrainInjury(body, application) + + page.onSave() + + expect(page.body).toEqual({ + isVulnerable: 'no', + }) + }) + + it('removes interaction difficulty data when the question is set to "no"', () => { + const body: Partial = { + hasDifficultyInteracting: 'no', + interactionDetail: 'Interaction detail', + } + + const page = new BrainInjury(body, application) + + page.onSave() + + expect(page.body).toEqual({ + hasDifficultyInteracting: 'no', + }) + }) + + it('removes additional support data when the question is set to "no"', () => { + const body: Partial = { + requiresAdditionalSupport: 'no', + addSupportDetail: 'Additional support detail', + } + + const page = new BrainInjury(body, application) + + page.onSave() + + expect(page.body).toEqual({ + requiresAdditionalSupport: 'no', + }) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/health-needs/brainInjury.ts b/server/form-pages/apply/risks-and-needs/health-needs/brainInjury.ts new file mode 100644 index 0000000..fded8e7 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/brainInjury.ts @@ -0,0 +1,109 @@ +import type { TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' + +export type BrainInjuryBody = { + hasBrainInjury: YesOrNo + injuryDetail: string + isVulnerable: YesOrNo + vulnerabilityDetail: string + hasDifficultyInteracting: YesOrNo + interactionDetail: string + requiresAdditionalSupport: YesOrNo + addSupportDetail: string +} + +@Page({ + name: 'brain-injury', + bodyProperties: [ + 'hasBrainInjury', + 'injuryDetail', + 'isVulnerable', + 'vulnerabilityDetail', + 'hasDifficultyInteracting', + 'interactionDetail', + 'requiresAdditionalSupport', + 'addSupportDetail', + ], +}) +export default class BrainInjury implements TaskListPage { + documentTitle = 'Brain injury needs for the person' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Brain injury needs for ${this.personName}` + + questions = getQuestions(this.personName)['health-needs']['brain-injury'] + + body: BrainInjuryBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as BrainInjuryBody + } + + previous() { + return 'learning-difficulties' + } + + next() { + return 'other-health' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.hasBrainInjury) { + errors.hasBrainInjury = `Confirm whether they have a brain injury` + } + if (this.body.hasBrainInjury === 'yes' && !this.body.injuryDetail) { + errors.injuryDetail = 'Describe their brain injury and needs' + } + + if (!this.body.isVulnerable) { + errors.isVulnerable = `Confirm whether they are vulnerable` + } + if (this.body.isVulnerable === 'yes' && !this.body.vulnerabilityDetail) { + errors.vulnerabilityDetail = 'Describe their level of vulnerability' + } + + if (!this.body.hasDifficultyInteracting) { + errors.hasDifficultyInteracting = `Confirm whether they have difficulties interacting` + } + if (this.body.hasDifficultyInteracting === 'yes' && !this.body.interactionDetail) { + errors.interactionDetail = 'Describe their difficulties interacting with other people' + } + + if (!this.body.requiresAdditionalSupport) { + errors.requiresAdditionalSupport = `Confirm whether additional support is required` + } + if (this.body.requiresAdditionalSupport === 'yes' && !this.body.addSupportDetail) { + errors.addSupportDetail = 'Describe the additional support required' + } + + return errors + } + + onSave(): void { + if (this.body.hasBrainInjury !== 'yes') { + delete this.body.injuryDetail + } + + if (this.body.isVulnerable !== 'yes') { + delete this.body.vulnerabilityDetail + } + + if (this.body.hasDifficultyInteracting !== 'yes') { + delete this.body.interactionDetail + } + + if (this.body.requiresAdditionalSupport !== 'yes') { + delete this.body.addSupportDetail + } + } +} diff --git a/server/form-pages/apply/risks-and-needs/health-needs/communicationAndLanguage.test.ts b/server/form-pages/apply/risks-and-needs/health-needs/communicationAndLanguage.test.ts new file mode 100644 index 0000000..bf3d945 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/communicationAndLanguage.test.ts @@ -0,0 +1,112 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import CommunicationAndLanguage, { CommunicationAndLanguageBody } from './communicationAndLanguage' + +describe('CommunicationAndLanguage', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new CommunicationAndLanguage({}, application) + + expect(page.title).toEqual('Communication and language needs for Roger Smith') + }) + }) + + describe('questions', () => { + const page = new CommunicationAndLanguage({}, application) + + describe('requiresInterpreter', () => { + it('has a question', () => { + expect(page.questions.requiresInterpreter.question).toBeDefined() + }) + it('has a follow-up question', () => { + expect(page.questions.interpretationDetail.question).toBeDefined() + }) + }) + + describe('hasSupportNeeds', () => { + it('has a question', () => { + expect(page.questions.hasSupportNeeds.question).toBeDefined() + }) + it('has a follow-up question', () => { + expect(page.questions.supportDetail.question).toBeDefined() + }) + }) + }) + + itShouldHaveNextValue(new CommunicationAndLanguage({}, application), 'learning-difficulties') + itShouldHavePreviousValue(new CommunicationAndLanguage({}, application), 'mental-health') + + describe('errors', () => { + describe('when top-level questions are unanswered', () => { + const page = new CommunicationAndLanguage({}, application) + + it('includes a validation error for _requiresInterpreter_', () => { + expect(page.errors()).toHaveProperty('requiresInterpreter', 'Confirm whether they need an interpreter') + }) + + it('includes a validation error for _hasSupportNeeds_', () => { + expect(page.errors()).toHaveProperty('hasSupportNeeds', 'Confirm they they need support') + }) + }) + + describe('when _requiresInterpreter_ is YES', () => { + const page = new CommunicationAndLanguage({ requiresInterpreter: 'yes' }, application) + + describe('and _interpretationDetail_ is UNANSWERED', () => { + it('includes a validation error for _interpretationDetail_', () => { + expect(page.errors()).toHaveProperty( + 'interpretationDetail', + 'Specify the language the interpreter is needed for', + ) + }) + }) + }) + + describe('when _hasSupportNeeds_ is YES', () => { + const page = new CommunicationAndLanguage({ hasSupportNeeds: 'yes' }, application) + + describe('and _supportDetail_ is UNANSWERED', () => { + it('includes a validation error for _supportDetail_', () => { + expect(page.errors()).toHaveProperty( + 'supportDetail', + 'Describe the support needed to see, hear, speak or understand', + ) + }) + }) + }) + }) + + describe('onSave', () => { + it('removes interpreter data when the question is set to "no"', () => { + const body: Partial = { + requiresInterpreter: 'no', + interpretationDetail: 'Interpretation detail', + } + + const page = new CommunicationAndLanguage(body, application) + + page.onSave() + + expect(page.body).toEqual({ + requiresInterpreter: 'no', + }) + }) + + it('removes support needs data when the question is set to "no"', () => { + const body: Partial = { + hasSupportNeeds: 'no', + supportDetail: 'Support detail', + } + + const page = new CommunicationAndLanguage(body, application) + + page.onSave() + + expect(page.body).toEqual({ + hasSupportNeeds: 'no', + }) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/health-needs/communicationAndLanguage.ts b/server/form-pages/apply/risks-and-needs/health-needs/communicationAndLanguage.ts new file mode 100644 index 0000000..a442cd3 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/communicationAndLanguage.ts @@ -0,0 +1,74 @@ +import type { TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' + +export type CommunicationAndLanguageBody = { + requiresInterpreter: YesOrNo + interpretationDetail: string + hasSupportNeeds: YesOrNo + supportDetail: string +} + +@Page({ + name: 'communication-and-language', + bodyProperties: ['requiresInterpreter', 'interpretationDetail', 'hasSupportNeeds', 'supportDetail'], +}) +export default class CommunicationAndLanguage implements TaskListPage { + documentTitle = 'Communication and language needs for the person' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Communication and language needs for ${this.personName}` + + questions = getQuestions(this.personName)['health-needs']['communication-and-language'] + + body: CommunicationAndLanguageBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as CommunicationAndLanguageBody + } + + previous() { + return 'mental-health' + } + + next() { + return 'learning-difficulties' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.requiresInterpreter) { + errors.requiresInterpreter = `Confirm whether they need an interpreter` + } + if (this.body.requiresInterpreter === 'yes' && !this.body.interpretationDetail) { + errors.interpretationDetail = 'Specify the language the interpreter is needed for' + } + + if (!this.body.hasSupportNeeds) { + errors.hasSupportNeeds = `Confirm they they need support` + } + if (this.body.hasSupportNeeds === 'yes' && !this.body.supportDetail) { + errors.supportDetail = 'Describe the support needed to see, hear, speak or understand' + } + + return errors + } + + onSave(): void { + if (this.body.requiresInterpreter !== 'yes') { + delete this.body.interpretationDetail + } + + if (this.body.hasSupportNeeds !== 'yes') { + delete this.body.supportDetail + } + } +} diff --git a/server/form-pages/apply/risks-and-needs/health-needs/guidance.test.ts b/server/form-pages/apply/risks-and-needs/health-needs/guidance.test.ts new file mode 100644 index 0000000..897a704 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/guidance.test.ts @@ -0,0 +1,26 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import Guidance from './guidance' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' + +describe('Guidance', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new Guidance({}, application) + + expect(page.title).toEqual('Request health information for Roger Smith') + }) + }) + + itShouldHaveNextValue(new Guidance({}, application), 'substance-misuse') + itShouldHavePreviousValue(new Guidance({}, application), 'taskList') + + describe('errors', () => { + it('returns no errors as this guidance page has no questions/answers', () => { + const page = new Guidance({}, application) + + expect(page.errors()).toEqual({}) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/health-needs/guidance.ts b/server/form-pages/apply/risks-and-needs/health-needs/guidance.ts new file mode 100644 index 0000000..010dad0 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/guidance.ts @@ -0,0 +1,40 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' + +type GuidanceBody = Record + +@Page({ + name: 'guidance', + bodyProperties: [], +}) +export default class Guidance implements TaskListPage { + documentTitle = 'Request health information for the person' + + title = `Request health information for ${nameOrPlaceholderCopy(this.application.person)}` + + body: GuidanceBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as GuidanceBody + } + + previous() { + return 'taskList' + } + + next() { + return 'substance-misuse' + } + + errors() { + const errors: TaskListErrors = {} + + return errors + } +} diff --git a/server/form-pages/apply/risks-and-needs/health-needs/index.ts b/server/form-pages/apply/risks-and-needs/health-needs/index.ts new file mode 100644 index 0000000..96b6906 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/index.ts @@ -0,0 +1,27 @@ +/* istanbul ignore file */ + +import { Task } from '../../../utils/decorators' +import BrainInjury from './brainInjury' +import CommunicationAndLanguage from './communicationAndLanguage' +import Guidance from './guidance' +import LearningDifficulties from './learningDifficulties' +import MentalHealth from './mentalHealth' +import OtherHealth from './otherHealth' +import PhysicalHealth from './physicalHealth' +import SubstanceMisuse from './substanceMisuse' + +@Task({ + name: 'Add health needs', + slug: 'health-needs', + pages: [ + Guidance, + SubstanceMisuse, + PhysicalHealth, + MentalHealth, + CommunicationAndLanguage, + LearningDifficulties, + BrainInjury, + OtherHealth, + ], +}) +export default class HealthNeeds {} diff --git a/server/form-pages/apply/risks-and-needs/health-needs/learningDifficulties.test.ts b/server/form-pages/apply/risks-and-needs/health-needs/learningDifficulties.test.ts new file mode 100644 index 0000000..8e3394a --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/learningDifficulties.test.ts @@ -0,0 +1,192 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import LearningDifficulties, { LearningDifficultiesBody } from './learningDifficulties' + +describe('LearningDifficulties', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new LearningDifficulties({}, application) + + expect(page.title).toEqual('Learning difficulties and neurodiversity for Roger Smith') + }) + }) + + describe('questions', () => { + const page = new LearningDifficulties({}, application) + + describe('hasLearningNeeds', () => { + it('has a question', () => { + expect(page.questions.hasLearningNeeds.question).toBeDefined() + }) + it('has one follow-up question', () => { + expect(page.questions.needsDetail.question).toBeDefined() + }) + }) + + describe('isVulnerable', () => { + it('has a question', () => { + expect(page.questions.isVulnerable.question).toBeDefined() + }) + it('has one follow-up question', () => { + expect(page.questions.vulnerabilityDetail.question).toBeDefined() + }) + }) + + describe('hasDifficultyInteracting', () => { + it('has a question', () => { + expect(page.questions.hasDifficultyInteracting.question).toBeDefined() + }) + it('has one follow-up question', () => { + expect(page.questions.interactionDetail.question).toBeDefined() + }) + }) + + describe('requiresAdditionalSupport', () => { + it('has a question', () => { + expect(page.questions.requiresAdditionalSupport.question).toBeDefined() + }) + it('has one follow-up question', () => { + expect(page.questions.addSupportDetail.question).toBeDefined() + }) + }) + }) + + itShouldHaveNextValue(new LearningDifficulties({}, application), 'brain-injury') + itShouldHavePreviousValue(new LearningDifficulties({}, application), 'communication-and-language') + + describe('errors', () => { + describe('when top-level questions are unanswered', () => { + const page = new LearningDifficulties({}, application) + + it('includes a validation error for _hasLearningNeeds_', () => { + expect(page.errors()).toHaveProperty('hasLearningNeeds', 'Confirm whether they have additional needs') + }) + + it('includes a validation error for _isVulnerable_', () => { + expect(page.errors()).toHaveProperty('isVulnerable', 'Confirm whether they are vulnerable') + }) + + it('includes a validation error for _hasDifficultyInteracting_', () => { + expect(page.errors()).toHaveProperty( + 'hasDifficultyInteracting', + 'Confirm whether they have difficulties interacting', + ) + }) + + it('includes a validation error for _requiresAdditionalSupport_', () => { + expect(page.errors()).toHaveProperty( + 'requiresAdditionalSupport', + 'Confirm whether additional support is required', + ) + }) + }) + + describe('when _hasLearningNeeds_ is YES', () => { + const page = new LearningDifficulties({ hasLearningNeeds: 'yes' }, application) + + describe('and _needsDetail_ is UNANSWERED', () => { + it('includes a validation error for _needsDetail_', () => { + expect(page.errors()).toHaveProperty( + 'needsDetail', + 'Describe their additional needs relating to learning difficulties or neurodiversity', + ) + }) + }) + }) + + describe('when _isVulnerable_ is YES', () => { + const page = new LearningDifficulties({ isVulnerable: 'yes' }, application) + + describe('and _vulnerabilityDetail_ is UNANSWERED', () => { + it('includes a validation error for _vulnerabilityDetail_', () => { + expect(page.errors()).toHaveProperty('vulnerabilityDetail', 'Describe their level of vulnerability') + }) + }) + }) + + describe('when _hasDifficultyInteracting_ is YES', () => { + const page = new LearningDifficulties({ hasDifficultyInteracting: 'yes' }, application) + + describe('and _interactionDetail_ is UNANSWERED', () => { + it('includes a validation error for _interactionDetail_', () => { + expect(page.errors()).toHaveProperty( + 'interactionDetail', + 'Describe their difficulties interacting with other people', + ) + }) + }) + }) + + describe('when _requiresAdditionalSupport_ is YES', () => { + const page = new LearningDifficulties({ requiresAdditionalSupport: 'yes' }, application) + + describe('and _addSupportDetail_ is UNANSWERED', () => { + it('includes a validation error for _addSupportDetail_', () => { + expect(page.errors()).toHaveProperty('addSupportDetail', 'Describe the type of support required') + }) + }) + }) + }) + + it('removes learning needs data when the question is set to "no"', () => { + const body: Partial = { + hasLearningNeeds: 'no', + needsDetail: 'Learning needs detail', + } + + const page = new LearningDifficulties(body, application) + + page.onSave() + + expect(page.body).toEqual({ + hasLearningNeeds: 'no', + }) + }) + + it('removes vulnerability data when the question is set to "no"', () => { + const body: Partial = { + isVulnerable: 'no', + vulnerabilityDetail: 'Vulnerability detail', + } + + const page = new LearningDifficulties(body, application) + + page.onSave() + + expect(page.body).toEqual({ + isVulnerable: 'no', + }) + }) + + it('removes interaction difficulty data when the question is set to "no"', () => { + const body: Partial = { + hasDifficultyInteracting: 'no', + interactionDetail: 'Interaction detail', + } + + const page = new LearningDifficulties(body, application) + + page.onSave() + + expect(page.body).toEqual({ + hasDifficultyInteracting: 'no', + }) + }) + + it('removes additional support data when the question is set to "no"', () => { + const body: Partial = { + requiresAdditionalSupport: 'no', + addSupportDetail: 'Additional support detail', + } + + const page = new LearningDifficulties(body, application) + + page.onSave() + + expect(page.body).toEqual({ + requiresAdditionalSupport: 'no', + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/health-needs/learningDifficulties.ts b/server/form-pages/apply/risks-and-needs/health-needs/learningDifficulties.ts new file mode 100644 index 0000000..4706c52 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/learningDifficulties.ts @@ -0,0 +1,109 @@ +import type { TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' + +export type LearningDifficultiesBody = { + hasLearningNeeds: YesOrNo + needsDetail: string + isVulnerable: YesOrNo + vulnerabilityDetail: string + hasDifficultyInteracting: YesOrNo + interactionDetail: string + requiresAdditionalSupport: YesOrNo + addSupportDetail: string +} + +@Page({ + name: 'learning-difficulties', + bodyProperties: [ + 'hasLearningNeeds', + 'needsDetail', + 'isVulnerable', + 'vulnerabilityDetail', + 'hasDifficultyInteracting', + 'interactionDetail', + 'requiresAdditionalSupport', + 'addSupportDetail', + ], +}) +export default class LearningDifficulties implements TaskListPage { + documentTitle = 'Learning difficulties and neurodiversity for the person' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Learning difficulties and neurodiversity for ${this.personName}` + + questions = getQuestions(this.personName)['health-needs']['learning-difficulties'] + + body: LearningDifficultiesBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as LearningDifficultiesBody + } + + previous() { + return 'communication-and-language' + } + + next() { + return 'brain-injury' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.hasLearningNeeds) { + errors.hasLearningNeeds = `Confirm whether they have additional needs` + } + if (this.body.hasLearningNeeds === 'yes' && !this.body.needsDetail) { + errors.needsDetail = 'Describe their additional needs relating to learning difficulties or neurodiversity' + } + + if (!this.body.isVulnerable) { + errors.isVulnerable = `Confirm whether they are vulnerable` + } + if (this.body.isVulnerable === 'yes' && !this.body.vulnerabilityDetail) { + errors.vulnerabilityDetail = 'Describe their level of vulnerability' + } + + if (!this.body.hasDifficultyInteracting) { + errors.hasDifficultyInteracting = `Confirm whether they have difficulties interacting` + } + if (this.body.hasDifficultyInteracting === 'yes' && !this.body.interactionDetail) { + errors.interactionDetail = 'Describe their difficulties interacting with other people' + } + + if (!this.body.requiresAdditionalSupport) { + errors.requiresAdditionalSupport = `Confirm whether additional support is required` + } + if (this.body.requiresAdditionalSupport === 'yes' && !this.body.addSupportDetail) { + errors.addSupportDetail = 'Describe the type of support required' + } + + return errors + } + + onSave(): void { + if (this.body.hasLearningNeeds !== 'yes') { + delete this.body.needsDetail + } + + if (this.body.isVulnerable !== 'yes') { + delete this.body.vulnerabilityDetail + } + + if (this.body.hasDifficultyInteracting !== 'yes') { + delete this.body.interactionDetail + } + + if (this.body.requiresAdditionalSupport !== 'yes') { + delete this.body.addSupportDetail + } + } +} diff --git a/server/form-pages/apply/risks-and-needs/health-needs/mentalHealth.test.ts b/server/form-pages/apply/risks-and-needs/health-needs/mentalHealth.test.ts new file mode 100644 index 0000000..2ac0569 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/mentalHealth.test.ts @@ -0,0 +1,170 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import MentalHealth, { MentalHealthBody } from './mentalHealth' + +describe('MentalHealth', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new MentalHealth({}, application) + + expect(page.title).toEqual('Mental health needs for Roger Smith') + }) + }) + + itShouldHaveNextValue(new MentalHealth({}, application), 'communication-and-language') + itShouldHavePreviousValue(new MentalHealth({}, application), 'physical-health') + + describe('errors', () => { + describe('when top-level questions are unanswered', () => { + const page = new MentalHealth({}, application) + + it('includes a validation error for _hasMentalHealthNeeds_', () => { + expect(page.errors()).toHaveProperty('hasMentalHealthNeeds', 'Confirm whether they have mental health needs') + }) + + it('includes a validation error for _isEngagedWithCommunity_', () => { + expect(page.errors()).toHaveProperty('isEngagedWithCommunity', 'Confirm whether they are engaged with services') + }) + + it('includes a validation error for _isEngagedWithServicesInCustody_', () => { + expect(page.errors()).toHaveProperty( + 'isEngagedWithServicesInCustody', + 'Confirm whether they are engaged with mental health services in custody', + ) + }) + + it('includes a validation error for _areIntendingToEngageWithServicesAfterCustody_', () => { + expect(page.errors()).toHaveProperty( + 'areIntendingToEngageWithServicesAfterCustody', + 'Confirm whether they are intending to engage with mental health services after custody', + ) + }) + + it('includes a validation error for _canManageMedication_', () => { + expect(page.errors()).toHaveProperty( + 'canManageMedication', + "Confirm whether they can manage their own mental health medication on release, or select 'They are not prescribed medication for their mental health'", + ) + }) + }) + + describe('when _hasMentalHealthNeeds_ is YES', () => { + const page = new MentalHealth({ hasMentalHealthNeeds: 'yes' }, application) + + describe('and _needsDetail_ is UNANSWERED', () => { + it('includes a validation error for _needsDetail_', () => { + expect(page.errors()).toHaveProperty('needsDetail', 'Describe mental health needs') + }) + }) + describe('and _needsPresentation_ is UNANSWERED', () => { + it('includes a validation error for _needsPresentation_', () => { + expect(page.errors()).toHaveProperty('needsPresentation', 'Describe how they are presenting') + }) + }) + }) + + describe('when _isEngagedWithCommunity_ is YES', () => { + const page = new MentalHealth({ isEngagedWithCommunity: 'yes' }, application) + + describe('and _servicesDetail_ is UNANSWERED', () => { + it('includes a validation error for _servicesDetail_', () => { + expect(page.errors()).toHaveProperty('servicesDetail', 'State the services with which they have engaged') + }) + }) + }) + + describe('when _canManageMedication_ is YES', () => { + const page = new MentalHealth({ canManageMedication: 'yes' }, application) + + describe('and _canManageMedicationNotes_ is UNANSWERED', () => { + it('includes NO validation error for _canManageMedicationNotes_ (optional)', () => { + expect(page.errors()).not.toHaveProperty('canManageMedicationNotes') + }) + }) + }) + + describe('when _canManageMedication_ is NO', () => { + const page = new MentalHealth({ canManageMedication: 'no' }, application) + + describe('and _medicationIssues_ is UNANSWERED', () => { + it('includes a validation error for _medicationIssues_', () => { + expect(page.errors()).toHaveProperty( + 'medicationIssues', + "Describe the applicant's issues with taking their mental health medication", + ) + }) + }) + + describe('and _cantManageMedicationNotes_ is UNANSWERED', () => { + it('includes NO validation error for _cantManageMedicationNotes_ (optional)', () => { + expect(page.errors()).not.toHaveProperty('cantManageMedicationNotes') + }) + }) + }) + }) + + describe('onSave', () => { + it('removes mental health data if the question is set to "no"', () => { + const body: Partial = { + hasMentalHealthNeeds: 'no', + needsDetail: 'Mental health needs detail', + } + + const page = new MentalHealth(body, application) + + page.onSave() + + expect(page.body).toEqual({ + hasMentalHealthNeeds: 'no', + }) + }) + + it('removes community mental health data if the question is set to "no"', () => { + const body: Partial = { + isEngagedWithCommunity: 'no', + servicesDetail: 'Services detail', + } + + const page = new MentalHealth(body, application) + + page.onSave() + + expect(page.body).toEqual({ + isEngagedWithCommunity: 'no', + }) + }) + + it("removes can't manage own medication data if the question is not set to 'no'", () => { + const body: Partial = { + canManageMedication: 'notPrescribedMedication', + cantManageMedicationNotes: 'some notes', + medicationIssues: 'some issues', + } + + const page = new MentalHealth(body, application) + + page.onSave() + + expect(page.body).toEqual({ + canManageMedication: 'notPrescribedMedication', + }) + }) + + it('removes can manage own medication notes if the question is not set to "yes"', () => { + const body: Partial = { + canManageMedication: 'no', + canManageMedicationNotes: 'some notes', + } + + const page = new MentalHealth(body, application) + + page.onSave() + + expect(page.body).toEqual({ + canManageMedication: 'no', + }) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/health-needs/mentalHealth.ts b/server/form-pages/apply/risks-and-needs/health-needs/mentalHealth.ts new file mode 100644 index 0000000..23417cf --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/mentalHealth.ts @@ -0,0 +1,125 @@ +import type { TaskListErrors, YesNoOrDontKnow, YesOrNo } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' + +export type MentalHealthBody = { + hasMentalHealthNeeds: YesOrNo + needsDetail: string + needsPresentation: string + isEngagedWithCommunity: YesOrNo + servicesDetail: string + isEngagedWithServicesInCustody: YesOrNo + areIntendingToEngageWithServicesAfterCustody: YesNoOrDontKnow + canManageMedication: YesOrNo | 'notPrescribedMedication' + canManageMedicationNotes: string + medicationIssues: string + cantManageMedicationNotes: string +} + +@Page({ + name: 'mental-health', + bodyProperties: [ + 'hasMentalHealthNeeds', + 'needsDetail', + 'needsPresentation', + 'isEngagedWithCommunity', + 'servicesDetail', + 'areIntendingToEngageWithServicesAfterCustody', + 'isEngagedWithServicesInCustody', + 'canManageMedication', + 'canManageMedicationNotes', + 'medicationIssues', + 'cantManageMedicationNotes', + ], +}) +export default class MentalHealth implements TaskListPage { + documentTitle = 'Mental health needs for the person' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Mental health needs for ${this.personName}` + + questions = getQuestions(this.personName)['health-needs']['mental-health'] + + body: MentalHealthBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as MentalHealthBody + } + + previous() { + return 'physical-health' + } + + next() { + return 'communication-and-language' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.hasMentalHealthNeeds) { + errors.hasMentalHealthNeeds = 'Confirm whether they have mental health needs' + } + if (this.body.hasMentalHealthNeeds === 'yes' && !this.body.needsDetail) { + errors.needsDetail = 'Describe mental health needs' + } + + if (this.body.hasMentalHealthNeeds === 'yes' && !this.body.needsPresentation) { + errors.needsPresentation = 'Describe how they are presenting' + } + + if (!this.body.isEngagedWithCommunity) { + errors.isEngagedWithCommunity = 'Confirm whether they are engaged with services' + } + if (this.body.isEngagedWithCommunity === 'yes' && !this.body.servicesDetail) { + errors.servicesDetail = 'State the services with which they have engaged' + } + + if (!this.body.isEngagedWithServicesInCustody) { + errors.isEngagedWithServicesInCustody = 'Confirm whether they are engaged with mental health services in custody' + } + + if (!this.body.areIntendingToEngageWithServicesAfterCustody) { + errors.areIntendingToEngageWithServicesAfterCustody = + 'Confirm whether they are intending to engage with mental health services after custody' + } + + if (!this.body.canManageMedication) { + errors.canManageMedication = + "Confirm whether they can manage their own mental health medication on release, or select 'They are not prescribed medication for their mental health'" + } + + if (this.body.canManageMedication === 'no' && !this.body.medicationIssues) { + errors.medicationIssues = "Describe the applicant's issues with taking their mental health medication" + } + + return errors + } + + onSave(): void { + if (this.body.hasMentalHealthNeeds !== 'yes') { + delete this.body.needsDetail + delete this.body.needsPresentation + } + + if (this.body.isEngagedWithCommunity !== 'yes') { + delete this.body.servicesDetail + } + + if (this.body.canManageMedication !== 'yes') { + delete this.body.canManageMedicationNotes + } + + if (this.body.canManageMedication !== 'no') { + delete this.body.medicationIssues + delete this.body.cantManageMedicationNotes + } + } +} diff --git a/server/form-pages/apply/risks-and-needs/health-needs/otherHealth.test.ts b/server/form-pages/apply/risks-and-needs/health-needs/otherHealth.test.ts new file mode 100644 index 0000000..3311e32 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/otherHealth.test.ts @@ -0,0 +1,131 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import OtherHealth, { OtherHealthBody } from './otherHealth' + +describe('OtherHealth', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new OtherHealth({}, application) + + expect(page.title).toEqual('Other health needs for Roger Smith') + }) + }) + + describe('questions', () => { + const page = new OtherHealth({}, application) + + describe('hasLongTermHealthCondition', () => { + it('has a question, with a hint', () => { + expect(page.questions.hasLongTermHealthCondition.question).toBeDefined() + expect(page.questions.hasLongTermHealthCondition.hint).toBeDefined() + }) + it('has 2 follow-up questions', () => { + expect(page.questions.healthConditionDetail.question).toBeDefined() + expect(page.questions.hasHadStroke.question).toBeDefined() + }) + }) + + describe('hasSeizures', () => { + it('has a question', () => { + expect(page.questions.hasSeizures.question).toBeDefined() + }) + it('has a follow-up question', () => { + expect(page.questions.seizuresDetail.question).toBeDefined() + }) + }) + + describe('beingTreatedForCancer', () => { + it('has a question, with no follow-up', () => { + expect(page.questions.beingTreatedForCancer.question).toBeDefined() + }) + }) + }) + + itShouldHaveNextValue(new OtherHealth({}, application), '') + itShouldHavePreviousValue(new OtherHealth({}, application), 'brain-injury') + + describe('errors', () => { + describe('when top-level questions are unanswered', () => { + const page = new OtherHealth({}, application) + + it('includes a validation error for _hasLongTermHealthCondition_', () => { + expect(page.errors()).toHaveProperty( + 'hasLongTermHealthCondition', + 'Confirm whether they have a long term health condition', + ) + }) + + it('includes a validation error for _hasSeizures_', () => { + expect(page.errors()).toHaveProperty('hasSeizures', 'Confirm whether they have seizures') + }) + + it('includes a validation error for _beingTreatedForCancer_', () => { + expect(page.errors()).toHaveProperty( + 'beingTreatedForCancer', + 'Confirm whether they are receiving cancer treatment', + ) + }) + }) + + describe('when _hasLongTermHealthCondition_ is YES', () => { + const page = new OtherHealth({ hasLongTermHealthCondition: 'yes' }, application) + + describe('and _healthConditionDetail_ is UNANSWERED', () => { + it('includes a validation error for _healthConditionDetail_', () => { + expect(page.errors()).toHaveProperty('healthConditionDetail', 'Provide details of their health conditions') + }) + }) + + describe('and _hasHadStroke_ is UNANSWERED', () => { + it('includes a validation error for _hasHadStroke_', () => { + expect(page.errors()).toHaveProperty('hasHadStroke', 'Confirm whether they have had a stroke') + }) + }) + }) + + describe('when _hasSeizures_ is YES', () => { + const page = new OtherHealth({ hasSeizures: 'yes' }, application) + + describe('and _seizuresDetail_ is UNANSWERED', () => { + it('includes a validation error for _seizuresDetail_', () => { + expect(page.errors()).toHaveProperty('seizuresDetail', 'Provide details of the seizure type and treatment') + }) + }) + }) + }) + + describe('onSave', () => { + it('removes long term health condition data when the question is set to "no"', () => { + const body: Partial = { + hasLongTermHealthCondition: 'no', + healthConditionDetail: 'Health condition detail', + hasHadStroke: 'yes', + } + + const page = new OtherHealth(body, application) + + page.onSave() + + expect(page.body).toEqual({ + hasLongTermHealthCondition: 'no', + }) + }) + + it('removes seizure data when the question is set to "no"', () => { + const body: Partial = { + hasSeizures: 'no', + seizuresDetail: 'Seizures detail', + } + + const page = new OtherHealth(body, application) + + page.onSave() + + expect(page.body).toEqual({ + hasSeizures: 'no', + }) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/health-needs/otherHealth.ts b/server/form-pages/apply/risks-and-needs/health-needs/otherHealth.ts new file mode 100644 index 0000000..91a70c6 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/otherHealth.ts @@ -0,0 +1,91 @@ +import type { TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' + +export type OtherHealthBody = { + hasLongTermHealthCondition: YesOrNo + healthConditionDetail: string + hasHadStroke: YesOrNo + hasSeizures: YesOrNo + seizuresDetail: string + beingTreatedForCancer: YesOrNo +} + +@Page({ + name: 'other-health', + bodyProperties: [ + 'hasLongTermHealthCondition', + 'healthConditionDetail', + 'hasHadStroke', + 'hasSeizures', + 'seizuresDetail', + 'beingTreatedForCancer', + ], +}) +export default class OtherHealth implements TaskListPage { + documentTitle = 'Other health needs for the person' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Other health needs for ${this.personName}` + + questions = getQuestions(this.personName)['health-needs']['other-health'] + + body: OtherHealthBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as OtherHealthBody + } + + previous() { + return 'brain-injury' + } + + next() { + return '' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.hasLongTermHealthCondition) { + errors.hasLongTermHealthCondition = `Confirm whether they have a long term health condition` + } + if (this.body.hasLongTermHealthCondition === 'yes' && !this.body.healthConditionDetail) { + errors.healthConditionDetail = 'Provide details of their health conditions' + } + if (this.body.hasLongTermHealthCondition === 'yes' && !this.body.hasHadStroke) { + errors.hasHadStroke = 'Confirm whether they have had a stroke' + } + + if (!this.body.hasSeizures) { + errors.hasSeizures = `Confirm whether they have seizures` + } + if (this.body.hasSeizures === 'yes' && !this.body.seizuresDetail) { + errors.seizuresDetail = 'Provide details of the seizure type and treatment' + } + + if (!this.body.beingTreatedForCancer) { + errors.beingTreatedForCancer = `Confirm whether they are receiving cancer treatment` + } + + return errors + } + + onSave(): void { + if (this.body.hasLongTermHealthCondition !== 'yes') { + delete this.body.healthConditionDetail + delete this.body.hasHadStroke + } + + if (this.body.hasSeizures !== 'yes') { + delete this.body.seizuresDetail + } + } +} diff --git a/server/form-pages/apply/risks-and-needs/health-needs/physicalHealth.test.ts b/server/form-pages/apply/risks-and-needs/health-needs/physicalHealth.test.ts new file mode 100644 index 0000000..cf9e7de --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/physicalHealth.test.ts @@ -0,0 +1,196 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import PhysicalHealth, { PhysicalHealthBody } from './physicalHealth' + +describe('PhysicalHealth', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new PhysicalHealth({}, application) + + expect(page.title).toEqual('Physical health needs for Roger Smith') + }) + }) + + itShouldHaveNextValue(new PhysicalHealth({}, application), 'mental-health') + itShouldHavePreviousValue(new PhysicalHealth({}, application), 'substance-misuse') + + describe('Questions', () => { + const page = new PhysicalHealth({}, application) + + describe('hasPhyHealthNeeds', () => { + it('has a question', () => { + expect(page.questions.hasPhyHealthNeeds.question).toBeDefined() + }) + it('has two follow-up questions', () => { + expect(page.questions.needsDetail.question).toBeDefined() + expect(page.questions.canClimbStairs.question).toBeDefined() + }) + }) + + describe('isReceivingMedicationOrTreatment', () => { + it('has a question', () => { + expect(page.questions.isReceivingMedicationOrTreatment.question).toBeDefined() + }) + it('has a follow-up question', () => { + expect(page.questions.medicationOrTreatmentDetail.question).toBeDefined() + }) + }) + + describe('canLiveIndependently', () => { + it('has a question', () => { + expect(page.questions.canLiveIndependently.question).toBeDefined() + }) + it('has a follow-up question', () => { + expect(page.questions.indyLivingDetail.question).toBeDefined() + }) + }) + + describe('requiresAdditionalSupport', () => { + it('has a question', () => { + expect(page.questions.requiresAdditionalSupport.question).toBeDefined() + }) + it('has a follow-up question', () => { + expect(page.questions.addSupportDetail.question).toBeDefined() + }) + }) + }) + + describe('errors', () => { + describe('when top-level questions are unanswered', () => { + const page = new PhysicalHealth({}, application) + + it('includes a validation error for _hasPhyHealthNeeds_', () => { + expect(page.errors()).toHaveProperty('hasPhyHealthNeeds', 'Confirm whether they have physical health needs') + }) + + it('includes a validation error for _isReceivingMedicationOrTreatment_', () => { + expect(page.errors()).toHaveProperty( + 'isReceivingMedicationOrTreatment', + 'Confirm whether they are currently receiving any medication or treatment', + ) + }) + + it('includes a validation error for _canLiveIndependently_', () => { + expect(page.errors()).toHaveProperty('canLiveIndependently', 'Confirm whether they can live independently') + }) + + it('includes a validation error for _requiresAdditionalSupport_', () => { + expect(page.errors()).toHaveProperty( + 'requiresAdditionalSupport', + 'Confirm whether they require additional support', + ) + }) + }) + + describe('when _hasPhyHealthNeeds_ is YES', () => { + const page = new PhysicalHealth({ hasPhyHealthNeeds: 'yes' }, application) + + describe('and _needsDetail_ is UNANSWERED', () => { + it('includes a validation error for _needsDetail_', () => { + expect(page.errors()).toHaveProperty('needsDetail', 'Describe physical health needs') + }) + }) + + describe('and _canClimbStairs_ is UNANSWERED', () => { + it('includes a validation error for _canClimbStairs_', () => { + expect(page.errors()).toHaveProperty('canClimbStairs', 'Confirm whether they can climb stairs') + }) + }) + }) + + describe('when _isReceivingMedicationOrTreatment_ is YES', () => { + const page = new PhysicalHealth({ isReceivingMedicationOrTreatment: 'yes' }, application) + + describe('and _medicationOrTreatmentDetail_ is UNANSWERED', () => { + it('includes a validation error for _medicationOrTreatmentDetail_', () => { + expect(page.errors()).toHaveProperty('medicationOrTreatmentDetail', 'Describe the medication or treatment') + }) + }) + }) + + describe('when _canLiveIndependently_ is NO', () => { + const page = new PhysicalHealth({ canLiveIndependently: 'no' }, application) + + describe('and _indyLivingDetail_ is UNANSWERED', () => { + it('includes a validation error for _indyLivingDetail_', () => { + expect(page.errors()).toHaveProperty('indyLivingDetail', 'Describe why they are unable to live independently') + }) + }) + }) + + describe('when _requiresAdditionalSupport_ is YES', () => { + const page = new PhysicalHealth({ requiresAdditionalSupport: 'yes' }, application) + + describe('and _addSupportDetail_ is UNANSWERED', () => { + it('includes a validation error for _addSupportDetail_', () => { + expect(page.errors()).toHaveProperty('addSupportDetail', 'Describe the support required') + }) + }) + }) + }) + + describe('onSave', () => { + it('removes physical needs data if the question is set to "no"', () => { + const body: Partial = { + hasPhyHealthNeeds: 'no', + needsDetail: 'Needs detail', + canClimbStairs: 'yes', + } + + const page = new PhysicalHealth(body, application) + + page.onSave() + + expect(page.body).toEqual({ + hasPhyHealthNeeds: 'no', + }) + }) + + it('removes medication and treatment data if the question is set to "no"', () => { + const body: Partial = { + isReceivingMedicationOrTreatment: 'no', + medicationOrTreatmentDetail: 'Treatment detail', + } + + const page = new PhysicalHealth(body, application) + + page.onSave() + + expect(page.body).toEqual({ + isReceivingMedicationOrTreatment: 'no', + }) + }) + + it('removes independent living data if the question is set to "yes"', () => { + const body: Partial = { + canLiveIndependently: 'yes', + indyLivingDetail: 'Independent living detail', + } + + const page = new PhysicalHealth(body, application) + + page.onSave() + + expect(page.body).toEqual({ + canLiveIndependently: 'yes', + }) + }) + + it('removes additional support data if the question is set to "no"', () => { + const body: Partial = { + requiresAdditionalSupport: 'no', + addSupportDetail: 'Additional support detail', + } + + const page = new PhysicalHealth(body, application) + + page.onSave() + + expect(page.body).toEqual({ + requiresAdditionalSupport: 'no', + }) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/health-needs/physicalHealth.ts b/server/form-pages/apply/risks-and-needs/health-needs/physicalHealth.ts new file mode 100644 index 0000000..62041d0 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/physicalHealth.ts @@ -0,0 +1,123 @@ +import type { TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' + +export type PhysicalHealthBody = { + hasPhyHealthNeeds: YesOrNo + needsDetail: string + canClimbStairs: YesOrNo + isReceivingMedicationOrTreatment: YesOrNo + medicationOrTreatmentDetail: string + canLiveIndependently: YesOrNo + indyLivingDetail: string + requiresAdditionalSupport: YesOrNo + addSupportDetail: string +} + +@Page({ + name: 'physical-health', + bodyProperties: [ + 'hasPhyHealthNeeds', + 'needsDetail', + 'canClimbStairs', + 'isReceivingMedicationOrTreatment', + 'medicationOrTreatmentDetail', + 'canLiveIndependently', + 'indyLivingDetail', + 'requiresAdditionalSupport', + 'addSupportDetail', + ], +}) +export default class PhysicalHealth implements TaskListPage { + documentTitle = 'Physical health needs for the person' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Physical health needs for ${this.personName}` + + questions = getQuestions(this.personName)['health-needs']['physical-health'] + + body: PhysicalHealthBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as PhysicalHealthBody + } + + previous() { + return 'substance-misuse' + } + + next() { + return 'mental-health' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.hasPhyHealthNeeds) { + errors.hasPhyHealthNeeds = 'Confirm whether they have physical health needs' + } + + if (this.body.hasPhyHealthNeeds === 'yes') { + if (!this.body.needsDetail) { + errors.needsDetail = 'Describe physical health needs' + } + + if (!this.body.canClimbStairs) { + errors.canClimbStairs = 'Confirm whether they can climb stairs' + } + } + + if (!this.body.isReceivingMedicationOrTreatment) { + errors.isReceivingMedicationOrTreatment = + 'Confirm whether they are currently receiving any medication or treatment' + } + + if (this.body.isReceivingMedicationOrTreatment === 'yes' && !this.body.medicationOrTreatmentDetail) { + errors.medicationOrTreatmentDetail = 'Describe the medication or treatment' + } + + if (!this.body.canLiveIndependently) { + errors.canLiveIndependently = 'Confirm whether they can live independently' + } + + if (this.body.canLiveIndependently === 'no' && !this.body.indyLivingDetail) { + errors.indyLivingDetail = 'Describe why they are unable to live independently' + } + + if (!this.body.requiresAdditionalSupport) { + errors.requiresAdditionalSupport = 'Confirm whether they require additional support' + } + + if (this.body.requiresAdditionalSupport === 'yes' && !this.body.addSupportDetail) { + errors.addSupportDetail = 'Describe the support required' + } + + return errors + } + + onSave(): void { + if (this.body.hasPhyHealthNeeds !== 'yes') { + delete this.body.needsDetail + delete this.body.canClimbStairs + } + + if (this.body.isReceivingMedicationOrTreatment !== 'yes') { + delete this.body.medicationOrTreatmentDetail + } + + if (this.body.canLiveIndependently !== 'no') { + delete this.body.indyLivingDetail + } + + if (this.body.requiresAdditionalSupport !== 'yes') { + delete this.body.addSupportDetail + } + } +} diff --git a/server/form-pages/apply/risks-and-needs/health-needs/substanceMisuse.test.ts b/server/form-pages/apply/risks-and-needs/health-needs/substanceMisuse.test.ts new file mode 100644 index 0000000..ccb5d8f --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/substanceMisuse.test.ts @@ -0,0 +1,195 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import SubstanceMisuse, { SubstanceMisuseBody } from './substanceMisuse' + +describe('SubstanceMisuse', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new SubstanceMisuse({}, application) + + expect(page.title).toEqual('Substance misuse needs for Roger Smith') + }) + }) + + describe('Questions', () => { + const page = new SubstanceMisuse({}, application) + + describe('usesIllegalSubstances', () => { + it('has a question', () => { + expect(page.questions.usesIllegalSubstances.question).toBeDefined() + }) + it('has two follow-up questions', () => { + expect(page.questions.substanceMisuse.question).toBeDefined() + }) + }) + + describe('engagedWithDrugAndAlcoholService', () => { + it('has a question', () => { + expect(page.questions.engagedWithDrugAndAlcoholService.question).toBeDefined() + }) + it('has a follow-up question', () => { + expect(page.questions.drugAndAlcoholServiceDetail.question).toBeDefined() + }) + }) + + describe('substituteMedication', () => { + it('has a question', () => { + expect(page.questions.requiresSubstituteMedication.question).toBeDefined() + }) + it('has a follow-up question', () => { + expect(page.questions.substituteMedicationDetail.question).toBeDefined() + }) + }) + }) + + itShouldHaveNextValue(new SubstanceMisuse({}, application), 'physical-health') + itShouldHavePreviousValue(new SubstanceMisuse({}, application), 'taskList') + + describe('errors', () => { + describe('when top-level questions are unanswered', () => { + const page = new SubstanceMisuse({}, application) + + it('includes a validation error for _usesIllegalSubstances_', () => { + expect(page.errors()).toHaveProperty( + 'usesIllegalSubstances', + 'Confirm whether they take any illegal substances', + ) + }) + + it('includes a validation error for _pastSubstanceMisuse_', () => { + expect(page.errors()).toHaveProperty( + 'pastSubstanceMisuse', + 'Confirm whether they had past issues with substance misuse', + ) + }) + + it('includes a validation error for _engagedWithDrugAndAlcoholService_', () => { + expect(page.errors()).toHaveProperty( + 'engagedWithDrugAndAlcoholService', + 'Confirm whether they are engaged with a drug and alcohol service', + ) + }) + + it('includes a validation error for _intentToReferToServiceOnRelease_', () => { + expect(page.errors()).toHaveProperty( + 'intentToReferToServiceOnRelease', + 'Confirm whether they will be referred to a drug and alcohol service after release', + ) + }) + + it('includes a validation error for _requiresSubstituteMedication_', () => { + expect(page.errors()).toHaveProperty( + 'requiresSubstituteMedication', + 'Confirm whether they require substitute medication', + ) + }) + + it('includes a validation error for _releasedWithNaloxone_', () => { + expect(page.errors()).toHaveProperty( + 'releasedWithNaloxone', + "Confirm whether they will be released with naloxone or select 'I don’t know'", + ) + }) + }) + + describe('when _usesIllegalSubstances_ is YES', () => { + const page = new SubstanceMisuse({ usesIllegalSubstances: 'yes' }, application) + + describe('and _substanceMisuse_ is UNANSWERED', () => { + it('includes a validation error for _substanceMisuse_', () => { + expect(page.errors()).toHaveProperty('substanceMisuse', 'Name the illegal substances they take') + }) + }) + }) + + describe('when _pastSubstanceMisuse_ is YES', () => { + const page = new SubstanceMisuse({ pastSubstanceMisuse: 'yes' }, application) + + describe('and _pastSubstanceMisuseDetail_ is UNANSWERED', () => { + it('includes a validation error for _pastSubstanceMisuseDetail_', () => { + expect(page.errors()).toHaveProperty( + 'pastSubstanceMisuseDetail', + 'Provide details of their past issues with substance misuse', + ) + }) + }) + }) + + describe('when _requiresSubstituteMedication_ is YES', () => { + const page = new SubstanceMisuse({ requiresSubstituteMedication: 'yes' }, application) + + describe('and _substituteMedicationDetail_ is UNANSWERED', () => { + it('includes a validation error for _substituteMedicationDetail_', () => { + expect(page.errors()).toHaveProperty( + 'substituteMedicationDetail', + 'Provide details of their substitute medication', + ) + }) + }) + }) + }) + + describe('onSave', () => { + it('removes illegal substance data if answer is no', () => { + const body: Partial = { + usesIllegalSubstances: 'no', + substanceMisuse: 'Substance misuse', + } + + const page = new SubstanceMisuse(body, application) + + page.onSave() + + expect(page.body).toEqual({ + usesIllegalSubstances: 'no', + }) + }) + + it('removes past substance misuse data if answer is no', () => { + const body: Partial = { + pastSubstanceMisuse: 'no', + pastSubstanceMisuseDetail: 'Substance misuse history', + } + + const page = new SubstanceMisuse(body, application) + + page.onSave() + + expect(page.body).toEqual({ + pastSubstanceMisuse: 'no', + }) + }) + + it('removes drug and alcohol service data if answer is no', () => { + const body: Partial = { + intentToReferToServiceOnRelease: 'no', + drugAndAlcoholServiceDetail: 'Drug and alcohol service detail', + } + + const page = new SubstanceMisuse(body, application) + + page.onSave() + + expect(page.body).toEqual({ + intentToReferToServiceOnRelease: 'no', + }) + }) + + it('removes substitute medical data if answer is no', () => { + const body: Partial = { + requiresSubstituteMedication: 'no', + substituteMedicationDetail: 'Substitute medical detail', + } + + const page = new SubstanceMisuse(body, application) + + page.onSave() + + expect(page.body).toEqual({ + requiresSubstituteMedication: 'no', + }) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/health-needs/substanceMisuse.ts b/server/form-pages/apply/risks-and-needs/health-needs/substanceMisuse.ts new file mode 100644 index 0000000..d26edf6 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/health-needs/substanceMisuse.ts @@ -0,0 +1,122 @@ +import type { TaskListErrors, YesNoOrDontKnow, YesOrNo } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' + +export type SubstanceMisuseBody = { + usesIllegalSubstances: YesOrNo + substanceMisuse: string + pastSubstanceMisuse: YesOrNo + pastSubstanceMisuseDetail: string + engagedWithDrugAndAlcoholService: YesOrNo + intentToReferToServiceOnRelease: YesOrNo + drugAndAlcoholServiceDetail: string + requiresSubstituteMedication: YesOrNo + substituteMedicationDetail: string + releasedWithNaloxone: YesNoOrDontKnow +} + +@Page({ + name: 'substance-misuse', + bodyProperties: [ + 'usesIllegalSubstances', + 'substanceMisuse', + 'pastSubstanceMisuse', + 'pastSubstanceMisuseDetail', + 'engagedWithDrugAndAlcoholService', + 'intentToReferToServiceOnRelease', + 'drugAndAlcoholServiceDetail', + 'requiresSubstituteMedication', + 'substituteMedicationDetail', + 'releasedWithNaloxone', + ], +}) +export default class SubstanceMisuse implements TaskListPage { + documentTitle = 'Substance misuse needs for the person' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Substance misuse needs for ${nameOrPlaceholderCopy(this.application.person)}` + + questions = getQuestions(this.personName)['health-needs']['substance-misuse'] + + body: SubstanceMisuseBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as SubstanceMisuseBody + } + + previous() { + return 'taskList' + } + + next() { + return 'physical-health' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.usesIllegalSubstances) { + errors.usesIllegalSubstances = `Confirm whether they take any illegal substances` + } + + if (this.body.usesIllegalSubstances === 'yes' && !this.body.substanceMisuse) { + errors.substanceMisuse = 'Name the illegal substances they take' + } + + if (!this.body.pastSubstanceMisuse) { + errors.pastSubstanceMisuse = 'Confirm whether they had past issues with substance misuse' + } + + if (this.body.pastSubstanceMisuse === 'yes' && !this.body.pastSubstanceMisuseDetail) { + errors.pastSubstanceMisuseDetail = 'Provide details of their past issues with substance misuse' + } + + if (!this.body.engagedWithDrugAndAlcoholService) { + errors.engagedWithDrugAndAlcoholService = `Confirm whether they are engaged with a drug and alcohol service` + } + + if (!this.body.intentToReferToServiceOnRelease) { + errors.intentToReferToServiceOnRelease = + 'Confirm whether they will be referred to a drug and alcohol service after release' + } + + if (!this.body.requiresSubstituteMedication) { + errors.requiresSubstituteMedication = `Confirm whether they require substitute medication` + } + + if (this.body.requiresSubstituteMedication === 'yes' && !this.body.substituteMedicationDetail) { + errors.substituteMedicationDetail = 'Provide details of their substitute medication' + } + + if (!this.body.releasedWithNaloxone) { + errors.releasedWithNaloxone = "Confirm whether they will be released with naloxone or select 'I don’t know'" + } + + return errors + } + + onSave(): void { + if (this.body.usesIllegalSubstances !== 'yes') { + delete this.body.substanceMisuse + } + + if (this.body.pastSubstanceMisuse !== 'yes') { + delete this.body.pastSubstanceMisuseDetail + } + + if (this.body.intentToReferToServiceOnRelease !== 'yes') { + delete this.body.drugAndAlcoholServiceDetail + } + + if (this.body.requiresSubstituteMedication !== 'yes') { + delete this.body.substituteMedicationDetail + } + } +} diff --git a/server/form-pages/apply/risks-and-needs/index.ts b/server/form-pages/apply/risks-and-needs/index.ts new file mode 100644 index 0000000..6efacb2 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/index.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ + +import { Section } from '../../utils/decorators' +import HealthNeeds from './health-needs' +import RiskToSelf from './risk-to-self' +import RiskOfSeriousHarm from './risk-of-serious-harm' + +@Section({ + title: 'Risks and needs', + tasks: [HealthNeeds, RiskToSelf, RiskOfSeriousHarm], +}) +export default class RisksAndNeeds {} diff --git a/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/additionalRiskInformation.test.ts b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/additionalRiskInformation.test.ts new file mode 100644 index 0000000..0151fa3 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/additionalRiskInformation.test.ts @@ -0,0 +1,62 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import AdditionalRiskInformation, { AdditionalRiskInformationBody } from './additionalRiskInformation' + +describe('AdditionalRiskInformation', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new AdditionalRiskInformation({}, application) + + expect(page.title).toEqual(`Additional risk information for Roger Smith`) + }) + }) + + itShouldHaveNextValue(new AdditionalRiskInformation({}, application), '') + itShouldHavePreviousValue(new AdditionalRiskInformation({}, application), 'cell-share-information') + + describe('errors', () => { + describe('when answer data is valid', () => { + it('returns empty object if valid data', () => { + const page = new AdditionalRiskInformation({ hasAdditionalInformation: 'no' }, application) + expect(page.errors()).toEqual({}) + }) + }) + + describe('when they have not answered the has additional information question', () => { + it('returns an error', () => { + const page = new AdditionalRiskInformation({}, application) + expect(page.errors()).toEqual({ + hasAdditionalInformation: 'Select whether there is any additional risk information', + }) + }) + }) + + describe('when they have answered Yes there is additional information but not provided any', () => { + it('returns an error', () => { + const page = new AdditionalRiskInformation({ hasAdditionalInformation: 'yes' }, application) + expect(page.errors()).toEqual({ + additionalInformationDetail: 'Enter additional information for risk to others', + }) + }) + }) + }) + + describe('onSave', () => { + it('removes additional information data when the question is set to "no"', () => { + const body: AdditionalRiskInformationBody = { + hasAdditionalInformation: 'no', + additionalInformationDetail: 'Additional information', + } + + const page = new AdditionalRiskInformation(body, application) + + page.onSave() + + expect(page.body).toEqual({ + hasAdditionalInformation: 'no', + }) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/additionalRiskInformation.ts b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/additionalRiskInformation.ts new file mode 100644 index 0000000..4b17d42 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/additionalRiskInformation.ts @@ -0,0 +1,60 @@ +import type { TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +export type AdditionalRiskInformationBody = { hasAdditionalInformation: YesOrNo; additionalInformationDetail: string } + +@Page({ + name: 'additional-risk-information', + bodyProperties: ['hasAdditionalInformation', 'additionalInformationDetail'], +}) +export default class AdditionalRiskInformation implements TaskListPage { + documentTitle = 'Additional risk information for the person' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Additional risk information for ${this.personName}` + + body: AdditionalRiskInformationBody + + exampleField = 'something' + + questions = getQuestions(this.personName)['risk-of-serious-harm']['additional-risk-information'] + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as AdditionalRiskInformationBody + } + + previous() { + return 'cell-share-information' + } + + next() { + return '' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.hasAdditionalInformation) { + errors.hasAdditionalInformation = 'Select whether there is any additional risk information' + } + if (this.body.hasAdditionalInformation === 'yes' && !this.body.additionalInformationDetail) { + errors.additionalInformationDetail = 'Enter additional information for risk to others' + } + + return errors + } + + onSave(): void { + if (this.body.hasAdditionalInformation !== 'yes') { + delete this.body.additionalInformationDetail + } + } +} diff --git a/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/cellShareInformation.test.ts b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/cellShareInformation.test.ts new file mode 100644 index 0000000..786cf0c --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/cellShareInformation.test.ts @@ -0,0 +1,62 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import CellShareInformation, { CellShareInformationBody } from './cellShareInformation' + +describe('CellShareInformation', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new CellShareInformation({}, application) + + expect(page.title).toEqual(`Cell share information for Roger Smith`) + }) + }) + + itShouldHaveNextValue(new CellShareInformation({}, application), 'additional-risk-information') + itShouldHavePreviousValue(new CellShareInformation({}, application), 'risk-management-arrangements') + + describe('errors', () => { + describe('when answer data is valid', () => { + it('returns empty object if valid data', () => { + const page = new CellShareInformation({ hasCellShareComments: 'no' }, application) + expect(page.errors()).toEqual({}) + }) + }) + + describe('when they have not answered the has cell share comments question', () => { + it('returns an error', () => { + const page = new CellShareInformation({}, application) + expect(page.errors()).toEqual({ + hasCellShareComments: 'Select whether there are any comments about cell sharing', + }) + }) + }) + + describe('when they have answered Yes there is cell share comments but not provided any', () => { + it('returns an error', () => { + const page = new CellShareInformation({ hasCellShareComments: 'yes' }, application) + expect(page.errors()).toEqual({ + cellShareInformationDetail: 'Enter cell sharing information', + }) + }) + }) + }) + + describe('onSave', () => { + it('removes cell share data when the question is set to "no"', () => { + const body: CellShareInformationBody = { + hasCellShareComments: 'no', + cellShareInformationDetail: 'Cell share information', + } + + const page = new CellShareInformation(body, application) + + page.onSave() + + expect(page.body).toEqual({ + hasCellShareComments: 'no', + }) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/cellShareInformation.ts b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/cellShareInformation.ts new file mode 100644 index 0000000..fb75746 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/cellShareInformation.ts @@ -0,0 +1,58 @@ +import type { TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getQuestions } from '../../../utils/questions' + +export type CellShareInformationBody = { hasCellShareComments: YesOrNo; cellShareInformationDetail: string } + +@Page({ + name: 'cell-share-information', + bodyProperties: ['hasCellShareComments', 'cellShareInformationDetail'], +}) +export default class CellShareInformation implements TaskListPage { + documentTitle = 'Cell share information for the person' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Cell share information for ${this.personName}` + + body: CellShareInformationBody + + questions = getQuestions(this.personName)['risk-of-serious-harm']['cell-share-information'] + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as CellShareInformationBody + } + + previous() { + return 'risk-management-arrangements' + } + + next() { + return 'additional-risk-information' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.hasCellShareComments) { + errors.hasCellShareComments = 'Select whether there are any comments about cell sharing' + } + if (this.body.hasCellShareComments === 'yes' && !this.body.cellShareInformationDetail) { + errors.cellShareInformationDetail = 'Enter cell sharing information' + } + + return errors + } + + onSave(): void { + if (this.body.hasCellShareComments !== 'yes') { + delete this.body.cellShareInformationDetail + } + } +} diff --git a/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/custom-forms/oasysImport.test.ts b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/custom-forms/oasysImport.test.ts new file mode 100644 index 0000000..6326018 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/custom-forms/oasysImport.test.ts @@ -0,0 +1,187 @@ +import { createMock } from '@golevelup/ts-jest' +import type { DataServices } from '@approved-premises/ui' +import { DateFormats } from '../../../../../utils/dateUtils' +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../../shared-examples' +import { personFactory, applicationFactory, roshRisksEnvelopeFactory } from '../../../../../testutils/factories/index' +import OasysImport, { RoshTaskData } from './oasysImport' +import PersonService from '../../../../../services/personService' +import oasysRoshFactory from '../../../../../testutils/factories/oasysRosh' +import Summary from '../summary' +import OldOasys from '../oldOasys' + +jest.mock('../oldOasys') +jest.mock('../summary') + +describe('OasysImport', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + const oasys = oasysRoshFactory.build({ + dateCompleted: DateFormats.dateObjToIsoDateTime(new Date(2023, 7, 29)), + dateStarted: DateFormats.dateObjToIsoDateTime(new Date(2023, 7, 28)), + }) + + const dataServices = createMock({ personService: createMock({}) }) + + const now = new Date() + + beforeAll(() => { + jest.useFakeTimers() + jest.setSystemTime(now) + }) + + afterAll(() => { + jest.useRealTimers() + }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new OasysImport({}, application, oasys, '') + + expect(page.title).toEqual("Import Roger Smith's risk of serious harm (RoSH) data from OASys") + }) + }) + + describe('initialize', () => { + describe('when oasys sections are returned', () => { + it('instantiates the class with the task data in the correct format', async () => { + oasys.rosh = [ + { + label: 'Who is at risk', + questionNumber: 'R10.1', + answer: 'who is at risk answer', + }, + { + label: 'What is the nature of the risk', + questionNumber: 'R10.2', + answer: 'nature of risk answer', + }, + { + label: 'What circumstances are likely to reduce the risk', + questionNumber: 'R10.5', + answer: 'circumstances likely to reduce risk answer', + }, + ] + + const riskSummary = roshRisksEnvelopeFactory.build() + + const taskData = { + 'risk-of-serious-harm': { + summary: {}, + 'summary-data': { + ...riskSummary, + oasysImportedDate: now, + oasysStartedDate: oasys.dateStarted, + oasysCompletedDate: oasys.dateCompleted, + }, + 'risk-to-others': { + whoIsAtRisk: 'who is at risk answer', + natureOfRisk: 'nature of risk answer', + }, + 'oasys-import': { oasysImportedDate: now }, + }, + } + + ;(dataServices.personService.getOasysRosh as jest.Mock).mockResolvedValue(oasys) + ;(dataServices.personService.getRoshRisks as jest.Mock).mockResolvedValue(riskSummary) + + const page = (await OasysImport.initialize({}, application, 'some-token', dataServices)) as OasysImport + + expect(page.taskData).toBe(JSON.stringify(taskData)) + expect(page.hasOasysRecord).toBe(true) + expect(page.oasysCompleted).toBe('29 August 2023') + expect(page.oasysStarted).toBe('28 August 2023') + }) + + describe('when there is not a completed date', () => { + it('does not assign a completed date', async () => { + const oasysIncomplete = oasysRoshFactory.build({ dateCompleted: null }) + + ;(dataServices.personService.getOasysRosh as jest.Mock).mockResolvedValue(oasysIncomplete) + ;(dataServices.personService.getRoshRisks as jest.Mock).mockResolvedValue(null) + + const page = (await OasysImport.initialize({}, application, 'some-token', dataServices)) as OasysImport + + expect(page.oasysCompleted).toBe(null) + }) + }) + }) + + describe('when oasys sections are not returned', () => { + it('sets hasOasysRecord to false when an error is returned', async () => { + ;(dataServices.personService.getOasysRosh as jest.Mock).mockRejectedValue(new Error()) + + const page = (await OasysImport.initialize({}, application, 'some-token', dataServices)) as OasysImport + + expect(page.hasOasysRecord).toBe(false) + expect(page.oasysCompleted).toBe(undefined) + expect(page.oasysStarted).toBe(undefined) + }) + }) + + describe('when OASys data has already been imported', () => { + it('returns the Rosh summary page', async () => { + const roshData = { + 'risk-of-serious-harm': { + 'oasys-import': { oasysImportedDate: now }, + }, + } as RoshTaskData + + const applicationWithData = applicationFactory.build({ + person: personFactory.build({ name: 'Roger Smith' }), + data: roshData, + }) + + const roshSummaryPageConstructor = jest.fn() + + ;(Summary as jest.Mock).mockImplementation(() => { + return roshSummaryPageConstructor + }) + + expect(OasysImport.initialize({}, applicationWithData, 'some-token', dataServices)).resolves.toEqual( + roshSummaryPageConstructor, + ) + + expect(Summary).toHaveBeenCalledWith({}, applicationWithData) + }) + + describe("when there is data but it hasn't been imported from OASys", () => { + it('returns the Old OASys page', async () => { + const roshData = { + 'risk-of-serious-harm': { + 'old-oasys': { + hasOldOasys: 'no', + }, + }, + } + + const applicationWithData = applicationFactory.build({ + person: personFactory.build({ name: 'Roger Smith' }), + data: roshData, + }) + + const oldOasysPageConstructor = jest.fn() + + ;(OldOasys as jest.Mock).mockImplementation(() => { + return oldOasysPageConstructor + }) + + expect(OasysImport.initialize({}, applicationWithData, 'some-token', dataServices)).resolves.toEqual( + oldOasysPageConstructor, + ) + + expect(OldOasys).toHaveBeenCalledWith(roshData['risk-of-serious-harm']['old-oasys'], applicationWithData) + }) + }) + }) + }) + + itShouldHaveNextValue(new OasysImport({}, application, oasys, ''), 'summary') + itShouldHavePreviousValue(new OasysImport({}, application, oasys, ''), 'taskList') + + describe('errors', () => { + it('returns empty object', () => { + const page = new OasysImport({}, application, oasys, '') + + expect(page.errors()).toEqual({}) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/custom-forms/oasysImport.ts b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/custom-forms/oasysImport.ts new file mode 100644 index 0000000..e00046f --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/custom-forms/oasysImport.ts @@ -0,0 +1,157 @@ +import type { DataServices, TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application, OASysRiskOfSeriousHarm, RoshRisksEnvelope } from '@approved-premises/api' +import { Page } from '../../../../utils/decorators' +import TaskListPage from '../../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../../utils/utils' +import { DateFormats } from '../../../../../utils/dateUtils' +import Summary, { SummaryData } from '../summary' +import { logOasysError } from '../../../../utils' +import OldOasys from '../oldOasys' + +type OasysImportBody = Record + +export type RoshTaskData = { + 'risk-of-serious-harm': { + 'oasys-import': { + oasysImportedDate: Date + } + summary: RoshRisksEnvelope & { + oasysImportedDate: Date + } + 'risk-to-others': { + whoIsAtRisk: string + natureOfRisk: string + } + } +} + +@Page({ + name: 'oasys-import', + bodyProperties: [], +}) +export default class OasysImport implements TaskListPage { + personName = nameOrPlaceholderCopy(this.application.person) + + documentTitle = "Import the person's risk of serious harm (RoSH) data from OASys" + + title = `Import ${nameOrPlaceholderCopy(this.application.person)}'s risk of serious harm (RoSH) data from OASys` + + body: OasysImportBody + + taskData: string + + hasOasysRecord: boolean + + oasysCompleted: string + + oasysStarted: string + + noOasysBannerText = `No OASys record available to import for ${this.personName}` + + noOasysDescriptiveText = `No information can be imported for the Risk of Serious Harm (RoSH) section because ${this.personName} + does not have a Layer 3 OASys completed in the last 6 months.` + + taskName = 'risk-of-serious-harm' + + constructor( + body: Partial, + private readonly application: Application, + oasys: OASysRiskOfSeriousHarm, + taskData: string, + ) { + this.body = body as OasysImportBody + this.hasOasysRecord = (oasys && Boolean(Object.keys(oasys).length)) || false + if (this.hasOasysRecord) { + this.oasysStarted = oasys.dateStarted && DateFormats.isoDateToUIDate(oasys.dateStarted, { format: 'medium' }) + this.oasysCompleted = + oasys.dateCompleted && DateFormats.isoDateToUIDate(oasys.dateCompleted, { format: 'medium' }) + } + this.taskData = taskData + } + + static async initialize( + body: Partial, + application: Application, + token: string, + dataServices: DataServices, + ) { + let oasys: OASysRiskOfSeriousHarm + let risks: RoshRisksEnvelope + let taskDataJson + + if (!application.data['risk-of-serious-harm']) { + try { + oasys = await dataServices.personService.getOasysRosh(token, application.person.crn) + risks = await dataServices.personService.getRoshRisks(token, application.person.crn) + taskDataJson = JSON.stringify(OasysImport.getTaskData(oasys, risks)) + } catch (e) { + logOasysError(e, application.person.crn) + oasys = null + } + return new OasysImport(body, application, oasys, taskDataJson) + } + if (OasysImport.isRoshApplicationDataImportedFromOASys(application)) { + return new Summary(application.data['risk-of-serious-harm'].summary ?? {}, application) + } + return new OldOasys(application.data['risk-of-serious-harm']['old-oasys'] ?? {}, application) + } + + private static isRoshApplicationDataImportedFromOASys(application: Application): boolean { + const rosh = application.data['risk-of-serious-harm'] + if (rosh?.['oasys-import']?.oasysImportedDate) { + return true + } + return false + } + + private static getTaskData(oasysSections: OASysRiskOfSeriousHarm, risks: RoshRisksEnvelope): Partial { + const taskData = { 'risk-of-serious-harm': { summary: {} } } as Partial + + const today = new Date() + + // @ts-expect-error Requires refactor to satisfy TS7053 + taskData['risk-of-serious-harm']['summary-data'] = { + ...risks, + oasysImportedDate: today, + oasysStartedDate: oasysSections.dateStarted, + oasysCompletedDate: oasysSections.dateCompleted, + } as SummaryData + + oasysSections.rosh.forEach(question => { + switch (question.questionNumber) { + case 'R10.1': + taskData['risk-of-serious-harm']['risk-to-others'] = { + ...taskData['risk-of-serious-harm']['risk-to-others'], + whoIsAtRisk: question.answer, + } + break + case 'R10.2': + taskData['risk-of-serious-harm']['risk-to-others'] = { + ...taskData['risk-of-serious-harm']['risk-to-others'], + natureOfRisk: question.answer, + } + break + default: + break + } + }) + + taskData['risk-of-serious-harm']['oasys-import'] = { oasysImportedDate: today } + + return taskData + } + + previous() { + return 'taskList' + } + + next() { + return 'summary' + } + + errors() { + const errors: TaskListErrors = {} + + return errors + } +} diff --git a/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/index.ts b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/index.ts new file mode 100644 index 0000000..e8ce447 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/index.ts @@ -0,0 +1,23 @@ +import { Task } from '../../../utils/decorators' +import OasysImport from './custom-forms/oasysImport' +import Summary from './summary' +import RiskToOthers from './riskToOthers' +import RiskManagementArrangements from './riskManagementArrangements' +import CellShareInformation from './cellShareInformation' +import AdditionalRiskInformation from './additionalRiskInformation' +import OldOasys from './oldOasys' + +@Task({ + name: 'Add risk of serious harm (RoSH) information', + slug: 'risk-of-serious-harm', + pages: [ + OasysImport, + Summary, + OldOasys, + RiskToOthers, + RiskManagementArrangements, + CellShareInformation, + AdditionalRiskInformation, + ], +}) +export default class RiskOfSeriousHarm {} diff --git a/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/oldOasys.test.ts b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/oldOasys.test.ts new file mode 100644 index 0000000..9750d3d --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/oldOasys.test.ts @@ -0,0 +1,94 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import OldOasys from './oldOasys' +import { dateAndTimeInputsAreValidDates, dateIsComplete } from '../../../../utils/dateUtils' + +jest.mock('../../../../utils/dateUtils', () => { + const actual = jest.requireActual('../../../../utils/dateUtils') + return { + ...actual, + dateAndTimeInputsAreValidDates: jest.fn(), + dateIsComplete: jest.fn(), + } +}) + +describe('OldOasys', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new OldOasys({}, application) + + expect(page.title).toEqual(`Does Roger Smith have an older OASys with risk of serious harm (RoSH) information?`) + }) + }) + + itShouldHaveNextValue(new OldOasys({}, application), 'risk-to-others') + itShouldHavePreviousValue(new OldOasys({}, application), 'taskList') + + describe('errors', () => { + it('returns an error when required fields are blank', () => { + const page = new OldOasys({}, application) + expect(page.errors()).toEqual({ + hasOldOasys: 'Confirm whether they have an older OASys with risk of serious harm (RoSH) information', + }) + }) + describe('when hasOldOasys is yes', () => { + it('returns an error when oasysCompletedDate is blank', () => { + const page = new OldOasys({ hasOldOasys: 'yes' }, application) + expect(page.errors()).toEqual({ + oasysCompletedDate: 'Enter the date the OASys was completed', + }) + }) + + describe('when the date is not valid', () => { + beforeEach(() => { + jest.resetAllMocks() + ;(dateAndTimeInputsAreValidDates as jest.Mock).mockImplementation(() => false) + ;(dateIsComplete as jest.Mock).mockImplementation(() => true) + }) + it('returns an error', () => { + const page = new OldOasys({ hasOldOasys: 'yes' }, application) + expect(page.errors()).toEqual({ + oasysCompletedDate: 'OASys completed date must be a real date', + }) + }) + }) + }) + }) + + describe('response', () => { + it('returns the full response when all fields are entered', () => { + const page = new OldOasys( + { + hasOldOasys: 'yes', + 'oasysCompletedDate-year': '2023', + 'oasysCompletedDate-month': '11', + 'oasysCompletedDate-day': '11', + }, + application, + ) + + const expected = { + 'Does Roger Smith have an older OASys with risk of serious harm (RoSH) information?': 'Yes', + 'When was the OASys completed?': '11 November 2023', + } + expect(page.response()).toEqual(expected) + }) + }) + + it('ignores the date field when there is no old OASys', () => { + const page = new OldOasys( + { + hasOldOasys: 'no', + }, + application, + ) + + const expected = { + 'Does Roger Smith have an older OASys with risk of serious harm (RoSH) information?': + 'No, they do not have an OASys', + } + expect(page.response()).toEqual(expected) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/oldOasys.ts b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/oldOasys.ts new file mode 100644 index 0000000..f976a1a --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/oldOasys.ts @@ -0,0 +1,75 @@ +import type { ObjectWithDateParts, TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { dateBodyProperties } from '../../../utils' +import { getQuestions } from '../../../utils/questions' +import { DateFormats, dateAndTimeInputsAreValidDates, dateIsComplete } from '../../../../utils/dateUtils' + +type OldOasysBody = { + hasOldOasys: YesOrNo +} & ObjectWithDateParts<'oasysCompletedDate'> + +@Page({ + name: 'old-oasys', + bodyProperties: ['hasOldOasys', ...dateBodyProperties('oasysCompletedDate')], +}) +export default class OldOasys implements TaskListPage { + documentTitle = 'Does the person have an older OASys with risk of serious harm (RoSH) information?' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Does ${this.personName} have an older OASys with risk of serious harm (RoSH) information?` + + body: OldOasysBody + + questions = getQuestions(this.personName)['risk-of-serious-harm']['old-oasys'] + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as OldOasysBody + } + + previous() { + return 'taskList' + } + + next() { + return 'risk-to-others' + } + + response() { + return { + [this.questions.hasOldOasys.question]: this.questions.hasOldOasys.answers[this.body.hasOldOasys], + ...(this.body.hasOldOasys === 'yes' && { + [this.questions.oasysCompletedDate.question]: DateFormats.dateAndTimeInputsToUiDate( + this.body, + 'oasysCompletedDate', + ), + }), + } + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.hasOldOasys) { + errors.hasOldOasys = 'Confirm whether they have an older OASys with risk of serious harm (RoSH) information' + } + if (this.body.hasOldOasys === 'yes') { + if (!dateIsComplete(this.body, 'oasysCompletedDate')) { + errors.oasysCompletedDate = 'Enter the date the OASys was completed' + return errors + } + + if (!dateAndTimeInputsAreValidDates(this.body, 'oasysCompletedDate')) { + errors.oasysCompletedDate = 'OASys completed date must be a real date' + } + } + + return errors + } +} diff --git a/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/riskManagementArrangements.test.ts b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/riskManagementArrangements.test.ts new file mode 100644 index 0000000..70be942 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/riskManagementArrangements.test.ts @@ -0,0 +1,178 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import RiskManagementArrangements, { RiskManagementArrangementsBody } from './riskManagementArrangements' + +describe('RiskManagementArrangements', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new RiskManagementArrangements({}, application) + + expect(page.title).toEqual(`Risk management arrangements for Roger Smith`) + }) + }) + + itShouldHaveNextValue(new RiskManagementArrangements({}, application), 'cell-share-information') + itShouldHavePreviousValue(new RiskManagementArrangements({}, application), 'risk-to-others') + + describe('items', () => { + it('returns the radio with the expected label text', () => { + const page = new RiskManagementArrangements( + { + arrangements: ['mappa', 'marac', 'iom'], + mappaDetails: 'mappa details', + maracDetails: 'marac details', + iomDetails: 'iom details', + }, + application, + ) + + expect(page.items('mappaHtml', 'maracHtml', 'iomHtml')).toEqual([ + { + value: 'mappa', + text: 'Multi-Agency Public Protection Arrangements (MAPPA)', + checked: true, + conditional: { + html: 'mappaHtml', + }, + attributes: { + 'data-selector': 'arrangements', + }, + }, + { + value: 'marac', + text: 'Multi-Agency Risk Assessment Conference (MARAC)', + checked: true, + conditional: { + html: 'maracHtml', + }, + attributes: { + 'data-selector': 'arrangements', + }, + }, + { + value: 'iom', + text: 'Integrated Offender Management (IOM)', + checked: true, + conditional: { + html: 'iomHtml', + }, + attributes: { + 'data-selector': 'arrangements', + }, + }, + { + divider: 'or', + }, + { + value: 'no', + text: 'No, this person does not have risk management arrangements', + checked: false, + }, + ]) + }) + }) + + describe('errors', () => { + const validAnswers = [ + { + arrangements: ['no'], + }, + { + arrangements: ['mappa', 'marac', 'iom'], + mappaDetails: 'mappa details', + maracDetails: 'marac details', + iomDetails: 'iom details', + }, + ] + it.each(validAnswers)('it does not return an error for valid answers', validAnswer => { + const page = new RiskManagementArrangements(validAnswer as RiskManagementArrangementsBody, application) + + expect(page.errors()).toEqual({}) + }) + + it('returns an error is nothing selected', () => { + const page = new RiskManagementArrangements({}, application) + + expect(page.errors()).toEqual({ + arrangements: + "Select risk management arrangements or 'No, this person does not have risk management arrangements'", + }) + }) + + it('returns an error if a MAPPA arrangement has been selected but no details given', () => { + const page = new RiskManagementArrangements( + { arrangements: ['mappa'] } as RiskManagementArrangementsBody, + application, + ) + + expect(page.errors()).toEqual({ mappaDetails: 'Provide MAPPA details' }) + }) + + it('returns an error if a MARAC arrangement has been selected but no details given', () => { + const page = new RiskManagementArrangements( + { arrangements: ['marac'] } as RiskManagementArrangementsBody, + application, + ) + + expect(page.errors()).toEqual({ maracDetails: 'Provide MARAC details' }) + }) + + it('returns an error if an IOM arrangement has been selected but no details given', () => { + const page = new RiskManagementArrangements( + { arrangements: ['iom'] } as RiskManagementArrangementsBody, + application, + ) + + expect(page.errors()).toEqual({ iomDetails: 'Provide IOM details' }) + }) + }) + + describe('onSave', () => { + it('removes MAPPA data if option is not selected', () => { + const body: RiskManagementArrangementsBody = { + arrangements: ['no'], + mappaDetails: 'MAPPA details', + } + + const page = new RiskManagementArrangements(body, application) + + page.onSave() + + expect(page.body).toEqual({ + arrangements: ['no'], + }) + }) + + it('removes IOM data if option is not selected', () => { + const body: RiskManagementArrangementsBody = { + arrangements: ['no'], + iomDetails: 'IOM details', + } + + const page = new RiskManagementArrangements(body, application) + + page.onSave() + + expect(page.body).toEqual({ + arrangements: ['no'], + }) + }) + + it('removes MARAC data if option is not selected', () => { + const body: RiskManagementArrangementsBody = { + arrangements: ['no'], + maracDetails: 'MARAC details', + } + + const page = new RiskManagementArrangements(body, application) + + page.onSave() + + expect(page.body).toEqual({ + arrangements: ['no'], + }) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/riskManagementArrangements.ts b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/riskManagementArrangements.ts new file mode 100644 index 0000000..676c86b --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/riskManagementArrangements.ts @@ -0,0 +1,109 @@ +import type { Radio, TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { convertKeyValuePairToCheckboxItems } from '../../../../utils/formUtils' +import { getQuestions } from '../../../utils/questions' + +const arrangementOptions = { + mappa: 'Multi-Agency Public Protection Arrangements (MAPPA)', + marac: 'Multi-Agency Risk Assessment Conference (MARAC)', + iom: 'Integrated Offender Management (IOM)', + no: 'No, this person does not have risk management arrangements', +} + +export type RiskManagementArrangementsOptions = keyof typeof arrangementOptions + +export type RiskManagementArrangementsBody = { + arrangements: Array + mappaDetails?: string + maracDetails?: string + iomDetails?: string +} + +@Page({ + name: 'risk-management-arrangements', + bodyProperties: ['arrangements', 'mappaDetails', 'maracDetails', 'iomDetails'], +}) +export default class RiskManagementArrangements implements TaskListPage { + documentTitle = 'Risk management arrangements' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Risk management arrangements for ${this.personName}` + + body: RiskManagementArrangementsBody + + questions = getQuestions(this.personName)['risk-of-serious-harm']['risk-management-arrangements'] + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as RiskManagementArrangementsBody + } + + previous() { + return 'risk-to-others' + } + + next() { + return 'cell-share-information' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.arrangements) { + errors.arrangements = + "Select risk management arrangements or 'No, this person does not have risk management arrangements'" + } else { + if (this.body.arrangements.includes('mappa') && !this.body.mappaDetails) { + errors.mappaDetails = 'Provide MAPPA details' + } + if (this.body.arrangements.includes('marac') && !this.body.maracDetails) { + errors.maracDetails = 'Provide MARAC details' + } + if (this.body.arrangements.includes('iom') && !this.body.iomDetails) { + errors.iomDetails = 'Provide IOM details' + } + } + + return errors + } + + items(mappaDetailsHtml: string, maracDetailsHtml: string, iomDetailsHtml: string) { + const items = convertKeyValuePairToCheckboxItems(arrangementOptions, this.body.arrangements) as [Radio] + + items.forEach(item => { + if (item.value === 'mappa') { + item.attributes = { 'data-selector': 'arrangements' } + item.conditional = { html: mappaDetailsHtml } + } else if (item.value === 'marac') { + item.attributes = { 'data-selector': 'arrangements' } + item.conditional = { html: maracDetailsHtml } + } else if (item.value === 'iom') { + item.attributes = { 'data-selector': 'arrangements' } + item.conditional = { html: iomDetailsHtml } + } + }) + const noCheckbox = items.pop() + + return [...items, { divider: 'or' }, { ...noCheckbox }] + } + + onSave(): void { + if (!this.body.arrangements.includes('mappa')) { + delete this.body.mappaDetails + } + + if (!this.body.arrangements.includes('iom')) { + delete this.body.iomDetails + } + + if (!this.body.arrangements.includes('marac')) { + delete this.body.maracDetails + } + } +} diff --git a/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/riskToOthers.test.ts b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/riskToOthers.test.ts new file mode 100644 index 0000000..7468238 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/riskToOthers.test.ts @@ -0,0 +1,47 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import RiskToOthers from './riskToOthers' + +describe('RiskToOthers', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new RiskToOthers({}, application) + + expect(page.title).toEqual(`Risk to others for Roger Smith`) + }) + }) + + describe('import date', () => { + it('sets importDate to null where application contains no OASys import date', () => { + const page = new RiskToOthers({}, application) + + expect(page.importDate).toEqual(null) + }) + }) + + itShouldHaveNextValue(new RiskToOthers({}, application), 'risk-management-arrangements') + itShouldHavePreviousValue(new RiskToOthers({}, application), 'summary') + + describe('errors', () => { + it('returns an error when required fields are blank', () => { + const page = new RiskToOthers({}, application) + expect(page.errors()).toEqual({ + confirmation: 'Confirm that the information is relevant and up to date', + whoIsAtRisk: 'Enter who is at risk', + natureOfRisk: 'Enter the nature of the risk', + }) + }) + }) + + describe('items', () => { + it('returns the checkbox as expected', () => { + const page = new RiskToOthers({}, application) + + expect(page.items()).toEqual([ + { value: 'confirmed', text: 'I confirm this information is relevant and up to date.', checked: false }, + ]) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/riskToOthers.ts b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/riskToOthers.ts new file mode 100644 index 0000000..7e28f93 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/riskToOthers.ts @@ -0,0 +1,70 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { getOasysImportDateFromApplication } from '../../../utils' +import { convertKeyValuePairToCheckboxItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { getQuestions } from '../../../utils/questions' +import { hasOasys } from '../../../../utils/applicationUtils' + +type RiskToOthersBody = { whoIsAtRisk: string; natureOfRisk: string; confirmation: string } + +@Page({ + name: 'risk-to-others', + bodyProperties: ['whoIsAtRisk', 'natureOfRisk', 'confirmation'], +}) +export default class RiskToOthers implements TaskListPage { + documentTitle = 'Risk to others for the person' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Risk to others for ${this.personName}` + + body: RiskToOthersBody + + questions = getQuestions(this.personName)['risk-of-serious-harm']['risk-to-others'] + + importDate = getOasysImportDateFromApplication(this.application, 'risk-of-serious-harm') + + hasOasysRecord: boolean + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as RiskToOthersBody + this.hasOasysRecord = hasOasys(application, 'risk-of-serious-harm') + } + + previous() { + return 'summary' + } + + next() { + return 'risk-management-arrangements' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.whoIsAtRisk) { + errors.whoIsAtRisk = errorLookups.whoIsAtRisk.empty + } + if (!this.body.natureOfRisk) { + errors.natureOfRisk = errorLookups.natureOfRisk.empty + } + if (!this.body.confirmation) { + errors.confirmation = errorLookups.oasysConfirmation.empty + } + + return errors + } + + items() { + return convertKeyValuePairToCheckboxItems({ confirmed: this.questions.confirmation.question }, [ + this.body.confirmation, + ]) + } +} diff --git a/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/summary.test.ts b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/summary.test.ts new file mode 100644 index 0000000..c408bbd --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/summary.test.ts @@ -0,0 +1,149 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import Summary, { SummaryData } from './summary' + +describe('Summary', () => { + const roshSummaryData = { + status: 'retrieved' as const, + oasysImportedDate: new Date('2023-09-15'), + oasysStartedDate: '2023-01-30', + oasysCompletedDate: '2023-01-31', + value: { + overallRisk: 'a risk', + riskToChildren: 'another risk', + riskToPublic: 'a third risk', + riskToKnownAdult: 'a fourth risk', + riskToStaff: 'a fifth risk', + }, + } as SummaryData + + const person = personFactory.build({ name: 'Roger Smith' }) + + const applicationWithSummaryData = applicationFactory.build({ + person, + data: { 'risk-of-serious-harm': { 'summary-data': roshSummaryData } }, + }) + + const applicationWithoutSummaryData = applicationFactory.build({ + person, + }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new Summary({}, applicationWithSummaryData) + + expect(page.title).toEqual(`Risk of serious harm (RoSH) summary for Roger Smith`) + }) + }) + + describe('import date', () => { + it('sets importDate to null where application contains no OASys import date', () => { + const page = new Summary({}, applicationWithSummaryData) + + expect(page.importDate).toEqual(null) + }) + }) + + describe('risks', () => { + describe('if the risks have not been found', () => { + it('sets the risks to undefined', () => { + const roshSummaryDataWithoutValue = { + status: 'not_found' as const, + oasysImportedDate: new Date('2023-09-15'), + } as SummaryData + + const page = new Summary( + {}, + { + ...applicationWithSummaryData, + data: { 'risk-of-serious-harm': { 'summary-data': roshSummaryDataWithoutValue } }, + }, + ) + + expect(page.risks).toBe(undefined) + }) + }) + + describe('if the risks have been found but there are no values', () => { + it('sets the risks to undefined', () => { + const roshSummaryDataWithoutValue = { + status: 'retrieved' as const, + oasysImportedDate: new Date('2023-09-15'), + } as SummaryData + + const page = new Summary( + {}, + { + ...applicationWithSummaryData, + data: { 'risk-of-serious-harm': { 'summary-data': roshSummaryDataWithoutValue } }, + }, + ) + + expect(page.risks.value).toBe(undefined) + }) + }) + + describe('if risk values exists', () => { + it('sets the risks', () => { + const page = new Summary({}, applicationWithSummaryData) + expect(page.risks).toEqual(roshSummaryData) + }) + }) + + describe('if there is no summary data', () => { + it('sets the risks to undefined', () => { + const page = new Summary({}, applicationWithoutSummaryData) + + expect(page.risks).toBe(undefined) + }) + }) + }) + + itShouldHaveNextValue(new Summary({}, applicationWithSummaryData), 'risk-to-others') + itShouldHavePreviousValue(new Summary({}, applicationWithSummaryData), 'taskList') + + describe('response', () => { + const body = { + additionalComments: 'some additional comments', + } + + const expectedResponse = { + 'OASys created': '30 January 2023', + 'OASys completed': '31 January 2023', + 'OASys imported': '15 September 2023', + 'Overall risk rating': roshSummaryData.value.overallRisk, + 'Risk to children': roshSummaryData.value.riskToChildren, + 'Risk to known adult': roshSummaryData.value.riskToKnownAdult, + 'Risk to public': roshSummaryData.value.riskToPublic, + 'Risk to staff': roshSummaryData.value.riskToStaff, + } + it('returns page body if no additional comments have been added', () => { + const page = new Summary({}, applicationWithSummaryData) + + expect(page.response()).toEqual(expectedResponse) + }) + + it('returns page body and additional comments if a comment is added', () => { + const page = new Summary(body, applicationWithSummaryData) + + expect(page.response()).toEqual({ + ...expectedResponse, + 'Additional comments (optional)': 'some additional comments', + }) + }) + + it('returns nothing if there is no answer data', () => { + const page = new Summary({}, applicationWithoutSummaryData) + + expect(page.response()).toEqual({}) + }) + }) + + describe('errors', () => { + it('not implemented', () => { + const page = new Summary({}, applicationWithSummaryData) + + expect(page.errors()).toEqual({}) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/summary.ts b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/summary.ts new file mode 100644 index 0000000..1417a9e --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-of-serious-harm/summary.ts @@ -0,0 +1,101 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application, RoshRisksEnvelope } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { DateFormats } from '../../../../utils/dateUtils' +import { getOasysImportDateFromApplication } from '../../../utils' +import { getQuestions } from '../../../utils/questions' + +export type SummaryBody = { + additionalComments?: string +} + +export type SummaryData = RoshRisksEnvelope & { + oasysImportedDate: Date + oasysStartedDate: string + oasysCompletedDate?: string +} + +@Page({ + name: 'summary', + bodyProperties: ['additionalComments'], +}) +export default class Summary implements TaskListPage { + documentTitle = 'Risk of serious harm (RoSH) summary for the person' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Risk of serious harm (RoSH) summary for ${this.personName}` + + body: SummaryBody + + risks: SummaryData + + questions: { + additionalComments: string + } + + importDate = getOasysImportDateFromApplication(this.application, 'risk-of-serious-harm') + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as SummaryBody + this.application = application + + if (this.isSummaryDataRetrieved(application)) { + const summaryData = application.data['risk-of-serious-harm']['summary-data'] as SummaryData + this.risks = { + ...summaryData, + } + } + const roshQuestions = getQuestions(this.personName)['risk-of-serious-harm'] + + this.questions = { + additionalComments: roshQuestions.summary.additionalComments.question, + } + } + + private isSummaryDataRetrieved(application: Application) { + return application.data['risk-of-serious-harm']?.['summary-data']?.status === 'retrieved' + } + + previous() { + return 'taskList' + } + + next() { + return 'risk-to-others' + } + + errors() { + const errors: TaskListErrors = {} + + return errors + } + + response() { + let response: Record = {} + if (this.isSummaryDataRetrieved(this.application)) { + const oasysData = this.application.data['risk-of-serious-harm']['summary-data'] + response = { + 'OASys created': DateFormats.isoDateToUIDate(oasysData.oasysStartedDate, { format: 'medium' }), + 'OASys completed': oasysData.oasysCompletedDate + ? DateFormats.isoDateToUIDate(oasysData.oasysCompletedDate, { format: 'medium' }) + : 'Unknown', + 'OASys imported': DateFormats.dateObjtoUIDate(oasysData.oasysImportedDate, { format: 'medium' }), + 'Overall risk rating': oasysData.value.overallRisk, + 'Risk to children': oasysData.value.riskToChildren, + 'Risk to known adult': oasysData.value.riskToKnownAdult, + 'Risk to public': oasysData.value.riskToPublic, + 'Risk to staff': oasysData.value.riskToStaff, + } + } + if (this.body.additionalComments) { + response[this.questions.additionalComments] = this.body.additionalComments + } + return response + } +} diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/acct.test.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/acct.test.ts new file mode 100644 index 0000000..b940808 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/acct.test.ts @@ -0,0 +1,113 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import Acct from './acct' + +describe('Acct', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new Acct({}, application) + + expect(page.title).toEqual("Roger Smith's ACCT notes") + }) + }) + + describe('acct data', () => { + describe('when there is acct data on the application', () => { + it('assigns them to the accts field on the page', () => { + const applicationWithData = applicationFactory.build({ + person: personFactory.build({ name: 'Roger Smith' }), + data: { + 'risk-to-self': { + 'acct-data': [ + { + referringInstitution: 'institution', + 'createdDate-day': '1', + 'createdDate-month': '2', + 'createdDate-year': '2012', + isOngoing: 'no', + 'closedDate-day': '10', + 'closedDate-month': '10', + 'closedDate-year': '2013', + acctDetails: 'detail info', + }, + { + referringInstitution: 'institution 2', + 'createdDate-day': '2', + 'createdDate-month': '3', + 'createdDate-year': '2012', + isOngoing: 'yes', + acctDetails: 'detail info 2', + }, + ], + }, + }, + }) + + const page = new Acct({}, applicationWithData) + + expect(page.accts).toEqual([ + { + referringInstitution: 'institution', + acctDetails: 'detail info', + closedDate: '10 October 2013', + createdDate: '1 February 2012', + removeLink: `/applications/${applicationWithData.id}/tasks/risk-to-self/pages/acct-data/0/removeFromList?redirectPage=acct`, + title: '1 February 2012 - 10 October 2013', + }, + { + referringInstitution: 'institution 2', + acctDetails: 'detail info 2', + closedDate: false, + createdDate: '2 March 2012', + removeLink: `/applications/${applicationWithData.id}/tasks/risk-to-self/pages/acct-data/1/removeFromList?redirectPage=acct`, + title: '2 March 2012 - Ongoing', + }, + ]) + }) + }) + }) + + itShouldHaveNextValue(new Acct({}, application), 'additional-information') + itShouldHavePreviousValue(new Acct({}, application), 'historical-risk') + + describe('response', () => { + it('returns formatted ACCTs', () => { + const page = new Acct({}, application) + + page.accts = [ + { + title: 'acct 1', + referringInstitution: 'hmp 1', + acctDetails: 'some details', + createdDate: 'created date', + closedDate: 'closed date', + }, + { + title: 'acct 2', + referringInstitution: 'hmp 2', + acctDetails: 'some different details', + createdDate: 'created date 2', + }, + ] + + expect(page.response()).toEqual({ + 'ACCT
Created: created date
Expiry: closed date': 'some details', + 'ACCT
Created: created date 2
Ongoing': 'some different details', + }) + }) + + it('returns empty object when no accts', () => { + const page = new Acct({}, application) + expect(page.response()).toEqual({}) + }) + }) + + describe('errors', () => { + it('returns empty object', () => { + const page = new Acct({}, application) + expect(page.errors()).toEqual({}) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/acct.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/acct.ts new file mode 100644 index 0000000..e7e59dc --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/acct.ts @@ -0,0 +1,102 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { AcctDataBody } from './custom-forms/acctData' +import { DateFormats } from '../../../../utils/dateUtils' +import paths from '../../../../paths/apply' +import { createQueryString, nameOrPlaceholderCopy } from '../../../../utils/utils' + +type AcctBody = Record + +type AcctUI = { + title: string + referringInstitution: string + acctDetails: string + createdDate: string + closedDate?: string +} + +@Page({ + name: 'acct', + bodyProperties: ['acctDetail'], +}) +export default class Acct implements TaskListPage { + documentTitle = "The person's ACCT notes" + + title = `${nameOrPlaceholderCopy(this.application.person)}'s ACCT notes` + + body: AcctBody + + accts: AcctUI[] + + constructor( + body: Partial, + private readonly application: Application, + ) { + if (application.data['risk-to-self'] && application.data['risk-to-self']['acct-data']) { + const acctData = application.data['risk-to-self']['acct-data'] as [AcctDataBody] + + this.accts = acctData.map((acct, index) => { + const query = { + redirectPage: 'acct', + } + const isOngoing = acct.isOngoing === 'yes' + const createdDate = DateFormats.dateAndTimeInputsToUiDate(acct, 'createdDate') + const closedDate = !isOngoing && DateFormats.dateAndTimeInputsToUiDate(acct, 'closedDate') + + return { + title: `${createdDate} - ${isOngoing ? 'Ongoing' : closedDate}`, + referringInstitution: acct.referringInstitution, + acctDetails: acct.acctDetails, + createdDate, + closedDate, + removeLink: `${paths.applications.removeFromList({ + id: application.id, + task: 'risk-to-self', + page: 'acct-data', + index: index.toString(), + })}?${createQueryString(query)}`, + } + }) + } + this.body = body as AcctBody + } + + previous() { + return 'historical-risk' + } + + next() { + return 'additional-information' + } + + errors() { + const errors: TaskListErrors = {} + + return errors + } + + response() { + const response: Record = {} + + this.accts?.forEach(acct => { + const key = getAcctMetadata(acct) + response[key] = acct.acctDetails + }) + + return response + } +} + +const getAcctMetadata = (acct: AcctUI): string => { + let key = `ACCT
Created: ${acct.createdDate}` + + if (acct.closedDate) { + key += `
Expiry: ${acct.closedDate}` + return key + } + + key += `
Ongoing` + return key +} diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/additionalInformation.test.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/additionalInformation.test.ts new file mode 100644 index 0000000..0e90827 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/additionalInformation.test.ts @@ -0,0 +1,70 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import AdditionalInformation, { AdditionalInformationBody } from './additionalInformation' + +describe('AdditionalInformation', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new AdditionalInformation({}, application) + + expect(page.title).toEqual('Additional Information') + }) + }) + + describe('Questions', () => { + const page = new AdditionalInformation({}, application) + + describe('additionalInformationDetail', () => { + it('has a question', () => { + expect(page.questions.additionalInformationDetail.question).toBeDefined() + }) + }) + }) + + itShouldHaveNextValue(new AdditionalInformation({}, application), '') + itShouldHavePreviousValue(new AdditionalInformation({}, application), 'acct') + + describe('errors', () => { + describe('when they have not provided any answer', () => { + it('returns an error', () => { + const page = new AdditionalInformation({}, application) + expect(page.errors()).toEqual({ hasAdditionalInformation: 'Confirm whether you have additional information' }) + }) + }) + + describe('when there is no additional data', () => { + it('does not return errors', () => { + const page = new AdditionalInformation({ hasAdditionalInformation: 'no' }, application) + expect(page.errors()).toEqual({}) + }) + }) + + describe('when they have answered Yes there is additional information but not provided any', () => { + it('returns additional information error', () => { + const page = new AdditionalInformation({ hasAdditionalInformation: 'yes' }, application) + expect(page.errors()).toEqual({ + additionalInformationDetail: 'Provide additional information about their risk to self', + }) + }) + }) + }) + + describe('onSave', () => { + it('removes additional information data if question is set to "no"', () => { + const body: AdditionalInformationBody = { + hasAdditionalInformation: 'no', + additionalInformationDetail: 'Additional information detail', + } + + const page = new AdditionalInformation(body, application) + + page.onSave() + + expect(page.body).toEqual({ + hasAdditionalInformation: 'no', + }) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/additionalInformation.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/additionalInformation.ts new file mode 100644 index 0000000..1e93e76 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/additionalInformation.ts @@ -0,0 +1,56 @@ +import type { TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getQuestions } from '../../../utils/questions' + +export type AdditionalInformationBody = { hasAdditionalInformation: YesOrNo; additionalInformationDetail: string } + +@Page({ + name: 'additional-information', + bodyProperties: ['hasAdditionalInformation', 'additionalInformationDetail'], +}) +export default class AdditionalInformation implements TaskListPage { + title = 'Additional Information' + + documentTitle = this.title + + questions = getQuestions(nameOrPlaceholderCopy(this.application.person))['risk-to-self']['additional-information'] + + body: AdditionalInformationBody + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as AdditionalInformationBody + } + + previous() { + return 'acct' + } + + next() { + return '' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.hasAdditionalInformation) { + errors.hasAdditionalInformation = 'Confirm whether you have additional information' + } + if (this.body.hasAdditionalInformation === 'yes' && !this.body.additionalInformationDetail) { + errors.additionalInformationDetail = 'Provide additional information about their risk to self' + } + + return errors + } + + onSave(): void { + if (this.body.hasAdditionalInformation !== 'yes') { + delete this.body.additionalInformationDetail + } + } +} diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/currentRisk.test.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/currentRisk.test.ts new file mode 100644 index 0000000..8c304c6 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/currentRisk.test.ts @@ -0,0 +1,95 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import CurrentRisk from './currentRisk' + +describe('CurrentRisk', () => { + const person = personFactory.build({ name: 'Roger Smith' }) + const application = applicationFactory.build({ person }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new CurrentRisk({}, application) + + expect(page.title).toEqual("Roger Smith's current risks") + }) + }) + + describe('import date', () => { + it('sets importDate to false where application contains no OASys import date', () => { + const page = new CurrentRisk({}, application) + + expect(page.importDate).toEqual(null) + }) + }) + + describe('Questions', () => { + const page = new CurrentRisk({}, application) + + describe('currentRiskDetail', () => { + it('has a question', () => { + expect(page.questions.currentRiskDetail.question).toBeDefined() + }) + }) + }) + + itShouldHaveNextValue(new CurrentRisk({}, application), 'historical-risk') + itShouldHavePreviousValue(new CurrentRisk({}, application), 'vulnerability') + + describe('errors', () => { + it('returns an error when the confirmation is blank', () => { + const page = new CurrentRisk({}, application) + expect(page.errors()).toEqual({ + currentRiskDetail: "Describe Roger Smith's current issues and needs related to self harm and suicide", + confirmation: 'Confirm that the information is relevant and up to date', + }) + }) + }) + + describe('items', () => { + it('returns the checkbox as expected', () => { + const page = new CurrentRisk({}, application) + + expect(page.items()).toEqual([ + { value: 'confirmed', text: 'I confirm this information is relevant and up to date.', checked: false }, + ]) + }) + }) + + describe('response', () => { + const body = { currentRiskDetail: 'some detail', confirmation: 'confirmed' } + + it('returns with OASys dates where they are in the application data', () => { + const applicationWithOasysDates = applicationFactory.build({ + person, + data: { + 'risk-to-self': { + 'oasys-import': { + oasysImportedDate: '2024-01-05', + oasysStartedDate: '2024-01-01', + oasysCompletedDate: '2024-01-02', + }, + }, + }, + }) + + const page = new CurrentRisk(body, applicationWithOasysDates) + + expect(page.response()).toEqual({ + 'OASys created': '1 January 2024', + 'OASys completed': '2 January 2024', + 'OASys imported': '5 January 2024', + "Describe Roger Smith's current issues and needs related to self harm and suicide": 'some detail', + 'I confirm this information is relevant and up to date.': 'Confirmed', + }) + }) + + it('returns without OASys dates where they are not present in the application data', () => { + const page = new CurrentRisk(body, application) + + expect(page.response()).toEqual({ + "Describe Roger Smith's current issues and needs related to self harm and suicide": 'some detail', + 'I confirm this information is relevant and up to date.': 'Confirmed', + }) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/currentRisk.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/currentRisk.ts new file mode 100644 index 0000000..aee17a9 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/currentRisk.ts @@ -0,0 +1,93 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getOasysImportDateFromApplication } from '../../../utils' +import { convertKeyValuePairToCheckboxItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { getQuestions } from '../../../utils/questions' +import { DateFormats } from '../../../../utils/dateUtils' +import { hasOasys } from '../../../../utils/applicationUtils' + +type CurrentRiskBody = { currentRiskDetail: string; confirmation: string } + +@Page({ + name: 'current-risk', + bodyProperties: ['currentRiskDetail', 'confirmation'], +}) +export default class CurrentRisk implements TaskListPage { + documentTitle = "The person's current risks" + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `${this.personName}'s current risks` + + questions = getQuestions(this.personName)['risk-to-self']['current-risk'] + + body: CurrentRiskBody + + importDate = getOasysImportDateFromApplication(this.application, 'risk-to-self') + + hasOasysRecord: boolean + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as CurrentRiskBody + this.hasOasysRecord = hasOasys(application, 'risk-to-self') + } + + previous() { + return 'vulnerability' + } + + next() { + return 'historical-risk' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.currentRiskDetail) { + errors.currentRiskDetail = `Describe ${this.personName}'s current issues and needs related to self harm and suicide` + } + if (!this.body.confirmation) { + errors.confirmation = errorLookups.oasysConfirmation.empty + } + + return errors + } + + items() { + return convertKeyValuePairToCheckboxItems({ confirmed: this.questions.confirmation.question }, [ + this.body.confirmation, + ]) + } + + response() { + const oasysData = this.application.data?.['risk-to-self']?.['oasys-import'] + + if (oasysData) { + return { + 'OASys created': DateFormats.isoDateToUIDate(oasysData.oasysStartedDate, { format: 'medium' }), + 'OASys completed': oasysData.oasysCompletedDate + ? DateFormats.isoDateToUIDate(oasysData.oasysCompletedDate, { format: 'medium' }) + : 'Unknown', + 'OASys imported': DateFormats.dateObjtoUIDate(oasysData.oasysImportedDate, { format: 'medium' }), + [this.questions.currentRiskDetail.question]: this.body.currentRiskDetail, + [this.questions.confirmation.question]: + this.questions.confirmation.answers[ + this.body.confirmation as keyof typeof this.questions.confirmation.answers + ], + } + } + + return { + [this.questions.currentRiskDetail.question]: this.body.currentRiskDetail, + [this.questions.confirmation.question]: + this.questions.confirmation.answers[this.body.confirmation as keyof typeof this.questions.confirmation.answers], + } + } +} diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/custom-forms/acctData.test.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/custom-forms/acctData.test.ts new file mode 100644 index 0000000..9ce5406 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/custom-forms/acctData.test.ts @@ -0,0 +1,89 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../../testutils/factories/index' +import AcctData from './acctData' + +describe('AcctData', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + const acctData = [ + { + referringInstitution: 'institution', + 'createdDate-day': '1', + 'createdDate-month': '2', + 'createdDate-year': '2012', + isOngoing: 'yes', + 'closedDate-day': '10', + 'closedDate-month': '10', + 'closedDate-year': '2013', + acctDetails: 'detail info', + }, + ] + + describe('title', () => { + it('has a page title', () => { + const page = new AcctData({}, application) + + expect(page.title).toEqual('Add an ACCT entry') + }) + }) + + itShouldHaveNextValue(new AcctData({}, application), 'acct') + itShouldHavePreviousValue(new AcctData({}, application), 'acct') + + describe('errors', () => { + describe('when there are no errors', () => { + it('returns empty object', () => { + const page = new AcctData(acctData[0], application) + expect(page.errors()).toEqual({}) + }) + }) + + const requiredFields = [ + ['createdDate', 'Add a valid created date, for example 2 3 2013'], + ['isOngoing', 'Select whether this ACCT is ongoing'], + ['referringInstitution', 'Add a referring institution'], + ['acctDetails', 'Enter the details of the ACCT'], + ] + + it.each(requiredFields)('it includes a validation error for %s', (field, message) => { + const page = new AcctData( + { + 'createdDate-day': '', + 'createdDate-month': '', + 'createdDate-year': '', + 'closedDate-day': '', + 'closedDate-month': '', + 'closedDate-year': '', + referringInstitution: '', + isOngoing: undefined, + acctDetails: '', + }, + application, + ) + const errors = page.errors() + + expect(errors[field as keyof typeof errors]).toEqual(message) + }) + + describe('when an ACCT is ongoing but a closed date has not been given', () => { + it('throws an error', () => { + const page = new AcctData( + { + isOngoing: 'no', + }, + application, + ) + const errors = page.errors() + + expect(errors.closedDate).toEqual('Add a valid closed date, for example 2 3 2013') + }) + }) + }) + + describe('response', () => { + it('returns empty object', () => { + const page = new AcctData({}, application) + expect(page.response()).toEqual({}) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/custom-forms/acctData.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/custom-forms/acctData.ts new file mode 100644 index 0000000..bceb259 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/custom-forms/acctData.ts @@ -0,0 +1,89 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application } from '@approved-premises/api' +import { Page } from '../../../../utils/decorators' +import TaskListPage from '../../../../taskListPage' +import { dateAndTimeInputsAreValidDates } from '../../../../../utils/dateUtils' +import { getQuestions } from '../../../../utils/questions' + +export type AcctDataBody = { + referringInstitution: string + createdDate: string + 'createdDate-day': string + 'createdDate-month': string + 'createdDate-year': string + isOngoing: string + closedDate?: string + 'closedDate-day': string + 'closedDate-month': string + 'closedDate-year': string + acctDetails: string +} + +@Page({ + name: 'acct-data', + bodyProperties: [ + 'referringInstitution', + 'createdDate-day', + 'createdDate-month', + 'createdDate-year', + 'isOngoing', + 'closedDate-day', + 'closedDate-month', + 'closedDate-year', + 'acctDetails', + ], +}) +export default class AcctData implements TaskListPage { + title = 'Add an ACCT entry' + + documentTitle = this.title + + body: AcctDataBody + + questions = getQuestions('')['risk-to-self']['acct-data'] + + taskName = 'risk-to-self' + + pageName = 'acct-data' + + constructor( + body: Partial, + private readonly application: Cas2Application, + ) { + this.body = body as AcctDataBody + } + + previous() { + return 'acct' + } + + next() { + return 'acct' + } + + errors() { + const errors: TaskListErrors = {} + + if (!dateAndTimeInputsAreValidDates(this.body, 'createdDate')) { + errors.createdDate = 'Add a valid created date, for example 2 3 2013' + } + if (!this.body.isOngoing) { + errors.isOngoing = 'Select whether this ACCT is ongoing' + } + if (this.body.isOngoing === 'no' && !dateAndTimeInputsAreValidDates(this.body, 'closedDate')) { + errors.closedDate = 'Add a valid closed date, for example 2 3 2013' + } + if (!this.body.referringInstitution) { + errors.referringInstitution = 'Add a referring institution' + } + if (!this.body.acctDetails) { + errors.acctDetails = 'Enter the details of the ACCT' + } + + return errors + } + + response() { + return {} + } +} diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/custom-forms/oasysImport.test.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/custom-forms/oasysImport.test.ts new file mode 100644 index 0000000..d567a0d --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/custom-forms/oasysImport.test.ts @@ -0,0 +1,222 @@ +import { createMock } from '@golevelup/ts-jest' +import type { DataServices } from '@approved-premises/ui' +import { DateFormats } from '../../../../../utils/dateUtils' +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../../shared-examples' +import { personFactory, applicationFactory, oasysRiskToSelfFactory } from '../../../../../testutils/factories/index' +import OasysImport from './oasysImport' +import PersonService from '../../../../../services/personService' +import Vulnerability from '../vulnerability' +import { AcctDataBody } from './acctData' +import OldOasys from '../oldOasys' + +jest.mock('../vulnerability') +jest.mock('../oldOasys') + +describe('OasysImport', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + const oasys = oasysRiskToSelfFactory.build({ + dateCompleted: DateFormats.dateObjToIsoDateTime(new Date(2023, 7, 29)), + dateStarted: DateFormats.dateObjToIsoDateTime(new Date(2023, 7, 28)), + }) + + const dataServices = createMock({ personService: createMock({}) }) + + const now = new Date() + + beforeAll(() => { + jest.useFakeTimers() + jest.setSystemTime(now) + }) + + afterAll(() => { + jest.useRealTimers() + }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new OasysImport({}, application, oasys, '') + + expect(page.title).toEqual("Import Roger Smith's risk to self data from OASys") + }) + }) + + describe('initialize', () => { + describe('when oasys sections are returned', () => { + it('instantiates the class with the task data in the correct format', async () => { + oasys.riskToSelf = [ + { + label: 'Current concerns about self-harm or suicide', + questionNumber: 'R8.1.1', + answer: 'self harm answer', + }, + { + label: 'Current concerns about Coping in Custody or Hostel', + questionNumber: 'R8.2.1', + answer: 'coping in custody answer', + }, + { + label: 'Current concerns about Vulnerability', + questionNumber: 'R8.3.1', + answer: 'vulnerability answer', + }, + { + label: 'Historical concerns about self-harm or suicide', + questionNumber: 'R8.1.4', + answer: 'historical answer', + }, + ] + + const taskData = { + 'risk-to-self': { + 'current-risk': { currentRiskDetail: 'self harm answer' }, + vulnerability: { vulnerabilityDetail: 'vulnerability answer' }, + 'historical-risk': { historicalRiskDetail: 'historical answer' }, + 'oasys-import': { + oasysImportedDate: now, + oasysStartedDate: oasys.dateStarted, + oasysCompletedDate: oasys.dateCompleted, + }, + }, + } + + ;(dataServices.personService.getOasysRiskToSelf as jest.Mock).mockResolvedValue(oasys) + + const page = (await OasysImport.initialize({}, application, 'some-token', dataServices)) as OasysImport + + expect(page.taskData).toBe(JSON.stringify(taskData)) + expect(page.hasOasysRecord).toBe(true) + expect(page.oasysCompleted).toBe('29 August 2023') + expect(page.oasysStarted).toBe('28 August 2023') + }) + + describe('when there is not a completed date', () => { + it('does not assign a completed date', async () => { + const oasysIncomplete = oasysRiskToSelfFactory.build({ dateCompleted: null }) + + ;(dataServices.personService.getOasysRiskToSelf as jest.Mock).mockResolvedValue(oasysIncomplete) + + const page = (await OasysImport.initialize({}, application, 'some-token', dataServices)) as OasysImport + + expect(page.oasysCompleted).toBe(null) + }) + }) + }) + + describe('when oasys sections are not returned', () => { + it('sets hasOasysRecord to false when there has been an error', async () => { + ;(dataServices.personService.getOasysRiskToSelf as jest.Mock).mockRejectedValue(new Error()) + + const page = (await OasysImport.initialize({}, application, 'some-token', dataServices)) as OasysImport + + expect(page.hasOasysRecord).toBe(false) + expect(page.oasysCompleted).toBe(undefined) + expect(page.oasysStarted).toBe(undefined) + }) + }) + + describe('when OASys has not been imported but the old OASys page has been completed', () => { + it('returns the Old OASys page', () => { + const riskToSelfData = { + 'risk-to-self': { 'old-oasys': { hasOldOasys: 'no' } }, + } + + const applicationWithData = applicationFactory.build({ + person: personFactory.build({ name: 'Roger Smith' }), + data: riskToSelfData, + }) + + const oldOasysPageConstructor = jest.fn() + + ;(OldOasys as jest.Mock).mockImplementation(() => { + return oldOasysPageConstructor + }) + + expect(OasysImport.initialize({}, applicationWithData, 'some-token', dataServices)).resolves.toEqual( + oldOasysPageConstructor, + ) + + expect(OldOasys).toHaveBeenCalledWith( + applicationWithData.data['risk-to-self']['old-oasys'], + applicationWithData, + ) + }) + }) + + describe('when OASys has been imported', () => { + it('returns the Vulnerability page', async () => { + const riskToSelfData = { + 'risk-to-self': { + 'oasys-import': { + oasysImportedDate: '2023-09-21T15:47:51.430Z', + oasysStartedDate: '2023-09-10', + oasysCompletedDate: '2023-09-11', + }, + vulnerability: { vulnerabilityDetail: 'some answer' }, + }, + } + + const applicationWithData = applicationFactory.build({ + person: personFactory.build({ name: 'Roger Smith' }), + data: riskToSelfData, + }) + + const vulnerabilityPageConstructor = jest.fn() + + ;(Vulnerability as jest.Mock).mockImplementation(() => { + return vulnerabilityPageConstructor + }) + + expect(OasysImport.initialize({}, applicationWithData, 'some-token', dataServices)).resolves.toEqual( + vulnerabilityPageConstructor, + ) + + expect(Vulnerability).toHaveBeenCalledWith( + applicationWithData.data['risk-to-self'].vulnerability, + applicationWithData, + ) + }) + describe('when the Vulnerability page has not been answered', () => { + it('returns the Vulnerability page', async () => { + const riskToSelfData = { + 'risk-to-self': { + 'oasys-import': { + oasysImportedDate: '2023-09-21T15:47:51.430Z', + oasysStartedDate: '2023-09-10', + oasysCompletedDate: '2023-09-11', + }, + 'acct-data': [{ acctDetails: 'some answer' }], + }, + } as Partial + + const applicationWithData = applicationFactory.build({ + person: personFactory.build({ name: 'Roger Smith' }), + data: riskToSelfData, + }) + + const vulnerabilityPageConstructor = jest.fn() + + ;(Vulnerability as jest.Mock).mockImplementation(() => { + return vulnerabilityPageConstructor + }) + + expect(OasysImport.initialize({}, applicationWithData, 'some-token', dataServices)).resolves.toEqual( + vulnerabilityPageConstructor, + ) + + expect(Vulnerability).toHaveBeenCalledWith({}, applicationWithData) + }) + }) + }) + }) + + itShouldHaveNextValue(new OasysImport({}, application, oasys, ''), 'vulnerability') + itShouldHavePreviousValue(new OasysImport({}, application, oasys, ''), 'taskList') + + describe('errors', () => { + it('returns empty object', () => { + const page = new OasysImport({}, application, oasys, '') + + expect(page.errors()).toEqual({}) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/custom-forms/oasysImport.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/custom-forms/oasysImport.ts new file mode 100644 index 0000000..49f5bf5 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/custom-forms/oasysImport.ts @@ -0,0 +1,146 @@ +import type { DataServices, TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application, Cas2Application, OASysRiskToSelf } from '@approved-premises/api' +import { Page } from '../../../../utils/decorators' +import TaskListPage from '../../../../taskListPage' +import { DateFormats } from '../../../../../utils/dateUtils' +import { nameOrPlaceholderCopy } from '../../../../../utils/utils' +import Vulnerability from '../vulnerability' +import { logOasysError } from '../../../../utils' +import OldOasys from '../oldOasys' + +type GuidanceBody = Record + +export type RiskToSelfTaskData = { + 'risk-to-self': { + 'oasys-import': { + oasysImportedDate: Date + oasysStartedDate: string + oasysCompletedDate: string + } + 'current-risk': { + currentRiskDetail: string + } + vulnerability: { + vulnerabilityDetail: string + } + 'historical-risk': { + historicalRiskDetail: string + } + } +} + +@Page({ + name: 'oasys-import', + bodyProperties: [], +}) +export default class OasysImport implements TaskListPage { + documentTitle = "Import the person's risk to self data from OASys" + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Import ${this.personName}'s risk to self data from OASys` + + body: GuidanceBody + + oasysCompleted: string + + oasysStarted: string + + hasOasysRecord: boolean + + noOasysBannerText = `No OASys record available to import for ${this.personName}` + + noOasysDescriptiveText = `No information can be imported for the risk to self section because ${this.personName} + does not have a Layer 3 OASys completed in the last 6 months.` + + taskData: string + + taskName = 'risk-to-self' + + constructor( + body: Partial, + private readonly application: Application, + oasys: OASysRiskToSelf, + taskData: string, + ) { + this.body = body as GuidanceBody + this.hasOasysRecord = (oasys && Boolean(Object.keys(oasys).length)) || false + if (oasys) { + this.oasysStarted = oasys.dateStarted && DateFormats.isoDateToUIDate(oasys.dateStarted, { format: 'medium' }) + this.oasysCompleted = + oasys.dateCompleted && DateFormats.isoDateToUIDate(oasys.dateCompleted, { format: 'medium' }) + } + this.taskData = taskData + } + + static async initialize( + body: Partial, + application: Cas2Application, + token: string, + dataServices: DataServices, + ) { + let oasys + let taskDataJson + + if (!application.data['risk-to-self']) { + try { + oasys = await dataServices.personService.getOasysRiskToSelf(token, application.person.crn) + + taskDataJson = JSON.stringify(OasysImport.getTaskData(oasys)) + } catch (e) { + logOasysError(e, application.person.crn) + oasys = null + } + return new OasysImport(body, application, oasys, taskDataJson) + } + if (!application.data['risk-to-self']['oasys-import']) { + return new OldOasys(application.data['risk-to-self']['old-oasys'] ?? {}, application) + } + return new Vulnerability(application.data['risk-to-self'].vulnerability ?? {}, application) + } + + private static getTaskData(oasysSections: OASysRiskToSelf): Partial { + const taskData = { 'risk-to-self': {} } as Partial + const today = new Date() + + oasysSections.riskToSelf.forEach(question => { + switch (question.questionNumber) { + case 'R8.1.1': + taskData['risk-to-self']['current-risk'] = { currentRiskDetail: question.answer } + break + case 'R8.3.1': + taskData['risk-to-self'].vulnerability = { vulnerabilityDetail: question.answer } + break + case 'R8.1.4': + taskData['risk-to-self']['historical-risk'] = { + historicalRiskDetail: question.answer, + } + break + default: + break + } + }) + + taskData['risk-to-self']['oasys-import'] = { + oasysImportedDate: today, + oasysStartedDate: oasysSections.dateStarted, + oasysCompletedDate: oasysSections.dateCompleted, + } + + return taskData + } + + previous() { + return 'taskList' + } + + next() { + return 'vulnerability' + } + + errors() { + const errors: TaskListErrors = {} + + return errors + } +} diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/historicalRisk.test.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/historicalRisk.test.ts new file mode 100644 index 0000000..a14ed94 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/historicalRisk.test.ts @@ -0,0 +1,56 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import HistoricalRisk from './historicalRisk' + +describe('HistoricalRisk', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new HistoricalRisk({}, application) + + expect(page.title).toEqual("Roger Smith's historical risks") + }) + }) + + describe('import date', () => { + it('sets importDate to false where application contains no OASys import date', () => { + const page = new HistoricalRisk({}, application) + + expect(page.importDate).toEqual(null) + }) + }) + + describe('Questions', () => { + const page = new HistoricalRisk({}, application) + + describe('historicalRiskDetail', () => { + it('has a question', () => { + expect(page.questions.historicalRiskDetail.question).toBeDefined() + }) + }) + }) + + itShouldHaveNextValue(new HistoricalRisk({}, application), 'acct') + itShouldHavePreviousValue(new HistoricalRisk({}, application), 'current-risk') + + describe('errors', () => { + it('returns an error when the confirmation is blank', () => { + const page = new HistoricalRisk({}, application) + expect(page.errors()).toEqual({ + historicalRiskDetail: "Describe Roger Smith's historical issues and needs related to self harm and suicide", + confirmation: 'Confirm that the information is relevant and up to date', + }) + }) + }) + + describe('items', () => { + it('returns the checkbox as expected', () => { + const page = new HistoricalRisk({}, application) + + expect(page.items()).toEqual([ + { value: 'confirmed', text: 'I confirm this information is relevant and up to date.', checked: false }, + ]) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/historicalRisk.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/historicalRisk.ts new file mode 100644 index 0000000..1c16494 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/historicalRisk.ts @@ -0,0 +1,67 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getOasysImportDateFromApplication } from '../../../utils' +import { convertKeyValuePairToCheckboxItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { getQuestions } from '../../../utils/questions' +import { hasOasys } from '../../../../utils/applicationUtils' + +type HistoricalRiskBody = { historicalRiskDetail: string; confirmation: string } + +@Page({ + name: 'historical-risk', + bodyProperties: ['historicalRiskDetail', 'confirmation'], +}) +export default class HistoricalRisk implements TaskListPage { + documentTitle = "The person's historical risks" + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `${this.personName}'s historical risks` + + questions = getQuestions(this.personName)['risk-to-self']['historical-risk'] + + importDate = getOasysImportDateFromApplication(this.application, 'risk-to-self') + + body: HistoricalRiskBody + + hasOasysRecord: boolean + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as HistoricalRiskBody + this.hasOasysRecord = hasOasys(application, 'risk-to-self') + } + + previous() { + return 'current-risk' + } + + next() { + return 'acct' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.historicalRiskDetail) { + errors.historicalRiskDetail = `Describe ${this.personName}'s historical issues and needs related to self harm and suicide` + } + if (!this.body.confirmation) { + errors.confirmation = errorLookups.oasysConfirmation.empty + } + + return errors + } + + items() { + return convertKeyValuePairToCheckboxItems({ confirmed: this.questions.confirmation.question }, [ + this.body.confirmation, + ]) + } +} diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/index.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/index.ts new file mode 100644 index 0000000..4477f20 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/index.ts @@ -0,0 +1,18 @@ +/* istanbul ignore file */ + +import { Task } from '../../../utils/decorators' +import Acct from './acct' +import AcctData from './custom-forms/acctData' +import AdditionalInformation from './additionalInformation' +import CurrentRisk from './currentRisk' +import HistoricalRisk from './historicalRisk' +import OasysImport from './custom-forms/oasysImport' +import Vulnerability from './vulnerability' +import OldOasys from './oldOasys' + +@Task({ + name: 'Add risk to self information', + slug: 'risk-to-self', + pages: [OasysImport, OldOasys, Vulnerability, CurrentRisk, HistoricalRisk, AcctData, Acct, AdditionalInformation], +}) +export default class RiskToSelf {} diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/oldOasys.test.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/oldOasys.test.ts new file mode 100644 index 0000000..de63c19 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/oldOasys.test.ts @@ -0,0 +1,93 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import OldOasys from './oldOasys' +import { dateAndTimeInputsAreValidDates, dateIsComplete } from '../../../../utils/dateUtils' + +jest.mock('../../../../utils/dateUtils', () => { + const actual = jest.requireActual('../../../../utils/dateUtils') + return { + ...actual, + dateAndTimeInputsAreValidDates: jest.fn(), + dateIsComplete: jest.fn(), + } +}) + +describe('OldOasys', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new OldOasys({}, application) + + expect(page.title).toEqual(`Does Roger Smith have an older OASys with risk to self information?`) + }) + }) + + itShouldHaveNextValue(new OldOasys({}, application), 'vulnerability') + itShouldHavePreviousValue(new OldOasys({}, application), 'taskList') + + describe('errors', () => { + it('returns an error when required fields are blank', () => { + const page = new OldOasys({}, application) + expect(page.errors()).toEqual({ + hasOldOasys: 'Confirm whether they have an older OASys with risk to self information', + }) + }) + describe('when hasOldOasys is yes', () => { + it('returns an error when oasysCompletedDate is blank', () => { + const page = new OldOasys({ hasOldOasys: 'yes' }, application) + expect(page.errors()).toEqual({ + oasysCompletedDate: 'Enter the date the OASys was completed', + }) + }) + + describe('when the date is not valid', () => { + beforeEach(() => { + jest.resetAllMocks() + ;(dateAndTimeInputsAreValidDates as jest.Mock).mockImplementation(() => false) + ;(dateIsComplete as jest.Mock).mockImplementation(() => true) + }) + it('returns an error ', () => { + const page = new OldOasys({ hasOldOasys: 'yes' }, application) + expect(page.errors()).toEqual({ + oasysCompletedDate: 'OASys completed date must be a real date', + }) + }) + }) + }) + }) + + describe('response', () => { + it('returns the full response when all fields are entered', () => { + const page = new OldOasys( + { + hasOldOasys: 'yes', + 'oasysCompletedDate-year': '2023', + 'oasysCompletedDate-month': '11', + 'oasysCompletedDate-day': '11', + }, + application, + ) + + const expected = { + 'Does Roger Smith have an older OASys with risk to self information?': 'Yes', + 'When was the OASys completed?': '11 November 2023', + } + expect(page.response()).toEqual(expected) + }) + }) + + it('ignores the date field when there is no old OASys', () => { + const page = new OldOasys( + { + hasOldOasys: 'no', + }, + application, + ) + + const expected = { + 'Does Roger Smith have an older OASys with risk to self information?': 'No, they do not have an OASys', + } + expect(page.response()).toEqual(expected) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/oldOasys.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/oldOasys.ts new file mode 100644 index 0000000..2d382f4 --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/oldOasys.ts @@ -0,0 +1,74 @@ +import type { ObjectWithDateParts, TaskListErrors, YesOrNo } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { dateBodyProperties } from '../../../utils' +import { getQuestions } from '../../../utils/questions' +import { DateFormats, dateAndTimeInputsAreValidDates, dateIsComplete } from '../../../../utils/dateUtils' + +type OldOasysBody = { + hasOldOasys: YesOrNo +} & ObjectWithDateParts<'oasysCompletedDate'> + +@Page({ + name: 'old-oasys', + bodyProperties: ['hasOldOasys', ...dateBodyProperties('oasysCompletedDate')], +}) +export default class OldOasys implements TaskListPage { + documentTitle = 'Does the person have an older OASys with risk to self information?' + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `Does ${this.personName} have an older OASys with risk to self information?` + + body: OldOasysBody + + questions = getQuestions(this.personName)['risk-to-self']['old-oasys'] + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as OldOasysBody + } + + previous() { + return 'taskList' + } + + next() { + return 'vulnerability' + } + + response() { + return { + [this.questions.hasOldOasys.question]: this.questions.hasOldOasys.answers[this.body.hasOldOasys], + ...(this.body.hasOldOasys === 'yes' && { + [this.questions.oasysCompletedDate.question]: DateFormats.dateAndTimeInputsToUiDate( + this.body, + 'oasysCompletedDate', + ), + }), + } + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.hasOldOasys) { + errors.hasOldOasys = 'Confirm whether they have an older OASys with risk to self information' + } + if (this.body.hasOldOasys === 'yes') { + if (!dateIsComplete(this.body, 'oasysCompletedDate')) { + errors.oasysCompletedDate = 'Enter the date the OASys was completed' + return errors + } + if (!dateAndTimeInputsAreValidDates(this.body, 'oasysCompletedDate')) { + errors.oasysCompletedDate = 'OASys completed date must be a real date' + } + } + + return errors + } +} diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/vulnerability.test.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/vulnerability.test.ts new file mode 100644 index 0000000..354801c --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/vulnerability.test.ts @@ -0,0 +1,56 @@ +import { itShouldHaveNextValue, itShouldHavePreviousValue } from '../../../shared-examples' +import { personFactory, applicationFactory } from '../../../../testutils/factories/index' +import Vulnerability from './vulnerability' + +describe('Vulnerability', () => { + const application = applicationFactory.build({ person: personFactory.build({ name: 'Roger Smith' }) }) + + describe('title', () => { + it('personalises the page title', () => { + const page = new Vulnerability({}, application) + + expect(page.title).toEqual("Roger Smith's vulnerability") + }) + }) + + describe('import date', () => { + it('sets importDate to null where application contains no OASys import date', () => { + const page = new Vulnerability({}, application) + + expect(page.importDate).toEqual(null) + }) + }) + + describe('Questions', () => { + const page = new Vulnerability({}, application) + + describe('vulnerabilityDetail', () => { + it('has a question', () => { + expect(page.questions.vulnerabilityDetail.question).toBeDefined() + }) + }) + }) + + itShouldHaveNextValue(new Vulnerability({}, application), 'current-risk') + itShouldHavePreviousValue(new Vulnerability({}, application), 'taskList') + + describe('errors', () => { + it('returns an error when the confirmation is blank', () => { + const page = new Vulnerability({}, application) + expect(page.errors()).toEqual({ + vulnerabilityDetail: "Describe Roger Smith's current circumstances, issues and needs related to vulnerability", + confirmation: 'Confirm that the information is relevant and up to date', + }) + }) + }) + + describe('items', () => { + it('returns the checkbox as expected', () => { + const page = new Vulnerability({}, application) + + expect(page.items()).toEqual([ + { value: 'confirmed', text: 'I confirm this information is relevant and up to date.', checked: false }, + ]) + }) + }) +}) diff --git a/server/form-pages/apply/risks-and-needs/risk-to-self/vulnerability.ts b/server/form-pages/apply/risks-and-needs/risk-to-self/vulnerability.ts new file mode 100644 index 0000000..c5d1edc --- /dev/null +++ b/server/form-pages/apply/risks-and-needs/risk-to-self/vulnerability.ts @@ -0,0 +1,66 @@ +import type { TaskListErrors } from '@approved-premises/ui' +import { Cas2Application as Application } from '@approved-premises/api' +import { nameOrPlaceholderCopy } from '../../../../utils/utils' +import { Page } from '../../../utils/decorators' +import TaskListPage from '../../../taskListPage' +import { getOasysImportDateFromApplication } from '../../../utils' +import { convertKeyValuePairToCheckboxItems } from '../../../../utils/formUtils' +import errorLookups from '../../../../i18n/en/errors.json' +import { getQuestions } from '../../../utils/questions' +import { hasOasys } from '../../../../utils/applicationUtils' + +type VulnerabilityBody = { vulnerabilityDetail: string; confirmation: string } + +@Page({ + name: 'vulnerability', + bodyProperties: ['vulnerabilityDetail', 'confirmation'], +}) +export default class Vulnerability implements TaskListPage { + documentTitle = "The person's vulnerability" + + personName = nameOrPlaceholderCopy(this.application.person) + + title = `${this.personName}'s vulnerability` + + questions = getQuestions(this.personName)['risk-to-self'].vulnerability + + importDate = getOasysImportDateFromApplication(this.application, 'risk-to-self') + + body: VulnerabilityBody + + hasOasysRecord: boolean + + constructor( + body: Partial, + private readonly application: Application, + ) { + this.body = body as VulnerabilityBody + this.hasOasysRecord = hasOasys(application, 'risk-to-self') + } + + previous() { + return 'taskList' + } + + next() { + return 'current-risk' + } + + errors() { + const errors: TaskListErrors = {} + + if (!this.body.vulnerabilityDetail) { + errors.vulnerabilityDetail = `Describe ${this.personName}'s current circumstances, issues and needs related to vulnerability` + } + if (!this.body.confirmation) { + errors.confirmation = errorLookups.oasysConfirmation.empty + } + return errors + } + + items() { + return convertKeyValuePairToCheckboxItems({ confirmed: this.questions.confirmation.question }, [ + this.body.confirmation, + ]) + } +} diff --git a/server/form-pages/baseForm.ts b/server/form-pages/baseForm.ts new file mode 100644 index 0000000..ed7ebde --- /dev/null +++ b/server/form-pages/baseForm.ts @@ -0,0 +1,7 @@ +import { FormPages, FormSections } from '@approved-premises/ui' + +export default class BaseForm { + static pages: FormPages + + static sections: FormSections +} diff --git a/server/form-pages/shared-examples/index.ts b/server/form-pages/shared-examples/index.ts new file mode 100644 index 0000000..77938a6 --- /dev/null +++ b/server/form-pages/shared-examples/index.ts @@ -0,0 +1,19 @@ +import type TaskListPage from '../taskListPage' + +const itShouldHaveNextValue = (target: TaskListPage, value: string) => { + describe('next', () => { + it(`should have a next value of ${value}`, () => { + expect(target.next()).toEqual(value) + }) + }) +} + +const itShouldHavePreviousValue = (target: TaskListPage, value: string) => { + describe('previous', () => { + it(`should have a previous value of ${value}`, () => { + expect(target.previous()).toEqual(value) + }) + }) +} + +export { itShouldHaveNextValue, itShouldHavePreviousValue } diff --git a/server/form-pages/taskListPage.ts b/server/form-pages/taskListPage.ts new file mode 100644 index 0000000..fce2f7b --- /dev/null +++ b/server/form-pages/taskListPage.ts @@ -0,0 +1,35 @@ +/* istanbul ignore file */ + +import type { DataServices, FormArtifact, TaskListErrors } from '@approved-premises/ui' + +export interface TaskListPageInterface { + new (body: Record, document?: FormArtifact, previousPage?: string): TaskListPage + initialize?( + body: Record, + document: FormArtifact, + token: string, + dataServices: DataServices, + ): Promise +} + +export default abstract class TaskListPage { + /** + * content for the HTML document element, should not contain any PII + (such as person's name) to avoid data leaks. + */ + abstract documentTitle: string + + abstract title: string + + abstract body: Record<string, unknown> + + abstract previous(): string + + abstract next(): string + + abstract errors(): TaskListErrors<this> + + abstract response?(): Record<string, string> + + abstract onSave?(): void +} diff --git a/server/form-pages/utils/decorators/form.decorator.test.ts b/server/form-pages/utils/decorators/form.decorator.test.ts new file mode 100644 index 0000000..a3348dd --- /dev/null +++ b/server/form-pages/utils/decorators/form.decorator.test.ts @@ -0,0 +1,52 @@ +import BaseForm from '../../baseForm' + +import Section from './section.decorator' +import Form from './form.decorator' +import Task from './task.decorator' + +describe('Task', () => { + it('records metadata about a class', () => { + @Task({ name: 'Task 1', slug: 'task-1', pages: [] }) + class Task1 {} + + @Task({ name: 'Task 2', slug: 'task-2', pages: [] }) + class Task2 {} + + @Task({ name: 'Task 3', slug: 'task-3', pages: [] }) + class Task3 {} + + @Section({ title: 'Section 1', tasks: [Task1] }) + class Section1 {} + + @Section({ title: 'Section 2', tasks: [Task2, Task3] }) + class Section2 {} + + @Section({ title: 'Section 3', tasks: [] }) + class Section3 {} + + @Form({ sections: [Section1, Section2, Section3] }) + class MyForm extends BaseForm {} + + expect(MyForm.pages).toEqual({ 'task-1': {}, 'task-2': {}, 'task-3': {} }) + expect(MyForm.sections).toEqual([ + { + title: 'Section 1', + name: 'Section1', + tasks: [{ id: 'task-1', title: 'Task 1', pages: {} }], + }, + { + title: 'Section 2', + name: 'Section2', + tasks: [ + { id: 'task-2', title: 'Task 2', pages: {} }, + { id: 'task-3', title: 'Task 3', pages: {} }, + ], + }, + { + title: 'Section 3', + name: 'Section3', + tasks: [], + }, + ]) + }) +}) diff --git a/server/form-pages/utils/decorators/form.decorator.ts b/server/form-pages/utils/decorators/form.decorator.ts new file mode 100644 index 0000000..0e30e0a --- /dev/null +++ b/server/form-pages/utils/decorators/form.decorator.ts @@ -0,0 +1,19 @@ +import 'reflect-metadata' +import { getPagesForSections, getSection } from '../index' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-object-type +type Constructor = new (...args: Array<any>) => {} + +const Form = (options: { sections: Array<unknown> }) => { + return <T extends Constructor>(constructor: T) => { + const FormClass = class extends constructor { + static pages = getPagesForSections(options.sections) + + static sections = options.sections.map(s => getSection(s)) + } + + return FormClass + } +} + +export default Form diff --git a/server/form-pages/utils/decorators/index.ts b/server/form-pages/utils/decorators/index.ts new file mode 100644 index 0000000..f2cc05b --- /dev/null +++ b/server/form-pages/utils/decorators/index.ts @@ -0,0 +1,6 @@ +import Page from './page.decorator' +import Task from './task.decorator' +import Section from './section.decorator' +import Form from './form.decorator' + +export { Page, Task, Section, Form } diff --git a/server/form-pages/utils/decorators/page.decorator.test.ts b/server/form-pages/utils/decorators/page.decorator.test.ts new file mode 100644 index 0000000..674473b --- /dev/null +++ b/server/form-pages/utils/decorators/page.decorator.test.ts @@ -0,0 +1,80 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { Cas2Application } from '@approved-premises/api' +import Page from './page.decorator' +import { applicationFactory } from '../../../testutils/factories' + +describe('taskListPageDecorator', () => { + describe('with a simple class', () => { + @Page({ + bodyProperties: ['foo', 'bar', 'baz'], + name: 'Some Name', + controllerActions: { update: 'foo' }, + }) + class SimpleClass { + name: string + + constructor(readonly body: Record<string, unknown>) {} + } + + const simpleClass = new SimpleClass({ foo: '1', bar: '2', baz: '3', something: 'else' }) + + it('adds properties to the class instance', () => { + expect(simpleClass.body).toEqual({ foo: '1', bar: '2', baz: '3' }) + expect(simpleClass.name).toEqual('Some Name') + }) + + it('sets metadata for the class', () => { + const name = Reflect.getMetadata('page:name', SimpleClass) + const className = Reflect.getMetadata('page:className', SimpleClass) + const bodyProperties = Reflect.getMetadata('page:bodyProperties', SimpleClass) + const controllerAction = Reflect.getMetadata('page:controllerActions:update', SimpleClass) + + expect(name).toEqual('Some Name') + expect(className).toEqual('SimpleClass') + expect(bodyProperties).toEqual(['foo', 'bar', 'baz']) + expect(controllerAction).toEqual('foo') + }) + }) + + it('adds additional arguments to the class instance', () => { + @Page({ + bodyProperties: ['foo', 'bar', 'baz'], + name: 'Some Name', + }) + class ClassWithApplication { + constructor( + readonly body: Record<string, unknown>, + readonly application: Cas2Application, + ) {} + } + + @Page({ + bodyProperties: ['foo', 'bar', 'baz'], + name: 'Some Name', + }) + class ClassWithApplicationAndPreviousPage { + constructor( + readonly body: Record<string, unknown>, + readonly application: Cas2Application, + readonly previousPage: string, + ) {} + } + + const application = applicationFactory.build() + const classWithApplication = new ClassWithApplication( + { foo: '1', bar: '2', baz: '3', something: 'else' }, + application, + ) + + const classWithApplicationAndPreviousPage = new ClassWithApplicationAndPreviousPage( + { foo: '1', bar: '2', baz: '3', something: 'else' }, + application, + 'previous', + ) + + expect(classWithApplication.application).toEqual(application) + + expect(classWithApplicationAndPreviousPage.application).toEqual(application) + expect(classWithApplicationAndPreviousPage.previousPage).toEqual('previous') + }) +}) diff --git a/server/form-pages/utils/decorators/page.decorator.ts b/server/form-pages/utils/decorators/page.decorator.ts new file mode 100644 index 0000000..b82ac78 --- /dev/null +++ b/server/form-pages/utils/decorators/page.decorator.ts @@ -0,0 +1,47 @@ +import { Cas2Application } from '@approved-premises/api' +import 'reflect-metadata' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-object-type +type Constructor = new (...args: Array<any>) => {} + +const Page = (options: { bodyProperties: Array<string>; name: string; controllerActions?: { update: string } }) => { + return <T extends Constructor>(constructor: T) => { + const TaskListPage = class extends constructor { + name = options.name + + body: Record<string, unknown> + + document: Cas2Application + + previousPage: string + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(...args: Array<any>) { + super(...args) + const [body, document, previousPage] = args + + this.body = this.createBody(body, ...options.bodyProperties) + this.document = document + this.previousPage = previousPage + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createBody<K extends string>(body: Record<string, any>, ...keys: Array<K>): { [Key in K]: Key } { + const record = {} as { [Key in K]: Key } + keys.forEach(key => { + record[key] = body[key] + }) + return record + } + } + + Reflect.defineMetadata('page:name', options.name, TaskListPage) + Reflect.defineMetadata('page:className', constructor.name, TaskListPage) + Reflect.defineMetadata('page:bodyProperties', options.bodyProperties, TaskListPage) + Reflect.defineMetadata('page:controllerActions:update', options.controllerActions?.update, TaskListPage) + + return TaskListPage + } +} + +export default Page diff --git a/server/form-pages/utils/decorators/section.decorator.test.ts b/server/form-pages/utils/decorators/section.decorator.test.ts new file mode 100644 index 0000000..779a6d6 --- /dev/null +++ b/server/form-pages/utils/decorators/section.decorator.test.ts @@ -0,0 +1,22 @@ +import Section from './section.decorator' + +describe('Task', () => { + it('records metadata about a class', () => { + class Task1 {} + + class Task2 {} + + class Task3 {} + + @Section({ title: 'My Section', tasks: [Task1, Task2, Task3] }) + class MySection {} + + const title = Reflect.getMetadata('section:title', MySection) + const name = Reflect.getMetadata('section:name', MySection) + const tasks = Reflect.getMetadata('section:tasks', MySection) + + expect(title).toEqual('My Section') + expect(name).toEqual('MySection') + expect(tasks).toEqual([Task1, Task2, Task3]) + }) +}) diff --git a/server/form-pages/utils/decorators/section.decorator.ts b/server/form-pages/utils/decorators/section.decorator.ts new file mode 100644 index 0000000..7f5576e --- /dev/null +++ b/server/form-pages/utils/decorators/section.decorator.ts @@ -0,0 +1,14 @@ +import 'reflect-metadata' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-object-type +type Constructor = new (...args: Array<any>) => {} + +const Section = (options: { title: string; tasks: Array<Constructor> }) => { + return <T extends Constructor>(constructor: T) => { + Reflect.defineMetadata('section:title', options.title, constructor) + Reflect.defineMetadata('section:name', constructor.name, constructor) + Reflect.defineMetadata('section:tasks', options.tasks, constructor) + } +} + +export default Section diff --git a/server/form-pages/utils/decorators/task.decorator.test.ts b/server/form-pages/utils/decorators/task.decorator.test.ts new file mode 100644 index 0000000..ce9b7b4 --- /dev/null +++ b/server/form-pages/utils/decorators/task.decorator.test.ts @@ -0,0 +1,67 @@ +import TaskListPage from '../../taskListPage' +import Page from './page.decorator' +import Task from './task.decorator' + +class PageBase implements TaskListPage { + documentTitle: string + + title: string + + body: Record<string, unknown> + + previous() { + return '' + } + + next() { + return '' + } + + errors() { + return {} + } + + response() { + return {} + } +} + +describe('Task', () => { + it('records metadata about a class', () => { + @Page({ name: 'page-1', bodyProperties: [] }) + class Page1 extends PageBase { + documentTitle = 'Page 1' + + title = 'Page 1' + } + + @Page({ name: 'page-2', bodyProperties: [] }) + class Page2 extends PageBase { + documentTitle = 'Page 2' + + title = 'Page 2' + } + + @Page({ name: 'page-3', bodyProperties: [] }) + class Page3 extends PageBase { + documentTitle = 'Page 3' + + title = 'Page 3' + } + + @Task({ name: 'My Task', slug: 'my-task', pages: [Page1, Page2, Page3] }) + class MyTask {} + + const slug = Reflect.getMetadata('task:slug', MyTask) + const name = Reflect.getMetadata('task:name', MyTask) + const pages = Reflect.getMetadata('task:pages', MyTask) + + expect(slug).toEqual('my-task') + expect(name).toEqual('My Task') + expect(pages).toEqual([Page1, Page2, Page3]) + + expect(Reflect.getMetadata('page:task', Page1)).toEqual('my-task') + expect(Reflect.getMetadata('page:task', Page2)).toEqual('my-task') + expect(Reflect.getMetadata('page:task', Page3)).toEqual('my-task') + }) +}) diff --git a/server/form-pages/utils/decorators/task.decorator.ts b/server/form-pages/utils/decorators/task.decorator.ts new file mode 100644 index 0000000..b01bebb --- /dev/null +++ b/server/form-pages/utils/decorators/task.decorator.ts @@ -0,0 +1,24 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-function-type */ +/* eslint-disable @typescript-eslint/no-empty-object-type */ + +import TaskListPage from '../../taskListPage' + +type Constructor = new (...args: Array<any>) => {} + +interface Type<T> extends Function { + new (...args: Array<any>): T +} + +const Task = (options: { name: string; slug: string; pages: Array<Type<TaskListPage>> }) => { + return <T extends Constructor>(constructor: T) => { + Reflect.defineMetadata('task:slug', options.slug, constructor) + Reflect.defineMetadata('task:name', options.name, constructor) + Reflect.defineMetadata('task:pages', options.pages, constructor) + options.pages.forEach(page => { + Reflect.defineMetadata('page:task', options.slug, page) + }) + } +} + +export default Task diff --git a/server/form-pages/utils/getTaskStatus.test.ts b/server/form-pages/utils/getTaskStatus.test.ts new file mode 100644 index 0000000..6c24fa9 --- /dev/null +++ b/server/form-pages/utils/getTaskStatus.test.ts @@ -0,0 +1,158 @@ +import { createMock } from '@golevelup/ts-jest' +import { applicationFactory } from '../../testutils/factories' +import getTaskStatus, { getPageData } from './getTaskStatus' +import TaskListPage from '../taskListPage' + +describe('getTaskStatus', () => { + const page1Instance = createMock<TaskListPage>() + const page2Instance = createMock<TaskListPage>() + const page3Instance = createMock<TaskListPage>() + + const Page1 = jest.fn(() => page1Instance) + const Page2 = jest.fn(() => page2Instance) + const Page3 = jest.fn(() => page3Instance) + + const task = { + id: 'my-task', + title: 'My Task', + pages: { + 'page-1': Page1, + 'page-2': Page2, + 'page-3': Page3, + }, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('returns not_started when there is no data for the first question in the task', () => { + const application = applicationFactory.build({}) + + expect(getTaskStatus(task, application)).toEqual('not_started') + + expect(Page1).not.toHaveBeenCalled() + expect(Page2).not.toHaveBeenCalled() + expect(Page3).not.toHaveBeenCalled() + }) + + it('returns in_progress when there is no data for the second question in the task', () => { + const application = applicationFactory.build({ data: { 'my-task': { 'page-1': { foo: 'bar' } } } }) + + page1Instance.errors.mockReturnValue({}) + page1Instance.next.mockReturnValue('page-2') + + expect(getTaskStatus(task, application)).toEqual('in_progress') + + expect(Page1).toHaveBeenCalled() + expect(page1Instance.errors).toHaveBeenCalled() + + expect(Page2).not.toHaveBeenCalled() + expect(Page3).not.toHaveBeenCalled() + }) + + it('returns in_progress when there are errors', () => { + const application = applicationFactory.build({ + data: { 'my-task': { 'page-1': { foo: 'bar' }, 'page-2': { foo: 'bar' } } }, + }) + + page1Instance.next.mockReturnValue('') + page1Instance.errors.mockReturnValue({ some: 'errors' }) + + expect(getTaskStatus(task, application)).toEqual('in_progress') + + expect(Page1).toHaveBeenCalled() + expect(page1Instance.errors).toHaveBeenCalled() + expect(page1Instance.next).toHaveBeenCalled() + }) + + it('returns complete when the second page does not have a next page', () => { + const application = applicationFactory.build({ + data: { 'my-task': { 'page-1': { foo: 'bar' }, 'page-2': { foo: 'bar' } } }, + }) + + page1Instance.errors.mockReturnValue({}) + page1Instance.next.mockReturnValue('page-2') + + page2Instance.errors.mockReturnValue({}) + page2Instance.next.mockReturnValue('') + + expect(getTaskStatus(task, application)).toEqual('complete') + + expect(Page1).toHaveBeenCalled() + expect(page1Instance.errors).toHaveBeenCalled() + expect(page1Instance.next).toHaveBeenCalled() + + expect(Page2).toHaveBeenCalled() + expect(page2Instance.errors).toHaveBeenCalled() + expect(page2Instance.next).toHaveBeenCalled() + + expect(Page3).not.toHaveBeenCalled() + }) + + it('returns complete when the third page does not have a next page', () => { + const application = applicationFactory.build({ + data: { 'my-task': { 'page-1': { foo: 'bar' }, 'page-2': { foo: 'bar' }, 'page-3': { foo: 'bar' } } }, + }) + + page1Instance.errors.mockReturnValue({}) + page1Instance.next.mockReturnValue('page-2') + + page2Instance.errors.mockReturnValue({}) + page2Instance.next.mockReturnValue('page-3') + + page3Instance.errors.mockReturnValue({}) + page3Instance.next.mockReturnValue('') + + expect(getTaskStatus(task, application)).toEqual('complete') + + expect(Page1).toHaveBeenCalled() + expect(page1Instance.errors).toHaveBeenCalled() + expect(page1Instance.next).toHaveBeenCalled() + + expect(Page2).toHaveBeenCalled() + expect(page2Instance.errors).toHaveBeenCalled() + expect(page2Instance.next).toHaveBeenCalled() + + expect(Page3).toHaveBeenCalled() + expect(page3Instance.errors).toHaveBeenCalled() + expect(page3Instance.next).toHaveBeenCalled() + }) + + it('returns complete when the first page does not have data, but subsequent ones do', () => { + const application = applicationFactory.build({ + data: { 'my-task': { 'page-2': { foo: 'bar' }, 'page-3': { foo: 'bar' } } }, + }) + + page2Instance.errors.mockReturnValue({}) + page2Instance.next.mockReturnValue('page-3') + + page3Instance.errors.mockReturnValue({}) + page3Instance.next.mockReturnValue('') + + expect(getTaskStatus(task, application)).toEqual('complete') + + expect(Page1).not.toHaveBeenCalled() + expect(page1Instance.errors).not.toHaveBeenCalled() + expect(page1Instance.next).not.toHaveBeenCalled() + + expect(Page2).toHaveBeenCalled() + expect(page2Instance.errors).toHaveBeenCalled() + expect(page2Instance.next).toHaveBeenCalled() + + expect(Page3).toHaveBeenCalled() + expect(page3Instance.errors).toHaveBeenCalled() + expect(page3Instance.next).toHaveBeenCalled() + }) +}) + +describe('getPageData', () => { + it('returns undefined when there is not a matching task name', () => { + const application = applicationFactory.build({ data: { nonMatchingTaskName: { page: 'page data' } } }) + expect(getPageData(application, 'taskName', 'pageName')).toEqual(undefined) + }) + it('returns undefined when there is no data', () => { + const application = applicationFactory.build({ data: null }) + expect(getPageData(application, 'taskName', 'pageName')).toEqual(undefined) + }) +}) diff --git a/server/form-pages/utils/getTaskStatus.ts b/server/form-pages/utils/getTaskStatus.ts new file mode 100644 index 0000000..b609d10 --- /dev/null +++ b/server/form-pages/utils/getTaskStatus.ts @@ -0,0 +1,57 @@ +import type { TaskStatus, UiTask } from '@approved-premises/ui' + +import { Cas2Application as Application } from '@approved-premises/api' +import { TaskListPageInterface } from '../taskListPage' + +export const getPageData = (application: Application, taskName: string, pageName: string) => { + return application.data?.[taskName]?.[pageName] +} + +const getTaskStatus = (task: UiTask, application: Application): TaskStatus => { + // Find the first page that has an answer + let pageId = Object.keys(task.pages).find((pageName: string) => !!getPageData(application, task.id, pageName)) + + let status: TaskStatus + + // If there's no page that's been completed, then we know the task is incomplete + if (!pageId) { + return 'not_started' + } + + while (pageId) { + const pageData = getPageData(application, task.id, pageId) + + // If there's no page data for this page, then we know it's incomplete + if (!pageData) { + status = 'in_progress' + break + } + + // Let's initialize this page + const Page = task.pages[pageId] as TaskListPageInterface + const page = new Page(pageData, application) + + // Get the errors for this page + const errors = page.errors() + // And the next page ID + pageId = page.next() + + if (Object.keys(errors).length) { + // Are there any errors? Then the task is incomplete + status = 'in_progress' + break + } + + if (!pageId) { + // Is the next page blank? Then the task is complete + status = 'complete' + break + } + + // If none of the above is true, we loop round again! + } + + return status +} + +export default getTaskStatus diff --git a/server/form-pages/utils/index.test.ts b/server/form-pages/utils/index.test.ts new file mode 100644 index 0000000..d2eff0b --- /dev/null +++ b/server/form-pages/utils/index.test.ts @@ -0,0 +1,260 @@ +import { Request } from 'express' +import { DeepMocked, createMock } from '@golevelup/ts-jest' +import 'reflect-metadata' + +// Use a wildcard import to allow us to use jest.spyOn on functions within this module +import * as utils from './index' +import { ApprovedPremisesApplication } from '../../@types/shared' + +import { applicationFactory } from '../../testutils/factories' +import { TaskListPageInterface } from '../taskListPage' + +import { DateFormats } from '../../utils/dateUtils' + +jest.mock('../../utils/dateUtils') + +describe('utils', () => { + beforeEach(() => { + jest.restoreAllMocks() + }) + + describe('Decorator metadata utils', () => { + class Section {} + + class Task {} + + class Page1 {} + class Page2 {} + + beforeEach(() => { + Reflect.defineMetadata('page:name', 'page-1', Page1) + Reflect.defineMetadata('page:name', 'page-2', Page2) + + Reflect.defineMetadata('page:task', 'task-1', Page1) + Reflect.defineMetadata('page:task', 'task-2', Page2) + + Reflect.defineMetadata('task:slug', 'slug', Task) + Reflect.defineMetadata('task:name', 'Name', Task) + Reflect.defineMetadata('task:pages', [Page1, Page2], Task) + + Reflect.defineMetadata('section:title', 'Section', Section) + Reflect.defineMetadata('section:tasks', [Task], Section) + }) + + describe('getTask', () => { + it('fetches metadata for a specific task and pages', () => { + expect(utils.getTask(Task)).toEqual({ id: 'slug', title: 'Name', pages: { 'page-1': Page1, 'page-2': Page2 } }) + }) + }) + + describe('getSection', () => { + it('fetches metadata for a specific section and tasks', () => { + expect(utils.getSection(Section)).toEqual({ + title: 'Section', + tasks: [utils.getTask(Task)], + }) + }) + }) + + describe('getPagesForSections', () => { + it('fetches pages for all supplied sections', () => { + class Section1 {} + class Section2 {} + + jest.spyOn(utils, 'getSection').mockImplementation((section: Section1 | Section2) => { + if (section === Section1) { + return { + title: 'Section 1', + name: 'Section1', + tasks: [{ id: 'foo', title: 'Foo', pages: { 'page-1': Page1, 'page-2': Page2 } }], + } + } + return { + title: 'Section 2', + name: 'Section2', + tasks: [{ id: 'bar', title: 'Bar', pages: { 'page-3': Page1, 'page-4': Page2 } }], + } + }) + + expect(utils.getPagesForSections([Section1, Section2])).toEqual({ + foo: { + 'page-1': Page1, + 'page-2': Page2, + }, + bar: { + 'page-3': Page1, + 'page-4': Page2, + }, + }) + }) + }) + + describe('viewPath', () => { + it('returns the view path for a page', () => { + const page1 = new Page1() + + expect(utils.viewPath(page1, 'applications')).toEqual('applications/pages/task-1/page-1') + }) + }) + + describe('getPageName', () => { + it('returns the page name', () => { + expect(utils.getPageName(Page1)).toEqual('page-1') + }) + }) + + describe('getTaskName', () => { + it('returns the task name', () => { + expect(utils.getTaskName(Page1)).toEqual('task-1') + }) + }) + }) + + describe('getBody', () => { + it('if there is userInput it returns it', () => { + const input = { user: 'input' } + + expect( + utils.getBody({} as TaskListPageInterface, {} as ApprovedPremisesApplication, {} as Request, input), + ).toEqual(input) + }) + + it('if there isnt userInput and there is a request body the request body is returned', () => { + const request: DeepMocked<Request> = createMock<Request>() + + expect(utils.getBody({} as TaskListPageInterface, {} as ApprovedPremisesApplication, request, {})) + }) + + it('if there is neither a request body or userInput then getPageFromApplicationData is called and returns the data for that page', () => { + const page: DeepMocked<TaskListPageInterface> = createMock<TaskListPageInterface>() + const pageData = { task: { page: 'returnMe' } } + const application = applicationFactory.build({ data: pageData }) + + jest.spyOn(utils, 'getPageName').mockImplementation(() => 'page') + jest.spyOn(utils, 'getTaskName').mockImplementation(() => 'task') + + jest.spyOn(utils, 'pageDataFromApplication').mockImplementation((_, applicationInput) => applicationInput.data) + + expect(utils.getBody(page, application, { body: {} } as Request, {})).toBe('returnMe') + }) + }) + + describe('pageDataFromApplication', () => { + describe('when there is not a matching task name on the application', () => { + it('returns an empty object', () => { + const page: DeepMocked<TaskListPageInterface> = createMock<TaskListPageInterface>() + const pageData = { taskThatDoesNotMatch: { pageThatDoNotMatch: 'notReturned' } } + const application = applicationFactory.build({ data: pageData }) + + jest.spyOn(utils, 'getPageName').mockImplementation(() => 'page') + jest.spyOn(utils, 'getTaskName').mockImplementation(() => 'task') + + expect(utils.pageDataFromApplication(page, application)).toEqual({}) + }) + }) + + describe('when there is no data on the application', () => { + it('returns null', () => { + const page: DeepMocked<TaskListPageInterface> = createMock<TaskListPageInterface>() + const application = applicationFactory.build({ data: null }) + + jest.spyOn(utils, 'getPageName').mockImplementation(() => 'page') + jest.spyOn(utils, 'getTaskName').mockImplementation(() => 'task') + + expect(utils.pageDataFromApplication(page, application)).toEqual({}) + }) + }) + }) + + describe('getOasysImportDateFromApplication', () => { + it('calls date formatting function when as OASys import date exists', () => { + const application = applicationFactory.build({ + data: { 'risk-to-self': { 'oasys-import': { oasysImportedDate: 'some date' } } }, + }) + + ;(DateFormats.isoDateToUIDate as jest.Mock).mockImplementation(() => null) + + utils.getOasysImportDateFromApplication(application, 'risk-to-self') + + expect(DateFormats.isoDateToUIDate).toHaveBeenCalledWith('some date', { format: 'medium' }) + }) + + it('returns null where no import date exists on application', () => { + const application = applicationFactory.build({ data: null }) + + expect(utils.getOasysImportDateFromApplication(application, 'risk-to-self')).toEqual(null) + }) + }) + + describe('pageBodyShallowEquals', () => { + it('returns true when the two parameters are equal', () => { + const value1 = { + 'some-key': 'some-value', + 'some-key-2': ['value1', 'value2'], + } + + const value2 = { + 'some-key': 'some-value', + 'some-key-2': ['value1', 'value2'], + } + + expect(utils.pageBodyShallowEquals(value1, value2)).toEqual(true) + expect(utils.pageBodyShallowEquals(value2, value1)).toEqual(true) + }) + + it('returns false when the one parameter is a subset of the other', () => { + const value1 = { + 'some-key': 'some-value', + 'some-key-2': ['value1', 'value2'], + } + + const value2 = { + 'some-key': 'some-value', + } + + expect(utils.pageBodyShallowEquals(value1, value2)).toEqual(false) + expect(utils.pageBodyShallowEquals(value2, value1)).toEqual(false) + }) + + it('returns false when one parameter contains a different array to the other', () => { + const value1 = { + 'some-key': 'some-value', + 'some-key-2': ['value1', 'value2', 'value3'], + } + + const value2 = { + 'some-key': 'some-value', + 'some-key-2': ['value1', 'value2'], + } + + expect(utils.pageBodyShallowEquals(value1, value2)).toEqual(false) + expect(utils.pageBodyShallowEquals(value2, value1)).toEqual(false) + }) + + it('returns false when parameters contains an inner object', () => { + const value1 = { + 'some-key': 'some-value', + 'some-key-2': { 'inner-key': 'inner-value' }, + } + + const value2 = { + 'some-key': 'some-value', + 'some-key-2': { 'inner-key': 'inner-value' }, + } + + expect(utils.pageBodyShallowEquals(value1, value2)).toEqual(false) + expect(utils.pageBodyShallowEquals(value2, value1)).toEqual(false) + }) + }) + + describe('dateBodyProperties', () => { + it('returns date field names for use in page body properties', () => { + expect(utils.dateBodyProperties('someDate')).toEqual([ + 'someDate', + 'someDate-year', + 'someDate-month', + 'someDate-day', + ]) + }) + }) +}) diff --git a/server/form-pages/utils/index.ts b/server/form-pages/utils/index.ts new file mode 100644 index 0000000..175ed12 --- /dev/null +++ b/server/form-pages/utils/index.ts @@ -0,0 +1,126 @@ +import type { Request } from 'express' +import { Cas2Application as Application } from '@approved-premises/api' +import type { FormArtifact, JourneyType, UiTask } from '@approved-premises/ui' +import logger from '../../../logger' +import { TaskListPageInterface } from '../taskListPage' +import { DateFormats } from '../../utils/dateUtils' + +export const getTask = <T>(task: T) => { + const taskPages = {} + const slug = Reflect.getMetadata('task:slug', task) + const name = Reflect.getMetadata('task:name', task) + const pageClasses = Reflect.getMetadata('task:pages', task) + + pageClasses.forEach(<PageType>(page: PageType) => { + const pageName = Reflect.getMetadata('page:name', page) + // @ts-expect-error Requires refactor to satisfy TS7053 + taskPages[pageName] = page + }) + + return { + id: slug, + title: name, + pages: taskPages, + } +} + +export const getSection = <T>(section: T) => { + const tasks: Array<UiTask> = [] + const title = Reflect.getMetadata('section:title', section) + const name = Reflect.getMetadata('section:name', section) + const taskClasses = Reflect.getMetadata('section:tasks', section) + + taskClasses.forEach(<PageType>(task: PageType) => { + tasks.push(getTask(task)) + }) + + return { + title, + name, + tasks, + } +} + +export const getPagesForSections = <T>(sections: Array<T>) => { + const pages: Record<string, unknown> = {} + sections.forEach(sectionClass => { + const section = getSection(sectionClass) + const { tasks } = section + tasks.forEach(t => { + pages[t.id] = t.pages + }) + }) + return pages +} +export function getBody( + Page: TaskListPageInterface, + application: FormArtifact, + request: Request, + userInput: Record<string, unknown>, +) { + if (userInput && Object.keys(userInput).length) { + return userInput + } + if (Object.keys(request.body).length) { + return request.body + } + return pageDataFromApplication(Page, application) +} + +export const viewPath = <T>(page: T, journeyType: JourneyType) => { + const pageName = getPageName(page.constructor) + const taskName = getTaskName(page.constructor) + return `${journeyType}/pages/${taskName}/${pageName}` +} + +export const getPageName = <T>(page: T) => { + return Reflect.getMetadata('page:name', page) +} + +export const getTaskName = <T>(page: T) => { + return Reflect.getMetadata('page:task', page) +} + +export function pageDataFromApplication(Page: TaskListPageInterface, application: FormArtifact) { + const pageName = getPageName(Page) + const taskName = getTaskName(Page) + + return application.data?.[taskName]?.[pageName] || {} +} + +export function getOasysImportDateFromApplication(application: Application, taskName: string): string | null { + if (application.data?.[taskName]?.['oasys-import']?.oasysImportedDate) { + return DateFormats.isoDateToUIDate(application.data?.[taskName]?.['oasys-import']?.oasysImportedDate, { + format: 'medium', + }) + } + return null +} + +export function logOasysError(e: Error, crn: string) { + logger.error(`Error retrieving Oasys for crn ${crn}`) + logger.error(e) +} + +export function pageBodyShallowEquals(body1: Record<string, unknown>, body2: Record<string, unknown>) { + const body1Keys = Object.keys(body1) + const body2Keys = Object.keys(body2) + + if (body1Keys.length !== body2Keys.length) { + return false + } + + return body1Keys.every(key => { + const value1 = body1[key] + const value2 = body2[key] + + if (Array.isArray(value1) && Array.isArray(value2)) { + return value1.length === value2.length && value1.every((arrayValue, index) => arrayValue === value2[index]) + } + return value1 === value2 + }) +} + +export const dateBodyProperties = (root: string) => { + return [root, `${root}-year`, `${root}-month`, `${root}-day`] +} diff --git a/server/form-pages/utils/questions.test.ts b/server/form-pages/utils/questions.test.ts new file mode 100644 index 0000000..3b65680 --- /dev/null +++ b/server/form-pages/utils/questions.test.ts @@ -0,0 +1,11 @@ +import { getQuestions } from './questions' + +describe('getQuestions', () => { + it('personalises the questions', () => { + const questions = getQuestions('Roger Smith') + + expect(questions['confirm-eligibility']['confirm-eligibility'].isEligible.question).toEqual( + 'Is Roger Smith eligible for Short-Term Accommodation (CAS-2)?', + ) + }) +}) diff --git a/server/form-pages/utils/questions.ts b/server/form-pages/utils/questions.ts new file mode 100644 index 0000000..8ffb3f7 --- /dev/null +++ b/server/form-pages/utils/questions.ts @@ -0,0 +1,860 @@ +export const getQuestions = (name: string) => { + const yesOrNo = { yes: 'Yes', no: 'No' } + const yesNoOrIDontKnow = { yes: 'Yes', no: 'No', dontKnow: `I don't know` } + + const dateExample = '27 3 2023' + + const offenceCategory = { + question: 'Offence type', + answers: { + stalkingOrHarassment: 'Stalking or Harassment', + weaponsOrFirearms: 'Weapons or Firearms', + arson: 'Arson', + violence: 'Violence', + domesticAbuse: 'Domestic abuse', + hateCrime: 'Hate crime', + drugs: 'Drugs', + other: 'Other', + }, + } + + const offenceSummaryHintHtml = + '<div id="offence-details-hint" class="govuk-hint"> <p class="govuk-hint">Include:</p> <ul class="govuk-list govuk-list--bullet govuk-hint"> <li>what happened (excluding names and other sensitive information)</li> <li>where it happened (excluding addresses)</li><li>when it happened</li><li>damage or injury caused</li><li>weapon type</li><li>motivations for the offence</li><li>if a violent offence, the relationship to the victim</li></ul></div>' + + return { + 'confirm-eligibility': { + 'confirm-eligibility': { + isEligible: { + question: `Is ${name} eligible for Short-Term Accommodation (CAS-2)?`, + answers: { + yes: `Yes, I confirm ${name} is eligible`, + no: `No, ${name} is not eligible`, + }, + }, + }, + }, + 'confirm-consent': { + 'confirm-consent': { + hasGivenConsent: { + question: `Has ${name} given their consent to apply for CAS-2?`, + answers: { + yes: `Yes, ${name} has given their consent`, + no: `No, ${name} has not given their consent`, + }, + }, + consentDate: { + question: 'When did they give consent?', + hint: `For example, ${dateExample}`, + }, + consentRefusalDetail: { + question: 'Why was consent refused?', + }, + }, + }, + 'hdc-licence-dates': { + 'hdc-licence-dates': { + hdcEligibilityDate: { + question: `What is ${name}'s HDC eligibility date?`, + hint: `For example, ${dateExample}`, + }, + conditionalReleaseDate: { + question: `What is ${name}'s conditional release date?`, + hint: `For example, ${dateExample}`, + }, + }, + }, + 'referrer-details': { + 'confirm-details': { + name: { question: 'Name' }, + email: { question: 'Email address' }, + }, + 'job-title': { + jobTitle: { question: 'What is your job title?', hint: 'For example, Prison Offender Manager (POM)' }, + }, + 'contact-number': { + telephone: { + question: 'What is your contact telephone number?', + hint: 'This will be used for any communication from the accommodation supplier', + }, + }, + }, + 'information-needed-from-applicant': { + 'information-needed-from-applicant': { + hasInformationNeeded: { + question: 'Have you got all the information you need from the applicant?', + answers: yesOrNo, + }, + }, + }, + 'personal-information': { + 'working-mobile-phone': { + hasWorkingMobilePhone: { + question: `Will ${name} have a working mobile phone when they are released?`, + }, + mobilePhoneNumber: { + question: 'What is their mobile number? (Optional)', + }, + isSmartPhone: { + question: 'Is their mobile a smart phone?', + }, + }, + 'immigration-status': { + immigrationStatus: { + question: `What is ${name}'s immigration status?`, + hint: 'Select their immigration status', + answers: { + ukCitizen: 'UK citizen', + leaveToRemain: 'Leave to remain', + indefiniteLeaveToRemain: 'Indefinite leave to remain', + discretionaryLeaveToRemain: 'Discretionary leave to remain', + eeaNational: 'EEA national', + refugee: 'Refugee', + asylumSeekerAwaitingDecision: 'Asylum seeker awaiting decision', + spousePartnerSponsorship: 'Spouse or partner sponsorship', + workVisa: 'Work visa', + studyVisa: 'Study visa', + notKnown: 'Not known', + }, + }, + }, + 'pregnancy-information': { + isPregnant: { + question: `Is ${name} pregnant?`, + answers: yesNoOrIDontKnow, + }, + dueDate: { + question: 'When is their due date?', + hint: `For example, ${dateExample}`, + }, + }, + 'support-worker-preference': { + hasSupportWorkerPreference: { + question: `Does ${name} have a gender preference for their support worker?`, + answers: yesNoOrIDontKnow, + }, + supportWorkerPreference: { + question: 'What is their preference?', + answers: { male: 'Male', female: 'Female' }, + }, + }, + }, + 'address-history': { + 'previous-address': { + hasPreviousAddress: { + question: `Did ${name} have an address before entering custody?`, + answers: { + yes: 'Yes', + no: 'No fixed address', + }, + }, + knownAddress: { + question: 'What was the address?', + }, + lastKnownAddress: { + question: 'What was their last known address? (Optional)', + }, + howLong: { + question: 'How long did the applicant have no fixed address for?', + }, + }, + }, + 'equality-and-diversity-monitoring': { + 'will-answer-equality-questions': { + willAnswer: { + question: `Equality questions for ${name}`, + answers: { + yes: 'Yes, answer the equality questions (takes 2 minutes)', + no: 'No, skip the equality questions', + }, + }, + }, + disability: { + hasDisability: { + question: `Does ${name} have a disability?`, + answers: { + yes: 'Yes', + no: 'No', + preferNotToSay: 'Prefer not to say', + }, + }, + typeOfDisability: { + question: `What type of disability?`, + hint: 'Select all that apply', + answers: { + sensoryImpairment: 'Sensory impairment', + physicalImpairment: 'Physical impairment', + learningDisability: 'Learning disability or difficulty', + mentalHealth: 'Mental health condition', + illness: 'Long-standing illness', + other: 'Other', + }, + }, + otherDisability: { question: 'What is the disability?' }, + }, + 'sex-and-gender': { + sex: { + question: `What is ${name}'s sex?`, + answers: { + female: 'Female', + male: 'Male', + preferNotToSay: 'Prefer not to say', + }, + }, + gender: { + question: `Is the gender ${name} identifies with the same as the sex registered at birth?`, + answers: { + yes: 'Yes', + no: 'No', + preferNotToSay: 'Prefer not to say', + }, + }, + optionalGenderIdentity: { question: 'What is their gender identity? (optional)' }, + }, + 'sexual-orientation': { + orientation: { + question: `Which of the following best describes ${name}'s sexual orientation?`, + answers: { + heterosexual: 'Heterosexual or straight', + gay: 'Gay', + lesbian: 'Lesbian', + bisexual: 'Bisexual', + other: 'Other', + preferNotToSay: 'Prefer not to say', + }, + }, + otherOrientation: { question: 'How would they describe their sexual orientation? (optional)' }, + }, + 'ethnic-group': { + ethnicGroup: { + question: `What is ${name}'s ethnic group?`, + answers: { + white: 'White', + mixed: 'Mixed or multiple ethnic groups', + asian: 'Asian or Asian British', + black: 'Black, African, Caribbean or Black British', + other: 'Other ethnic group', + preferNotToSay: 'Prefer not to say', + }, + }, + }, + 'asian-background': { + asianBackground: { + question: `Which of the following best describes ${name}'s Asian or Asian British background?`, + answers: { + indian: 'Indian', + pakistani: 'Pakistani', + chinese: 'Chinese', + bangladeshi: 'Bangladeshi', + other: 'Any other Asian background', + preferNotToSay: 'Prefer not to say', + }, + }, + optionalAsianBackground: { question: 'How would they describe their background? (optional)' }, + }, + 'black-background': { + blackBackground: { + question: `Which of the following best describes ${name}'s Black, African, Caribbean or Black British background?`, + answers: { + african: 'African', + caribbean: 'Caribbean', + other: 'Any other Black, African or Caribbean background', + preferNotToSay: 'Prefer not to say', + }, + }, + optionalBlackBackground: { question: 'How would they describe their background? (optional)' }, + }, + 'white-background': { + whiteBackground: { + question: `Which of the following best describes ${name}'s White background?`, + answers: { + english: 'English, Welsh, Scottish, Northern Irish or British', + irish: 'Irish', + gypsy: 'Gypsy or Irish Traveller', + other: 'Any other White background', + preferNotToSay: 'Prefer not to say', + }, + }, + optionalWhiteBackground: { question: 'How would they describe their background? (optional)' }, + }, + 'mixed-background': { + mixedBackground: { + question: `Which of the following best describes ${name}'s mixed or multiple ethnic groups background?`, + answers: { + whiteAndBlackCaribbean: 'White and Black Caribbean', + whiteAndBlackAfrican: 'White and Black African', + whiteAndAsian: 'White and Asian', + other: 'Any other mixed or multiple ethnic background', + preferNotToSay: 'Prefer not to say', + }, + }, + optionalMixedBackground: { question: 'How would they describe their background? (optional)' }, + }, + 'other-background': { + otherBackground: { + question: `Which of the following best describes ${name}'s background?`, + answers: { + arab: 'Arab', + other: 'Any other ethnic group', + preferNotToSay: 'Prefer not to say', + }, + }, + optionalOtherBackground: { question: 'How would they describe their background? (optional)' }, + }, + religion: { + religion: { + question: `What is ${name}'s religion?`, + answers: { + noReligion: 'No religion', + atheist: 'Atheist or Humanist', + agnostic: 'Agnostic', + christian: 'Christian', + buddhist: 'Buddhist', + hindu: 'Hindu', + jewish: 'Jewish', + muslim: 'Muslim', + sikh: 'Sikh', + other: 'Any other religion', + preferNotToSay: 'Prefer not to say', + }, + }, + otherReligion: { question: 'What is their religion? (optional)' }, + }, + 'military-veteran': { + isVeteran: { + question: `Is ${name} a military veteran?`, + answers: yesNoOrIDontKnow, + }, + }, + 'care-leaver': { + isCareLeaver: { + question: `Is ${name} a care leaver?`, + answers: yesNoOrIDontKnow, + }, + }, + 'parental-carer-responsibilities': { + hasParentalOrCarerResponsibilities: { + question: `Does ${name} have parental or carer responsibilities?`, + answers: yesNoOrIDontKnow, + }, + }, + 'marital-status': { + maritalStatus: { + question: `What is ${name}'s legal marital or registered civil partnership status?`, + answers: { + neverMarried: 'Never married and never registered in a civil partnership', + married: 'Married', + inCivilPartnership: 'In a registered civil partnership', + marriedButSeparated: 'Separated, but still legally married', + inCivilPartnershipButSeparated: 'Separated, but still legally in a civil partnership', + divorced: 'Divorced', + formerlyInCivilPartnershipNowDissolved: 'Formerly in a civil partnership which is now legally dissolved', + widowed: 'Widowed', + survivingPartnerFromCivilPartnership: 'Surviving partner from a registered civil partnership', + preferNotToSay: 'Prefer not to say', + }, + }, + }, + }, + 'area-information': { + 'first-preferred-area': { + preferredArea: { + question: 'First preferred area', + hint: 'Specify a town, city or region', + }, + preferenceReason: { + question: 'Reason for first preference', + hint: 'Include the type of local connection the applicant has with the area', + }, + }, + 'second-preferred-area': { + preferredArea: { + question: 'Second preferred area', + hint: 'Specify a town, city or region', + }, + preferenceReason: { + question: 'Reason for second preference', + hint: 'Include the type of local connection the applicant has with the area', + }, + }, + 'exclusion-zones': { + hasExclusionZones: { + question: `Does ${name} have any exclusion zones?`, + answers: yesOrNo, + }, + exclusionZonesDetail: { + question: 'Provide the required safeguarding details about the exclusion zone', + }, + }, + 'gang-affiliations': { + hasGangAffiliations: { + question: `Does ${name} have any gang affiliations?`, + answers: yesOrNo, + }, + gangName: { + question: 'What is the name of the gang?', + }, + gangOperationArea: { + question: 'Where do they operate?', + }, + rivalGangDetail: { + question: 'Name any known rival gangs and where they operate (optional)', + }, + }, + 'family-accommodation': { + familyProperty: { + question: 'Do they want to apply to live with their children in a family property?', + answers: yesOrNo, + }, + }, + }, + 'funding-information': { + 'funding-source': { + fundingSource: { + question: `How will ${name} pay for their accommodation and service charge?`, + hint: 'Applicants must pay for a weekly service charge using their personal money or wages. This service charge is not eligible to be covered by Housing Benefit.', + answers: { + personalSavings: 'Personal money or wages', + benefits: 'Housing Benefit and personal money or wages', + }, + }, + }, + 'national-insurance': { + nationalInsuranceNumber: { + question: `What is ${name}'s National Insurance number? (Optional)`, + hint: 'We need this to set up a Universal Credit and Housing Benefit claim if required.', + }, + }, + identification: { + idDocuments: { + question: `What identification documentation (ID) does ${name} have?`, + hint: 'Expired ID will be accepted. Select all that apply.', + answers: { + passport: 'Passport', + travelPass: 'Travel pass with photograph', + birthCertificate: 'Birth certificate', + bankOrDebitCard: 'Bank account or debit card', + bankStatements: 'Bank, building society or Post Office card account statements', + drivingLicence: 'UK photo driving licence', + wageSlip: 'Recent wage slip', + none: 'None of these options', + }, + }, + }, + 'alternative-identification': { + alternativeIDDocuments: { + question: `What alternative identification documentation (ID) does ${name} have?`, + hint: 'Expired ID will be accepted. Select all that apply.', + answers: { + contract: 'Employer letter/contract of employment', + tradeUnion: 'Trade union membership card', + invoice: 'Invoices (self-employed)', + hmrc: 'HMRC correspondence', + citizenCard: 'CitizenCard', + foreignBirthCertificate: 'Foreign birth certificate', + citizenCertificate: 'British citizen registration/naturalisation certificate', + residenceCard: 'Permanent residence card', + residencePermit: 'Residence permit', + biometricResidencePermit: 'Biometric Residence Permit', + laRentCard: 'Local authority rent card', + marriageCertificate: 'Original marriage/civil partnership certificate', + divorcePapers: 'Divorce or annulment papers', + dissolutionPapers: 'Dissolution of marriage/civil partnership papers', + buildingSociety: 'Building society passbook', + councilTax: 'Council tax documents', + insurance: 'Life assurance or insurance policies', + chequeBook: 'Personal cheque book', + mortgage: 'Mortgage repayment policies', + savingAccount: 'Saving account book', + studentID: 'Student ID card', + educationalInstitution: 'Educational institution letter (student)', + youngScot: 'Young Scot card', + deedPoll: 'Deed poll certificate', + vehicleRegistration: 'Vehicle registration/motor insurance documents', + nhsCard: 'NHS medical card', + other: 'Other type of identification', + none: 'No ID available', + }, + }, + other: { question: `What other ID does ${name} have?` }, + }, + }, + 'health-needs': { + 'substance-misuse': { + usesIllegalSubstances: { + question: 'Do they take any illegal substances in custody?', + answers: yesOrNo, + }, + substanceMisuse: { question: 'What substances do they take?' }, + pastSubstanceMisuse: { + question: 'Did they have any past issues with substance misuse before custody?', + answers: yesOrNo, + }, + pastSubstanceMisuseDetail: { + question: 'Describe their previous substance misuse', + hint: 'Include previous substance misuse that support would be needed for or that could lead to potential issues in a CAS-2 placement. For example, relapse prevention support, substitute medication or risk of overdose', + }, + engagedWithDrugAndAlcoholService: { + question: 'Are they engaged with a drug and alcohol service in custody?', + answers: yesOrNo, + }, + intentToReferToServiceOnRelease: { + question: 'Is there an intention to refer them to a drug and alcohol service when they are released?', + answers: yesOrNo, + }, + drugAndAlcoholServiceDetail: { question: 'Name the drug and alcohol service (optional)' }, + requiresSubstituteMedication: { + question: 'Do they require any substitute medication for misused substances?', + answers: yesOrNo, + }, + substituteMedicationDetail: { question: 'What substitute medication do they take?' }, + releasedWithNaloxone: { + question: 'Are they being released with naloxone?', + answers: yesNoOrIDontKnow, + }, + }, + 'physical-health': { + hasPhyHealthNeeds: { question: 'Do they have any physical health needs?', answers: yesOrNo }, + needsDetail: { question: 'Please describe their needs.' }, + canClimbStairs: { + question: 'Can they climb stairs?', + }, + isReceivingMedicationOrTreatment: { + question: 'Are they currently receiving any medication or treatment for their physical health?', + answers: yesOrNo, + }, + medicationOrTreatmentDetail: { + question: 'Describe the medication or treatment', + }, + medicationDetail: { question: 'Describe the medication they receive for physical health needs' }, + canLiveIndependently: { question: 'Can they live independently?', answers: yesOrNo }, + indyLivingDetail: { question: 'Describe why they are unable to live independently' }, + requiresAdditionalSupport: { + question: 'Do they require any additional support?', + answers: yesOrNo, + }, + addSupportDetail: { question: 'Please describe the types of support.' }, + }, + 'mental-health': { + hasMentalHealthNeeds: { question: 'Do they have any mental health needs?', answers: yesOrNo }, + needsDetail: { question: 'Please describe their mental health needs.' }, + needsPresentation: { question: 'How are they presenting?' }, + isEngagedWithCommunity: { + question: 'Were they engaged in mental health services before custody?', + answers: yesOrNo, + }, + servicesDetail: { question: 'Please state which services.' }, + isEngagedWithServicesInCustody: { + question: 'Are they engaged with any mental health services in custody?', + answers: yesOrNo, + }, + areIntendingToEngageWithServicesAfterCustody: { + question: 'Are they intending to engage with mental health services after custody?', + answers: yesNoOrIDontKnow, + }, + canManageMedication: { + question: 'Can they manage their own mental health medication on release?', + answers: { + yes: 'Yes', + no: 'No', + notPrescribedMedication: 'They are not prescribed medication for their mental health', + }, + }, + canManageMedicationNotes: { + question: 'Provide any relevant medication notes (optional)', + hint: 'For example, storage requirements', + }, + medicationIssues: { + question: 'Describe the issues they have with taking their medication', + }, + cantManageMedicationNotes: { + question: 'Provide any relevant medication notes (optional)', + hint: 'For example, storage requirements', + }, + }, + 'communication-and-language': { + requiresInterpreter: { question: 'Do they need an interpreter?', answers: yesOrNo }, + interpretationDetail: { question: 'What language do they need an interpreter for?' }, + hasSupportNeeds: { + question: 'Do they need any support to see, hear, speak, or understand?', + answers: yesOrNo, + }, + supportDetail: { question: 'Please describe their support needs.' }, + }, + 'learning-difficulties': { + hasLearningNeeds: { + question: 'Do they have any additional needs relating to learning difficulties or neurodiversity?', + answers: yesOrNo, + }, + needsDetail: { question: 'Please describe their additional needs.' }, + isVulnerable: { + question: 'Are they vulnerable as a result of this condition?', + answers: yesOrNo, + }, + vulnerabilityDetail: { question: 'Please describe their level of vulnerability.' }, + hasDifficultyInteracting: { + question: 'Do they have difficulties interacting with other people as a result of this condition?', + answers: yesOrNo, + }, + interactionDetail: { question: 'Please describe these difficulties.' }, + requiresAdditionalSupport: { question: 'Is additional support required?', answers: yesOrNo }, + addSupportDetail: { question: 'Please describe the type of support.' }, + }, + 'brain-injury': { + hasBrainInjury: { + question: 'Do they have a brain injury?', + answers: yesOrNo, + }, + injuryDetail: { question: 'Please describe their brain injury and needs.' }, + isVulnerable: { + question: 'Are they vulnerable as a result of this injury?', + answers: yesOrNo, + }, + vulnerabilityDetail: { question: 'Please describe their level of vulnerability.' }, + hasDifficultyInteracting: { + question: 'Do they have difficulties interacting with other people as a result of this injury?', + answers: yesOrNo, + }, + interactionDetail: { question: 'Please describe these difficulties.' }, + requiresAdditionalSupport: { question: 'Is additional support required?', answers: yesOrNo }, + addSupportDetail: { question: 'Please describe the type of support.' }, + }, + 'other-health': { + hasLongTermHealthCondition: { + question: 'Are they managing any long term health conditions?', + hint: 'For example, diabetes, arthritis or high blood pressure.', + answers: yesOrNo, + }, + healthConditionDetail: { + question: 'Please describe the long term health conditions.', + }, + hasHadStroke: { question: 'Have they experienced a stroke?', answers: yesOrNo }, + hasSeizures: { question: 'Do they experience seizures?', answers: yesOrNo }, + seizuresDetail: { + question: 'Please describe the type and any treatment.', + }, + beingTreatedForCancer: { + question: 'Are they currently receiving regular treatment for cancer?', + answers: yesOrNo, + }, + }, + }, + 'risk-to-self': { + 'old-oasys': { + hasOldOasys: { + question: `Does ${name} have an older OASys with risk to self information?`, + answers: { yes: 'Yes', no: 'No, they do not have an OASys' }, + }, + oasysCompletedDate: { + question: 'When was the OASys completed?', + hint: `For example, ${dateExample}`, + }, + }, + vulnerability: { + vulnerabilityDetail: { + question: `Describe ${name}'s current circumstances, issues and needs related to vulnerability`, + hint: 'Include all current risk information and remove sensitive information, such as names and addresses.', + }, + confirmation: { + question: 'I confirm this information is relevant and up to date.', + answers: { confirmed: 'Confirmed' }, + }, + }, + 'current-risk': { + currentRiskDetail: { + question: `Describe ${name}'s current issues and needs related to self harm and suicide`, + hint: 'Include all current risk information and remove sensitive information, such as names and addresses.', + }, + confirmation: { + question: 'I confirm this information is relevant and up to date.', + answers: { confirmed: 'Confirmed' }, + }, + }, + 'historical-risk': { + historicalRiskDetail: { + question: `Describe ${name}'s historical issues and needs related to self harm and suicide`, + hint: 'Remove sensitive information, such as names and addresses.', + }, + confirmation: { + question: 'I confirm this information is relevant and up to date.', + answers: { confirmed: 'Confirmed' }, + }, + }, + 'acct-data': { + createdDate: { + question: 'When was the ACCT created?', + hint: 'For example, 22 4 2003', + }, + isOngoing: { + question: 'Is the ACCT ongoing?', + }, + closedDate: { + question: 'When was the ACCT closed?', + hint: 'For example, 22 4 2003', + }, + referringInstitution: { + question: 'Referring institution', + hint: 'Where the applicant was based at the time the ACCT was created', + }, + acctDetail: { + question: 'Details about the ACCT', + }, + }, + 'additional-information': { + hasAdditionalInformation: { + question: `Is there anything else to include about ${name}'s risk to self?`, + hint: 'Record any additional information about their risk to self.', + answers: yesOrNo, + }, + additionalInformationDetail: { question: 'Additional information' }, + }, + }, + 'risk-of-serious-harm': { + summary: { + status: 'retrieved', + overallRisk: { question: 'Overall risk' }, + riskToChildren: { question: 'Risk to children' }, + riskToPublic: { question: 'Risk to the public' }, + riskToKnownAdult: { question: 'Risk to a known adult' }, + riskToStaff: { question: 'Risk to staff' }, + additionalComments: { question: 'Additional comments (optional)' }, + }, + 'old-oasys': { + hasOldOasys: { + question: `Does ${name} have an older OASys with risk of serious harm (RoSH) information?`, + answers: { yes: 'Yes', no: 'No, they do not have an OASys' }, + }, + oasysCompletedDate: { + question: 'When was the OASys completed?', + hint: `For example, ${dateExample}`, + }, + }, + 'risk-to-others': { + whoIsAtRisk: { + question: 'Who is at risk?', + }, + natureOfRisk: { + question: 'What is the nature of the risk?', + }, + confirmation: { + question: 'I confirm this information is relevant and up to date.', + answers: { confirmed: 'Confirmed' }, + }, + }, + 'risk-management-arrangements': { + arrangements: { + question: `Is ${name} subject to any of these multi-agency risk management arrangements upon release?`, + hint: 'Select all that apply', + answers: { + mappa: 'MAPPA', + marac: 'MARAC', + iom: 'IOM', + }, + }, + mappaDetails: { + question: 'Provide MAPPA details', + hint: 'Specify whether the MAPPA is Category 2 or Category 3. Include lead contact details where possible.', + }, + maracDetails: { + question: 'Provide MARAC details', + hint: 'Include lead contact details where possible.', + }, + iomDetails: { + question: 'Provide IOM details', + hint: 'Include lead contact details where possible.', + }, + }, + 'cell-share-information': { + hasCellShareComments: { + question: 'Are there any comments to add about cell sharing?', + answers: yesOrNo, + }, + cellShareInformationDetail: { question: 'Cell sharing information' }, + }, + 'additional-risk-information': { + hasAdditionalInformation: { + question: `Is there any other risk information for ${name}?`, + hint: 'If known, state their incentive level, also known as Incentive and Enhanced Privileges (IEP), and any other information about their risk to others.', + answers: yesOrNo, + }, + additionalInformationDetail: { question: 'Additional information' }, + }, + }, + 'current-offences': { + 'current-offence-data': { + titleAndNumber: { + question: 'Offence title', + hint: "For example, 'Stalking'", + }, + offenceCategory, + offenceDate: { + question: 'When did they commit the offence?', + hint: `For example, ${dateExample}`, + }, + sentenceLength: { + question: 'How long were they sentenced for?', + hint: 'For example, 6 months', + }, + summary: { + question: 'Provide a summary of the offence', + hint: offenceSummaryHintHtml, + }, + outstandingCharges: { + question: `Are there outstanding charges committed prior to the current sentence?`, + answers: yesOrNo, + }, + outstandingChargesDetail: { + question: 'Details of any outstanding charges', + }, + }, + }, + 'offending-history': { + 'any-previous-convictions': { + hasAnyPreviousConvictions: { + question: `Does ${name} have any previous unspent convictions?`, + answers: { + yesRelevantRisk: 'Yes, and there is relevant risk', + yesNoRelevantRisk: 'Yes, but there is no relevant risk', + no: 'No, they do not have any previous unspent convictions', + }, + }, + }, + 'offence-history-data': { + offenceGroupName: { + question: 'Offence group name', + hint: 'For example, grievous bodily harm (GBH)', + }, + offenceCategory, + numberOfOffences: { + question: 'Number of offences', + hint: 'The number of the same offence type. For example, 3', + }, + sentenceTypes: { + question: 'Sentence type(s)', + hint: 'For example, 1 custodial and 1 suspended', + }, + summary: { + question: 'Offence details', + hint: offenceSummaryHintHtml, + }, + }, + }, + 'cpp-details-and-hdc-licence-conditions': { + 'cpp-details': { + cppDetails: { + question: `Who is ${name}'s Community Probation Practitioner (CPP)?`, + hint: 'A Community Probation Practitioner (CPP) is also known as Community Offender Manager (COM).', + }, + }, + 'non-standard-licence-conditions': { + nonStandardLicenceConditions: { + question: `Does ${name} have any non-standard licence conditions?`, + answers: yesNoOrIDontKnow, + hint: 'Check with their Community Probation Practitioner (CPP), also known as Community Offender Manager (COM). Non-standard licence conditions may also be in NDelius.', + }, + nonStandardLicenceConditionsDetail: { + question: 'Describe the conditions', + }, + }, + }, + } +} diff --git a/server/i18n/en/errors.json b/server/i18n/en/errors.json new file mode 100644 index 0000000..d3a1ab9 --- /dev/null +++ b/server/i18n/en/errors.json @@ -0,0 +1,95 @@ +{ + "fundingSource": { + "empty": "Select a funding source" + }, + "willAnswer": { + "empty": "Choose either Yes or No" + }, + "hasDisability": { + "empty": "Choose either Yes, No or Prefer not to say", + "otherDisability": "Enter the other disability", + "typeOfDisability": "Choose a disability type" + }, + "sexAndGender": { + "sex": "Choose either Female, Male or Prefer not to say", + "gender": "Choose either Yes, No or Prefer not to say" + }, + "sexualOrientation": { + "orientation": "Select an orientation or choose 'Prefer not to say'" + }, + "ethnicGroup": { + "empty": "Select an ethnic group or choose 'Prefer not to say'" + }, + "background": { + "empty": "Select a background or 'Prefer not to say'" + }, + "religion": { + "empty": "Select a religion or 'Prefer not to say'" + }, + "isVeteran": { + "empty": "Choose either Yes, No or I don't know" + }, + "isCareLeaver": { + "empty": "Choose either Yes, No or I don't know" + }, + "hasParentalOrCarerResponsibilities": { + "empty": "Choose either Yes, No or I don't know" + }, + "maritalStatus": { + "empty": "Select a marital status or 'Prefer not to say'" + }, + "oasysConfirmation": { + "empty": "Confirm that the information is relevant and up to date" + }, + "circumstancesLikelyToIncreaseRisk": { + "empty": "Enter the circumstances that are likely to increase risk" + }, + "whenIsRiskLikelyToBeGreatest": { + "empty": "Enter when the risk is likely to be the greatest" + }, + "factorsLikelyToReduceRisk": { + "empty": "Enter the factors that are likely to reduce risk" + }, + "natureOfRisk": { + "empty": "Enter the nature of the risk" + }, + "whoIsAtRisk": { + "empty": "Enter who is at risk" + }, + "idDocuments": { + "empty": "Select an ID document or 'None of these options'" + }, + "alternativeIDDocuments": { + "empty": "Select an ID document or 'Other type of identification'", + "other": "Enter the other type of ID" + }, + "hasPreviousAddress": { + "empty": "Select whether applicant had an address before entering custody" + }, + "addressLine1": { + "empty": "Enter the first line of the address" + }, + "townOrCity": { + "empty": "Enter a town or city" + }, + "postCode": { + "empty": "Enter a postcode" + }, + "newStatus": { + "empty": "Select an application status" + }, + "newStatusDetails": { + "moreInfoRequested": { + "empty": "Select the types of information you need" + }, + "offerDeclined": { + "empty": "Select why the offer was declined or withdrawn" + }, + "withdrawn": { + "empty": "Select why the referral was withdrawn" + }, + "cancelled": { + "empty": "Select why the referral was cancelled" + } + } +} diff --git a/server/sanitisedError.ts b/server/sanitisedError.ts index 7172c1a..728d712 100644 --- a/server/sanitisedError.ts +++ b/server/sanitisedError.ts @@ -4,7 +4,11 @@ export interface SanitisedError extends Error { text?: string status?: number headers?: unknown - data?: unknown + data?: { + 'invalid-params'?: Array<unknown> + detail?: string + [key: string]: unknown + } stack: string message: string } diff --git a/server/services/index.ts b/server/services/index.ts index 0f97910..c296e6d 100644 --- a/server/services/index.ts +++ b/server/services/index.ts @@ -1,5 +1,6 @@ import { dataAccess } from '../data' import AuditService from './auditService' +import TaskListService from './taskListService' export const services = () => { const { applicationInfo, hmppsAuditClient } = dataAccess() @@ -13,3 +14,5 @@ export const services = () => { } export type Services = ReturnType<typeof services> + +export { TaskListService } diff --git a/server/services/personService.test.ts b/server/services/personService.test.ts new file mode 100644 index 0000000..6822817 --- /dev/null +++ b/server/services/personService.test.ts @@ -0,0 +1,75 @@ +import { oasysRiskToSelfFactory, oasysRoshFactory, personFactory, risksFactory } from '../testutils/factories' +import PersonService from './personService' +import { PersonClient } from '../data' + +jest.mock('../data/personClient.ts') + +describe('Person Service', () => { + const personClient = new PersonClient(null) as jest.Mocked<PersonClient> + const personClientFactory = jest.fn() + + const service = new PersonService(personClientFactory) + + const token = 'SOME_TOKEN' + + beforeEach(() => { + jest.resetAllMocks() + personClientFactory.mockReturnValue(personClient) + }) + + describe('findByPrisonNumber', () => { + it('on success returns the person given their prison number', async () => { + const person = personFactory.build() + personClient.search.mockResolvedValue(person) + + const postedPerson = await service.findByPrisonNumber(token, 'prisonNumber') + + expect(postedPerson).toEqual(person) + + expect(personClientFactory).toHaveBeenCalledWith(token) + expect(personClient.search).toHaveBeenCalledWith('prisonNumber') + }) + }) + + describe('getOasysRiskToSelf', () => { + it('returns risk to self data', async () => { + const expectedRiskToSelf = oasysRiskToSelfFactory.build() + personClient.oasysRiskToSelf.mockResolvedValue(expectedRiskToSelf) + + const oasysSections = await service.getOasysRiskToSelf(token, 'crn') + + expect(oasysSections).toEqual(expectedRiskToSelf) + + expect(personClientFactory).toHaveBeenCalledWith(token) + expect(personClient.oasysRiskToSelf).toHaveBeenCalledWith('crn') + }) + }) + + describe('getOasysRosh', () => { + it('returns Rosh data', async () => { + const expectedRosh = oasysRoshFactory.build() + personClient.oasysRosh.mockResolvedValue(expectedRosh) + + const oasysSections = await service.getOasysRosh(token, 'crn') + + expect(oasysSections).toEqual(expectedRosh) + + expect(personClientFactory).toHaveBeenCalledWith(token) + expect(personClient.oasysRosh).toHaveBeenCalledWith('crn') + }) + }) + + describe('getRoshRisks', () => { + it("on success returns the person's risks given their CRN", async () => { + const expectedRisks = risksFactory.build() + personClient.risks.mockResolvedValue(expectedRisks) + + const risks = await service.getRoshRisks(token, 'crn') + + expect(risks).toEqual(expectedRisks.roshRisks) + + expect(personClientFactory).toHaveBeenCalledWith(token) + expect(personClient.risks).toHaveBeenCalledWith('crn') + }) + }) +}) diff --git a/server/services/personService.ts b/server/services/personService.ts new file mode 100644 index 0000000..4adaa5a --- /dev/null +++ b/server/services/personService.ts @@ -0,0 +1,35 @@ +import type { FullPerson, OASysRiskOfSeriousHarm, OASysRiskToSelf, RoshRisksEnvelope } from '@approved-premises/api' +import type { PersonClient, RestClientBuilder } from '../data' + +export default class PersonService { + constructor(private readonly personClientFactory: RestClientBuilder<PersonClient>) {} + + async findByPrisonNumber(token: string, nomsNumber: string): Promise<FullPerson> { + const personClient = this.personClientFactory(token) + + const person = await personClient.search(nomsNumber) + return person + } + + async getOasysRiskToSelf(token: string, crn: string): Promise<OASysRiskToSelf> { + const personClient = this.personClientFactory(token) + + const riskToSelf = await personClient.oasysRiskToSelf(crn) + return riskToSelf + } + + async getOasysRosh(token: string, crn: string): Promise<OASysRiskOfSeriousHarm> { + const personClient = this.personClientFactory(token) + + const rosh = await personClient.oasysRosh(crn) + return rosh + } + + async getRoshRisks(token: string, crn: string): Promise<RoshRisksEnvelope> { + const personClient = this.personClientFactory(token) + + const risks = await personClient.risks(crn) + + return risks.roshRisks + } +} diff --git a/server/services/taskListService.test.ts b/server/services/taskListService.test.ts new file mode 100644 index 0000000..5af5e71 --- /dev/null +++ b/server/services/taskListService.test.ts @@ -0,0 +1,180 @@ +import { applicationFactory } from '../testutils/factories' +import TaskListService from './taskListService' +import getTaskStatus from '../form-pages/utils/getTaskStatus' + +jest.mock('../form-pages/apply', () => { + return { + sections: [ + { + title: 'First Section', + tasks: [ + { + id: 'first-task', + title: 'First task', + }, + { + id: 'second-task', + title: 'Second task', + }, + ], + }, + { + title: 'Second Section', + tasks: [ + { + id: 'third-task', + title: 'Third task', + }, + { + id: 'fourth-task', + title: 'Fourth task', + }, + { + id: 'fifth-task', + title: 'Fifth task', + }, + ], + }, + { + title: 'Check your answers', + tasks: [ + { + id: 'check-your-answers', + title: 'Check your answers', + }, + ], + }, + ], + } +}) + +jest.mock('../form-pages/utils/getTaskStatus') + +describe('taskListService', () => { + const application = applicationFactory.build({ id: 'some-uuid' }) + + beforeEach(() => { + jest.resetAllMocks() + }) + + describe('taskStatuses', () => { + it('returns task statuses and sets the statuses of check your answers to be cannot_start when other tasks have not been completed', () => { + ;(getTaskStatus as jest.Mock).mockReturnValue('in_progress') + + const taskListService = new TaskListService(application) + + expect(taskListService.taskStatuses).toEqual({ + 'first-task': 'in_progress', + 'second-task': 'in_progress', + 'third-task': 'in_progress', + 'fourth-task': 'in_progress', + 'fifth-task': 'in_progress', + 'check-your-answers': 'cannot_start', + }) + + expect(getTaskStatus).toHaveBeenCalledTimes(5) + }) + + it('allows check your answers to be complete when other tasks have been completed', () => { + ;(getTaskStatus as jest.Mock).mockReturnValue('complete') + + const taskListService = new TaskListService(application) + + expect(taskListService.taskStatuses).toEqual({ + 'first-task': 'complete', + 'second-task': 'complete', + 'third-task': 'complete', + 'fourth-task': 'complete', + 'fifth-task': 'complete', + 'check-your-answers': 'complete', + }) + }) + }) + + describe('completeTaskCount', () => { + it('returns zero when there are no complete sections', () => { + ;(getTaskStatus as jest.Mock).mockReturnValue('not_started') + + const taskListService = new TaskListService(application) + + expect(taskListService.completeTaskCount).toEqual(0) + }) + + it('returns 1 when there one task is complete', () => { + ;(getTaskStatus as jest.Mock).mockImplementation(t => + ['first-task'].includes(t.id) ? 'complete' : 'not_started', + ) + + const taskListService = new TaskListService(application) + + expect(taskListService.completeTaskCount).toEqual(1) + }) + + it('returns 5 when all tasks are complete', () => { + ;(getTaskStatus as jest.Mock).mockReturnValue('complete') + + const taskListService = new TaskListService(application) + + expect(taskListService.completeTaskCount).toEqual(6) + }) + }) + + describe('status', () => { + it('returns complete when all sections are complete', () => { + ;(getTaskStatus as jest.Mock).mockReturnValue('complete') + + const taskListService = new TaskListService(application) + + expect(taskListService.status).toEqual('complete') + }) + + it('returns incomplete when not all sections are complete', () => { + ;(getTaskStatus as jest.Mock).mockReturnValue('not_started') + + const taskListService = new TaskListService(application) + + expect(taskListService.status).toEqual('incomplete') + }) + }) + + describe('sections', () => { + it('returns the section data with the status of each task and the section number', () => { + ;(getTaskStatus as jest.Mock).mockReturnValue('not_started') + + const taskListService = new TaskListService(application) + + expect(taskListService.sections).toEqual([ + { + sectionNumber: 1, + title: 'First Section', + tasks: [ + { id: 'first-task', title: 'First task', status: 'not_started' }, + { id: 'second-task', title: 'Second task', status: 'not_started' }, + ], + }, + { + sectionNumber: 2, + title: 'Second Section', + tasks: [ + { id: 'third-task', title: 'Third task', status: 'not_started' }, + { id: 'fourth-task', title: 'Fourth task', status: 'not_started' }, + { id: 'fifth-task', title: 'Fifth task', status: 'not_started' }, + ], + }, + { + sectionNumber: 3, + title: 'Check your answers', + tasks: [{ id: 'check-your-answers', title: 'Check your answers', status: 'cannot_start' }], + }, + ]) + }) + }) + + describe('taskCount', () => { + it('returns the number of total tasks', () => { + const taskListService = new TaskListService(application) + + expect(taskListService.taskCount).toEqual(6) + }) + }) +}) diff --git a/server/services/taskListService.ts b/server/services/taskListService.ts new file mode 100644 index 0000000..4607045 --- /dev/null +++ b/server/services/taskListService.ts @@ -0,0 +1,63 @@ +import { Cas2Application as Application } from '@approved-premises/api' +import { FormSections, TaskStatus, TaskWithStatus, UiTask } from '@approved-premises/ui' +import Apply from '../form-pages/apply' +import getTaskStatus from '../form-pages/utils/getTaskStatus' + +export default class TaskListService { + taskStatuses: Record<string, TaskStatus> + + formSections: FormSections + + constructor(application: Application) { + this.formSections = Apply.sections + this.taskStatuses = {} + + this.formSections.forEach(section => { + section.tasks.forEach(task => { + if ( + task.id === 'check-your-answers' && + (Object.values(this.taskStatuses).includes('not_started') || + Object.values(this.taskStatuses).includes('in_progress')) + ) { + this.taskStatuses[task.id] = 'cannot_start' + } else { + this.taskStatuses[task.id] = getTaskStatus(task, application) + } + }) + }) + } + + get completeTaskCount() { + let completeTaskCount = 0 + + this.formSections.forEach(section => { + const taskIds = section.tasks.map(s => s.id) + const completeTasks = Object.keys(this.taskStatuses) + .filter(k => taskIds.includes(k)) + .filter(k => this.taskStatuses[k] === 'complete') + completeTaskCount += completeTasks.length + }) + + return completeTaskCount + } + + get sections() { + return this.formSections.map((s, i) => { + const tasks = s.tasks.map(t => this.addStatusToTask(t)) + return { sectionNumber: i + 1, title: s.title, tasks } + }) + } + + get taskCount() { + return this.formSections.flatMap(s => s.tasks).length + } + + get status() { + const completeTasks = Object.values(this.taskStatuses).filter(t => t === 'complete') + return completeTasks.length === Object.keys(this.taskStatuses).length ? 'complete' : 'incomplete' + } + + addStatusToTask(task: UiTask): TaskWithStatus { + return { ...task, status: this.taskStatuses[task.id] } + } +} diff --git a/server/utils/activePrisons.ts b/server/utils/activePrisons.ts new file mode 100644 index 0000000..d745747 --- /dev/null +++ b/server/utils/activePrisons.ts @@ -0,0 +1,4 @@ +// All prisons are marked as active. All users from an active prison can see and +// navigate to CAS2 on their DPS landing page. +// https://dsdmoj.atlassian.net/wiki/spaces/NDSS/pages/4616488213/Publishing+the+deployment+scope+of+a+product +export default ['***'] diff --git a/server/utils/applicationUtils.test.ts b/server/utils/applicationUtils.test.ts new file mode 100644 index 0000000..2f34fd5 --- /dev/null +++ b/server/utils/applicationUtils.test.ts @@ -0,0 +1,409 @@ +import { QuestionAndAnswer } from '@approved-premises/ui' + +import { applicationFactory, applicationSummaryFactory } from '../testutils/factories' + +import { + documentSummaryListRows, + inProgressApplicationTableRows, + submittedApplicationTableRows, + assessmentsTableRows, + getStatusTag, + prisonDashboardTableRows, + hasOasys, + arePreTaskListTasksIncomplete, +} from './applicationUtils' +import submittedApplicationSummary from '../testutils/factories/submittedApplicationSummary' + +describe('inProgressApplicationTableRows', () => { + it('returns an array of applications as table rows', async () => { + const applicationA = applicationSummaryFactory.build({ personName: 'A', createdAt: '2022-11-10T21:47:28Z' }) + const applicationB = applicationSummaryFactory.build({ personName: 'B', createdAt: '2022-11-11T21:47:28Z' }) + + const result = inProgressApplicationTableRows([applicationA, applicationB]) + + expect(result).toEqual([ + [ + { + html: `<a href=/applications/${applicationA.id} data-cy-id="${applicationA.id}">A</a>`, + }, + { + text: applicationA.nomsNumber, + }, + { + text: applicationA.crn, + }, + { + text: '10 November 2022', + }, + { + html: `<a id="cancel-${applicationA.id}" href=/applications/${applicationA.id}/cancel>Cancel</a>`, + }, + ], + [ + { + html: `<a href=/applications/${applicationB.id} data-cy-id="${applicationB.id}">B</a>`, + }, + { + text: applicationB.nomsNumber, + }, + { + text: applicationB.crn, + }, + { + text: '11 November 2022', + }, + { + html: `<a id="cancel-${applicationB.id}" href=/applications/${applicationB.id}/cancel>Cancel</a>`, + }, + ], + ]) + }) +}) + +describe('submittedApplicationTableRows', () => { + it('returns an array of applications as table rows', async () => { + const applicationA = applicationSummaryFactory.build({ personName: 'A', submittedAt: '2022-12-10T21:47:28Z' }) + const applicationB = applicationSummaryFactory.build({ personName: 'B', submittedAt: '2022-12-11T21:47:28Z' }) + + const result = submittedApplicationTableRows([applicationA, applicationB]) + + expect(result).toEqual([ + [ + { + html: `<a href=/applications/${applicationA.id}/overview data-cy-id="${applicationA.id}">A</a>`, + }, + { + text: applicationA.nomsNumber, + }, + { + text: applicationA.crn, + }, + { + text: '10 December 2022', + }, + { + html: '<strong class="govuk-tag govuk-tag--light-blue">More information requested</strong>', + }, + ], + [ + { + html: `<a href=/applications/${applicationB.id}/overview data-cy-id="${applicationB.id}">B</a>`, + }, + { + text: applicationB.nomsNumber, + }, + { + text: applicationB.crn, + }, + { + text: '11 December 2022', + }, + { + html: '<strong class="govuk-tag govuk-tag--light-blue">More information requested</strong>', + }, + ], + ]) + }) +}) + +describe('prisonDashboardTableRows', () => { + it('returns an array of applications as table rows', async () => { + const applicationA = applicationSummaryFactory.build({ + personName: 'A', + hdcEligibilityDate: '2024-12-10T21:47:28Z', + }) + const applicationB = applicationSummaryFactory.build({ + personName: 'B', + hdcEligibilityDate: '2024-12-11T21:47:28Z', + }) + + const result = prisonDashboardTableRows([applicationA, applicationB]) + + expect(result).toEqual([ + [ + { + html: `<a href=/applications/${applicationA.id}/overview data-cy-id="${applicationA.id}">A</a>`, + }, + { + text: applicationA.nomsNumber, + }, + { + text: applicationA.createdByUserName, + }, + { + text: '10 December 2024', + }, + { + html: '<strong class="govuk-tag govuk-tag--light-blue">More information requested</strong>', + }, + ], + [ + { + html: `<a href=/applications/${applicationB.id}/overview data-cy-id="${applicationB.id}">B</a>`, + }, + { + text: applicationB.nomsNumber, + }, + { + text: applicationB.createdByUserName, + }, + { + text: '11 December 2024', + }, + { + html: '<strong class="govuk-tag govuk-tag--light-blue">More information requested</strong>', + }, + ], + ]) + }) + + it('returns null for hdcEligibilityDate if it is undefined', async () => { + const applicationA = applicationSummaryFactory.build({ + personName: 'A', + hdcEligibilityDate: null, + }) + const applicationB = applicationSummaryFactory.build({ + personName: 'B', + hdcEligibilityDate: '2024-12-11T21:47:28Z', + }) + + const result = prisonDashboardTableRows([applicationA, applicationB]) + + expect(result).toEqual([ + [ + { + html: `<a href=/applications/${applicationA.id}/overview data-cy-id="${applicationA.id}">A</a>`, + }, + { + text: applicationA.nomsNumber, + }, + { + text: applicationA.createdByUserName, + }, + null, + { + html: '<strong class="govuk-tag govuk-tag--light-blue">More information requested</strong>', + }, + ], + [ + { + html: `<a href=/applications/${applicationB.id}/overview data-cy-id="${applicationB.id}">B</a>`, + }, + { + text: applicationB.nomsNumber, + }, + { + text: applicationB.createdByUserName, + }, + { + text: '11 December 2024', + }, + { + html: '<strong class="govuk-tag govuk-tag--light-blue">More information requested</strong>', + }, + ], + ]) + }) +}) + +describe('assessmentsTableRows', () => { + it('returns an array of applications as table rows', async () => { + const applicationA = submittedApplicationSummary.build({ + submittedAt: '2022-12-10T21:47:28Z', + personName: 'A', + }) + const applicationB = submittedApplicationSummary.build({ + submittedAt: '2022-12-11T21:47:28Z', + personName: 'B', + }) + + const result = assessmentsTableRows([applicationA, applicationB]) + + expect(result).toEqual([ + [ + { + html: `<a href=/assess/applications/${applicationA.id}/overview data-cy-id="${applicationA.id}">A</a>`, + }, + { + text: applicationA.nomsNumber, + }, + { + text: applicationA.crn, + }, + { + text: '10 December 2022', + }, + ], + [ + { + html: `<a href=/assess/applications/${applicationB.id}/overview data-cy-id="${applicationB.id}">B</a>`, + }, + { + text: applicationB.nomsNumber, + }, + { + text: applicationB.crn, + }, + { + text: '11 December 2022', + }, + ], + ]) + }) +}) + +describe('documentSummaryListRows', () => { + it('returns an array of summary list rows', () => { + const questionsAndAnswers: Array<QuestionAndAnswer> = [ + { question: 'Question 1', answer: 'Answer 1' }, + { question: 'Question 2', answer: 'Answer 2' }, + ] + const rows = documentSummaryListRows(questionsAndAnswers) + expect(rows).toEqual([ + { + key: { html: 'Question 1' }, + value: { html: 'Answer 1' }, + }, + { + key: { html: 'Question 2' }, + value: { html: 'Answer 2' }, + }, + ]) + }) + + describe('getStatusTag', () => { + it('returns the correct HTML string', () => { + const expected = `<strong class="govuk-tag govuk-tag--light-blue">More information requested</strong>` + expect(getStatusTag('More information requested', 'f5cd423b-08eb-4efb-96ff-5cc6bb073905')).toEqual(expected) + }) + + it('returns the Received string if status is undefined', () => { + const expected = `<strong class="govuk-tag govuk-tag--grey">Received</strong>` + expect(getStatusTag(undefined, undefined)).toEqual(expected) + }) + }) + + describe('hasOasys', () => { + it('returns true when there is an oasys import date', () => { + const application = applicationFactory.build({ + data: { + 'risk-to-self': { + 'oasys-import': { + oasysImportedDate: '2023-09-21T15:47:51.430Z', + oasysStartedDate: '2023-09-10', + oasysCompletedDate: '2023-09-11', + }, + }, + }, + }) + + expect(hasOasys(application, 'risk-to-self')).toEqual(true) + }) + + it('returns true when there is an old oasys', () => { + const application = applicationFactory.build({ + data: { + 'risk-of-serious-harm': { + 'old-oasys': { + hasOldOasys: 'yes', + oasysCompletedDate: '2023-09-11', + }, + }, + }, + }) + + expect(hasOasys(application, 'risk-of-serious-harm')).toEqual(true) + }) + + it('returns false when there is no import date or old oasys', () => { + const application = applicationFactory.build({ + data: { + 'risk-of-serious-harm': { + 'old-oasys': { + hasOldOasys: 'no', + oasysCompletedDate: '2023-09-11', + }, + }, + }, + }) + + expect(hasOasys(application, 'risk-of-serious-harm')).toEqual(false) + }) + }) +}) + +describe('arePreTaskListTasksIncomplete', () => { + it('returns false if all there is data for all three tasks', () => { + const application = applicationFactory.build({ + data: { + 'confirm-eligibility': { + 'confirm-eligibility': { + isEligible: 'yes', + }, + }, + 'confirm-consent': { + 'confirm-consent': { + hasGivenConsent: 'yes', + consentDate: '2023-01-01', + 'consentDate-year': '2023', + 'consentDate-month': '1', + 'consentDate-day': '1', + }, + }, + 'hdc-licence-dates': { + 'hdc-licence-dates': { + hdcEligibilityDate: '2026-02-22', + 'hdcEligibilityDate-year': '2026', + 'hdcEligibilityDate-month': '2', + 'hdcEligibilityDate-day': '22', + conditionalReleaseDate: '2026-03-28', + 'conditionalReleaseDate-year': '2026', + 'conditionalReleaseDate-month': '3', + 'conditionalReleaseDate-day': '28', + }, + 'hdc-warning': {}, + 'hdc-ineligible': {}, + }, + }, + }) + + expect(arePreTaskListTasksIncomplete(application)).toEqual(false) + }) + + it('returns true if all there is data for none of the tasks', () => { + const application = applicationFactory.build({ + data: { + 'referrer-details': { + 'confirm-details': { name: 'Eric Dier', email: 'eric.dier@moj.gov.uk' }, + 'job-title': { jobTitle: 'POM' }, + 'contact-number': { telephone: '1234567' }, + }, + }, + }) + + expect(arePreTaskListTasksIncomplete(application)).toEqual(true) + }) + + it('returns true if all there is data for some of the tasks', () => { + const application = applicationFactory.build({ + data: { + 'confirm-eligibility': { + 'confirm-eligibility': { + isEligible: 'yes', + }, + }, + 'confirm-consent': { + 'confirm-consent': { + hasGivenConsent: 'yes', + consentDate: '2023-01-01', + 'consentDate-year': '2023', + 'consentDate-month': '1', + 'consentDate-day': '1', + }, + }, + }, + }) + + expect(arePreTaskListTasksIncomplete(application)).toEqual(true) + }) +}) diff --git a/server/utils/applicationUtils.ts b/server/utils/applicationUtils.ts new file mode 100644 index 0000000..485cad7 --- /dev/null +++ b/server/utils/applicationUtils.ts @@ -0,0 +1,147 @@ +import type { Cas2SubmittedApplicationSummary, Cas2ApplicationSummary, Cas2Application } from '@approved-premises/api' +import type { QuestionAndAnswer, TableRow } from '@approved-premises/ui' +import applyPaths from '../paths/apply' +import assessPaths from '../paths/assess' +import { DateFormats } from './dateUtils' +import { formatLines } from './viewUtils' + +export const inProgressApplicationTableRows = (applications: Array<Cas2ApplicationSummary>): Array<TableRow> => { + return applications.map(application => { + return [ + nameAnchorElement(application.personName, application.id, false, true), + textValue(application.nomsNumber), + textValue(application.crn), + textValue(DateFormats.isoDateToUIDate(application.createdAt, { format: 'medium' })), + cancelAnchorElement(application.id), + ] + }) +} + +export const submittedApplicationTableRows = ( + applications: Array<Cas2ApplicationSummary>, + isAssessPath: boolean = false, +): Array<TableRow> => { + return applications.map(application => { + return [ + nameAnchorElement(application.personName, application.id, isAssessPath), + textValue(application.nomsNumber), + textValue(application.crn), + textValue(DateFormats.isoDateToUIDate(application.submittedAt, { format: 'medium' })), + htmlValue(getStatusTag(application.latestStatusUpdate?.label, application.latestStatusUpdate?.statusId)), + ] + }) +} + +export const prisonDashboardTableRows = (applications: Array<Cas2ApplicationSummary>): Array<TableRow> => { + return applications.map(application => { + return [ + nameAnchorElement(application.personName, application.id), + textValue(application.nomsNumber), + textValue(application.createdByUserName), + application.hdcEligibilityDate + ? textValue(DateFormats.isoDateToUIDate(application.hdcEligibilityDate, { format: 'medium' })) + : null, + htmlValue(getStatusTag(application.latestStatusUpdate?.label, application.latestStatusUpdate?.statusId)), + ] + }) +} + +export const assessmentsTableRows = (applications: Array<Cas2SubmittedApplicationSummary>): Array<TableRow> => { + return applications.map(application => { + return [ + nameAnchorElement(application.personName, application.id, true), + textValue(application.nomsNumber), + textValue(application.crn), + textValue(DateFormats.isoDateToUIDate(application.submittedAt, { format: 'medium' })), + ] + }) +} + +export const documentSummaryListRows = (questionsAndAnswers: Array<QuestionAndAnswer>) => { + return questionsAndAnswers.map(question => { + return { + key: { + html: question.question, + }, + value: { + html: formatLines(question.answer), + }, + } + }) +} + +const htmlValue = (value: string) => { + return { html: value } +} + +const nameAnchorElement = ( + name: string, + applicationId: string, + isAssessPath: boolean = false, + inProgress: boolean = false, +) => { + let href = '' + if (inProgress) { + href = applyPaths.applications.show({ id: applicationId }) + } else if (isAssessPath) { + href = assessPaths.submittedApplications.overview({ id: applicationId }) + } else { + href = applyPaths.applications.overview({ id: applicationId }) + } + return htmlValue(`<a href=${href} data-cy-id="${applicationId}">${name}</a>`) +} + +const cancelAnchorElement = (applicationId: string) => + htmlValue(`<a id="cancel-${applicationId}" href=${applyPaths.applications.cancel({ id: applicationId })}>Cancel</a>`) + +const textValue = (value: string) => { + return { text: value } +} + +export const getStatusTag = (statusLabel: string, statusId: string) => { + return `<strong class="govuk-tag govuk-tag--${getStatusTagColour(statusId)}">${statusLabel || 'Received'}</strong>` +} + +const getStatusTagColour = (statusId: string) => { + // status IDs located at https://github.com/ministryofjustice/hmpps-approved-premises-api/blob/main/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/model/reference/Cas2ApplicationStatusSeeding.kt + switch (statusId) { + case 'f5cd423b-08eb-4efb-96ff-5cc6bb073905': + return 'light-blue' + case 'ba4d8432-250b-4ab9-81ec-7eb4b16e5dd1': + return 'yellow' + case 'a919097d-b324-471c-9834-756f255e87ea': + return 'yellow' + case '176bbda0-0766-4d77-8d56-18ed8f9a4ef2': + return 'purple' + case 'fe254d88-ce1d-4cd8-8bd6-88de88f39019': + return 'green' + case '9a381bc6-22d3-41d6-804d-4e49f428c1de': + return 'orange' + case '004e2419-9614-4c1e-a207-a8418009f23d': + return 'pink' + case 'f13bbdd6-44f1-4362-b9d3-e6f1298b1bf9': + return 'pink' + case '89458555-3219-44a2-9584-c4f715d6b565': + return 'green' + default: + return 'grey' + } +} + +export const hasOasys = (application: Cas2Application, task: 'risk-to-self' | 'risk-of-serious-harm'): boolean => { + if (application.data[task]?.['oasys-import'] || application.data[task]?.['old-oasys']?.hasOldOasys === 'yes') { + return true + } + return false +} + +export const arePreTaskListTasksIncomplete = (application: Cas2Application): boolean => { + if ( + application.data?.['confirm-eligibility'] && + application.data?.['confirm-consent'] && + application.data?.['hdc-licence-dates'] + ) { + return false + } + return true +} diff --git a/server/utils/applications/deleteOrphanedData.test.ts b/server/utils/applications/deleteOrphanedData.test.ts new file mode 100644 index 0000000..6159c36 --- /dev/null +++ b/server/utils/applications/deleteOrphanedData.test.ts @@ -0,0 +1,215 @@ +import deleteOrphanedFollowOnAnswers from './deleteOrphanedData' + +describe('deleteOrphanedFollowOnAnswers', () => { + describe('funding-information', () => { + describe('when fundingSource is personalSavings', () => { + const applicationData = { + 'funding-information': { + 'funding-source': { + fundingSource: 'personalSavings', + }, + identification: { + idDocuments: 'passport', + }, + 'alternative-identification': { + alternativeIDDocuments: 'citizenCard', + }, + }, + } + + it('removes identification and alternative-identification data', () => { + expect(deleteOrphanedFollowOnAnswers(applicationData)).toEqual({ + 'funding-information': { + 'funding-source': { + fundingSource: 'personalSavings', + }, + }, + }) + }) + }) + }) + + describe('equality-and-diversity-monitoring', () => { + describe('when willAnswer is set to no', () => { + const applicationData = { + 'equality-and-diversity-monitoring': { + 'will-answer-equality-questions': { + willAnswer: 'no', + }, + disability: { + hasDisability: 'no', + }, + 'sex-and-gender': { + sex: 'female', + gender: 'yes', + }, + 'sexual-orientation': { + orientation: 'gay', + }, + 'ethnic-group': { + ethnicGroup: 'white', + }, + 'white-background': { + whiteBackground: 'english', + }, + religion: { + religion: 'atheist', + }, + 'military-veteran': { + isVeteran: 'yes', + }, + 'care-leaver': { + isCareLeaver: 'no', + }, + 'parental-carer-responsibilities': { + hasParentalOrCarerResponsibilities: 'yes', + }, + 'marital-status': { + maritalStatus: 'widowed', + }, + }, + } + + it('removes all equality and diversity data', () => { + expect(deleteOrphanedFollowOnAnswers(applicationData)).toEqual({ + 'equality-and-diversity-monitoring': { + 'will-answer-equality-questions': { + willAnswer: 'no', + }, + }, + }) + }) + }) + }) + + describe('offending-history', () => { + describe('when hasAnyPreviousConvictions is set to no', () => { + const applicationData = { + 'offending-history': { + 'any-previous-convictions': { hasAnyPreviousConvictions: 'no' }, + 'offence-history-data': [ + { + offenceGroupName: 'Arson (09000)', + offenceCategory: 'Arson', + 'offenceDate-day': '5', + 'offenceDate-month': '6', + 'offenceDate-year': '1940', + sentenceLength: '3 years', + summary: 'summary detail', + }, + { + offenceGroupName: 'Stalking (08000)', + offenceCategory: 'Stalking', + 'offenceDate-day': '6', + 'offenceDate-month': '7', + 'offenceDate-year': '2023', + sentenceLength: '2 months', + summary: 'more summary detail', + }, + ], + 'offence-history': {}, + }, + } + + it('removes offence history data', () => { + expect(deleteOrphanedFollowOnAnswers(applicationData)).toEqual({ + 'offending-history': { + 'any-previous-convictions': { hasAnyPreviousConvictions: 'no' }, + 'offence-history': {}, + }, + }) + }) + }) + + describe('when hasAnyPreviousConvictions is set to yesNoRelevantRisk', () => { + const applicationData = { + 'offending-history': { + 'any-previous-convictions': { hasAnyPreviousConvictions: 'yesNoRelevantRisk' }, + 'offence-history-data': [ + { + offenceGroupName: 'Arson (09000)', + offenceCategory: 'Arson', + 'offenceDate-day': '5', + 'offenceDate-month': '6', + 'offenceDate-year': '1940', + sentenceLength: '3 years', + summary: 'summary detail', + }, + { + offenceGroupName: 'Stalking (08000)', + offenceCategory: 'Stalking', + 'offenceDate-day': '6', + 'offenceDate-month': '7', + 'offenceDate-year': '2023', + sentenceLength: '2 months', + summary: 'more summary detail', + }, + ], + 'offence-history': {}, + }, + } + + it('removes offence history data', () => { + expect(deleteOrphanedFollowOnAnswers(applicationData)).toEqual({ + 'offending-history': { + 'any-previous-convictions': { hasAnyPreviousConvictions: 'yesNoRelevantRisk' }, + 'offence-history': {}, + }, + }) + }) + }) + }) + + describe('address-history', () => { + describe('when hasPreviousAddresses is set to no', () => { + const applicationData = { + 'address-history': { + 'previous-address': { + hasPreviousAddress: 'no', + previousAddressLine1: '1 Example Road', + previousAddressLine2: 'Pretend Close', + previousTownOrCity: 'Aberdeen', + previousCounty: 'Gloucestershire', + previousPostcode: 'AB1 2CD', + }, + }, + } + + it('removes previous address history data', () => { + expect(deleteOrphanedFollowOnAnswers(applicationData)).toEqual({ + 'address-history': { + 'previous-address': { + hasPreviousAddress: 'no', + }, + }, + }) + }) + }) + + describe('when hasPreviousAddresses is set to yes', () => { + const applicationData = { + 'address-history': { + 'previous-address': { + hasPreviousAddress: 'yes', + howLong: '6 months', + lastKnownAddressLine1: '1 Example Road', + lastKnownAddressLine2: 'Pretend Close', + lastKnownTownOrCity: 'Aberdeen', + lastKnownCounty: 'Gloucestershire', + lastKnownPostcode: 'AB1 2CD', + }, + }, + } + + it('removes last known address history data', () => { + expect(deleteOrphanedFollowOnAnswers(applicationData)).toEqual({ + 'address-history': { + 'previous-address': { + hasPreviousAddress: 'yes', + }, + }, + }) + }) + }) + }) +}) diff --git a/server/utils/applications/deleteOrphanedData.ts b/server/utils/applications/deleteOrphanedData.ts new file mode 100644 index 0000000..11c087b --- /dev/null +++ b/server/utils/applications/deleteOrphanedData.ts @@ -0,0 +1,94 @@ +import { AnyValue } from '@approved-premises/api' +import { lastKnownKeys, previousKeys } from '../../form-pages/apply/about-the-person/address-history/previousAddress' +import { PreviousConvictionsAnswers } from '../../form-pages/apply/offence-and-licence-information/offending-history/anyPreviousConvictions' + +export default function deleteOrphanedFollowOnAnswers(applicationData: AnyValue): AnyValue { + const deleteOrphanedFundingInformation = () => { + delete applicationData['funding-information'].identification + delete applicationData['funding-information']['alternative-identification'] + } + + const deleteOrphanedEqualityInformation = () => { + Object.keys(applicationData['equality-and-diversity-monitoring']).forEach(key => { + if (key !== 'will-answer-equality-questions') { + delete applicationData['equality-and-diversity-monitoring'][key] + } + }) + } + + const deleteOrphanedOffendingHistoryInformation = () => { + delete applicationData['offending-history']['offence-history-data'] + } + + const deleteAddressHistoryInformation = () => { + if (applicationData['address-history']['previous-address'].hasPreviousAddress === 'yes') { + lastKnownKeys.forEach(key => delete applicationData['address-history']['previous-address'][key]) + } else if (applicationData['address-history']['previous-address'].hasPreviousAddress === 'no') { + previousKeys.forEach(key => delete applicationData['address-history']['previous-address'][key]) + } + } + + const hasOrphanedInformation = ({ + taskName, + pageName, + questionKey, + answerToCheck, + }: { + taskName: string + pageName: string + questionKey: string + answerToCheck: string + }) => { + return applicationData[taskName]?.[pageName]?.[questionKey] === answerToCheck + } + + if ( + hasOrphanedInformation({ + taskName: 'funding-information', + pageName: 'funding-source', + questionKey: 'fundingSource', + answerToCheck: 'personalSavings', + }) + ) { + deleteOrphanedFundingInformation() + } + + if ( + hasOrphanedInformation({ + taskName: 'equality-and-diversity-monitoring', + pageName: 'will-answer-equality-questions', + questionKey: 'willAnswer', + answerToCheck: 'no', + }) + ) { + deleteOrphanedEqualityInformation() + } + + if ( + hasOrphanedInformation({ + taskName: 'offending-history', + pageName: 'any-previous-convictions', + questionKey: 'hasAnyPreviousConvictions', + answerToCheck: PreviousConvictionsAnswers.No, + }) + ) { + deleteOrphanedOffendingHistoryInformation() + } + + if ( + hasOrphanedInformation({ + taskName: 'offending-history', + pageName: 'any-previous-convictions', + questionKey: 'hasAnyPreviousConvictions', + answerToCheck: PreviousConvictionsAnswers.YesNoRelevantRisk, + }) + ) { + deleteOrphanedOffendingHistoryInformation() + } + + if (applicationData['address-history']?.['previous-address']?.hasPreviousAddress) { + deleteAddressHistoryInformation() + } + + return applicationData +} diff --git a/server/utils/applications/documentUtils.test.ts b/server/utils/applications/documentUtils.test.ts new file mode 100644 index 0000000..060657d --- /dev/null +++ b/server/utils/applications/documentUtils.test.ts @@ -0,0 +1,59 @@ +import { buildDocument } from './documentUtils' +import { applicationFactory } from '../../testutils/factories' +import { getSections, getTaskAnswersAsSummaryListItems } from '../checkYourAnswersUtils' + +jest.mock('../checkYourAnswersUtils') + +const mockQuestionData = { + question: 'Question', + answer: 'Answer', +} + +const mockSectionData = { + title: 'Section', + name: 'section', + tasks: [{ title: 'Task' }], +} + +describe('documentUtils', () => { + describe('buildDocument', () => { + it('returns a correctly structured application document', () => { + ;(getTaskAnswersAsSummaryListItems as jest.Mock).mockReturnValue([mockQuestionData]) + ;(getSections as jest.Mock).mockReturnValue([mockSectionData]) + + const application = applicationFactory.build({ + data: { + 'equality-and-diversity-monitoring': { + 'will-answer-equality-questions': { + willAnswer: 'yes', + }, + disability: { + hasDisability: 'no', + }, + }, + }, + }) + + const expected = { + sections: [ + { + title: 'Section', + tasks: [ + { + title: 'Task', + questionsAndAnswers: [ + { + question: 'Question', + answer: 'Answer', + }, + ], + }, + ], + }, + ], + } + + expect(buildDocument(application)).toEqual(expected) + }) + }) +}) diff --git a/server/utils/applications/documentUtils.ts b/server/utils/applications/documentUtils.ts new file mode 100644 index 0000000..948a2e8 --- /dev/null +++ b/server/utils/applications/documentUtils.ts @@ -0,0 +1,23 @@ +import { ApplicationDocument, QuestionAndAnswer } from '@approved-premises/ui' +import { getSections, getTaskAnswersAsSummaryListItems } from '../checkYourAnswersUtils' +import { Cas2Application as Application } from '../../@types/shared' + +export const buildDocument = (application: Application): ApplicationDocument => { + return { + sections: getSections().map(section => { + return { + title: section.title, + tasks: section.tasks.map(task => { + return { + title: task.title, + questionsAndAnswers: getTaskAnswersAsSummaryListItems( + task.id, + application, + 'document', + ) as Array<QuestionAndAnswer>, + } + }), + } + }), + } +} diff --git a/server/utils/applications/getApplicationData.test.ts b/server/utils/applications/getApplicationData.test.ts new file mode 100644 index 0000000..ad58097 --- /dev/null +++ b/server/utils/applications/getApplicationData.test.ts @@ -0,0 +1,28 @@ +import { applicationFactory } from '../../testutils/factories' +import applicationDataJson from '../../../integration_tests/fixtures/applicationData.json' +import { getApplicationSubmissionData, getApplicationUpdateData } from './getApplicationData' + +describe('getApplicationUpdateData', () => { + it('returns the application data', () => { + const mockApplication = applicationFactory.build() + expect(getApplicationUpdateData(mockApplication)).toEqual({ + type: 'CAS2', + data: mockApplication.data, + }) + }) +}) + +describe('getApplicationSubmissionData', () => { + it('returns the submission data', () => { + const mockApplication = applicationFactory.build({ data: applicationDataJson }) + + expect(getApplicationSubmissionData(mockApplication)).toEqual({ + applicationId: mockApplication.id, + translatedDocument: mockApplication.document, + preferredAreas: 'London | Birmingham', + hdcEligibilityDate: '2026-02-22', + conditionalReleaseDate: '2026-03-28', + telephoneNumber: '1234567', + }) + }) +}) diff --git a/server/utils/applications/getApplicationData.ts b/server/utils/applications/getApplicationData.ts new file mode 100644 index 0000000..8974d1f --- /dev/null +++ b/server/utils/applications/getApplicationData.ts @@ -0,0 +1,26 @@ +import { Cas2Application as Application, SubmitCas2Application, UpdateApplication } from '@approved-premises/api' + +import { + preferredAreasFromAppData, + hdcEligibilityDateFromAppData, + conditionalReleaseDateFromAppData, + telephoneNumberFromAppData, +} from './managementInfoFromAppData' + +export const getApplicationUpdateData = (application: Application): UpdateApplication => { + return { + type: 'CAS2', + data: application.data, + } +} + +export const getApplicationSubmissionData = (application: Application): SubmitCas2Application => { + return { + translatedDocument: application.document, + applicationId: application.id, + preferredAreas: preferredAreasFromAppData(application), + hdcEligibilityDate: hdcEligibilityDateFromAppData(application), + conditionalReleaseDate: conditionalReleaseDateFromAppData(application), + telephoneNumber: telephoneNumberFromAppData(application), + } +} diff --git a/server/utils/applications/getPage.test.ts b/server/utils/applications/getPage.test.ts new file mode 100644 index 0000000..fef0002 --- /dev/null +++ b/server/utils/applications/getPage.test.ts @@ -0,0 +1,68 @@ +import Apply from '../../form-pages/apply' +import { UnknownPageError } from '../errors' +import { getPage } from './getPage' + +const FirstApplyPage = jest.fn() +const SecondApplyPage = jest.fn() + +const applySection1Task1 = { + id: 'first-apply-section-task-1', + title: 'First Apply section, task 1', + actionText: '', + pages: { + first: FirstApplyPage, + second: SecondApplyPage, + }, +} +const applySection1Task2 = { + id: 'first-apply-section-task-2', + title: 'First Apply section, task 2', + actionText: '', + pages: {}, +} + +const applySection2Task1 = { + id: 'second-apply-section-task-1', + title: 'Second Apply section, task 1', + actionText: '', + pages: {}, +} + +const applySection2Task2 = { + id: 'second-apply-section-task-2', + title: 'Second Apply section, task 2', + actionText: '', + pages: {}, +} + +const applySection1 = { + name: 'first-apply-section', + title: 'First Apply section', + tasks: [applySection1Task1, applySection1Task2], +} + +const applySection2 = { + name: 'second-apply-section', + title: 'Second Apply section', + tasks: [applySection2Task1, applySection2Task2], +} + +Apply.sections = [applySection1, applySection2] + +Apply.pages['first-apply-section-task-1' as keyof typeof Apply.pages] = { + first: FirstApplyPage, + second: SecondApplyPage, +} + +describe('getPage', () => { + it('should return a page from Apply if it exists', () => { + expect(getPage('first-apply-section-task-1', 'first', 'applications')).toEqual(FirstApplyPage) + expect(getPage('first-apply-section-task-1', 'second', 'applications')).toEqual(SecondApplyPage) + }) + + it('should raise an error if the page is not found', async () => { + expect(() => { + getPage('funding-information', 'bar', 'applications') + }).toThrow(UnknownPageError) + }) +}) diff --git a/server/utils/applications/getPage.ts b/server/utils/applications/getPage.ts new file mode 100644 index 0000000..842850c --- /dev/null +++ b/server/utils/applications/getPage.ts @@ -0,0 +1,15 @@ +import { JourneyType, FormPages } from '../../@types/ui' +import { TaskListPageInterface } from '../../form-pages/taskListPage' +import { UnknownPageError } from '../errors' +import { journeyPages } from './utils' + +export const getPage = (taskName: string, pageName: string, journeyType: JourneyType): TaskListPageInterface => { + const pageList = journeyPages(journeyType)[taskName as keyof FormPages] + const Page = pageList[pageName] + + if (!Page) { + throw new UnknownPageError(pageName) + } + + return Page as TaskListPageInterface +} diff --git a/server/utils/applications/managementInfoFromAppData.test.ts b/server/utils/applications/managementInfoFromAppData.test.ts new file mode 100644 index 0000000..accb177 --- /dev/null +++ b/server/utils/applications/managementInfoFromAppData.test.ts @@ -0,0 +1,156 @@ +import { + preferredAreasFromAppData, + hdcEligibilityDateFromAppData, + conditionalReleaseDateFromAppData, + telephoneNumberFromAppData, +} from './managementInfoFromAppData' + +import { applicationFactory } from '../../testutils/factories' + +describe('managementInfoFromAppData', () => { + describe('preferredAreasFromAppData', () => { + it('concatenates the first and second choices into a pipe-delimited string', () => { + const application = applicationFactory.build({ + data: { + 'area-information': { + 'first-preferred-area': { preferredArea: 'Bradford' }, + 'second-preferred-area': { preferredArea: 'Leeds' }, + }, + }, + }) + expect(preferredAreasFromAppData(application)).toEqual('Bradford | Leeds') + }) + + const noAreasData = [ + { + 'area-information': null, + }, + { + 'area-information': { + 'first-preferred-area': null, + 'second-preferred-area': null, + }, + }, + { + 'area-information': { + 'first-preferred-area': { preferredArea: '' }, + 'second-preferred-area': { preferredArea: '' }, + }, + }, + {}, + null, + ] + + it.each(noAreasData)('returns an empty string when no areas are specified', data => { + const application = applicationFactory.build({ + data, + }) + expect(preferredAreasFromAppData(application)).toEqual('') + }) + + it('does not include a pipe when only one area is specified', () => { + const application = applicationFactory.build({ + data: { + 'area-information': { + 'first-preferred-area': { preferredArea: 'Bradford' }, + 'second-preferred-area': { preferredArea: '' }, + }, + }, + }) + expect(preferredAreasFromAppData(application)).toEqual('Bradford') + }) + }) + + describe('hdcEligibilityDateFromAppData', () => { + it('returns the given date', () => { + const application = applicationFactory.build({ + data: { + 'hdc-licence-dates': { + 'hdc-licence-dates': { hdcEligibilityDate: '2024-02-27' }, + }, + }, + }) + expect(hdcEligibilityDateFromAppData(application)).toEqual('2024-02-27') + }) + + const noDateData = [ + { + 'hdc-licence-dates': null, + }, + { + 'hdc-licence-dates': { 'hdc-licence-dates': null }, + }, + {}, + null, + ] + + it.each(noDateData)('returns null if no date is given', data => { + const application = applicationFactory.build({ + data, + }) + expect(hdcEligibilityDateFromAppData(application)).toEqual(null) + }) + }) + + describe('conditionalReleaseDateFromAppData', () => { + it('returns the given date', () => { + const application = applicationFactory.build({ + data: { + 'hdc-licence-dates': { + 'hdc-licence-dates': { conditionalReleaseDate: '2024-03-15' }, + }, + }, + }) + expect(conditionalReleaseDateFromAppData(application)).toEqual('2024-03-15') + }) + + const noDateData = [ + { + 'hdc-licence-dates': null, + }, + { + 'hdc-licence-dates': { 'hdc-licence-dates': null }, + }, + {}, + null, + ] + + it.each(noDateData)('returns null if no date is given', data => { + const application = applicationFactory.build({ + data, + }) + expect(conditionalReleaseDateFromAppData(application)).toEqual(null) + }) + }) + + describe('telephoneNumberFromAppData', () => { + it('returns the given contact number', () => { + const application = applicationFactory.build({ + data: { + 'referrer-details': { + 'contact-number': { telephone: '0800 123' }, + }, + }, + }) + expect(telephoneNumberFromAppData(application)).toEqual('0800 123') + }) + + const noDateData = [ + { + 'referrer-details': null, + }, + { + 'referrer-details': { 'contact-number': null }, + }, + {}, + null, + ] + + it.each(noDateData)('returns null if no contact number is given', data => { + const application = applicationFactory.build({ + data, + }) + expect(telephoneNumberFromAppData(application)).toEqual(null) + }) + }) +}) diff --git a/server/utils/applications/managementInfoFromAppData.ts b/server/utils/applications/managementInfoFromAppData.ts new file mode 100644 index 0000000..464be3e --- /dev/null +++ b/server/utils/applications/managementInfoFromAppData.ts @@ -0,0 +1,47 @@ +import { Cas2Application as Application } from '@approved-premises/api' + +const preferredAreasFromAppData = (application: Application): string => { + // @ts-expect-error Requires refactor to satisfy TS7053 + const firstPreference: string = (application.data as Record<string, unknown>)?.['area-information']?.[ + 'first-preferred-area' + ]?.preferredArea + + // @ts-expect-error Requires refactor to satisfy TS7053 + const secondPreference: string = (application.data as Record<string, unknown>)?.['area-information']?.[ + 'second-preferred-area' + ]?.preferredArea + + return [firstPreference, secondPreference].filter(x => x).join(' | ') +} + +const hdcEligibilityDateFromAppData = (application: Application): string => { + // @ts-expect-error Requires refactor to satisfy TS7053 + const date: string = (application.data as Record<string, unknown>)?.['hdc-licence-dates']?.['hdc-licence-dates'] + ?.hdcEligibilityDate + + return date || null +} + +const conditionalReleaseDateFromAppData = (application: Application): string => { + // @ts-expect-error Requires refactor to satisfy TS7053 + const date: string = (application.data as Record<string, unknown>)?.['hdc-licence-dates']?.['hdc-licence-dates'] + ?.conditionalReleaseDate + + return date || null +} + +const telephoneNumberFromAppData = (application: Application): string | null => { + // @ts-expect-error Requires refactor to satisfy TS7053 + const telephoneNumber: string = (application.data as Record<string, unknown>)?.['referrer-details']?.[ + 'contact-number' + ]?.telephone + + return telephoneNumber || null +} + +export { + preferredAreasFromAppData, + hdcEligibilityDateFromAppData, + conditionalReleaseDateFromAppData, + telephoneNumberFromAppData, +} diff --git a/server/utils/applications/utils.test.ts b/server/utils/applications/utils.test.ts new file mode 100644 index 0000000..91648d5 --- /dev/null +++ b/server/utils/applications/utils.test.ts @@ -0,0 +1,449 @@ +import { isAfter } from 'date-fns' +import { DeepMocked, createMock } from '@golevelup/ts-jest' +import type { Request, Response } from 'express' +import { applicationFactory, submittedApplicationFactory, timelineEventsFactory } from '../../testutils/factories' +import { + eligibilityQuestionIsAnswered, + getApplicationTimelineEvents, + getSideNavLinksForDocument, + getSideNavLinksForApplication, + showMissingRequiredTasksOrTaskList, +} from './utils' +import { fetchErrorsAndUserInput } from '../validation' +import { DateFormats } from '../dateUtils' +import { getSections } from '../checkYourAnswersUtils' +import config from '../../config' +import paths from '../../paths/apply' +import { TaskListService } from '../../services' +import { formatLines, validateReferer } from '../viewUtils' + +jest.mock('../../services/taskListService') +jest.mock('../checkYourAnswersUtils') +jest.mock('../../utils/validation') +jest.mock('../../utils/viewUtils') + +const mockSections = [ + { + title: 'Section 1', + tasks: [ + { + title: 'Task 1', + questionsAndAnswers: [ + { + question: 'a question', + answer: 'an answer', + }, + ], + }, + ], + }, + { + title: 'Section 2', + tasks: [ + { + title: 'Task 2', + questionsAndAnswers: [ + { + question: 'a question', + answer: 'an answer', + }, + ], + }, + ], + }, +] + +describe('utils', () => { + describe('eligibilityQuestionIsAnswered', () => { + describe('when the isEligible property is _yes_', () => { + it('returns true', async () => { + const application = applicationFactory.build({ + data: { + 'confirm-eligibility': { + 'confirm-eligibility': { isEligible: 'yes' }, + }, + }, + }) + + expect(eligibilityQuestionIsAnswered(application)).toEqual(true) + }) + }) + + describe('when the isEligible property is _no_', () => { + it('returns true', async () => { + const application = applicationFactory.build({ + data: { + 'confirm-eligibility': { + 'confirm-eligibility': { isEligible: 'no' }, + }, + }, + }) + + expect(eligibilityQuestionIsAnswered(application)).toEqual(true) + }) + }) + + describe('when the isEligible property is something else', () => { + it('returns false', async () => { + const application = applicationFactory.build({ + data: { + 'confirm-eligibility': { + 'confirm-eligibility': { isEligible: 'something else' }, + }, + }, + }) + + expect(eligibilityQuestionIsAnswered(application)).toEqual(false) + }) + }) + + describe('when the isEligible property is missing', () => { + it('returns false', async () => { + const application = applicationFactory.build({ + data: {}, + }) + + expect(eligibilityQuestionIsAnswered(application)).toEqual(false) + }) + + it('returns false', async () => { + const application = applicationFactory.build({ + data: null, + }) + + expect(eligibilityQuestionIsAnswered(application)).toEqual(false) + }) + }) + }) + describe('getApplicationTimelineEvents', () => { + const priorConfigFlags = config.flags + + afterAll(() => { + config.flags = priorConfigFlags + }) + + describe('when there are timeline events', () => { + it('returns them in the timeline events', () => { + const application = submittedApplicationFactory.build({ + timelineEvents: [ + timelineEventsFactory.build({ + label: 'a status update', + body: 'the status description', + createdByName: 'A Nacro', + occurredAt: '2023-06-22T08:54:50', + }), + timelineEventsFactory.build({ + type: 'cas2_application_submitted', + label: 'Application submitted', + body: 'The application was received by an assessor.', + createdByName: 'Anne Nomis', + occurredAt: '2023-06-21T07:54:50', + }), + ], + }) + + ;(formatLines as jest.MockedFunction<typeof formatLines>).mockReturnValue( + 'The application was received by an assessor.', + ) + + expect(getApplicationTimelineEvents(application)).toEqual([ + { + byline: { + text: 'A Nacro', + }, + datetime: { + date: '22 June 2023 at 08:54am', + timestamp: '2023-06-22T08:54:50', + }, + description: { + text: 'the status description', + }, + label: { + text: 'a status update', + }, + }, + { + byline: { + text: 'Anne Nomis', + }, + datetime: { + date: '21 June 2023 at 07:54am', + timestamp: '2023-06-21T07:54:50', + }, + description: { + text: 'The application was received by an assessor.', + }, + label: { + text: 'Application submitted', + }, + }, + ]) + }) + + it('sorts the events in ascending order', () => { + const application = submittedApplicationFactory.build({ + timelineEvents: [ + timelineEventsFactory.build({ + label: 'a status update', + body: 'the status description', + createdByName: 'A Nacro', + occurredAt: '2023-06-22T08:54:50', + }), + timelineEventsFactory.build({ + label: 'a status update', + body: 'the status description', + createdByName: 'A Nacro', + occurredAt: '2023-06-27T08:54:50', + }), + timelineEventsFactory.build({ + label: 'a status update', + body: 'the status description', + createdByName: 'A Nacro', + occurredAt: '2023-06-20T08:54:50', + }), + timelineEventsFactory.build({ + type: 'cas2_application_submitted', + label: 'Application submitted', + body: 'The application was received by an assessor.', + createdByName: 'Anne Nomis', + occurredAt: '2023-06-19T07:54:50', + }), + ], + }) + + const actual = getApplicationTimelineEvents(application) + + expect( + isAfter( + DateFormats.isoToDateObj(actual[0].datetime.timestamp), + DateFormats.isoToDateObj(actual[1].datetime.timestamp), + ), + ).toEqual(true) + + expect( + isAfter( + DateFormats.isoToDateObj(actual[0].datetime.timestamp), + DateFormats.isoToDateObj(actual[2].datetime.timestamp), + ), + ).toEqual(true) + + expect( + isAfter( + DateFormats.isoToDateObj(actual[1].datetime.timestamp), + DateFormats.isoToDateObj(actual[2].datetime.timestamp), + ), + ).toEqual(true) + }) + + describe('when the timeline event is a status update', () => { + it('formats the description text', () => { + const application = submittedApplicationFactory.build({ + timelineEvents: [ + timelineEventsFactory.build({ + body: 'the status description, and another, and another', + }), + ], + }) + expect(getApplicationTimelineEvents(application)[0].description.text).toEqual( + 'the status description<br>and another<br>and another', + ) + }) + }) + }) + }) + + describe('getSideNavLinksForDocument', () => { + it('returns an array with a side nav item for each task', () => { + const document = { + sections: mockSections, + } + + expect(getSideNavLinksForDocument(document)).toEqual([ + { text: 'Task 1', href: '#task-1' }, + { text: 'Task 2', href: '#task-2' }, + ]) + }) + }) + + describe('getSideNavLinksForApplication', () => { + it('returns an array with a side nav item for each task', () => { + ;(getSections as jest.Mock).mockReturnValue(mockSections) + + expect(getSideNavLinksForApplication()).toEqual([ + { text: 'Task 1', href: '#task-1' }, + { text: 'Task 2', href: '#task-2' }, + ]) + }) + }) + + describe('showMissingRequiredTasksOrTaskList', () => { + const request: DeepMocked<Request> = createMock<Request>({ user: { token: 'SOME_TOKEN' } }) + const response: DeepMocked<Response> = createMock<Response>({}) + + describe('when "Confirm eligibility" task is NOT complete', () => { + it('renders "Confirm eligibility" page from the "Before you start" section', async () => { + const application = applicationFactory.build({ data: {} }) + + const actual = showMissingRequiredTasksOrTaskList(request, response, application) + + expect(actual).toEqual( + response.redirect( + paths.applications.pages.show({ + id: application.id, + task: 'confirm-eligibility', + page: 'confirm-eligibility', + }), + ), + ) + }) + }) + + describe('when "Confirm eligibility" task is complete and the candidate is INELIGIBLE', () => { + it('renders the "ineligible" page', async () => { + const application = applicationFactory.build({ + data: { + 'confirm-eligibility': { + 'confirm-eligibility': { isEligible: 'no' }, + }, + }, + }) + + const actual = showMissingRequiredTasksOrTaskList(request, response, application) + + expect(actual).toEqual(response.redirect(paths.applications.ineligible({ id: application.id }))) + }) + }) + + describe('when the person is confirmed ELIGIBLE but consent has been DENIED', () => { + it('redirects to the _consent refused_ page', async () => { + const application = applicationFactory.build({ + data: { + 'confirm-eligibility': { + 'confirm-eligibility': { isEligible: 'yes' }, + }, + 'confirm-consent': { + 'confirm-consent': { + hasGivenConsent: 'no', + consentRefusalDetail: 'some reason', + }, + }, + }, + }) + + const actual = showMissingRequiredTasksOrTaskList(request, response, application) + + expect(actual).toEqual(response.redirect(paths.applications.consentRefused({ id: application.id }))) + }) + }) + + describe('when the person is confirmed ELIGIBLE but the consent task has not been completed', () => { + it('redirects to the _confirm consent_ page', async () => { + const application = applicationFactory.build({ + data: { + 'confirm-eligibility': { + 'confirm-eligibility': { isEligible: 'yes' }, + }, + }, + }) + + const actual = showMissingRequiredTasksOrTaskList(request, response, application) + + expect(actual).toEqual( + response.redirect( + paths.applications.pages.show({ + id: application.id, + task: 'confirm-consent', + page: 'confirm-consent', + }), + ), + ) + }) + }) + describe('when the person is confirmed ELIGIBLE and consent is confirmed, but the HDC licence dates task has not been completed', () => { + it('redirects to the _HDC licence dates_ page', async () => { + const application = applicationFactory.build({ + data: { + 'confirm-eligibility': { + 'confirm-eligibility': { isEligible: 'yes' }, + }, + 'confirm-consent': { + 'confirm-consent': { + hasGivenConsent: 'yes', + consentDate: '2022-02-22', + 'consentDate-year': '2022', + 'consentDate-month': '2', + 'consentDate-day': '22', + }, + }, + 'hdc-licence-dates': { + 'hdc-licence-dates': { + hdcEligibilityDate: '2024-02-28', + 'hdcEligibilityDate-year': '2024', + 'hdcEligibilityDate-month': '2', + 'hdcEligibilityDate-day': '28', + conditionalReleaseDate: '2024-02-22', + 'conditionalReleaseDate-year': '2024', + 'conditionalReleaseDate-month': '2', + 'conditionalReleaseDate-day': '22', + }, + }, + }, + }) + + const stubTaskList = jest.fn() + ;(TaskListService as jest.Mock).mockImplementation(() => { + return stubTaskList + }) + ;(fetchErrorsAndUserInput as jest.Mock).mockImplementation(() => { + return { errors: {}, errorSummary: [], userInput: {} } + }) + ;(validateReferer as jest.MockedFunction<typeof validateReferer>).mockReturnValue('some-validated-referer') + + const actual = showMissingRequiredTasksOrTaskList(request, response, application) + + expect(actual).toEqual( + response.render('applications/taskList', { + application, + taskList: stubTaskList, + errors: {}, + errorSummary: [], + referrer: 'some-validated-referer', + }), + ) + }) + }) + + describe('when eligibility and consent have been confirmed, and HDC dates have been entered', () => { + it('redirects to the _task list_ page', async () => { + const application = applicationFactory.build({ + data: { + 'confirm-eligibility': { + 'confirm-eligibility': { isEligible: 'yes' }, + }, + 'confirm-consent': { + 'confirm-consent': { + hasGivenConsent: 'yes', + consentDate: '2022-02-22', + 'consentDate-year': '2022', + 'consentDate-month': '2', + 'consentDate-day': '22', + }, + }, + }, + }) + + const actual = showMissingRequiredTasksOrTaskList(request, response, application) + + expect(actual).toEqual( + response.redirect( + paths.applications.pages.show({ + id: application.id, + task: 'hdc-licence-dates', + page: 'hdc-licence-dates', + }), + ), + ) + }) + }) + }) +}) diff --git a/server/utils/applications/utils.ts b/server/utils/applications/utils.ts new file mode 100644 index 0000000..e4e80b0 --- /dev/null +++ b/server/utils/applications/utils.ts @@ -0,0 +1,156 @@ +import { Request, Response } from 'express' +import type { ApplicationDocument, FormPages, JourneyType, SideNavItem, UiTimelineEvent } from '@approved-premises/ui' +import type { + Cas2Application as Application, + Cas2Application, + Cas2SubmittedApplication, + Cas2TimelineEvent, +} from '@approved-premises/api' +import { getSections } from '../checkYourAnswersUtils' +import { stringToKebabCase, formatCommaToLinebreak } from '../utils' +import { formatLines, validateReferer } from '../viewUtils' +import Apply from '../../form-pages/apply' +import paths from '../../paths/apply' +import { DateFormats } from '../dateUtils' +import { fetchErrorsAndUserInput } from '../validation' +import { TaskListService } from '../../services' + +export const journeyPages = (_journeyType: JourneyType): FormPages => { + return Apply.pages +} + +export const firstPageOfBeforeYouStartSection = (application: Application) => { + return paths.applications.pages.show({ id: application.id, task: 'confirm-eligibility', page: 'confirm-eligibility' }) +} + +export const eligibilityQuestionIsAnswered = (application: Application): boolean => { + return eligibilityAnswer(application) === 'yes' || eligibilityAnswer(application) === 'no' +} + +export const eligibilityIsConfirmed = (application: Application): boolean => { + return eligibilityAnswer(application) === 'yes' +} +export const eligibilityIsDenied = (application: Application): boolean => { + return eligibilityAnswer(application) === 'no' +} + +const eligibilityAnswer = (application: Application): string => { + return application.data?.['confirm-eligibility']?.['confirm-eligibility']?.isEligible +} + +export const firstPageOfConsentTask = (application: Application) => { + return paths.applications.pages.show({ id: application.id, task: 'confirm-consent', page: 'confirm-consent' }) +} + +export const consentIsConfirmed = (application: Application): boolean => { + return consentAnswer(application) === 'yes' +} +export const consentIsDenied = (application: Application): boolean => { + return consentAnswer(application) === 'no' +} + +export const hdcDatesHaveBeenEntered = (application: Application): boolean => { + return Boolean(application.data?.['hdc-licence-dates']?.['hdc-licence-dates']) +} + +const consentAnswer = (application: Application): string => { + return application.data?.['confirm-consent']?.['confirm-consent']?.hasGivenConsent +} + +export const getTimelineEvents = (timelineEvents: Array<Cas2TimelineEvent>): Array<UiTimelineEvent> => { + if (timelineEvents) { + return timelineEvents + .sort((a, b) => Number(DateFormats.isoToDateObj(b.occurredAt)) - Number(DateFormats.isoToDateObj(a.occurredAt))) + .map(sortedTimelineEvents => { + const description = + sortedTimelineEvents.type === 'cas2_status_update' && sortedTimelineEvents.body + ? formatCommaToLinebreak(sortedTimelineEvents.body) + : formatLines(sortedTimelineEvents.body) + + return { + label: { text: sortedTimelineEvents.label }, + byline: { + text: sortedTimelineEvents.createdByName, + }, + datetime: { + timestamp: sortedTimelineEvents.occurredAt, + date: DateFormats.isoDateTimeToUIDateTime(sortedTimelineEvents.occurredAt), + }, + description: { + text: description, + }, + } + }) + } + return [] +} + +export const getApplicationTimelineEvents = ( + application: Cas2Application | Cas2SubmittedApplication, +): Array<UiTimelineEvent> => getTimelineEvents(application.timelineEvents) + +export const generateSuccessMessage = (pageName: string): string => { + switch (pageName) { + case 'current-offence-data': + return 'The offence has been saved' + case 'offence-history-data': + return 'The offence has been saved' + case 'acct-data': + return 'The ACCT has been saved' + default: + return '' + } +} + +export const getSideNavLinksForDocument = (document: ApplicationDocument) => { + const tasks: Array<SideNavItem> = [] + + document.sections.forEach(section => { + section.tasks.forEach(task => tasks.push({ href: `#${stringToKebabCase(task.title)}`, text: task.title })) + }) + + return tasks +} + +export const getSideNavLinksForApplication = () => { + const sections = getSections() + + const tasks: Array<SideNavItem> = [] + + sections.forEach(section => { + section.tasks.forEach(task => tasks.push({ href: `#${stringToKebabCase(task.title)}`, text: task.title })) + }) + + return tasks +} + +export const showMissingRequiredTasksOrTaskList = (req: Request, res: Response, application: Application) => { + if (eligibilityIsConfirmed(application)) { + if (consentIsConfirmed(application)) { + if (hdcDatesHaveBeenEntered(application)) { + const { errors, errorSummary } = fetchErrorsAndUserInput(req) + + const referer = validateReferer(req.headers.referer) + const taskList = new TaskListService(application) + return res.render('applications/taskList', { application, taskList, errors, errorSummary, referer }) + } + return res.redirect( + paths.applications.pages.show({ + id: application.id, + task: 'hdc-licence-dates', + page: 'hdc-licence-dates', + }), + ) + } + if (consentIsDenied(application)) { + return res.redirect(paths.applications.consentRefused({ id: application.id })) + } + return res.redirect(firstPageOfConsentTask(application)) + } + + if (eligibilityIsDenied(application)) { + return res.redirect(paths.applications.ineligible({ id: application.id })) + } + + return res.redirect(firstPageOfBeforeYouStartSection(application)) +} diff --git a/server/utils/assessUtils.test.ts b/server/utils/assessUtils.test.ts new file mode 100644 index 0000000..9bc906e --- /dev/null +++ b/server/utils/assessUtils.test.ts @@ -0,0 +1,193 @@ +import { Cas2ApplicationStatusDetail } from '@approved-premises/api' +import { + statusUpdateFactory, + statusUpdateDetailFactory, + applicationStatusDetailFactory, + applicationStatusFactory, +} from '../testutils/factories' +import { + applicationStatusRadios, + applicationStatusDetailOptions, + getStatusDetailsByStatusName, + getStatusDetailQuestionText, +} from './assessUtils' + +describe('applicationStatusRadios', () => { + const statuses = [ + { + id: 'f5cd423b-08eb-4efb-96ff-5cc6bb073905', + name: 'moreInfoRequested', + label: 'More information requested', + description: 'More information about the application has been requested from the POM (Prison Offender Manager).', + }, + { + id: 'ba4d8432-250b-4ab9-81ec-7eb4b16e5dd1', + name: 'awaitingDecision', + label: 'Awaiting decision', + description: 'All information has been received and the application is awaiting assessment.', + }, + { + id: 'cd4d8432-250b-4ab9-81ec-7eb4b16e5cc2', + name: 'example', + label: 'Example', + }, + ] + + it('returns an array of radios', () => { + const expected = [ + { + value: 'moreInfoRequested', + text: 'More information requested', + checked: false, + }, + { + value: 'awaitingDecision', + text: 'Awaiting decision', + checked: true, + }, + { + value: 'example', + text: 'Example', + checked: false, + }, + ] + + const previousStatuses = [statusUpdateFactory.build({ name: 'awaitingDecision' })] + + expect(applicationStatusRadios(statuses, previousStatuses)).toEqual(expected) + }) + + it('does not check a radio if previousStatuses is empty array', () => { + const expected = [ + { + value: 'moreInfoRequested', + text: 'More information requested', + checked: false, + }, + { + value: 'awaitingDecision', + text: 'Awaiting decision', + checked: false, + }, + { + value: 'example', + text: 'Example', + checked: false, + }, + ] + + expect(applicationStatusRadios(statuses, [])).toEqual(expected) + }) +}) + +describe('applicationStatusUpdateDetailCheckboxes', () => { + const statusDetails = [ + { + id: '9019c886-dcba-4277-b667-423a2ab847c3', + name: 'aboutTheApplicant', + label: 'About the applicant', + }, + { + id: 'b174b4ab-25c2-4808-993b-dd00d646cb34', + name: 'areaFundingAndId', + label: 'Area, Funding and ID', + }, + { + id: 'b7636e4e-bf05-4c35-a79f-41c1089cb578', + name: 'risksAndNeeds', + label: 'Risks and needs', + }, + ] as Array<Cas2ApplicationStatusDetail> + + it('returns an array of checkboxes', () => { + const expected = [ + { + value: 'aboutTheApplicant', + text: 'About the applicant', + checked: true, + }, + { + value: 'areaFundingAndId', + text: 'Area, Funding and ID', + checked: true, + }, + { + value: 'risksAndNeeds', + text: 'Risks and needs', + checked: false, + }, + ] + + const previousStatuses = [ + statusUpdateFactory.build({ + name: 'moreInfoRequested', + statusUpdateDetails: [ + statusUpdateDetailFactory.build({ name: 'aboutTheApplicant' }), + statusUpdateDetailFactory.build({ name: 'areaFundingAndId' }), + ], + }), + ] + + expect(applicationStatusDetailOptions(statusDetails, previousStatuses)).toEqual(expected) + }) + + it('does not check boxes if previous statuses is an empty array', () => { + const expected = [ + { + value: 'aboutTheApplicant', + text: 'About the applicant', + checked: false, + }, + { + value: 'areaFundingAndId', + text: 'Area, Funding and ID', + checked: false, + }, + { + value: 'risksAndNeeds', + text: 'Risks and needs', + checked: false, + }, + ] + + expect(applicationStatusDetailOptions(statusDetails, [])).toEqual(expected) + }) +}) + +describe('getStatusDetailsByStatusName', () => { + it('returns a list of status details belonging to the status name', () => { + const applicationStatus1Detail = applicationStatusDetailFactory.build() + const applicationStatus2Detail = applicationStatusDetailFactory.build() + + const applicationStatus1 = applicationStatusFactory.build({ statusDetails: [applicationStatus1Detail] }) + const applicationStatus2 = applicationStatusFactory.build({ statusDetails: [applicationStatus2Detail] }) + + const statusName = applicationStatus1.name + + expect(getStatusDetailsByStatusName([applicationStatus1, applicationStatus2], statusName)).toEqual( + applicationStatus1.statusDetails, + ) + }) + + it('returns an empty array if application status does not contain status detail', () => { + const applicationStatus1 = applicationStatusFactory.build({ statusDetails: [] }) + const applicationStatus2 = applicationStatusFactory.build({ statusDetails: [] }) + + const statusName = applicationStatus1.name + + expect(getStatusDetailsByStatusName([applicationStatus1, applicationStatus2], statusName)).toEqual([]) + }) +}) + +describe('getStatusDetailQuestionText', () => { + it('returns question associated with status name', () => { + expect(getStatusDetailQuestionText('moreInfoRequested')).toEqual('What information do you need?') + expect(getStatusDetailQuestionText('offerDeclined')).toEqual('Why was the offer declined or withdrawn?') + expect(getStatusDetailQuestionText('withdrawn')).toEqual('Why was the referral withdrawn?') + expect(getStatusDetailQuestionText('cancelled')).toEqual('Why was the referral cancelled?') + }) + + it('returns an empty string if the status has no associated question', () => { + expect(getStatusDetailQuestionText('statusNotFound')).toEqual('') + }) +}) diff --git a/server/utils/assessUtils.ts b/server/utils/assessUtils.ts new file mode 100644 index 0000000..c3664cd --- /dev/null +++ b/server/utils/assessUtils.ts @@ -0,0 +1,53 @@ +import { Cas2ApplicationStatus, Cas2ApplicationStatusDetail, Cas2StatusUpdate } from '@approved-premises/api' + +export const applicationStatusRadios = ( + statuses: Array<Record<string, string>>, + previousStatuses: Array<Cas2StatusUpdate>, +) => { + return statuses.map(status => { + return { + value: status.name, + text: status.label, + checked: previousStatuses.length ? status.name === previousStatuses[0].name : false, + } + }) +} + +export const applicationStatusDetailOptions = ( + statusDetails: Array<Cas2ApplicationStatusDetail>, + previousStatuses: Array<Cas2StatusUpdate>, +) => { + const previousStatusUpdateDetails = previousStatuses.length ? previousStatuses[0].statusUpdateDetails : [] + + return statusDetails.map(statusDetail => { + return { + value: statusDetail.name, + text: statusDetail.label, + checked: previousStatusUpdateDetails.some(detail => detail.name === statusDetail.name), + } + }) +} + +export const getStatusDetailsByStatusName = ( + statuses: Array<Cas2ApplicationStatus>, + statusName: string, +): Array<Cas2ApplicationStatusDetail | null> => { + if (!statuses.length) { + return [] + } + + const statusDetails = statuses.filter(status => status.name === statusName)[0]?.statusDetails + + return statusDetails +} + +export const getStatusDetailQuestionText = (status: string): string => { + const questions = { + moreInfoRequested: 'What information do you need?', + offerDeclined: 'Why was the offer declined or withdrawn?', + withdrawn: 'Why was the referral withdrawn?', + cancelled: 'Why was the referral cancelled?', + } + + return questions[status as keyof typeof questions] || '' +} diff --git a/server/utils/assessmentUtils.test.ts b/server/utils/assessmentUtils.test.ts new file mode 100644 index 0000000..d471828 --- /dev/null +++ b/server/utils/assessmentUtils.test.ts @@ -0,0 +1,26 @@ +import { assessmentFactory } from '../testutils/factories' +import { assessmentHasExistingData } from './assessmentUtils' + +describe('assessmentHasExistingData', () => { + describe('when the assessment has existing data', () => { + it('returns true', () => { + const assessment = assessmentFactory.build({ + nacroReferralId: 'nacro-referral-id', + assessorName: null, + }) + + expect(assessmentHasExistingData(assessment)).toBe(true) + }) + }) + + describe('when the assessment does not have existing data', () => { + it('returns false', () => { + const assessment = assessmentFactory.build({ + nacroReferralId: null, + assessorName: null, + }) + + expect(assessmentHasExistingData(assessment)).toBe(false) + }) + }) +}) diff --git a/server/utils/assessmentUtils.ts b/server/utils/assessmentUtils.ts new file mode 100644 index 0000000..86261d0 --- /dev/null +++ b/server/utils/assessmentUtils.ts @@ -0,0 +1,5 @@ +import { Cas2Assessment } from '@approved-premises/api' + +export const assessmentHasExistingData = (assessment: Cas2Assessment): boolean => { + return Boolean(assessment.assessorName) || Boolean(assessment.nacroReferralId) +} diff --git a/server/utils/checkYourAnswersUtils.test.ts b/server/utils/checkYourAnswersUtils.test.ts new file mode 100644 index 0000000..ae79d4f --- /dev/null +++ b/server/utils/checkYourAnswersUtils.test.ts @@ -0,0 +1,793 @@ +import { SummaryListItem } from '@approved-premises/ui' +import applicationData from '../../integration_tests/fixtures/applicationData.json' +import Apply from '../form-pages/apply' +import * as getQuestionsUtil from '../form-pages/utils/questions' +import { applicationFactory, personFactory } from '../testutils/factories' +import * as checkYourAnswersUtils from './checkYourAnswersUtils' +import { DateFormats } from './dateUtils' +import { UnknownPageError } from './errors' +import { formatLines } from './viewUtils' + +jest.mock('./formUtils') +jest.mock('./viewUtils') + +const { + getTaskAnswersAsSummaryListItems, + addPageAnswersToItemsArray, + arrayAnswersAsString, + getAnswer, + summaryListItemForQuestion, + checkYourAnswersSections, + getSections, + getPage, + getKeysForPages, + getApplicantDetails, + removeAnyOldPageKeys, +} = checkYourAnswersUtils + +const { getQuestions } = getQuestionsUtil + +type Questions = ReturnType<typeof getQuestions> + +const mockQuestions = { + task1: { + page1: { question1: { question: 'A question', answers: { yes: 'Yes', no: 'No' } } }, + page2: { question2: { question: 'Another question' } }, + }, + task2: { + page1: { question1: { question: 'question 3', answers: { yes: 'Yes', no: 'No' } } }, + page2: { question2: { question: 'question 4' } }, + }, +} as unknown as Questions + +describe('checkYourAnswersUtils', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + const person = personFactory.build({ name: 'Roger Smith' }) + + const questions = getQuestions(person.name) + + const application = applicationFactory.build({ + person, + data: applicationData, + }) + + ;(formatLines as jest.MockedFunction<typeof formatLines>).mockImplementation(text => text) + + describe('checkYourAnswersSections', () => { + it('returns an array of section tiles', () => { + const sections = [ + { name: 'section1', tasks: [{ id: 'task1', pages: {}, title: 'Task 1' }], title: 'Section 1' }, + { name: 'section2', tasks: [{ id: 'task2', pages: {}, title: 'Task 2' }], title: 'Section 2' }, + ] + + jest.spyOn(checkYourAnswersUtils, 'getSections').mockImplementationOnce(jest.fn(() => sections)) + jest.spyOn(checkYourAnswersUtils, 'getTaskAnswersAsSummaryListItems').mockImplementation(jest.fn(() => [])) + + const expected = [ + { title: 'Section 1', tasks: [{ id: 'task1', title: 'Task 1', rows: [] as Array<SummaryListItem> }] }, + { title: 'Section 2', tasks: [{ id: 'task2', title: 'Task 2', rows: [] as Array<SummaryListItem> }] }, + ] + + expect(checkYourAnswersSections(application)).toEqual(expected) + + jest.clearAllMocks() + }) + }) + + describe('getTaskAnswersAsSummaryListItems', () => { + it('returns an array of summary list items for a given task', () => { + const mockApplication = applicationFactory.build({ + data: { + task1: { + page1: { + question1: 'no', + }, + page2: { + question2: 'some answer', + }, + }, + }, + }) + + jest.spyOn(getQuestionsUtil, 'getQuestions').mockImplementationOnce(jest.fn(() => mockQuestions)) + + jest.spyOn(checkYourAnswersUtils, 'getPage').mockReturnValue(jest.fn()) + + const expected = [ + { + key: { html: 'A question' }, + value: { html: 'No' }, + actions: { + items: [ + { + href: `/applications/${mockApplication.id}/tasks/task1/pages/page1`, + text: 'Change', + visuallyHiddenText: 'A question', + }, + ], + }, + }, + { + key: { html: 'Another question' }, + value: { html: 'some answer' }, + actions: { + items: [ + { + href: `/applications/${mockApplication.id}/tasks/task1/pages/page2`, + text: 'Change', + visuallyHiddenText: 'Another question', + }, + ], + }, + }, + ] + expect(getTaskAnswersAsSummaryListItems('task1', mockApplication)).toEqual(expected) + }) + + it('ignores irrelevant page keys', () => { + jest.spyOn(getQuestionsUtil, 'getQuestions').mockImplementationOnce(jest.fn(() => mockQuestions)) + + jest.spyOn(checkYourAnswersUtils, 'getPage').mockReturnValue(jest.fn()) + + const removeAnyOldTaskKeysSpy = jest.spyOn(checkYourAnswersUtils, 'removeAnyOldPageKeys') + const addPageAnswersToItemsArraySpy = jest.spyOn(checkYourAnswersUtils, 'addPageAnswersToItemsArray') + + const mockApplication = applicationFactory.build({ + data: { + task1: { + page1: { + question1: 'no', + }, + oldPageKey: { + question1: 'no', + }, + 'oasys-import': { + question1: 'no', + }, + 'summary-data': { + question2: 'some answer', + }, + }, + }, + }) + + const expected = [ + { + key: { html: 'A question' }, + value: { html: 'No' }, + actions: { + items: [ + { + href: `/applications/${mockApplication.id}/tasks/task1/pages/page1`, + text: 'Change', + visuallyHiddenText: 'A question', + }, + ], + }, + }, + ] + + expect(getTaskAnswersAsSummaryListItems('task1', mockApplication)).toEqual(expected) + + expect(removeAnyOldTaskKeysSpy).toHaveBeenCalledWith(mockQuestions, 'task1', ['page1', 'oldPageKey']) + expect(addPageAnswersToItemsArraySpy).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + 'oldPageKey', + expect.anything(), + expect.anything(), + ) + }) + }) + + describe('addPageAnswersToItemsArray', () => { + const mockApplication = applicationFactory.build({ + data: { + 'confirm-eligibility': { + page1: { + question1: 'no', + question2: 'an answer', + }, + }, + }, + }) + + describe('when the output format is checkYourAnswers', () => { + it('adds each page answer to the items array by default', () => { + const mockedConfirmEligibilityQuestion = { + 'confirm-eligibility': { + page1: { + question1: { question: 'A question', answers: { yes: 'Yes', no: 'No' } }, + question2: { question: 'Another question' }, + }, + }, + } as unknown as Questions + + jest.spyOn(checkYourAnswersUtils, 'getPage').mockReturnValueOnce(jest.fn()) + + const items: Array<SummaryListItem> = [] + + const expected = [ + { + key: { html: 'A question' }, + value: { html: 'No' }, + actions: { + items: [ + { + href: `/applications/${mockApplication.id}/tasks/confirm-eligibility/pages/page1`, + text: 'Change', + visuallyHiddenText: 'A question', + }, + ], + }, + }, + { + key: { html: 'Another question' }, + value: { html: 'an answer' }, + actions: { + items: [ + { + href: `/applications/${mockApplication.id}/tasks/confirm-eligibility/pages/page1`, + text: 'Change', + visuallyHiddenText: 'Another question', + }, + ], + }, + }, + ] + + addPageAnswersToItemsArray({ + items, + application: mockApplication, + task: 'confirm-eligibility', + pageKey: 'page1', + questions: mockedConfirmEligibilityQuestion, + outputFormat: 'checkYourAnswers', + }) + + expect(items).toEqual(expected) + }) + + it('does not add questions to the items array if there is no answer', () => { + jest.spyOn(checkYourAnswersUtils, 'getPage').mockReturnValueOnce(jest.fn()) + + const mockApplicationNoAnswers = applicationFactory.build({ + data: { + 'confirm-eligibility': { + page1: { + question1: '', + question2: '', + }, + }, + }, + }) + + const mockedQuestions = { + 'confirm-eligibility': { + page1: { + question1: { question: 'A question', answers: { yes: 'Yes', no: 'No' } }, + question2: { question: 'Another question' }, + }, + }, + } + + const items: Array<SummaryListItem> = [] + + addPageAnswersToItemsArray({ + items, + application: mockApplicationNoAnswers, + task: 'confirm-eligibility', + pageKey: 'page1', + questions: mockedQuestions, + outputFormat: 'checkYourAnswers', + }) + + expect(items).toEqual([]) + }) + + it('does not add to the items array if there is no corresponding question for the answer data', () => { + jest.spyOn(checkYourAnswersUtils, 'getPage').mockReturnValueOnce(jest.fn()) + + const mockApplicationRemovedQuestion = applicationFactory.build({ + data: { + 'confirm-eligibility': { + page1: { + question1: '', + question2: '', + }, + }, + }, + }) + + const mockedQuestions = { + 'confirm-eligibility': { + page2: { + question3: { question: 'A question', answers: { yes: 'Yes', no: 'No' } }, + question4: { question: 'Another question' }, + }, + }, + } + + const items: Array<SummaryListItem> = [] + + addPageAnswersToItemsArray({ + items, + application: mockApplicationRemovedQuestion, + task: 'confirm-eligibility', + pageKey: 'page1', + questions: mockedQuestions, + outputFormat: 'checkYourAnswers', + }) + + expect(items).toEqual([]) + }) + + it('if there is a page response method, items are generated from its return value', () => { + const ApplyPage = jest.fn() + + Apply.pages['confirm-eligibility'] = { + somePage: ApplyPage, + } + + ApplyPage.mockReturnValueOnce({ + response: () => { + return { foo: 'bar' } + }, + }) + + const items: Array<SummaryListItem> = [] + + jest.spyOn(checkYourAnswersUtils, 'getPage').mockImplementationOnce(jest.fn(() => ApplyPage)) + jest.spyOn(checkYourAnswersUtils, 'getAnswer').mockImplementationOnce(jest.fn()) + + const expected = [ + { + key: { html: 'foo' }, + value: { html: 'bar' }, + actions: { + items: [ + { + href: `/applications/${mockApplication.id}/tasks/confirm-eligibility/pages/page1`, + text: 'Change', + visuallyHiddenText: 'foo', + }, + ], + }, + }, + ] + + addPageAnswersToItemsArray({ + items, + application: mockApplication, + task: 'confirm-eligibility', + pageKey: 'page1', + questions, + outputFormat: 'checkYourAnswers', + }) + expect(items).toEqual(expected) + expect(checkYourAnswersUtils.getAnswer).toHaveBeenCalledTimes(0) + }) + }) + + describe('when the output format is the document', () => { + it('adds each question and answer to the items array', () => { + const mockedConfirmEligibilityQuestion = { + 'confirm-eligibility': { + page1: { + question1: { question: 'A question', answers: { yes: 'Yes', no: 'No' } }, + question2: { question: 'Another question' }, + }, + }, + } as unknown as Questions + jest.spyOn(checkYourAnswersUtils, 'getPage').mockReturnValueOnce(jest.fn()) + + const items: Array<SummaryListItem> = [] + const expected = [ + { + question: 'A question', + answer: 'No', + }, + { + question: 'Another question', + answer: 'an answer', + }, + ] + addPageAnswersToItemsArray({ + items, + application: mockApplication, + task: 'confirm-eligibility', + pageKey: 'page1', + questions: mockedConfirmEligibilityQuestion, + outputFormat: 'document', + }) + expect(items).toEqual(expected) + }) + + it('if there is a page response method, items are generated from its return value', () => { + const ApplyPage = jest.fn() + + Apply.pages['confirm-eligibility'] = { + somePage: ApplyPage, + } + + ApplyPage.mockReturnValueOnce({ + response: () => { + return { 'A question': 'An answer' } + }, + }) + + const items: Array<SummaryListItem> = [] + + jest.spyOn(checkYourAnswersUtils, 'getPage').mockImplementationOnce(jest.fn(() => ApplyPage)) + jest.spyOn(checkYourAnswersUtils, 'getAnswer').mockImplementationOnce(jest.fn()) + + const expected = [ + { + question: 'A question', + answer: 'An answer', + }, + ] + + addPageAnswersToItemsArray({ + items, + application: mockApplication, + task: 'confirm-eligibility', + pageKey: 'page1', + questions, + outputFormat: 'document', + }) + expect(items).toEqual(expected) + expect(checkYourAnswersUtils.getAnswer).toHaveBeenCalledTimes(0) + }) + }) + }) + + describe('getAnswer', () => { + it('returns array answers as string given an array of defined answers', () => { + const arrayAnswersAsStringSpy = jest.spyOn(checkYourAnswersUtils, 'arrayAnswersAsString') + + getAnswer(application, questions, 'risk-of-serious-harm', 'risk-management-arrangements', 'arrangements') + + expect(arrayAnswersAsStringSpy).toHaveBeenCalledTimes(1) + }) + + it('returns an array answer that is not predefined', () => { + const expected = { + 'createdDate-day': '1', + 'createdDate-month': '2', + 'createdDate-year': '2012', + isOngoing: 'no', + 'closedDate-day': '10', + 'closedDate-month': '10', + 'closedDate-year': '2013', + referringInstitution: 'HMPPS prison', + acctDetails: 'ACCT details\nsome more details on another line', + } + + expect(getAnswer(application, questions, 'risk-to-self', 'acct-data', '0')).toEqual(expected) + }) + + it('returns the answer string by default', () => { + expect(getAnswer(application, questions, 'confirm-eligibility', 'confirm-eligibility', 'isEligible')).toEqual( + 'Yes, I confirm Roger Smith is eligible', + ) + }) + }) + + describe('arrayAnswersAsString', () => { + it('returns an array of string answers as a comma separated string', () => { + expect( + arrayAnswersAsString( + application, + questions, + 'risk-of-serious-harm', + 'risk-management-arrangements', + 'arrangements', + ), + ).toEqual('MAPPA,MARAC,IOM') + }) + }) + + describe('summaryListItemForQuestion', () => { + it('returns a summary list item for a given question', () => { + const question = { question: 'a question', answer: 'an answer' } + + const expected = { + key: { html: 'a question' }, + value: { html: 'an answer' }, + actions: { + items: [ + { + href: `/applications/${application.id}/tasks/task1/pages/page1`, + text: 'Change', + visuallyHiddenText: 'a question', + }, + ], + }, + } + + expect(summaryListItemForQuestion(application, 'task1', 'page1', question)).toEqual(expected) + }) + + describe('when the question is OASys imported', () => { + it('returns the task response as a Summary List item without the actions object', () => { + const question = { question: 'OASys imported', answer: 'an answer' } + + const expected = { + key: { html: 'OASys imported' }, + value: { html: 'an answer' }, + } + + expect(summaryListItemForQuestion(application, 'task1', 'page1', question)).toEqual(expected) + }) + }) + }) + + describe('getSections', () => { + it('returns all sections except check your answers', () => { + const sections = getSections() + + expect(sections.filter(section => section.name === 'Check answers')).toHaveLength(0) + }) + }) + + describe('getKeysForPages', () => { + it('returns an array of page keys for risk to self', () => { + expect(getKeysForPages(application, 'risk-to-self')).toEqual([ + 'oasys-import', + 'current-risk', + 'vulnerability', + 'historical-risk', + 'acct', + 'acct-data', + 'additional-information', + ]) + }) + }) +}) + +describe('getPage', () => { + const FirstApplyPage = jest.fn() + const SecondApplyPage = jest.fn() + + const applySection1Task1 = { + id: 'first-apply-section-task-1', + title: 'First Apply section, task 1', + actionText: '', + pages: { + first: FirstApplyPage, + second: SecondApplyPage, + }, + } + const applySection1Task2 = { + id: 'first-apply-section-task-2', + title: 'First Apply section, task 2', + actionText: '', + pages: {}, + } + + const applySection2Task1 = { + id: 'second-apply-section-task-1', + title: 'Second Apply section, task 1', + actionText: '', + pages: {}, + } + + const applySection2Task2 = { + id: 'second-apply-section-task-2', + title: 'Second Apply section, task 2', + actionText: '', + pages: {}, + } + + const applySection1 = { + name: 'first-apply-section', + title: 'First Apply section', + tasks: [applySection1Task1, applySection1Task2], + } + + const applySection2 = { + name: 'second-apply-section', + title: 'Second Apply section', + tasks: [applySection2Task1, applySection2Task2], + } + + Apply.sections = [applySection1, applySection2] + + Apply.pages['first-apply-section-task-1' as keyof typeof Apply.pages] = { + first: FirstApplyPage, + second: SecondApplyPage, + } + + it('should return a page from Apply if it exists', () => { + expect(getPage('first-apply-section-task-1', 'first')).toEqual(FirstApplyPage) + expect(getPage('first-apply-section-task-1', 'second')).toEqual(SecondApplyPage) + }) + + it('should raise an error if the page is not found', async () => { + expect(() => { + getPage('confirm-eligibility', 'bar') + }).toThrow(UnknownPageError) + }) + + describe('getApplicantDetails', () => { + it('should return applicant details in the correct format', () => { + const person = personFactory.build({}) + + const application = applicationFactory.build({ person }) + + const expected = [ + { + key: { + text: 'Full name', + }, + value: { + html: person.name, + }, + }, + { + key: { + text: 'Date of birth', + }, + value: { + html: DateFormats.isoDateToUIDate(person.dateOfBirth, { format: 'short' }), + }, + }, + { + key: { + text: 'Nationality', + }, + value: { + html: person.nationality, + }, + }, + { + key: { + text: 'Sex', + }, + value: { + html: person.sex, + }, + }, + { + key: { + text: 'Prison number', + }, + value: { + html: person.nomsNumber, + }, + }, + { + key: { + text: 'Prison', + }, + value: { + html: person.prisonName, + }, + }, + { + key: { + text: 'PNC number', + }, + value: { + html: person.pncNumber, + }, + }, + { + key: { + text: 'CRN from NDelius', + }, + value: { + html: person.crn, + }, + }, + ] + + expect(getApplicantDetails(application)).toEqual(expected) + }) + + it('should return applicant details with nationality as unknown', () => { + const person = personFactory.build({ nationality: null }) + + const application = applicationFactory.build({ person }) + + const expected = [ + { + key: { + text: 'Full name', + }, + value: { + html: person.name, + }, + }, + { + key: { + text: 'Date of birth', + }, + value: { + html: DateFormats.isoDateToUIDate(person.dateOfBirth, { format: 'short' }), + }, + }, + { + key: { + text: 'Nationality', + }, + value: { + html: 'Unknown', + }, + }, + { + key: { + text: 'Sex', + }, + value: { + html: person.sex, + }, + }, + { + key: { + text: 'Prison number', + }, + value: { + html: person.nomsNumber, + }, + }, + { + key: { + text: 'Prison', + }, + value: { + html: person.prisonName, + }, + }, + { + key: { + text: 'PNC number', + }, + value: { + html: person.pncNumber, + }, + }, + { + key: { + text: 'CRN from NDelius', + }, + value: { + html: person.crn, + }, + }, + ] + + expect(getApplicantDetails(application)).toEqual(expected) + }) + }) + + describe('removeAnyOldPageKeys', () => { + it('should remove any page keys that appear in the application but are no longer part of the latest set of questions', () => { + const questions = { + task1: { + page1: { question1: { question: 'A question', answers: { yes: 'Yes', no: 'No' } } }, + page2: { question2: { question: 'Another question' } }, + }, + } as unknown as Questions + const applicationPageKeys = ['page1', 'page2', 'oldPageKey'] + const expected = ['page1', 'page2'] + expect(removeAnyOldPageKeys(questions, 'task1', applicationPageKeys)).toEqual(expected) + }) + }) + it('should retain ACCT, current offences and historic offences even where they do not appear in the question schema', () => { + const questions = { + task1: { + page1: { question1: { question: 'A question', answers: { yes: 'Yes', no: 'No' } } }, + page2: { question2: { question: 'Another question' } }, + }, + } as unknown as Questions + const applicationPageKeys = ['page1', 'page2', 'oldPageKey', 'acct', 'current-offences', 'offence-history'] + const expected = ['page1', 'page2', 'acct', 'current-offences', 'offence-history'] + expect(removeAnyOldPageKeys(questions, 'task1', applicationPageKeys)).toEqual(expected) + }) +}) diff --git a/server/utils/checkYourAnswersUtils.ts b/server/utils/checkYourAnswersUtils.ts new file mode 100644 index 0000000..085764e --- /dev/null +++ b/server/utils/checkYourAnswersUtils.ts @@ -0,0 +1,313 @@ +import { Cas2Application as Application, Cas2SubmittedApplication, FullPerson } from '@approved-premises/api' +import { SummaryListItem, FormSection, QuestionAndAnswer } from '@approved-premises/ui' +import Apply from '../form-pages/apply/index' +import CheckYourAnswers from '../form-pages/apply/check-your-answers' +import paths from '../paths/apply' +import { getQuestions } from '../form-pages/utils/questions' +import { nameOrPlaceholderCopy } from './utils' +import { formatLines } from './viewUtils' +import TaskListPage, { TaskListPageInterface } from '../form-pages/taskListPage' +import { UnknownPageError } from './errors' +import { DateFormats } from './dateUtils' + +export const checkYourAnswersSections = (application: Application) => { + const sections = getSections() + + const sectionsWithAnswers = sections.map(section => { + return { + title: section.title, + tasks: section.tasks.map(task => { + return { + id: task.id, + title: task.title, + rows: getTaskAnswersAsSummaryListItems(task.id, application), + } + }), + } + }) + + return sectionsWithAnswers +} + +export const getTaskAnswersAsSummaryListItems = ( + task: string, + application: Application, + outputFormat: 'checkYourAnswers' | 'document' = 'checkYourAnswers', +): Array<SummaryListItem | QuestionAndAnswer> => { + const items: Array<SummaryListItem | QuestionAndAnswer> = [] + + // Get the latest question schema + const questions = getQuestions(nameOrPlaceholderCopy(application.person)) + + // Get the page keys stored on the application at creation + const applicationPageKeys = getKeysForPages(application, task).filter(key => pagesWeNeverWantToPresent(key) === false) + + // Filter out any keys that are no longer in the latest question schema + const relevantPagesKeys = removeAnyOldPageKeys(questions, task, applicationPageKeys) + + // For each page key we know we have a matching question for, prepare it for display + relevantPagesKeys.forEach(pageKey => { + addPageAnswersToItemsArray({ + items, + application, + task, + pageKey, + questions, + outputFormat, + }) + }) + return items +} + +export const addPageAnswersToItemsArray = (params: { + items: Array<SummaryListItem | QuestionAndAnswer> + application: Application + task: string + pageKey: string + questions: Record<string, unknown> + outputFormat: 'checkYourAnswers' | 'document' +}) => { + const { items, application, task, pageKey, questions, outputFormat } = params + const PageClass = getPage(task, pageKey) + + const body = application?.data?.[task]?.[pageKey] + + const page = new PageClass(body, application) + + if (hasResponseMethod(page)) { + const response = page.response() + + Object.keys(response).forEach(question => { + if (outputFormat === 'checkYourAnswers') { + items.push(summaryListItemForQuestion(application, task, pageKey, { question, answer: response[question] })) + } else { + items.push({ question, answer: response[question] }) + } + }) + } else { + const questionKeys = Object.keys(application.data[task][pageKey]) + if (containsQuestions(questionKeys)) { + questionKeys.forEach(questionKey => { + const answer = getAnswer(application, questions, task, pageKey, questionKey) + if (!answer) { + return + } + + // @ts-expect-error Requires refactor to satisfy TS7053 + const questionText = questions[task][pageKey]?.[questionKey]?.question + + if (!questionText) { + return + } + + if (outputFormat === 'checkYourAnswers') { + items.push(summaryListItemForQuestion(application, task, pageKey, { question: questionText, answer })) + } else { + items.push({ question: questionText, answer }) + } + }) + } + } +} + +export const getAnswer = ( + application: Application, + questions: Record<string, unknown>, + task: string, + pageKey: string, + questionKey: string, +) => { + if (hasDefinedAnswers(questions, task, pageKey, questionKey)) { + if (Array.isArray(application.data[task][pageKey][questionKey])) { + return arrayAnswersAsString(application, questions, task, pageKey, questionKey) + } + // @ts-expect-error Requires refactor to satisfy TS7053 + return questions[task][pageKey][questionKey].answers[application.data[task][pageKey][questionKey]] + } + return application.data[task][pageKey][questionKey] +} + +export const arrayAnswersAsString = ( + application: Application, + questions: Record<string, unknown>, + task: string, + pageKey: string, + questionKey: string, +): string => { + const answerKeys = application.data[task][pageKey][questionKey] + const textAnswers: Array<string> = [] + answerKeys.forEach((answerKey: string) => { + // @ts-expect-error Requires refactor to satisfy TS7053 + textAnswers.push(questions[task][pageKey][questionKey].answers[answerKey]) + }) + return textAnswers.join() +} + +export const summaryListItemForQuestion = ( + application: Application, + task: string, + pageKey: string, + questionAndAnswer: Record<string, string>, +) => { + const nonEditablePages = ['summary'] + const nonEditableQuestions = ['OASys created', 'OASys completed', 'OASys imported'] + + const { question, answer } = questionAndAnswer + + const actions = { + items: [ + { + href: paths.applications.pages.show({ task, page: pageKey, id: application.id }), + text: 'Change', + visuallyHiddenText: question, + }, + ], + } + + return { + key: { + html: question, + }, + value: { html: formatLines(answer as string) }, + ...(nonEditablePages.includes(pageKey) || nonEditableQuestions.includes(question) ? {} : { actions }), + } +} + +export const getSections = (): Array<FormSection> => { + const { sections } = Apply + + return sections.filter(section => section.name !== CheckYourAnswers.name) +} + +export const getKeysForPages = (application: Application, task: string) => { + const pages = application.data[task] + + // Allow viewing of the CYA page with incomplete tasks + if (!pages) { + return [] + } + + const pagesKeys = Object.keys(pages) + + return pagesKeys +} + +const containsQuestions = (questionKeys: Array<string>): boolean => { + if (!questionKeys.length) { + return false + } + return true +} + +const hasDefinedAnswers = ( + questions: Record<string, unknown>, + task: string, + pageKey: string, + questionKey: string, +): boolean => { + // @ts-expect-error Requires refactor to satisfy TS7053 + return questions[task][pageKey]?.[questionKey]?.answers +} + +export const hasResponseMethod = (page: TaskListPage): boolean => { + if ('response' in page) { + return true + } + return false +} + +export const getPage = (taskName: string, pageName: string): TaskListPageInterface => { + const pageList = Apply.pages[taskName as keyof typeof Apply.pages] + + const Page = pageList[pageName] + + if (!Page) { + throw new UnknownPageError(pageName) + } + + return Page as TaskListPageInterface +} + +export const getApplicantDetails = (application: Application | Cas2SubmittedApplication): Array<SummaryListItem> => { + const { crn, nomsNumber, pncNumber, name, dateOfBirth, nationality, sex, prisonName } = + application.person as FullPerson + + return [ + { + key: { + text: 'Full name', + }, + value: { + html: name, + }, + }, + { + key: { + text: 'Date of birth', + }, + value: { + html: DateFormats.isoDateToUIDate(dateOfBirth, { format: 'short' }), + }, + }, + { + key: { + text: 'Nationality', + }, + value: { + html: nationality || 'Unknown', + }, + }, + { + key: { + text: 'Sex', + }, + value: { + html: sex, + }, + }, + { + key: { + text: 'Prison number', + }, + value: { + html: nomsNumber, + }, + }, + { + key: { + text: 'Prison', + }, + value: { + html: prisonName, + }, + }, + { + key: { + text: 'PNC number', + }, + value: { + html: pncNumber, + }, + }, + { + key: { + text: 'CRN from NDelius', + }, + value: { + html: crn, + }, + }, + ] +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const removeAnyOldPageKeys = (questions: any, task: string, applicationPageKeys: string[]): string[] => { + const latestPageKeys = Object.keys(questions[task]) + const matchedKeys = applicationPageKeys.filter( + key => latestPageKeys.includes(key) || ['acct', 'current-offences', 'offence-history'].includes(key), + ) + return matchedKeys +} +const pagesWeNeverWantToPresent = (key: string): boolean => { + return ['summary-data', 'oasys-import'].includes(key) +} diff --git a/server/utils/errors.ts b/server/utils/errors.ts new file mode 100644 index 0000000..557d377 --- /dev/null +++ b/server/utils/errors.ts @@ -0,0 +1,28 @@ +/* eslint-disable max-classes-per-file */ +import type { TaskListErrors } from '@approved-premises/ui' +import TaskListPage from '../form-pages/taskListPage' + +export class ValidationError<T extends TaskListPage> extends Error { + data: TaskListErrors<T> + + constructor(data: TaskListErrors<T>) { + super('Validation error') + this.data = data + } +} + +export class SessionDataError extends Error {} +export class UnknownPageError extends Error { + constructor(pageName: string) { + super(`Cannot find the page ${pageName}`) + } +} + +export class TaskListAPIError extends Error { + field: string + + constructor(message: string, field: string) { + super(message) + this.field = field + } +} diff --git a/server/utils/formUtils.test.ts b/server/utils/formUtils.test.ts new file mode 100644 index 0000000..2cc6d81 --- /dev/null +++ b/server/utils/formUtils.test.ts @@ -0,0 +1,246 @@ +import { createMock } from '@golevelup/ts-jest' +import { ErrorMessages } from '@approved-premises/ui' +import { + escape, + convertKeyValuePairToRadioItems, + convertKeyValuePairToCheckboxItems, + dateFieldValues, +} from './formUtils' + +describe('formutils', () => { + describe('escape', () => { + it('escapes HTML tags', () => { + expect(escape('<b>Formatted text</b>')).toEqual('<b>Formatted text</b>') + }) + + it('escapes reserved characters', () => { + expect(escape('"Quoted text"')).toEqual('"Quoted text"') + }) + + it('returns the empty string when given null', () => { + expect(escape(null)).toEqual('') + }) + }) + + describe('convertKeyValuePairToRadioItems', () => { + const obj = { + foo: 'Foo', + bar: 'Bar', + } + + it('should convert a key value pair to radio items', () => { + expect(convertKeyValuePairToRadioItems(obj, '')).toEqual([ + { + value: 'foo', + text: 'Foo', + checked: false, + }, + { + value: 'bar', + text: 'Bar', + checked: false, + }, + ]) + }) + + it('should check the checked item', () => { + expect(convertKeyValuePairToRadioItems(obj, 'foo')).toEqual([ + { + value: 'foo', + text: 'Foo', + checked: true, + }, + { + value: 'bar', + text: 'Bar', + checked: false, + }, + ]) + + expect(convertKeyValuePairToRadioItems(obj, 'bar')).toEqual([ + { + value: 'foo', + text: 'Foo', + checked: false, + }, + { + value: 'bar', + text: 'Bar', + checked: true, + }, + ]) + }) + }) + + describe('convertKeyValuePairToCheckboxItems', () => { + const obj = { + foo: 'Foo', + bar: 'Bar', + } + + it('should convert a key value pair to checkbox items', () => { + expect(convertKeyValuePairToCheckboxItems(obj, [])).toEqual([ + { + value: 'foo', + text: 'Foo', + checked: false, + }, + { + value: 'bar', + text: 'Bar', + checked: false, + }, + ]) + }) + + it('should handle an undefined checkedItems value', () => { + expect(convertKeyValuePairToCheckboxItems(obj, undefined)).toEqual([ + { + value: 'foo', + text: 'Foo', + checked: false, + }, + { + value: 'bar', + text: 'Bar', + checked: false, + }, + ]) + }) + + it('should check the checked item', () => { + expect(convertKeyValuePairToCheckboxItems(obj, ['foo'])).toEqual([ + { + value: 'foo', + text: 'Foo', + checked: true, + }, + { + value: 'bar', + text: 'Bar', + checked: false, + }, + ]) + + expect(convertKeyValuePairToCheckboxItems(obj, ['bar'])).toEqual([ + { + value: 'foo', + text: 'Foo', + checked: false, + }, + { + value: 'bar', + text: 'Bar', + checked: true, + }, + ]) + + expect(convertKeyValuePairToCheckboxItems(obj, ['foo', 'bar'])).toEqual([ + { + value: 'foo', + text: 'Foo', + checked: true, + }, + { + value: 'bar', + text: 'Bar', + checked: true, + }, + ]) + }) + }) + + describe('dateFieldValues', () => { + it('returns items with an error class when errors are present for the field', () => { + const errors = createMock<ErrorMessages>({ + myField: { + text: 'Some error message', + }, + }) + const context = { + 'myField-day': 12, + 'myField-month': 11, + 'myField-year': 2022, + } + const fieldName = 'myField' + + expect(dateFieldValues(fieldName, context, errors)).toEqual([ + { + classes: 'govuk-input--width-2 govuk-input--error', + name: 'day', + value: context['myField-day'], + }, + { + classes: 'govuk-input--width-2 govuk-input--error', + name: 'month', + value: context['myField-month'], + }, + { + classes: 'govuk-input--width-4 govuk-input--error', + name: 'year', + value: context['myField-year'], + }, + ]) + }) + + it('returns items without an error class when no errors are present for the field', () => { + const errors = createMock<ErrorMessages>({ + someOtherField: { + text: 'Some error message', + }, + myField: undefined, + }) + const context = { + 'myField-day': 12, + 'myField-month': 11, + 'myField-year': 2022, + } + const fieldName = 'myField' + + expect(dateFieldValues(fieldName, context, errors)).toEqual([ + { + classes: 'govuk-input--width-2 ', + name: 'day', + value: context['myField-day'], + }, + { + classes: 'govuk-input--width-2 ', + name: 'month', + value: context['myField-month'], + }, + { + classes: 'govuk-input--width-4 ', + name: 'year', + value: context['myField-year'], + }, + ]) + }) + + it('returns items without an error class when no errors are present at all', () => { + const context = { + 'myField-day': 12, + 'myField-month': 11, + 'myField-year': 2022, + } + const fieldName = 'myField' + + expect(dateFieldValues(fieldName, context)).toEqual([ + { + classes: 'govuk-input--width-2 ', + name: 'day', + value: context['myField-day'], + }, + { + classes: 'govuk-input--width-2 ', + name: 'month', + value: context['myField-month'], + }, + { + classes: 'govuk-input--width-4 ', + name: 'year', + value: context['myField-year'], + }, + ]) + }) + }) +}) diff --git a/server/utils/formUtils.ts b/server/utils/formUtils.ts new file mode 100644 index 0000000..d28976b --- /dev/null +++ b/server/utils/formUtils.ts @@ -0,0 +1,53 @@ +import * as nunjucks from 'nunjucks' +import { RadioItem, CheckboxItem, ErrorMessages } from '@approved-premises/ui' + +export const escape = (text: string): string => { + const escapeFilter = new nunjucks.Environment().getFilter('escape') + return escapeFilter(text).val +} + +export function convertKeyValuePairToRadioItems<T>(object: T, checkedItem: string): Array<RadioItem> { + return Object.keys(object).map(key => { + return { + value: key, + // @ts-expect-error Requires refactor to satisfy TS7053 + text: object[key], + checked: checkedItem === key, + } + }) +} + +export function convertKeyValuePairToCheckboxItems<T>( + object: T, + checkedItems: Array<string> = [], +): Array<CheckboxItem> { + return Object.keys(object).map(key => { + return { + value: key, + // @ts-expect-error Requires refactor to satisfy TS7053 + text: object[key], + checked: checkedItems.includes(key), + } + }) +} + +export const dateFieldValues = (fieldName: string, context: Record<string, unknown>, errors: ErrorMessages = {}) => { + const errorClass = errors[fieldName] ? 'govuk-input--error' : '' + return [ + { + classes: `govuk-input--width-2 ${errorClass}`, + name: 'day', + value: context[`${fieldName}-day`], + }, + { + classes: `govuk-input--width-2 ${errorClass}`, + name: 'month', + value: context[`${fieldName}-month`], + }, + { + classes: `govuk-input--width-4 ${errorClass}`, + name: 'year', + value: context[`${fieldName}-year`], + }, + ] +} diff --git a/server/utils/getPaginationDetails.test.ts b/server/utils/getPaginationDetails.test.ts new file mode 100644 index 0000000..1d2809a --- /dev/null +++ b/server/utils/getPaginationDetails.test.ts @@ -0,0 +1,25 @@ +import { createMock } from '@golevelup/ts-jest' +import type { Request } from 'express' +import { getPaginationDetails } from './getPaginationDetails' + +describe('getPaginationDetails', () => { + const basePath = 'http://localhost/example' + + it('should return the hrefPrefix with a query string prefix if there are no query parameters', () => { + const request = createMock<Request>({ query: {} }) + + expect(getPaginationDetails(request, basePath)).toEqual({ + pageNumber: undefined, + hrefPrefix: `${basePath}?`, + }) + }) + + it('should append additonal parameters to the hrefPrefix', () => { + const request = createMock<Request>({ query: { page: '1', sortBy: 'something', sortDirection: 'asc' } }) + + expect(getPaginationDetails(request, basePath, { foo: 'bar' })).toEqual({ + pageNumber: 1, + hrefPrefix: `${basePath}?foo=bar&`, + }) + }) +}) diff --git a/server/utils/getPaginationDetails.ts b/server/utils/getPaginationDetails.ts new file mode 100644 index 0000000..a350fab --- /dev/null +++ b/server/utils/getPaginationDetails.ts @@ -0,0 +1,16 @@ +import type { Request } from 'express' +import { createQueryString } from './utils' + +export const getPaginationDetails = ( + request: Request, + basePath: string, + additionalParams: Record<string, unknown> = {}, +) => { + const pageNumber = request.query.page ? Number(request.query.page) : undefined + + const queryString = createQueryString({ ...additionalParams }, { addQueryPrefix: true }) + const queryStringSuffix = queryString.length > 0 ? '&' : '?' + const hrefPrefix = `${basePath}${queryString}${queryStringSuffix}` + + return { pageNumber, hrefPrefix } +} diff --git a/server/utils/oasysImportUtils.test.ts b/server/utils/oasysImportUtils.test.ts new file mode 100644 index 0000000..d0eec3c --- /dev/null +++ b/server/utils/oasysImportUtils.test.ts @@ -0,0 +1,30 @@ +import { roshSummaryFactory } from '../testutils/factories' +import { textareas } from './oasysImportUtils' + +describe('OASysImportUtils', () => { + describe('textareas', () => { + it('it returns reoffending needs as textareas', () => { + const roshSummaries = roshSummaryFactory.buildList(2) + const sectionName = 'roshAnswers' + const result = textareas(roshSummaries, sectionName) + + expect(result).toMatchStringIgnoringWhitespace(` + <div class="govuk-form-group"> + <h3 class="govuk-label-wrapper"> + <label class="govuk-label govuk-label--m" for=${sectionName}[${roshSummaries[0].questionNumber}]> + ${roshSummaries[0].label}? + </label> + </h3> + <textarea class="govuk-textarea" id=${sectionName}[${roshSummaries[0].questionNumber}] name=${sectionName}[${roshSummaries[0].questionNumber}] rows="8">${roshSummaries[0].answer}</textarea> + </div> + <div class="govuk-form-group"> + <h3 class="govuk-label-wrapper"> + <label class="govuk-label govuk-label--m" for=${sectionName}[${roshSummaries[1].questionNumber}]> + ${roshSummaries[1].label}? + </label> + </h3> + <textarea class="govuk-textarea" id=${sectionName}[${roshSummaries[1].questionNumber}] name=${sectionName}[${roshSummaries[1].questionNumber}] rows="8">${roshSummaries[1].answer}</textarea> + </div>`) + }) + }) +}) diff --git a/server/utils/oasysImportUtils.ts b/server/utils/oasysImportUtils.ts new file mode 100644 index 0000000..ede0c11 --- /dev/null +++ b/server/utils/oasysImportUtils.ts @@ -0,0 +1,19 @@ +import { OasysImportArrays } from '../@types/ui' +import { escape } from './formUtils' + +export const textareas = (questions: OasysImportArrays, key: 'roshAnswers' | 'offenceDetails') => { + return questions + .map((question: { questionNumber: string; label: string; answer: string }) => { + return `<div class="govuk-form-group"> + <h3 class="govuk-label-wrapper"> + <label class="govuk-label govuk-label--m" for=${key}[${question.questionNumber}]> + ${question.label}? + </label> + </h3> + <textarea class="govuk-textarea" id=${key}[${question.questionNumber}] name=${key}[${ + question.questionNumber + }] rows="8">${escape(question?.answer)}</textarea> + </div>` + }) + .join('') +} diff --git a/server/utils/pagination.test.ts b/server/utils/pagination.test.ts new file mode 100644 index 0000000..ae64e95 --- /dev/null +++ b/server/utils/pagination.test.ts @@ -0,0 +1,121 @@ +import { type Pagination, pagination } from './pagination' + +describe('pagination', () => { + it('should be empty when there are no pages', () => { + expect(pagination(1, 0, '?a=b&')).toEqual<Pagination>({}) + }) + + it('should be empty when there’s only 1 page', () => { + expect(pagination(1, 1, '?a=b&')).toEqual<Pagination>({}) + }) + + it('should work on page 1 of 2', () => { + expect(pagination(1, 2, '?a=b&')).toEqual<Pagination>({ + next: { href: '?a=b&page=2' }, + items: [ + { number: 1, href: '?a=b&page=1', current: true }, + { number: 2, href: '?a=b&page=2' }, + ], + }) + }) + + it('should work on page 2 of 2', () => { + expect(pagination(2, 2, '?a=b&')).toEqual<Pagination>({ + previous: { href: '?a=b&page=1' }, + items: [ + { number: 1, href: '?a=b&page=1' }, + { number: 2, href: '?a=b&page=2', current: true }, + ], + }) + }) + + it('should work on page 2 of 3', () => { + expect(pagination(2, 3, '?a=b&')).toEqual<Pagination>({ + previous: { href: '?a=b&page=1' }, + next: { href: '?a=b&page=3' }, + items: [ + { number: 1, href: '?a=b&page=1' }, + { number: 2, href: '?a=b&page=2', current: true }, + { number: 3, href: '?a=b&page=3' }, + ], + }) + }) + + it('should work on page 1 of 7', () => { + expect(pagination(1, 7, '?a=b&')).toHaveProperty('items', [ + { number: 1, href: '?a=b&page=1', current: true }, + { number: 2, href: '?a=b&page=2' }, + { ellipsis: true }, + { number: 6, href: '?a=b&page=6' }, + { number: 7, href: '?a=b&page=7' }, + ]) + }) + + it('should work on page 2 of 7', () => { + expect(pagination(2, 7, '?a=b&')).toHaveProperty('items', [ + { number: 1, href: '?a=b&page=1' }, + { number: 2, href: '?a=b&page=2', current: true }, + { number: 3, href: '?a=b&page=3' }, + { ellipsis: true }, + { number: 6, href: '?a=b&page=6' }, + { number: 7, href: '?a=b&page=7' }, + ]) + }) + + it('should work on page 3 of 7', () => { + expect(pagination(3, 7, '?a=b&')).toHaveProperty('items', [ + { number: 1, href: '?a=b&page=1' }, + { number: 2, href: '?a=b&page=2' }, + { number: 3, href: '?a=b&page=3', current: true }, + { number: 4, href: '?a=b&page=4' }, + { ellipsis: true }, + { number: 6, href: '?a=b&page=6' }, + { number: 7, href: '?a=b&page=7' }, + ]) + }) + + it('should work on page 4 of 7', () => { + expect(pagination(4, 7, '?a=b&')).toHaveProperty('items', [ + { number: 1, href: '?a=b&page=1' }, + { number: 2, href: '?a=b&page=2' }, + { number: 3, href: '?a=b&page=3' }, + { number: 4, href: '?a=b&page=4', current: true }, + { number: 5, href: '?a=b&page=5' }, + { number: 6, href: '?a=b&page=6' }, + { number: 7, href: '?a=b&page=7' }, + ]) + }) + + it('should work on page 5 of 7', () => { + expect(pagination(5, 7, '?a=b&')).toHaveProperty('items', [ + { number: 1, href: '?a=b&page=1' }, + { number: 2, href: '?a=b&page=2' }, + { ellipsis: true }, + { number: 4, href: '?a=b&page=4' }, + { number: 5, href: '?a=b&page=5', current: true }, + { number: 6, href: '?a=b&page=6' }, + { number: 7, href: '?a=b&page=7' }, + ]) + }) + + it('should work on page 6 of 7', () => { + expect(pagination(6, 7, '?a=b&')).toHaveProperty('items', [ + { number: 1, href: '?a=b&page=1' }, + { number: 2, href: '?a=b&page=2' }, + { ellipsis: true }, + { number: 5, href: '?a=b&page=5' }, + { number: 6, href: '?a=b&page=6', current: true }, + { number: 7, href: '?a=b&page=7' }, + ]) + }) + + it('should work on page 7 of 7', () => { + expect(pagination(7, 7, '?a=b&')).toHaveProperty('items', [ + { number: 1, href: '?a=b&page=1' }, + { number: 2, href: '?a=b&page=2' }, + { ellipsis: true }, + { number: 6, href: '?a=b&page=6' }, + { number: 7, href: '?a=b&page=7', current: true }, + ]) + }) +}) diff --git a/server/utils/pagination.ts b/server/utils/pagination.ts new file mode 100644 index 0000000..9a72a2a --- /dev/null +++ b/server/utils/pagination.ts @@ -0,0 +1,82 @@ +export type PaginationPreviousOrNext = { + href: string + text?: string + attributes?: Record<string, string> +} + +export type PaginationItem = + | { + number: number + href: string + current?: boolean + } + | { + ellipsis: true + } + +export type Pagination = { + previous?: PaginationPreviousOrNext + items?: Array<PaginationItem> + next?: PaginationPreviousOrNext + landmarkLabel?: string +} + +/** + * Produces parameters for GOV.UK Pagination component macro + * NB: `page` starts at 1 + * + * Accessibility notes: + * - set `landmarkLabel` on the returned object otherwise the navigation box is announced as "results" + * - set `previous.attributes.aria-label` and `next.attributes.aria-label` on the returned object if "Previous" and "Next" are not clear enough + */ +export function pagination(currentPage: number, pageCount: number, hrefPrefix: string): Pagination { + const params: Pagination = {} + + if (!pageCount || pageCount <= 1) { + return params + } + + if (currentPage !== 1) { + params.previous = { + href: `${hrefPrefix}page=${currentPage - 1}`, + } + } + if (currentPage < pageCount) { + params.next = { + href: `${hrefPrefix}page=${currentPage + 1}`, + } + } + + let pages: Array<number | null> + if (currentPage >= 5) { + pages = [1, 2, null, currentPage - 1, currentPage] + } else { + pages = [1, 2, 3, 4].slice(0, currentPage) + } + const maxPage = Math.max(currentPage, pages.at(-1)) + if (maxPage === pageCount - 1) { + pages.push(pageCount) + } else if (maxPage === pageCount - 2) { + pages.push(pageCount - 1, pageCount) + } else if (maxPage === pageCount - 3) { + pages.push(maxPage + 1, pageCount - 1, pageCount) + } else if (maxPage <= pageCount - 4) { + pages.push(maxPage + 1, null, pageCount - 1, pageCount) + } + + params.items = pages.map((somePage: number | null): PaginationItem => { + if (somePage) { + const item: PaginationItem = { + number: somePage, + href: `${hrefPrefix}page=${somePage}`, + } + if (somePage === currentPage) { + item.current = true + } + return item + } + return { ellipsis: true } + }) + + return params +} diff --git a/server/utils/personUtils.test.ts b/server/utils/personUtils.test.ts new file mode 100644 index 0000000..e3452bb --- /dev/null +++ b/server/utils/personUtils.test.ts @@ -0,0 +1,36 @@ +import { personFactory } from '../testutils/factories' +import { isPersonMale, statusTag } from './personUtils' + +describe('personUtils', () => { + describe('statusTag', () => { + it('returns an "In Community" tag for an InCommunity status', () => { + expect(statusTag('InCommunity')).toEqual( + `<strong class="govuk-tag" data-cy-status="InCommunity">In Community</strong>`, + ) + }) + + it('returns an "In Custody" tag for an InCustody status', () => { + expect(statusTag('InCustody')).toEqual(`<strong class="govuk-tag" data-cy-status="InCustody">In Custody</strong>`) + }) + }) + + describe('isPersonMale', () => { + it('returns true if person type is full person and sex is male', () => { + const malePerson = personFactory.build({ type: 'FullPerson', sex: 'Male' }) + + expect(isPersonMale(malePerson)).toEqual(true) + }) + + it('returns false if person type is full person and sex is female', () => { + const malePerson = personFactory.build({ type: 'FullPerson', sex: 'Female' }) + + expect(isPersonMale(malePerson)).toEqual(false) + }) + + it('returns false if person type is not full person', () => { + const malePerson = personFactory.build({ type: 'RestrictedPerson' }) + + expect(isPersonMale(malePerson)).toEqual(false) + }) + }) +}) diff --git a/server/utils/personUtils.ts b/server/utils/personUtils.ts new file mode 100644 index 0000000..60dac3f --- /dev/null +++ b/server/utils/personUtils.ts @@ -0,0 +1,20 @@ +import { FullPerson, Person } from '@approved-premises/api' +import { PersonStatus } from '@approved-premises/ui' + +const statusTag = (status: PersonStatus): string => { + if (status === 'InCommunity') { + return `<strong class="govuk-tag" data-cy-status="${status}">In Community</strong>` + } + + return `<strong class="govuk-tag" data-cy-status="${status}">In Custody</strong>` +} + +const isPersonMale = (person: Person): boolean => { + if (person.type === 'FullPerson' && (person as FullPerson).sex === 'Male') { + return true + } + + return false +} + +export { statusTag, isPersonMale } diff --git a/server/utils/phaseBannerUtils.test.ts b/server/utils/phaseBannerUtils.test.ts new file mode 100644 index 0000000..65ef0cf --- /dev/null +++ b/server/utils/phaseBannerUtils.test.ts @@ -0,0 +1,9 @@ +import { getContent } from './phaseBannerUtils' + +describe('PhaseBannerUtils', () => { + it('returns the phase banner content', () => { + const content = `This is a new service. <a class="govuk-link" rel="noreferrer noopener" target="_blank" href="https://forms.office.com/Pages/ResponsePage.aspx?id=KEeHxuZx_kGp4S6MNndq2KxXUZ1jbxlMsEoiPoZPWGNURVpKVERMMzRYOEpYS1cwVVBDUTZQUThFSS4u">Complete our feedback form</a> (opens in new tab) or <a class="govuk-link" href="mailto:cas2support@digital.justice.gov.uk">email us</a> (cas2support@digital.justice.gov.uk) to report a bug` + + expect(getContent()).toEqual(content) + }) +}) diff --git a/server/utils/phaseBannerUtils.ts b/server/utils/phaseBannerUtils.ts new file mode 100644 index 0000000..82b0f44 --- /dev/null +++ b/server/utils/phaseBannerUtils.ts @@ -0,0 +1,7 @@ +export const supportEmail = 'cas2support@digital.justice.gov.uk' +export const feedbackSurveyUrl = + 'https://forms.office.com/Pages/ResponsePage.aspx?id=KEeHxuZx_kGp4S6MNndq2KxXUZ1jbxlMsEoiPoZPWGNURVpKVERMMzRYOEpYS1cwVVBDUTZQUThFSS4u' + +export const getContent = () => { + return `This is a new service. <a class="govuk-link" rel="noreferrer noopener" target="_blank" href="${feedbackSurveyUrl}">Complete our feedback form</a> (opens in new tab) or <a class="govuk-link" href="mailto:${supportEmail}">email us</a> (${supportEmail}) to report a bug` +} diff --git a/server/utils/taskListUtils.test.ts b/server/utils/taskListUtils.test.ts new file mode 100644 index 0000000..ab97862 --- /dev/null +++ b/server/utils/taskListUtils.test.ts @@ -0,0 +1,69 @@ +import { TaskWithStatus } from '../@types/ui' +import applyPaths from '../paths/apply' +import { applicationFactory } from '../testutils/factories' +import { statusTag, taskLink } from './taskListUtils' + +describe('taskListUtils', () => { + const task = { + id: 'second-task', + title: 'Second Task', + pages: { foo: 'bar', bar: 'baz' }, + status: 'in_progress', + } as TaskWithStatus + + describe('taskLink', () => { + describe('with an application', () => { + const application = applicationFactory.build({ id: 'some-uuid' }) + + it('should return a link to a task the task can be started', () => { + task.status = 'in_progress' + + expect(taskLink(task, application)).toEqual( + `<a class="govuk-link govuk-task-list__link govuk-link--no-visited-state" href="${applyPaths.applications.pages.show( + { + id: 'some-uuid', + task: 'second-task', + page: 'foo', + }, + )}" aria-describedby="second-task-status" data-cy-task-name="second-task">Second Task</a>`, + ) + }) + + it('should return the task name when the task cannot be started', () => { + task.status = 'cannot_start' + + expect(taskLink(task, application)).toEqual(`Second Task`) + }) + }) + }) + + describe('statusTag', () => { + it('returns a an in progress tag when the task is in progress', () => { + task.status = 'in_progress' + + expect(statusTag(task)).toEqual( + '<strong class="govuk-tag govuk-tag--light-blue" id="second-task-status">In progress</strong>', + ) + }) + + it('returns a not started tag when the task has not been started', () => { + task.status = 'not_started' + + expect(statusTag(task)).toEqual( + '<strong class="govuk-tag govuk-tag--blue" id="second-task-status">Not yet started</strong>', + ) + }) + + it('returns a cannot start tag when the task cannot be started', () => { + task.status = 'cannot_start' + + expect(statusTag(task)).toEqual('<span id="second-task-status">Cannot start yet</span>') + }) + + it('returns a completed tag when the task cannot be started', () => { + task.status = 'complete' + + expect(statusTag(task)).toEqual('<span id="second-task-status">Completed</span>') + }) + }) +}) diff --git a/server/utils/taskListUtils.ts b/server/utils/taskListUtils.ts new file mode 100644 index 0000000..5b719ad --- /dev/null +++ b/server/utils/taskListUtils.ts @@ -0,0 +1,30 @@ +import type { TaskWithStatus } from '@approved-premises/ui' +import { Cas2Application as Application } from '../@types/shared' +import applyPaths from '../paths/apply' + +export const statusTag = (task: TaskWithStatus): string => { + switch (task.status) { + case 'complete': + return `<span id="${task.id}-status">Completed</span>` + case 'in_progress': + return `<strong class="govuk-tag govuk-tag--light-blue" id="${task.id}-status">In progress</strong>` + case 'not_started': + return `<strong class="govuk-tag govuk-tag--blue" id="${task.id}-status">Not yet started</strong>` + default: + return `<span id="${task.id}-status">Cannot start yet</span>` + } +} + +export const taskLink = (task: TaskWithStatus, application: Application): string => { + if (task.status !== 'cannot_start') { + const firstPage = Object.keys(task.pages)[0] + const link = applyPaths.applications.pages.show({ + id: application.id, + task: task.id, + page: firstPage, + }) + + return `<a class="govuk-link govuk-task-list__link govuk-link--no-visited-state" href="${link}" aria-describedby="${task.id}-status" data-cy-task-name="${task.id}">${task.title}</a>` + } + return task.title +} diff --git a/server/utils/utils.ts b/server/utils/utils.ts index 87318fe..35aca38 100644 --- a/server/utils/utils.ts +++ b/server/utils/utils.ts @@ -1,4 +1,6 @@ import qs, { IStringifyOptions } from 'qs' +import { Cas2Application, Cas2SubmittedApplication, FullPerson, Person } from '@approved-premises/api' +import { SummaryListItem } from '@approved-premises/ui' const properCase = (word: string): string => word.length >= 1 ? word[0].toUpperCase() + word.toLowerCase().slice(1) : word @@ -30,3 +32,81 @@ export const createQueryString = ( ): string => { return qs.stringify(params, options) } + +export const isFullPerson = (person: Person): person is FullPerson => { + return person.type === 'FullPerson' +} + +/** + * Returns the person's name if they are a FullPerson, otherwise returns 'the person' + * @param {Person} person + * @returns 'the person' | person.name + */ +export const nameOrPlaceholderCopy = (person: Person, copyForRestrictedPerson = 'the person'): string => { + return isFullPerson(person) ? person.name : copyForRestrictedPerson +} + +/** + * Removes any items in an array of summary list items that are blank or undefined + * @param items an array of summary list items + * @returns all items with non-blank values + */ +export const removeBlankSummaryListItems = (items: Array<SummaryListItem>): Array<SummaryListItem> => { + return items.filter(item => { + if ('html' in item.value) { + return item.value.html + } + if ('text' in item.value) { + return item.value.text + } + return false + }) +} + +export const stringToKebabCase = (stringToTransform: string) => { + return stringToTransform.replace(/\s+/g, '-').toLowerCase() +} + +export const isSubmittedApplication = ( + application: Cas2Application | Cas2SubmittedApplication, +): application is Cas2SubmittedApplication => { + return !Object.keys(application).includes('createdBy') +} + +/** + * Transforms a kebab-case formatted string into camelCase + * @param str a string that is formatted in kebab-case + * @returns string formatted in camelCase + */ +export const kebabToCamelCase = (str: string) => { + return str.replace(/-./g, match => match[1].toUpperCase()) +} + +/** + * Transforms a camelCase formatted string into kebab-case + * @param str a string that is formatted in camelCase + * @returns string formatted in kebab-case + */ +export const camelToKebabCase = (str: string) => { + return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() +} + +/** + * Substitutes commas in a comma separated list with HTML linebreaks + * example: "This, is, a, list" --> "This<br>is<br>a<br>list<br>" + * @param str + * @returns string + */ +export const formatCommaToLinebreak = (str: string) => { + return str.replace(/,\s*/g, '<br>') +} + +/** + * Removes HTML tags from a string + * example: <strong>Arson</strong> --> "Arson" + * @param str + * @returns string + */ +export const htmlToPlainText = (str: string) => { + return str.replace(/(<([^>]+)>)/gi, '') +} diff --git a/server/utils/validation.test.ts b/server/utils/validation.test.ts new file mode 100644 index 0000000..5c8d92f --- /dev/null +++ b/server/utils/validation.test.ts @@ -0,0 +1,241 @@ +import { Request, Response } from 'express' +import { createMock } from '@golevelup/ts-jest' +import type { ErrorMessages, ErrorSummary } from '@approved-premises/ui' +import { SanitisedError } from 'server/sanitisedError' +import TaskListPage from 'server/form-pages/taskListPage' +import { + catchAPIErrorOrPropogate, + catchValidationErrorOrPropogate, + fetchErrorsAndUserInput, + firstFlashItem, +} from './validation' +import { TaskListAPIError, ValidationError } from './errors' +import errorLookups from '../i18n/en/errors.json' +import { validateReferer } from './viewUtils' + +jest.mock('./viewUtils') + +jest.mock('../i18n/en/errors.json', () => { + return { + fundingSource: { + empty: 'Select a funding source', + }, + } +}) + +describe('catchValidationErrorOrPropogate', () => { + const request = createMock<Request>({}) + const response = createMock<Response>() + + const expectedErrors = { + fundingSource: { text: errorLookups.fundingSource.empty, attributes: { 'data-cy-error-fundingSource': true } }, + } + + const expectedErrorSummary = [{ text: errorLookups.fundingSource.empty, href: '#fundingSource' }] + + beforeEach(() => { + request.body = { + some: 'field', + } + }) + + it('sets the errors and request body as flash messages and redirects back to the form', () => { + const error = createMock<SanitisedError>({ + data: { + 'invalid-params': [ + { + propertyName: '$.fundingSource', + errorType: 'empty', + }, + ], + }, + }) + + catchValidationErrorOrPropogate(request, response, error, 'some/url') + + expect(request.flash).toHaveBeenCalledWith('errors', expectedErrors) + expect(request.flash).toHaveBeenCalledWith('errorSummary', expectedErrorSummary) + expect(request.flash).toHaveBeenCalledWith('userInput', request.body) + + expect(response.redirect).toHaveBeenCalledWith('some/url') + }) + + it('sets a generic error and redirects back to the form', () => { + const error = createMock<SanitisedError>({ + data: { + detail: 'Some generic error', + 'invalid-params': [], + }, + }) + + catchValidationErrorOrPropogate(request, response, error, 'some/url') + + expect(request.flash).toHaveBeenCalledWith('errorSummary', { text: 'Some generic error' }) + expect(request.flash).toHaveBeenCalledWith('userInput', request.body) + + expect(response.redirect).toHaveBeenCalledWith('some/url') + }) + + it('gets errors from a ValidationError type', () => { + const error = new ValidationError<TaskListPage>({ + data: { + crn: 'You must enter a valid crn', + error: 'You must enter a valid arrival date', + }, + }) + + catchValidationErrorOrPropogate(request, response, error, 'some/url') + + expect(request.flash).toHaveBeenCalledWith('errors', expectedErrors) + expect(request.flash).toHaveBeenCalledWith('errorSummary', expectedErrorSummary) + expect(request.flash).toHaveBeenCalledWith('userInput', request.body) + + expect(response.redirect).toHaveBeenCalledWith('some/url') + }) + + it('throws the error if the error is not the type we expect', () => { + const err = new Error('Some unhandled error') + err.name = 'SomeUnhandledError' + err.stack = 'STACK_GOES_HERE' + + const result = () => catchValidationErrorOrPropogate(request, response, err, 'some/url') + + expect(result).toThrowError(err) + expect(result).toThrowError( + expect.objectContaining({ + message: 'Some unhandled error', + name: 'SomeUnhandledError', + stack: 'STACK_GOES_HERE', + }), + ) + }) + + it('throws an error if the property is not found in the error lookup', () => { + const error = createMock<SanitisedError>({ + data: { + 'invalid-params': [ + { + propertyName: '$.foo', + errorType: 'bar', + }, + ], + }, + }) + + expect(() => catchValidationErrorOrPropogate(request, response, error, 'some/url')).toThrowError( + 'Cannot find a translation for an error at the path $.foo', + ) + }) + + it('throws an error if the error type is not found in the error lookup', () => { + const error = createMock<SanitisedError>({ + data: { + 'invalid-params': [ + { + propertyName: '$.fundingSource', + errorType: 'invalid', + }, + ], + }, + }) + + expect(() => catchValidationErrorOrPropogate(request, response, error, 'some/url')).toThrowError( + 'Cannot find a translation for an error at the path $.fundingSource with the type invalid', + ) + }) +}) + +describe('catchAPIErrorOrPropogate', () => { + const request = createMock<Request>({ headers: { referer: 'foo/bar' } }) + const response = createMock<Response>() + + it('populates the error and redirects to the previous page if the API finds an error', () => { + const error = new TaskListAPIError('some message', 'field') + ;(validateReferer as jest.MockedFunction<typeof validateReferer>).mockReturnValue('some-validated-referer') + + catchAPIErrorOrPropogate(request, response, error) + + expect(request.flash).toHaveBeenCalledWith('errors', { + crn: { text: error.message, attributes: { 'data-cy-error-field': true } }, + }) + expect(request.flash).toHaveBeenCalledWith('errorSummary', [ + { + text: error.message, + href: `#${error.field}`, + }, + ]) + + expect(validateReferer).toHaveBeenCalledWith(request.headers.referer) + expect(response.redirect).toHaveBeenCalledWith('some-validated-referer') + }) + + it('throws the error if not of type TaskListAPIError', () => { + const error = Error('some unhandled error') + + const result = () => catchAPIErrorOrPropogate(request, response, error) + + expect(result).toThrowError(error) + }) +}) + +describe('fetchErrorsAndUserInput', () => { + const request = createMock<Request>({}) + + let errors: ErrorMessages + let userInput: Record<string, unknown> + let errorSummary: ErrorSummary + let errorTitle: string + + beforeEach(() => { + ;(request.flash as jest.Mock).mockImplementation((message: string) => { + return { + errors: [errors], + userInput: [userInput], + errorSummary, + errorTitle: [errorTitle], + }[message] + }) + }) + + it('returns default values if there is nothing present', () => { + const result = fetchErrorsAndUserInput(request) + + expect(result).toEqual({ errors: {}, errorSummary: [], userInput: {}, errorTitle: undefined }) + }) + + it('fetches the values from the flash', () => { + errors = createMock<ErrorMessages>() + errorSummary = createMock<ErrorSummary>() + userInput = { foo: 'bar' } + errorTitle = 'Some title' + + const result = fetchErrorsAndUserInput(request) + + expect(result).toEqual({ errors, errorSummary, userInput, errorTitle }) + }) +}) + +describe('firstFlashItem', () => { + describe('when there is an item', () => { + it('returns the first flash item', () => { + const request = createMock<Request>({}) + const flashMessage = 'flash message' + ;(request.flash as jest.Mock).mockImplementation(() => [flashMessage]) + + const result = firstFlashItem(request, 'key') + + expect(result).toBe(flashMessage) + }) + }) + + describe('when there is nothing in the flash', () => { + it('returns undefined', () => { + const request = createMock<Request>({}) + ;(request.flash as jest.Mock).mockImplementation(() => null) + + const result = firstFlashItem(request, 'key') + + expect(result).toBe(undefined) + }) + }) +}) diff --git a/server/utils/validation.ts b/server/utils/validation.ts new file mode 100644 index 0000000..8305b22 --- /dev/null +++ b/server/utils/validation.ts @@ -0,0 +1,135 @@ +import type { Request, Response } from 'express' +import jsonpath from 'jsonpath' + +import type { ErrorMessage, ErrorMessages, ErrorSummary, ErrorsAndUserInput } from '@approved-premises/ui' +import { SanitisedError } from '../sanitisedError' +import errorLookup from '../i18n/en/errors.json' +import { TaskListAPIError, ValidationError } from './errors' +import { validateReferer } from './viewUtils' + +interface InvalidParams { + propertyName: string + errorType: string +} + +export const firstFlashItem = (request: Request, key: string) => { + const message = request.flash(key) + return message ? message[0] : undefined +} + +export const fetchErrorsAndUserInput = (request: Request): ErrorsAndUserInput => { + const errors = firstFlashItem(request, 'errors') || {} + const errorSummary = request.flash('errorSummary') || [] + const userInput = firstFlashItem(request, 'userInput') || {} + const errorTitle = firstFlashItem(request, 'errorTitle') + + return { errors, errorTitle, errorSummary, userInput } +} + +export const catchAPIErrorOrPropogate = (request: Request, response: Response, error: SanitisedError | Error): void => { + if (error instanceof TaskListAPIError) { + request.flash('errors', { + crn: errorMessage(error.field, error.message), + }) + request.flash('errorSummary', [errorSummary(error.field, error.message)]) + + response.redirect(validateReferer(request.headers.referer)) + } else { + throw error + } +} + +export const catchValidationErrorOrPropogate = ( + request: Request, + response: Response, + error: SanitisedError | Error, + redirectPath: string, +): void => { + const errors = extractValidationErrors(error) + + if (typeof errors === 'string') { + request.flash('errorSummary', { text: errors }) + } else { + const errorMessages = generateErrorMessages(errors) + const errorSummary = generateErrorSummary(errors) + + request.flash('errors', errorMessages) + request.flash('errorSummary', errorSummary) + } + + request.flash('userInput', request.body) + + response.redirect(redirectPath) +} + +export const errorMessage = (field: string, text: string): ErrorMessage => { + return { + text, + attributes: { + [`data-cy-error-${field}`]: true, + }, + } +} + +export const errorSummary = (field: string, text: string): ErrorSummary => { + return { + text, + href: `#${field}`, + } +} + +export const generateErrorMessages = (errors: Record<string, string>): ErrorMessages => { + return Object.keys(errors).reduce((obj, key) => { + return { + ...obj, + [key]: errorMessage(key, errors[key]), + } + }, {}) +} +const errorText = (error: InvalidParams): ErrorSummary => { + const errors = + jsonpath.value(errorLookup, error.propertyName) || + throwUndefinedError(`Cannot find a translation for an error at the path ${error.propertyName}`) + + const text = + errors[error.errorType] || + throwUndefinedError( + `Cannot find a translation for an error at the path ${error.propertyName} with the type ${error.errorType}`, + ) + + return text +} + +const throwUndefinedError = (message: string) => { + throw new Error(message) +} + +const generateErrors = (params: Array<InvalidParams>): Record<string, string> => { + return params.reduce((obj, error) => { + const key = error.propertyName.split('.').slice(1).join('_') + return { + ...obj, + [key]: errorText(error), + } + }, {}) +} + +const extractValidationErrors = (error: SanitisedError | Error) => { + if ('data' in error) { + if (Array.isArray(error.data['invalid-params']) && error.data['invalid-params'].length) { + return generateErrors(error.data['invalid-params'] as InvalidParams[]) + } + if (typeof error.data === 'object' && error.data !== null && 'detail' in error.data) { + return error.data.detail as string + } + if (error instanceof ValidationError) { + return error.data as Record<string, string> + } + } + + throw error +} + +export const generateErrorSummary = (errors: Record<string, string>): Array<ErrorSummary> => { + return Object.keys(errors).map(k => errorSummary(k, errors[k])) +} diff --git a/server/utils/viewUtils.test.ts b/server/utils/viewUtils.test.ts new file mode 100644 index 0000000..5f4f5af --- /dev/null +++ b/server/utils/viewUtils.test.ts @@ -0,0 +1,67 @@ +import * as viewUtilsModule from './viewUtils' +import * as formUtilsModule from './formUtils' + +const { formatLines, validateReferer } = viewUtilsModule + +jest.mock('../config', () => ({ + ingressUrl: 'https://test-domain.com', +})) + +describe('formatLines', () => { + let escapeSpy: jest.SpyInstance<string, [text: string]> + + beforeEach(() => { + escapeSpy = jest.spyOn(formUtilsModule, 'escape') + escapeSpy.mockClear() + }) + + it('replaces newlines with HTML line breaks', () => { + expect(formatLines('Line 1\nLine 2\r\nLine 3\rLine 4')).toEqual('Line 1<br />Line 2<br />Line 3<br />Line 4') + expect(escapeSpy).toBeCalledTimes(4) + }) + + it('replaces consecutive newlines with HTML paragraphs', () => { + expect(formatLines('Paragraph 1, Line 1\nParagraph 1, Line 2\n\nParagraph 2')).toEqual( + '<p>Paragraph 1, Line 1<br />Paragraph 1, Line 2</p><p>Paragraph 2</p>', + ) + expect(escapeSpy).toBeCalledTimes(3) + }) + + it('ignores trailing whiespace', () => { + expect(formatLines('\n\nParagraph 1, Line 1\nParagraph 1, Line 2\n\nParagraph 2 ')).toEqual( + '<p>Paragraph 1, Line 1<br />Paragraph 1, Line 2</p><p>Paragraph 2</p>', + ) + expect(escapeSpy).toBeCalledTimes(3) + }) + + it('escapes line contents', () => { + expect(formatLines('<h2>Line 1</h2>\n<h2>Line 2</h2>')).toEqual( + '<h2>Line 1</h2><br /><h2>Line 2</h2>', + ) + expect(escapeSpy).toHaveBeenCalledTimes(2) + }) + + it('returns the empty string when given null', () => { + expect(formatLines(null)).toEqual('') + }) +}) + +describe('validateReferer', () => { + describe('when the referer is from our own domain', () => { + it('returns the referer path', () => { + expect(validateReferer('https://test-domain.com/some-referer')).toEqual('https://test-domain.com/some-referer') + }) + }) + + describe('when the referer is from outside our domain', () => { + it('returns the root path', () => { + expect(validateReferer('https://example.com/some-external-referer')).toEqual('/') + }) + }) + + describe('when the referer is undefined, for any reason', () => { + it('returns the root path', () => { + expect(validateReferer(undefined)).toEqual('/') + }) + }) +}) diff --git a/server/utils/viewUtils.ts b/server/utils/viewUtils.ts new file mode 100644 index 0000000..21d4ca8 --- /dev/null +++ b/server/utils/viewUtils.ts @@ -0,0 +1,40 @@ +import config from '../config' +import { escape } from './formUtils' + +export const formatLines = (text: string): string => { + if (!text) { + return '' + } + + const normalizedText = normalizeText(text) + + const paragraphs = normalizedText.split('\n\n').map(paragraph => + paragraph + .split('\n') + .map(line => escape(line)) + .join('<br />'), + ) + + if (paragraphs.length === 1) { + return paragraphs[0] + } + return `<p>${paragraphs.join('</p><p>')}</p>` +} + +export const validateReferer = (referer?: string): string => { + if (!referer || !referer.startsWith(config.ingressUrl)) { + return '/' + } + + return referer +} + +function normalizeText(text: string): string { + let output = text.trim() + + output = output.replace(/(\r\n)/g, '\n') + output = output.replace(/(\r)/g, '\n') + output = output.replace(/(\n){2,}/g, '\n\n') + + return output +} diff --git a/tsconfig.json b/tsconfig.json index 0170047..5e36819 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "extends": "@tsconfig/node22/tsconfig.json", "compileOnSave": true, "compilerOptions": { + "target": "es2018", "outDir": "./dist", "sourceMap": true, "skipLibCheck": true, @@ -13,6 +14,7 @@ "allowSyntheticDefaultImports": true, "noImplicitAny": true, "experimentalDecorators": true, + "strictPropertyInitialization": false, "typeRoots": ["./server/@types", "./node_modules/@types"], "baseUrl": ".", "paths": {