From bd674eede96060ecd254a3a3d162bbd42d4cb88c Mon Sep 17 00:00:00 2001 From: Paul McPhee Date: Mon, 3 Feb 2025 17:40:34 +0000 Subject: [PATCH] WIP --- assets/js/index.js | 17 + .../mas/personal-details/personalDetails.ts | 2 +- integration_tests/e2e/personalDetails.cy.ts | 2 +- .../personalDetails/editContactDetails.cy.ts | 89 ++++ integration_tests/pages/personalDetails.ts | 2 +- .../personalDetails/editContactDetails.ts | 27 ++ package-lock.json | 6 + package.json | 3 +- server/@types/Route.type.ts | 1 + server/data/masApiClient.ts | 10 + server/data/model/personalDetails.ts | 32 ++ .../middleware/validation/validationUtils.ts | 74 +++ server/properties/errorMessages.ts | 1 + server/properties/index.ts | 187 ++++++++ server/routes/personalDetails.ts | 119 ++++- server/utils/nunjucksSetup.ts | 4 + server/utils/utils.ts | 21 + .../_compliance-previous-orders.njk | 3 +- .../edit-contact-details.njk | 426 ++++++++++++++++++ server/views/pages/personal-details.njk | 69 ++- server/views/partials/layout.njk | 6 +- wiremock/mappings/X000001-full.json | 19 +- 22 files changed, 1068 insertions(+), 52 deletions(-) create mode 100644 integration_tests/e2e/personalDetails/editContactDetails.cy.ts create mode 100644 integration_tests/pages/personalDetails/editContactDetails.ts create mode 100644 server/middleware/validation/validationUtils.ts create mode 100644 server/views/pages/edit-contact-details/edit-contact-details.njk diff --git a/assets/js/index.js b/assets/js/index.js index 0157ff4f..012eca1c 100644 --- a/assets/js/index.js +++ b/assets/js/index.js @@ -57,5 +57,22 @@ const resetConditionals = () => { }) } } + +const setNoFixedAddressConditional = () => { + const fixedAddressSection = document.querySelector('div[fixed-address-section]') + const fixedAddressCheckbox = document.querySelector('input[name="noFixedAddress"]') + const showOrHide = () => { + if (fixedAddressCheckbox.checked) { + fixedAddressSection.classList.add('govuk-visually-hidden') + } else { + fixedAddressSection.classList.remove('govuk-visually-hidden') + } + } + if (fixedAddressSection && fixedAddressCheckbox) { + showOrHide() + fixedAddressCheckbox.addEventListener('click', showOrHide) + } +} +setNoFixedAddressConditional() lastAppointment() resetConditionals() diff --git a/e2e_tests/steps/mas/personal-details/personalDetails.ts b/e2e_tests/steps/mas/personal-details/personalDetails.ts index 81f28b2c..a7122f58 100644 --- a/e2e_tests/steps/mas/personal-details/personalDetails.ts +++ b/e2e_tests/steps/mas/personal-details/personalDetails.ts @@ -5,7 +5,7 @@ export const loginMasAndGoToPersonalDetails = async (page: Page, crn: string) => await loginToManageMySupervision(page) await searchForCrn(page, crn) await page.getByRole('link', { name: 'Personal details' }).first().click() - await expect(page.locator('h1.govuk-heading-l')).toContainText('Personal Details') + await expect(page.locator('h1.govuk-heading-l')).toContainText('Personal details') } export const searchForCrn = async (page: Page, crn: string) => { diff --git a/integration_tests/e2e/personalDetails.cy.ts b/integration_tests/e2e/personalDetails.cy.ts index 9a948a92..58be81e1 100644 --- a/integration_tests/e2e/personalDetails.cy.ts +++ b/integration_tests/e2e/personalDetails.cy.ts @@ -8,7 +8,7 @@ context('Personal Details', () => { page.assertRiskTags() page.headerCrn().should('contain.text', 'X000001') page.headerName().should('contain.text', 'Eula Schmeler') - page.pageHeading().should('contain.text', 'Personal Details') + page.pageHeading().should('contain.text', 'Personal details') page.getTab('overview').should('contain.text', 'Overview') page.getTab('personalDetails').should('contain.text', 'Personal details') page.getTab('risk').should('contain.text', 'Risk') diff --git a/integration_tests/e2e/personalDetails/editContactDetails.cy.ts b/integration_tests/e2e/personalDetails/editContactDetails.cy.ts new file mode 100644 index 00000000..416a5976 --- /dev/null +++ b/integration_tests/e2e/personalDetails/editContactDetails.cy.ts @@ -0,0 +1,89 @@ +import Page from '../../pages/page' +import EditContactDetails from '../../pages/personalDetails/editContactDetails' + +context('Edit contact details', () => { + it('Edit contact details page is rendered based on non fixed address', () => { + cy.visit('/case/X000001/personal-details/edit-contact-details') + const page = Page.verifyOnPage(EditContactDetails) + page.getElement('phoneNumber').should('be.visible') + page.getElement('mobileNumber').should('be.visible') + page.getElement('emailAddress').should('be.visible') + page.getElement('buildingName').should('not.be.visible') + page.getElement('buildingNumber').should('not.be.visible') + page.getElement('streetName').should('not.be.visible') + page.getElement('district').should('not.be.visible') + page.getElement('town').should('not.be.visible') + page.getElement('county').should('not.be.visible') + page.getElement('postcode').should('not.be.visible') + page.getElement('startDate').should('be.visible') + page.getElement('endDate').should('be.visible') + page.getElement('notes').should('be.visible') + page.getCheckboxField('noFixedAddress').click() + page.getElement('buildingName').should('be.visible') + page.getElement('buildingNumber').should('be.visible') + page.getElement('streetName').should('be.visible') + page.getElement('district').should('be.visible') + page.getElement('town').should('be.visible') + page.getElement('county').should('be.visible') + page.getElement('postcode').should('be.visible') + }) + it('Submitting successfully should redirect to Personal details screen with update banner', () => { + cy.visit('/case/X000001/personal-details/edit-contact-details') + const page = Page.verifyOnPage(EditContactDetails) + page.getElement('submit-btn').click() + page.getElement('updateBanner').should('contain.text', 'Contact details updated') + }) + it('Submitting with an end date should stay on edit page with warning banner, then second submit will redirect to Personal details screen with update banner', () => { + cy.visit('/case/X000001/personal-details/edit-contact-details') + const page = Page.verifyOnPage(EditContactDetails) + page.getElementInput('endDate').type('03/02/2025') + page.getElement('submit-btn').click() + page + .getElement('infoBanner') + .should( + 'contain.text', + 'An end will change this to a previous address and you will need to add a new main address.', + ) + page.getElement('submit-btn').click() + page.getElement('updateBanner').should('contain.text', 'Contact details updated') + }) + + it('Submitting with invalid data with over 35 chars should show error messages', () => { + cy.visit('/case/X000001/personal-details/edit-contact-details') + const page = Page.verifyOnPage(EditContactDetails) + page.getElementInput('phoneNumber').clear().type('1'.repeat(36)) + page.getElementInput('mobileNumber').clear().type('1'.repeat(36)) + page.getElementInput('emailAddress').clear().type('1'.repeat(36)) + page.getCheckboxField('noFixedAddress').click() + page.getElementInput('buildingName').clear().type('1'.repeat(36)) + page.getElementInput('buildingNumber').clear().type('1'.repeat(36)) + page.getElementInput('streetName').clear().type('1'.repeat(36)) + page.getElementInput('district').clear().type('1'.repeat(36)) + page.getElementInput('town').clear().type('1'.repeat(36)) + page.getElementInput('county').clear().type('1'.repeat(36)) + page.getElementInput('postcode').clear().type('1'.repeat(36)) + + page.getElement('submit-btn').click() + page.getElement('phoneNumberError').should('contain.text', 'Phone number must be 35 characters or less.') + page.getElement('mobileNumberError').should('contain.text', 'Mobile number must be 35 characters or less.') + page.getElement('emailAddress').should('contain.text', 'Enter an email address in the correct format.') + + page.getElement('buildingName').should('contain.text', 'Building name must be 35 characters or less.') + page.getElement('buildingNumber').should('contain.text', 'Building number must be 35 characters or less.') + page.getElement('streetName').should('contain.text', 'Street name must be 35 characters or less.') + page.getElement('district').should('contain.text', 'District must be 35 characters or less.') + page.getElement('town').should('contain.text', 'Town or city must be 35 characters or less.') + page.getElement('county').should('contain.text', 'County must be 35 characters or less.') + page.getElement('postcode').should('contain.text', 'Enter a full UK postcode.') + }) + + it('Submitting with a dates later than today should show error messages', () => { + cy.visit('/case/X000001/personal-details/edit-contact-details') + const page = Page.verifyOnPage(EditContactDetails) + page.getElementInput('endDate').type('03/02/2099') + page.getElementInput('startDate').clear().type('03/02/2099') + page.getElement('submit-btn').click() + page.getElement('endDateError').should('contain.text', 'End date can not be later than today.') + page.getElement('startDateError').should('contain.text', 'Start date can not be later than today.') + }) +}) diff --git a/integration_tests/pages/personalDetails.ts b/integration_tests/pages/personalDetails.ts index e61c1d99..f7651a71 100644 --- a/integration_tests/pages/personalDetails.ts +++ b/integration_tests/pages/personalDetails.ts @@ -2,6 +2,6 @@ import Page from './page' export default class PersonalDetailsPage extends Page { constructor() { - super('Personal Details') + super('Personal details') } } diff --git a/integration_tests/pages/personalDetails/editContactDetails.ts b/integration_tests/pages/personalDetails/editContactDetails.ts new file mode 100644 index 00000000..7603e186 --- /dev/null +++ b/integration_tests/pages/personalDetails/editContactDetails.ts @@ -0,0 +1,27 @@ +import Page, { PageElement } from '../page' + +export default class EditContactDetails extends Page { + constructor() { + super('Edit contact details') + } + + getNoFixedAddress = (addressId: string, rowName: string, type: string): PageElement => { + return cy.get(`#noFixedAddress`) + } + + getElement = (name: string): PageElement => { + return cy.get(`[data-qa="${name}"]`) + } + + getElementInput = (name: string): PageElement => { + return cy.get(`[data-qa="${name}"] input`) + } + + getDateElementInput = (name: string): PageElement => { + return cy.get(`[data-qa="${name}"] govuk-input moj-js-datepicker-input`) + } + + getCheckboxField = (name: string): PageElement => { + return cy.get(`input[type="checkbox"]`) + } +} diff --git a/package-lock.json b/package-lock.json index 4e92db5a..d92833cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "nunjucks": "^3.2.4", "passport": "^0.7.0", "passport-oauth2": "^1.8.0", + "postcode-validator": "^3.10.2", "prom-client": "^15.1.3", "redis": "^4.7.0", "slugify": "^1.6.6", @@ -14130,6 +14131,11 @@ "node": ">= 0.4" } }, + "node_modules/postcode-validator": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/postcode-validator/-/postcode-validator-3.10.2.tgz", + "integrity": "sha512-KIwfGxBYfLVLmjfVbc2Uge340q+rAuICeLyRXX6Ul4FjahZ1xnGSt843fj4AVtliy29I0g0pda3YK/T43EO3Gw==" + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", diff --git a/package.json b/package.json index 069e90bf..f52da950 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test": "jest", "test:ci": "jest --runInBand", "security_audit": "npx audit-ci --config audit-ci.json", - "int-test": "cypress run --config video=false", + "int-test": "cypress run --spec integration_tests/e2e/personalDetails/editContactDetails.cy.ts --config video=false", "int-test-ui": "cypress open --e2e --browser chrome", "clean": "rm -rf dist build node_modules stylesheets", "sentry:sourcemaps": "sentry-cli sourcemaps inject --org ministryofjustice --project hmpps-manage-people-on-probation-ui ./dist && sentry-cli sourcemaps upload --org ministryofjustice --project hmpps-manage-people-on-probation-ui ./dist", @@ -125,6 +125,7 @@ "nunjucks": "^3.2.4", "passport": "^0.7.0", "passport-oauth2": "^1.8.0", + "postcode-validator": "^3.10.2", "prom-client": "^15.1.3", "redis": "^4.7.0", "slugify": "^1.6.6", diff --git a/server/@types/Route.type.ts b/server/@types/Route.type.ts index 787be0bb..250dc7df 100644 --- a/server/@types/Route.type.ts +++ b/server/@types/Route.type.ts @@ -8,6 +8,7 @@ import { Location } from '../data/model/caseload' import { SentryConfig } from '../config' interface Locals { + errorMessages: Record filters?: ActivityLogFiltersResponse user: { token: string; authSource: string; username?: string } compactView?: boolean diff --git a/server/data/masApiClient.ts b/server/data/masApiClient.ts index 9dd9389e..baebe66d 100644 --- a/server/data/masApiClient.ts +++ b/server/data/masApiClient.ts @@ -7,6 +7,7 @@ import { DisabilityOverview, PersonalContact, PersonalDetails, + PersonalDetailsUpdateRequest, ProvisionOverview, } from './model/personalDetails' import { AddressOverview, PersonSummary } from './model/common' @@ -84,6 +85,15 @@ export default class MasApiClient extends RestClient { return this.get({ path: `/personal-details/${crn}`, handle404: false }) } + async updatePersonalDetails(crn: string, body: PersonalDetailsUpdateRequest): Promise { + return this.post({ + data: body, + path: `/personal-details/${crn}`, + handle404: true, + handle500: true, + }) + } + async getPersonalContact(crn: string, id: string): Promise { return this.get({ path: `/personal-details/${crn}/personal-contact/${id}`, handle404: false }) } diff --git a/server/data/model/personalDetails.ts b/server/data/model/personalDetails.ts index 1947476a..fe2aab4e 100644 --- a/server/data/model/personalDetails.ts +++ b/server/data/model/personalDetails.ts @@ -23,6 +23,27 @@ export interface PersonalDetails { religionOrBelief?: string sexualOrientation?: string documents: Document[] + addressTypes: AddressType[] +} + +export interface PersonalDetailsUpdateRequest { + [index: string]: string | boolean + phoneNumber?: string + mobileNumber?: string + emailAddress?: string + buildingName?: string + buildingNumber?: string + streetName?: string + district?: string + town?: string + county?: string + postcode?: string + addressType?: string + startDate?: string + endDate?: string + notes?: string + verified?: boolean + noFixedAddress?: boolean } export interface PersonalContact { @@ -49,6 +70,11 @@ export interface Address { lastUpdatedBy?: Name } +export interface AddressType { + code?: string + description?: string +} + export interface PersonAddress { buildingName?: string buildingNumber?: string @@ -63,7 +89,13 @@ export interface PersonAddress { from: string to: string type?: string + typeCode?: string status?: string + verified?: boolean + noFixedAddress?: boolean + notes?: string + startDate?: string + endDate?: string } export interface Circumstances { diff --git a/server/middleware/validation/validationUtils.ts b/server/middleware/validation/validationUtils.ts new file mode 100644 index 00000000..6c8e8a2e --- /dev/null +++ b/server/middleware/validation/validationUtils.ts @@ -0,0 +1,74 @@ +import { postcodeValidator } from 'postcode-validator' +import { DateTime } from 'luxon' +import { PersonalDetailsUpdateRequest } from '../../data/model/personalDetails' + +export interface ErrorCheck { + fn: (...args: any[]) => boolean + msg: string + length?: number + crossField?: string +} + +export interface ErrorChecks { + optional: boolean + checks: ErrorCheck[] +} + +export interface ValidationSpec { + [index: string]: ErrorChecks +} + +export const isEmail = (string: string) => /[a-z0-9]+@[a-z]+\.[a-z]{2,3}/.test(string) +export const isNotEmpty = (args: any[]) => { + return !!args[0] && args[0] !== undefined +} +export const isNumeric = (args: any[]) => /^[+-]?\d+(\.\d+)?$/.test(args[0]) +export const isUkEmail = (args: any[]) => { + return postcodeValidator(args[0], 'GB') +} +export const charsOrLess = (args: any[]) => { + return !(args[1].length > args[0]) +} +export const isValidDate = (args: any[]) => { + return DateTime.fromFormat(args[0], 'd/M/yyyy').isValid +} + +export const isNotLaterThanToday = (args: any[]) => { + const date = DateTime.fromFormat(args[0], 'd/M/yyyy') + return date.isValid && date <= DateTime.now() +} + +export const isNotLaterThan = (args: any[]) => { + const notLaterThanDate = DateTime.fromFormat(args[0], 'd/M/yyyy') + const date = DateTime.fromFormat(args[1], 'd/M/yyyy') + if (!notLaterThanDate.isValid || !date.isValid) { + return true + } + return notLaterThanDate <= date +} + +export function validateWithSpec(request: PersonalDetailsUpdateRequest, validationSpec: ValidationSpec) { + const errors: Record = {} + Object.entries(validationSpec).forEach(entry => { + const fieldName = entry[0] + const checks = entry[1] + if (Object.prototype.hasOwnProperty.call(request, fieldName)) { + if (!request[fieldName] && checks.optional === true) { + return + } + for (const c of checks.checks) { + let args: any[] = c?.length ? [c.length, request[fieldName]] : [request[fieldName]] + if (c?.crossField) { + args = [request[c?.crossField], request[fieldName]] + } + if (!c.fn(args)) { + errors[fieldName] = c.msg + break + } + } + } else if (checks.optional === false) { + errors[fieldName] = checks.checks[0].msg + } + }) + return errors +} diff --git a/server/properties/errorMessages.ts b/server/properties/errorMessages.ts index 2e03e6b8..444fe668 100644 --- a/server/properties/errorMessages.ts +++ b/server/properties/errorMessages.ts @@ -8,6 +8,7 @@ const ruleKeys = [ 'isAfterTo', 'isBeforeFrom', ] as const +const personalContactKeys = ['phoneNumber', 'mobileNumber'] as const const appointmentsKeys = [ 'type', 'sentence', diff --git a/server/properties/index.ts b/server/properties/index.ts index f5fde6fc..2fcf94ee 100644 --- a/server/properties/index.ts +++ b/server/properties/index.ts @@ -1,5 +1,16 @@ import errorMessages from './errorMessages' import appointmentTypes from './appointmentTypes' +import { + charsOrLess, + isEmail, + isNotEmpty, + isNotLaterThan, + isNotLaterThanToday, + isNumeric, + isUkEmail, + isValidDate, + ValidationSpec, +} from '../middleware/validation/validationUtils' const validate = { errorMessages, @@ -7,3 +18,179 @@ const validate = { } export default validate + +export const personDetailsValidation: ValidationSpec = { + phoneNumber: { + optional: true, + checks: [ + { + fn: isNumeric, + msg: 'Enter a phone number in the correct format.', + }, + { + fn: charsOrLess, + length: 35, + msg: `Phone number must be 35 characters or less.`, + }, + ], + }, + mobileNumber: { + optional: true, + checks: [ + { + fn: isNumeric, + msg: 'Enter a mobile number in the correct format.', + }, + { + fn: charsOrLess, + length: 35, + msg: `Mobile number must be 35 characters or less.`, + }, + ], + }, + emailAddress: { + optional: true, + checks: [ + { + fn: isEmail, + msg: 'Enter an email address in the correct format.', + }, + { + fn: charsOrLess, + length: 35, + msg: `Mobile number must be 35 characters or less.`, + }, + ], + }, + + buildingName: { + optional: true, + checks: [ + { + fn: charsOrLess, + length: 35, + msg: `Building name must be 35 characters or less.`, + }, + ], + }, + + buildingNumber: { + optional: true, + checks: [ + { + fn: charsOrLess, + length: 35, + msg: `Building number must be 35 characters or less.`, + }, + ], + }, + + streetName: { + optional: true, + checks: [ + { + fn: charsOrLess, + length: 35, + msg: `Street name must be 35 characters or less.`, + }, + ], + }, + + district: { + optional: true, + checks: [ + { + fn: charsOrLess, + length: 35, + msg: `District must be 35 characters or less.`, + }, + ], + }, + + town: { + optional: true, + checks: [ + { + fn: charsOrLess, + length: 35, + msg: `Town or city must be 35 characters or less.`, + }, + ], + }, + + county: { + optional: true, + checks: [ + { + fn: charsOrLess, + length: 35, + msg: `County must be 35 characters or less.`, + }, + ], + }, + + postcode: { + optional: true, + checks: [ + { + fn: isUkEmail, + msg: 'Enter a full UK postcode.', + }, + ], + }, + + addressType: { + optional: false, + checks: [ + { + fn: isNotEmpty, + msg: 'Select an address type.', + }, + ], + }, + + verified: { + optional: false, + checks: [ + { + fn: isNotEmpty, + msg: 'Select yes if the address is verified.', + }, + ], + }, + startDate: { + optional: false, + checks: [ + { + fn: isNotEmpty, + msg: 'Enter or select a start date.', + }, + { + fn: isValidDate, + msg: 'Enter or select a start date.', + }, + { + fn: isNotLaterThanToday, + msg: 'Start date can not be later than today.', + }, + ], + }, + endDate: { + optional: true, + checks: [ + { + fn: isValidDate, + msg: 'Enter or select an end date.', + }, + { + fn: isNotLaterThanToday, + msg: 'End date can not be later than today.', + }, + { + fn: isNotLaterThan, + crossField: 'startDate', + msg: 'End date can not be earlier than the start date.', + }, + ], + }, +} diff --git a/server/routes/personalDetails.ts b/server/routes/personalDetails.ts index 2916e165..301773b4 100644 --- a/server/routes/personalDetails.ts +++ b/server/routes/personalDetails.ts @@ -7,13 +7,18 @@ import MasApiClient from '../data/masApiClient' import ArnsApiClient from '../data/arnsApiClient' import TierApiClient from '../data/tierApiClient' import type { Route } from '../@types' -import { toPredictors, toRoshWidget } from '../utils/utils' +import { toIsoDateFromPicker, toPredictors, toRoshWidget, toTimeline } from '../utils/utils' +import { PersonalDetailsUpdateRequest } from '../data/model/personalDetails' +import { validateWithSpec } from '../middleware/validation/validationUtils' +import { personDetailsValidation } from '../properties' export default function personalDetailRoutes(router: Router, { hmppsAuthClient }: Services) { const get = (path: string | string[], handler: Route) => router.get(path, asyncMiddleware(handler)) + const post = (path: string, handler: Route) => router.post(path, asyncMiddleware(handler)) get('/case/:crn/personal-details', async (req, res, _next) => { const { crn } = req.params + const success = req.query.update const token = await hmppsAuthClient.getSystemClientToken(res.locals.user.username) const masClient = new MasApiClient(token) const arnsClient = new ArnsApiClient(token) @@ -45,9 +50,121 @@ export default function personalDetailRoutes(router: Router, { hmppsAuthClient } crn, risksWidget, predictorScores, + success, }) }) + get('/case/:crn/personal-details/edit-contact-details', async (req, res, _next) => { + const { crn } = req.params + const token = await hmppsAuthClient.getSystemClientToken(res.locals.user.username) + const masClient = new MasApiClient(token) + const arnsClient = new ArnsApiClient(token) + const tierClient = new TierApiClient(token) + + await auditService.sendAuditMessage({ + action: 'VIEW_EDIT_PERSONAL_DETAILS', + who: res.locals.user.username, + subjectId: crn, + subjectType: 'CRN', + correlationId: v4(), + service: 'hmpps-manage-people-on-probation-ui', + }) + const [personalDetails, risks, needs, tierCalculation, predictors] = await Promise.all([ + masClient.getPersonalDetails(crn), + arnsClient.getRisks(crn), + arnsClient.getNeeds(crn), + tierClient.getCalculationDetails(crn), + arnsClient.getPredictorsAll(crn), + ]) + + const risksWidget = toRoshWidget(risks) + + const predictorScores = toPredictors(predictors) + res.render('pages/edit-contact-details/edit-contact-details', { + personalDetails, + needs, + tierCalculation, + crn, + risksWidget, + predictorScores, + backLink: `/case/${crn}/personal-details`, + }) + }) + + post('/case/:crn/personal-details/edit-contact-details', async (req, res, _next) => { + const errorMessages = validateWithSpec(req.body, personDetailsValidation) + res.locals.errorMessages = errorMessages + + const request: PersonalDetailsUpdateRequest = { + ...req.body, + endDate: toIsoDateFromPicker(req.body.endDate), + startDate: toIsoDateFromPicker(req.body.startDate), + noFixedAddress: req.body.noFixedAddress === 'true', + verified: req.body.verified ? req.body.verified === 'true' : null, + } + + const warningDisplayed: boolean = + !request.endDate || Object.prototype.hasOwnProperty.call(req.body, 'endDateWarningDisplayed') + const isValid = Object.keys(errorMessages).length === 0 && warningDisplayed + const { crn } = req.params + const token = await hmppsAuthClient.getSystemClientToken(res.locals.user.username) + const masClient = new MasApiClient(token) + const arnsClient = new ArnsApiClient(token) + const tierClient = new TierApiClient(token) + await auditService.sendAuditMessage({ + action: 'SAVE_EDIT_PERSONAL_DETAILS', + who: res.locals.user.username, + subjectId: crn, + subjectType: 'CRN', + correlationId: v4(), + service: 'hmpps-manage-people-on-probation-ui', + }) + + if (!isValid) { + const [personalDetails, risks, needs, tierCalculation, predictors] = await Promise.all([ + masClient.getPersonalDetails(crn), + arnsClient.getRisks(crn), + arnsClient.getNeeds(crn), + tierClient.getCalculationDetails(crn), + arnsClient.getPredictorsAll(crn), + ]) + + const risksWidget = toRoshWidget(risks) + const predictorScores = toPredictors(predictors) + const mainAddress = { ...personalDetails.mainAddress } + personalDetails.telephoneNumber = request.phoneNumber + personalDetails.mobileNumber = request.mobileNumber + personalDetails.email = request.emailAddress + mainAddress.noFixedAddress = request.noFixedAddress + mainAddress.buildingName = request.buildingName + mainAddress.buildingNumber = request.buildingNumber + mainAddress.streetName = request.streetName + mainAddress.district = request.district + mainAddress.town = request.town + mainAddress.county = request.county + mainAddress.postcode = request.postcode + mainAddress.typeCode = request.addressType + mainAddress.verified = request.verified + mainAddress.from = request.startDate + mainAddress.to = request.endDate + mainAddress.notes = request.notes + personalDetails.mainAddress = mainAddress + + res.render('pages/edit-contact-details/edit-contact-details', { + personalDetails, + needs, + tierCalculation, + crn, + risksWidget, + predictorScores, + backLink: `/case/${crn}/personal-details`, + }) + } else { + await masClient.updatePersonalDetails(crn, request) + res.redirect(`/case/${crn}/personal-details?update=success`) + } + }) + get('/case/:crn/personal-details/personal-contact/:id', async (req, res, _next) => { const { crn } = req.params const { id } = req.params diff --git a/server/utils/nunjucksSetup.ts b/server/utils/nunjucksSetup.ts index 6bf027d6..1da4615b 100644 --- a/server/utils/nunjucksSetup.ts +++ b/server/utils/nunjucksSetup.ts @@ -20,6 +20,7 @@ import { deliusDateFormat, deliusDeepLinkUrl, deliusHomepageUrl, + fromIsoDateToPicker, fullName, getAppointmentsToAction, getComplianceStatus, @@ -51,6 +52,7 @@ import { tierLink, timeForSort, timeFromTo, + toIsoDateFromPicker, toSlug, toYesNo, yearsSince, @@ -152,4 +154,6 @@ export default function nunjucksSetup(app: express.Express, applicationInfo: App njkEnv.addGlobal('isDefined', isDefined) njkEnv.addGlobal('hasValue', hasValue) njkEnv.addGlobal('riskLevelLabel', riskLevelLabel) + njkEnv.addGlobal('toIsoDateFromPicker', toIsoDateFromPicker) + njkEnv.addGlobal('fromIsoDateToPicker', fromIsoDateToPicker) } diff --git a/server/utils/utils.ts b/server/utils/utils.ts index 51bdabc2..6ea5ffbf 100644 --- a/server/utils/utils.ts +++ b/server/utils/utils.ts @@ -91,6 +91,27 @@ export const monthsOrDaysElapsed = (datetimeString: string): string => { return `${months} months` } +export const toIsoDateFromPicker = (datetimeString: string): string => { + if (!datetimeString || isBlank(datetimeString)) return null + const converted = DateTime.fromFormat(datetimeString, 'd/M/yyyy').toFormat('yyyy-MM-dd') + if (converted === 'Invalid DateTime') { + return datetimeString + } + return converted +} + +export const fromIsoDateToPicker = (datetimeString: string): string => { + if (!datetimeString || isBlank(datetimeString)) return null + if (!DateTime.fromFormat(datetimeString, 'yyyy-MM-dd').isValid) { + return datetimeString + } + const converted = DateTime.fromISO(datetimeString).toFormat('d/M/yyyy') + if (converted === 'Invalid DateTime') { + return datetimeString + } + return converted +} + export const dateWithYearShortMonth = (datetimeString: string): string => { if (!datetimeString || isBlank(datetimeString)) return null return DateTime.fromISO(datetimeString).toFormat('d MMM yyyy') diff --git a/server/views/pages/compliance/_compliance-previous-orders.njk b/server/views/pages/compliance/_compliance-previous-orders.njk index 6990dd33..ed178a96 100644 --- a/server/views/pages/compliance/_compliance-previous-orders.njk +++ b/server/views/pages/compliance/_compliance-previous-orders.njk @@ -67,7 +67,8 @@ items: [ { text: 'View order', - href: '#' + href: deliusDeepLinkUrl('ContactList', offenderId), + attributes: {'aria-label': 'View order ended ' + order.endDate | dateWithYear + ' (opens in new tab)', 'target': '_blank' } } ] } diff --git a/server/views/pages/edit-contact-details/edit-contact-details.njk b/server/views/pages/edit-contact-details/edit-contact-details.njk new file mode 100644 index 00000000..ec86687e --- /dev/null +++ b/server/views/pages/edit-contact-details/edit-contact-details.njk @@ -0,0 +1,426 @@ +{% extends "../../partials/case.njk" %} +{% set pageTitle = applicationName + " - Personal Details" %} +{% set currentNavSection = 'personal-details' %} +{% set currentSectionName = 'Edit contact details for ' + personalDetails.name.forename %} +{% set headerPersonName = personalDetails.name.forename + ' ' + personalDetails.name.surname %} +{% set headerCRN = crn %} +{% set headerDob = personalDetails.dateOfBirth %} +{% set headerGender = personalDetails.preferredGender %} + +{% set addressTypeSelectItems = [{text: "Select address type", value: null }] %} + {% for addressType in personalDetails.addressTypes %} + {% set addressTypeSelectItems = (addressTypeSelectItems.push({ + selected: personalDetails.mainAddress.typeCode === addressType.code, + text: addressType.description, + value: addressType.code + }), addressTypeSelectItems) %} + {% endfor %} + +{% block beforeContent %} + + {% set bannerHtml %} +

An end will change this to a previous address and you will need to add a new main address.

+ {% endset %} + + + {{ govukBackLink({ + href: backLink + }) }} + + {{ govukBreadcrumbs({ + items: [ + { + text: "My cases", + href: "/case" + }, + { + text: headerPersonName, + href: "/case/" + crn, + attributes: { "data-ai-id": "breadcrumbPersonNameLink" } + }, + { + text: "Personal details", + href: "/case/" + crn + "/personal-details" + }, + { + text: currentSectionName + } + ] + }) }} + + {{ mojBanner({ + type: 'information', + html: bannerHtml, + attributes: { "data-qa": "infoBanner"}, + classes: 'govuk-!-margin-bottom-2' + }) if personalDetails.mainAddress.to }} + +{% endblock %} + +{% block pageContent %} + +
+
+ + {% if personalDetails.mainAddress.to %} + + {% endif %} + + {{ govukInput({ + value: personalDetails.telephoneNumber if personalDetails.telephoneNumber, + label: { + text: "Phone number (optional)", + classes: "govuk-label--s govuk-!-font-weight-bold" + }, + hint: { + text: "For example, 01632 960 000" + }, + name: 'phoneNumber', + formGroup: { + attributes: { + 'data-qa': 'phoneNumber' + } + }, + classes: "govuk-input--width", + errorMessage: { + text: errorMessages.phoneNumber, + attributes: { + 'data-qa': 'phoneNumberError' + } + } if errorMessages.phoneNumber + }) }} + + {{ govukInput({ + value: personalDetails.mobileNumber if personalDetails.mobileNumber, + label: { + text: "Mobile number (optional)", + classes: "govuk-label--s govuk-!-font-weight-bold" + }, + hint: { + text: "For example, 07771 900 900" + }, + name: 'mobileNumber', + formGroup: { + attributes: { + 'data-qa': 'mobileNumber' + } + }, + classes: "govuk-input", + errorMessage: { + text: errorMessages.mobileNumber, + attributes: { + 'data-qa': 'mobileNumberError' + } + } if errorMessages.mobileNumber + }) }} + + + {{ govukInput({ + value: personalDetails.email if personalDetails.email, + label: { + text: "Email address (optional)", + classes: "govuk-label--s govuk-!-font-weight-bold" + }, + hint: { + text: "For example, name@example.com" + }, + name: 'emailAddress', + formGroup: { + attributes: { + 'data-qa': 'emailAddress' + } + }, + classes: "govuk-input", + errorMessage: { + text: errorMessages.emailAddress, + attributes: { + 'data-qa': 'emailAddressError' + } + } if errorMessages.emailAddress + }) }} + + +

Main address

+ +

Select if they have no fixed main address

+ + {{ govukCheckboxes ({ + "name": 'noFixedAddress', + "items": [ + { + 'value': true, + 'text': 'No fixed address', + 'checked': personalDetails.mainAddress.postcode === 'NF1 1NF' or personalDetails.mainAddress.noFixedAddress === true, + 'attributes': { "data-qa": "noFixedAddress"} + } + ] + }) }} + +
+ + + {{ govukInput({ + value: personalDetails.mainAddress.buildingName if personalDetails.mainAddress.buildingName, + label: { + text: "Building name (optional)", + classes: "govuk-label--s govuk-!-font-weight-regular" + }, + name: 'buildingName', + formGroup: { + attributes: { + 'data-qa': 'buildingName' + } + }, + classes: "govuk-input", + errorMessage: { + text: errorMessages.buildingName, + attributes: { + 'data-qa': 'buildingNameError' + } + } if errorMessages.buildingName + }) }} + + {{ govukInput({ + value: personalDetails.mainAddress.buildingNumber if personalDetails.mainAddress.buildingNumber, + label: { + text: "Building number (optional)", + classes: "govuk-label--s govuk-!-font-weight-regular" + }, + name: 'buildingNumber', + formGroup: { + attributes: { + 'data-qa': 'buildingNumber' + } + }, + classes: "govuk-input", + errorMessage: { + text: errorMessages.buildingNumber, + attributes: { + 'data-qa': 'buildingNumber' + } + } if errorMessages.buildingNumber + }) }} + + {{ govukInput({ + value: personalDetails.mainAddress.streetName if personalDetails.mainAddress.streetName, + label: { + text: "Street name (optional)", + classes: "govuk-label--s govuk-!-font-weight-regular" + }, + name: 'streetName', + formGroup: { + attributes: { + 'data-qa': 'streetName' + } + }, + classes: "govuk-input", + errorMessage: { + text: errorMessages.streetName, + attributes: { + 'data-qa': 'streetNameError' + } + } if errorMessages.streetName + }) }} + + {{ govukInput({ + value: personalDetails.mainAddress.district if personalDetails.mainAddress.district, + label: { + text: "District (optional)", + classes: "govuk-label--s govuk-!-font-weight-regular" + }, + name: 'district', + formGroup: { + attributes: { + 'data-qa': 'district' + } + }, + classes: "govuk-input", + errorMessage: { + text: errorMessages.district, + attributes: { + 'data-qa': 'districtError' + } + } if errorMessages.district + }) }} + + {{ govukInput({ + value: personalDetails.mainAddress.town if personalDetails.mainAddress.town, + label: { + text: "Town or city (optional)", + classes: "govuk-label--s govuk-!-font-weight-regular" + }, + name: 'town', + formGroup: { + attributes: { + 'data-qa': 'town' + } + }, + classes: "govuk-input--width-20", + errorMessage: { + text: errorMessages.town, + attributes: { + 'data-qa': 'townError' + } + } if errorMessages.town + }) }} + + {{ govukInput({ + value: personalDetails.mainAddress.county if personalDetails.mainAddress.county, + label: { + text: "County (optional)", + classes: "govuk-label--s govuk-!-font-weight-regular" + }, + name: 'county', + formGroup: { + attributes: { + 'data-qa': 'county' + } + }, + classes: "govuk-input--width-20", + errorMessage: { + text: errorMessages.county, + attributes: { + 'data-qa': 'countyError' + } + } if errorMessages.county + }) }} + + + {{ govukInput({ + value: personalDetails.mainAddress.postcode if personalDetails.mainAddress.postcode, + label: { + text: "Postcode (optional)", + classes: "govuk-label--s govuk-!-font-weight-regular" + }, + name: 'postcode', + formGroup: { + attributes: { + 'data-qa': 'postcode' + } + }, + classes: "govuk-input--width-10", + errorMessage: { + text: errorMessages.postcode, + attributes: { + 'data-qa': 'postcodeError' + } + } if errorMessages.postcode + }) }} +
+ {{ govukSelect({ + label: { + text: "Type", + classes: "govuk-label--s govuk-!-font-weight-bold" + }, + classes: "govuk-select--width-30", + name: 'addressType', + formGroup: { + attributes: { + 'data-qa': 'addressType' + } + }, + items: addressTypeSelectItems, + errorMessage: { + text: errorMessages.addressType, + attributes: { + 'data-qa': 'addressTypeError' + } + } if errorMessages.addressType + }) }} + + {{ govukRadios({ + classes: "govuk-radios--inline", + fieldset: { + legend: { + text: "Is the address verified?", + classes: "govuk-label--s govuk-!-font-weight-bold" + } + }, + name: "verified", + attributes: { "data-qa": "verified"}, + items: [ + { + checked: personalDetails.mainAddress.verified === true, + text: "Yes", + value: true + }, + { + checked: personalDetails.mainAddress.verified === false, + text: "No", + value: false + } + ], + errorMessage: { + text: errorMessages.verified, + attributes: { + 'data-qa': 'verfiedError' + } + } if errorMessages.verified + }) }} + + {{ mojDatePicker({ + value: fromIsoDateToPicker(personalDetails.mainAddress.from), + name: "startDate", + label: { + text: "Start date", + classes: "govuk-label--s" + }, + formGroup: { + attributes: { + 'data-qa': 'startDate' + } + }, + hint: { + text: "For example, 17/5/2024." + }, + errorMessage: { + text: errorMessages.startDate, + attributes: { + 'data-qa': 'startDateError' + } + } if errorMessages.startDate + }) }} + + {{ mojDatePicker({ + value: fromIsoDateToPicker(personalDetails.mainAddress.to), + name: "endDate", + label: { + text: "End date (optional)", + classes: "govuk-label--s" + }, + formGroup: { + attributes: { + 'data-qa': 'endDate' + } + }, + hint: { + text: "For example, 17/5/2024." + }, + errorMessage: { + text: errorMessages.endDate, + attributes: { + 'data-qa': 'endDateError' + } + } if errorMessages.endDate + }) }} + + {{ govukTextarea({ + name: "notes", + attributes: { "data-qa": "notes"}, + value: personalDetails.mainAddress.notes, + label: { + text: "Notes (optional)", + classes: 'govuk-label--s' + } + }) }} + + {{ govukButton({ + html: "Save changes", + attributes: { + 'data-qa': 'submit-btn' + } + }) }} + +
+
+{% endblock %} \ No newline at end of file diff --git a/server/views/pages/personal-details.njk b/server/views/pages/personal-details.njk index f57bbeb8..2bc2fe46 100644 --- a/server/views/pages/personal-details.njk +++ b/server/views/pages/personal-details.njk @@ -1,7 +1,7 @@ {% extends "../partials/case.njk" %} {% set pageTitle = makePageTitle({ pageHeading: ["Contact", "Personal details"] }) %} {% set currentNavSection = 'personal-details' %} -{% set currentSectionName = 'Personal Details' %} +{% set currentSectionName = 'Personal details' %} {% set headerPersonName = personalDetails.name.forename + ' ' + personalDetails.name.surname %} {% set headerCRN = crn %} {% set headerDob = personalDetails.dateOfBirth %} @@ -24,6 +24,14 @@ } ] }) }} + + {{ mojBanner({ + type: 'success', + text: 'Contact details updated', + classes: 'govuk-!-margin-bottom-2', + attributes: { "data-qa": "updateBanner"}, + iconFallbackText: 'Success' + }) if success }} {% endblock %} {% block pageContent %} @@ -33,35 +41,19 @@ {% set address %} {% if personalDetails.mainAddress %} {{ addressToList(mainAddress).join('
') | safe }} - {% set addressDetails %} - {{ govukSummaryList({ - rows: [ - { - key: { html: 'Address telephone' }, - value: { html: '' + mainAddress.telephoneNumber + '' } - } if mainAddress.telephoneNumber, - { - key: { html: 'Type of address' }, - value: { html: '' + mainAddress.type + (' (verified)' if mainAddress.verified else ' (not verified)') + '' } - }, - { - key: { html: 'Start date' }, - value: { html: '' + mainAddress.from | dateWithYear + '' } - }, - { - key: { html: 'Notes' }, - value: { html: '' + mainAddress.notes | nl2br if mainAddress.notes else 'No notes' + '' } - } - ] - }) }} - {% endset %} - - {{ govukDetails({ - summaryText: "View address details", - classes: 'govuk-!-margin-top-2 govuk-!-margin-bottom-0', - html: addressDetails - }) }} - +
+

+ Type of address + {{ mainAddress.type + ' (verified)' if mainAddress.verified else mainAddress.type + ' (not verified)' }} +

+

+ Start date + {{ mainAddress.from | dateWithYear }} +

+

+ Notes + {{ mainAddress.notes | nl2br if mainAddress.notes else 'No notes' }} +

{% else %} No main address
Add a main address in NDelius @@ -131,16 +123,7 @@ }, { key: { html: 'Email address' }, - value: { html: '' + personalDetails.email + '' if personalDetails.email else 'No email address' }, - actions: { - items: [ - { - href: "/case/" + crn + "/handoff/delius", - text: "Change", - visuallyHiddenText: "email address" - } - ] - } + value: { html: '' + personalDetails.email + '' if personalDetails.email else 'No email address' } }, { key: { html: '' + 'Main address
' + mainAddress.lastUpdated | lastUpdatedDate + '
' }, @@ -166,11 +149,11 @@ actions: { items: [ { - href: "#", - text: "Change in NDelius" + href: "/case/" + crn + "/personal-details/edit-contact-details", + text: "Change" } ] - } if false + } }) }} {% set personalDetailsHtml %} diff --git a/server/views/partials/layout.njk b/server/views/partials/layout.njk index 5b112069..d838bfd2 100644 --- a/server/views/partials/layout.njk +++ b/server/views/partials/layout.njk @@ -17,8 +17,12 @@ {% from "govuk/components/pagination/macro.njk" import govukPagination %} {% from "govuk/components/input/macro.njk" import govukInput %} {% from "govuk/components/select/macro.njk" import govukSelect %} +{% from "govuk/components/checkboxes/macro.njk" import govukCheckboxes %} {% from "govuk/components/panel/macro.njk" import govukPanel %} -{%- from "moj/components/date-picker/macro.njk" import mojDatePicker -%} +{% from "moj/components/date-picker/macro.njk" import mojDatePicker %} +{% from "govuk/components/textarea/macro.njk" import govukTextarea %} +{% from "moj/components/banner/macro.njk" import mojBanner %} + {% block head %} diff --git a/wiremock/mappings/X000001-full.json b/wiremock/mappings/X000001-full.json index fd3e5e88..87500259 100644 --- a/wiremock/mappings/X000001-full.json +++ b/wiremock/mappings/X000001-full.json @@ -206,8 +206,7 @@ }, { "request": { - "urlPattern": "/mas/personal-details/X000001", - "method": "GET" + "urlPattern": "/mas/personal-details/X000001" }, "response": { "status": 200, @@ -319,6 +318,20 @@ "relationshipType": "Emergency Contact" } ], + "addressTypes": [ + { + "code": "BASS", + "description": "BASS accommodation 13 weeks or more" + }, + { + "code": "HL", + "description": "Homeless - Shelter/Emergency Hostel/Campsite" + }, + { + "code": "HO", + "description": "Householder (Owner - freehold or leasehold)" + } + ], "mainAddress": { "buildingNumber": "32", "streetName": "SCOTLAND STREET", @@ -329,7 +342,9 @@ "verified": true, "lastUpdated": "2023-03-20", "type": "Householder (Owner - freehold or leasehold)", + "typeCode": "HO", "status": "Main", + "noFixedAddress": true, "lastUpdatedBy": { "forename": "NDelius03", "surname": "NDelius03"