+ 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
+
+ abstract previous(): string
+
+ abstract next(): string
+
+ abstract errors(): TaskListErrors
+
+ abstract response?(): Record
+
+ 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) => {}
+
+const Form = (options: { sections: Array }) => {
+ return (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) {}
+ }
+
+ 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,
+ readonly application: Cas2Application,
+ ) {}
+ }
+
+ @Page({
+ bodyProperties: ['foo', 'bar', 'baz'],
+ name: 'Some Name',
+ })
+ class ClassWithApplicationAndPreviousPage {
+ constructor(
+ readonly body: Record,
+ 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) => {}
+
+const Page = (options: { bodyProperties: Array; name: string; controllerActions?: { update: string } }) => {
+ return (constructor: T) => {
+ const TaskListPage = class extends constructor {
+ name = options.name
+
+ body: Record
+
+ document: Cas2Application
+
+ previousPage: string
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ constructor(...args: Array) {
+ 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(body: Record, ...keys: Array): { [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) => {}
+
+const Section = (options: { title: string; tasks: Array }) => {
+ return (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
+
+ 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) => {}
+
+interface Type extends Function {
+ new (...args: Array): T
+}
+
+const Task = (options: { name: string; slug: string; pages: Array> }) => {
+ return