diff --git a/app/prototype_v4_1/data/questions.yaml b/app/prototype_v4_1/data/questions.yaml index 981d7db2..552f3f7b 100644 --- a/app/prototype_v4_1/data/questions.yaml +++ b/app/prototype_v4_1/data/questions.yaml @@ -162,7 +162,7 @@ questions: valueKey: metric label: What is your height in centimetres? suffix: cm - inputmode: numeric + inputmode: decimal classes: nhsuk-input--width-4 switchUnits: text: Switch to feet and inches @@ -171,17 +171,17 @@ questions: validation: required: true type: number - min: 100 - max: 250 + min: 139.7 + max: 243.8 errors: required: text: Enter your height in centimetres invalid: text: Enter your height in centimetres using numbers min: - text: Height in centimetres must be 100 or more + text: Height in centimetres must be 139.7cm or more max: - text: Height in centimetres must be 250 or fewer + text: Height in centimetres must be 243.8cm or fewer - id: height-imperial type: text_group @@ -220,13 +220,21 @@ questions: - answerKey: feet required: true type: number - min: 3 - max: 8 + integer: true + min: 0 - answerKey: inches required: true type: number + integer: true min: 0 max: 11 + total: + min: 55 + max: 96 + items: + - answerKey: feet + multiplier: 12 + - answerKey: inches errors: items: feet: @@ -236,11 +244,11 @@ questions: invalid: text: Enter your height in feet using numbers href: "#height-imperial-feet" - min: - text: Height in feet must be 3 or more + integer: + text: Height in feet must be a whole number href: "#height-imperial-feet" - max: - text: Height in feet must be 8 or fewer + min: + text: Height in feet must be 0ft or more href: "#height-imperial-feet" inches: required: @@ -249,12 +257,22 @@ questions: invalid: text: Enter your height in inches using numbers href: "#height-imperial-inches" + integer: + text: Height in inches must be a whole number + href: "#height-imperial-inches" min: - text: Height in inches must be 0 or more + text: Height in inches must be 0in or more href: "#height-imperial-inches" max: - text: Height in inches must be 11 or fewer + text: Height in inches must be 11in or fewer href: "#height-imperial-inches" + total: + min: + text: Height must be between 4 feet 7 inches and 8 feet + href: "#height-imperial-feet" + max: + text: Height must be between 4 feet 7 inches and 8 feet + href: "#height-imperial-feet" - id: weight-metric type: text @@ -272,7 +290,7 @@ questions: valueKey: metric label: What is your weight in kilograms? suffix: kg - inputmode: numeric + inputmode: decimal classes: nhsuk-input--width-4 switchUnits: text: Switch to stones and pounds @@ -281,17 +299,20 @@ questions: validation: required: true type: number - min: 30 - max: 250 + decimalPlaces: 2 + min: 25.4 + max: 317.5 errors: required: text: Enter your weight in kilograms invalid: text: Enter your weight in kilograms using numbers + decimalPlaces: + text: Weight in kilograms must have 2 decimal places or fewer min: - text: Weight in kilograms must be 30 or more + text: Weight in kilograms must be 25.4kg or more max: - text: Weight in kilograms must be 250 or fewer + text: Weight in kilograms must be 317.5kg or fewer - id: weight-imperial type: text_group @@ -330,13 +351,21 @@ questions: - answerKey: stones required: true type: number - min: 4 - max: 40 + integer: true + min: 0 - answerKey: pounds required: true type: number + integer: true min: 0 max: 13 + total: + min: 56 + max: 700 + items: + - answerKey: stones + multiplier: 14 + - answerKey: pounds errors: items: stones: @@ -346,11 +375,11 @@ questions: invalid: text: Enter your weight in stones using numbers href: "#weight-imperial-stones" - min: - text: Weight in stones must be 4 or more + integer: + text: Weight in stones must be a whole number href: "#weight-imperial-stones" - max: - text: Weight in stones must be 40 or fewer + min: + text: Weight in stones must be 0st or more href: "#weight-imperial-stones" pounds: required: @@ -359,12 +388,22 @@ questions: invalid: text: Enter your weight in pounds using numbers href: "#weight-imperial-pounds" + integer: + text: Weight in pounds must be a whole number + href: "#weight-imperial-pounds" min: - text: Weight in pounds must be 0 or more + text: Weight in pounds must be 0lb or more href: "#weight-imperial-pounds" max: - text: Weight in pounds must be 13 or fewer + text: Weight in pounds must be 13lb or fewer href: "#weight-imperial-pounds" + total: + min: + text: Weight must be between 4 stone and 50 stone + href: "#weight-imperial-stones" + max: + text: Weight must be between 4 stone and 50 stone + href: "#weight-imperial-stones" - id: gender type: single diff --git a/app/prototype_v4_1/docs/error-messages.md b/app/prototype_v4_1/docs/error-messages.md new file mode 100644 index 00000000..e88bebd7 --- /dev/null +++ b/app/prototype_v4_1/docs/error-messages.md @@ -0,0 +1,533 @@ +# Error messages + +This document summarises validation logic and error messages for `prototype_v4_1`. + +Sources: + +- `data/questions.yaml` for base question content, validation rules and error text +- `lib/question-validator.js` for shared validation behaviour +- `lib/tobacco-flow.js` for runtime tobacco question overrides +- `controllers/question.js` for route branching and answer-clearing logic + +## Shared validation logic + +- Blank values are `undefined`, `null`, an empty string after trimming, or an empty array. +- `required` errors stop further validation for that question or field. +- Date validation only accepts real calendar dates. If no date part is supplied, the `required` message is used; if any date part is supplied but the date is not real, the `invalid` message is used. +- Number validation rejects non-finite numbers, then checks whole-number, decimal-place, minimum and maximum rules in that order. +- Grouped text inputs validate each item first. Total minimum and maximum checks only run when the grouped values are present and numerically valid. +- Conditional reveal validation only runs for the selected trigger value. If the conditional value is blank, the conditional `required` message is used; number rules then use the question-level number error messages unless a runtime override replaces them. +- Error links default to `#${question.input.id || question.id}`. Date, grouped and conditional inputs usually set explicit `href` values. + +## Flow logic + +- `phone-questionnaire`: `yes` goes to `phone-questionnaire-exit`; `no` continues to `smoker`. +- `smoker`: `no` or `yes_fewer_than_100` goes to `not-eligible-for-screening`; current or previous smokers continue to `date-of-birth`. +- `date-of-birth`: valid dates outside the eligible scan age range go to `not-eligible-for-scan`; eligible users continue to `face-to-face-appointment`. +- `face-to-face-appointment`: `yes` goes to `book-appointment`; `no` continues to height and weight. +- `height-*` and `weight-*`: metric and imperial pages are alternatives. Submitting one unit clears the other unit answer. +- `cancer-diagnosis-relatives`: `yes` continues to `cancer-diagnosis-relatives-age`; `no` clears that answer and continues to smoking history. +- `age-stopped-smoking`: only shown when `smoker` is `yes_previous`; otherwise the answer is cleared and the flow continues to `periods-stopped-smoking`. +- `periods-stopped-smoking`: if `no`, `yearsStoppedSmoking` is cleared. +- `smoking-type`: `none` goes to `smoking-type-exit`; otherwise the selected tobacco types create the tobacco sub-flow in tobacco.yaml order. Former smokers skip each type-specific `smoking-status` question. +- Non-shisha tobacco types ask `smoking-frequency`, `smoking-quantity`, then `smoking-change`. Shisha asks `smoking-setting`, then asks `smoking-frequency` and `smoking-quantity` for each selected setting. +- `smoking-change`: selected `greater` and `fewer` options create corresponding changed-smoking follow-up steps. Unselected changed-smoking answer groups are cleared. +- `smoking-quantity` and `smoking-quantity-change`: if `another_amount` is not selected, the related `smokingQuantityOther` answer is cleared. + +## Runtime tobacco messages + +- Tobacco sub-flow pages use `getSmokingContentQuestionOverrides()` before validation. The validator sees the runtime heading, input name, selected value and tobacco-specific variant. +- `smoking-status`, `smoking-frequency`, `smoking-setting`, `smoking-quantity`, `smoking-change`, `smoking-frequency-change`, `smoking-quantity-change`, and `smoking-years-change` can replace their base `required` text with contextual text generated from the runtime heading. +- For headings beginning `How often`, the required message is `Select ...`; for `How much`, `How many`, or `How long`, the message is `Enter ...` for text inputs and `Select ...` for single-choice inputs; yes/no headings become `Select whether ...`. +- For numeric tobacco quantity text inputs, non-rolling-tobacco and non-shisha types require whole numbers. The integer message is generated as `[answer phrase] must be a whole number`. +- For shisha `another_amount`, the conditional input messages are `Enter the number of hours`, `Number of hours must be a number`, `Number of hours must be 0.5 or more`, and `Number of hours must be [maxHours] or fewer`. `maxHours` is 24 for daily/non-shisha, 168 weekly, 744 monthly, and 8760 yearly. +- `smoking-quantity-change` adds a comparison error when a `greater` answer is not greater than the original amount, or a `fewer` answer is not fewer than the original amount after normalising by frequency. The message is generated as `[quantity phrase] must be more/fewer/less than [original quantity] [original frequency]`. + +## Question messages + +### accept-terms + +Question: Terms of use + +Type: `multiple` + +Answer key: `acceptTerms` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Confirm that you have read and agree to the terms of use | `#accept-terms` | + +### phone-questionnaire + +Question: Confirm if you have completed a lung cancer risk questionnaire by phone + +Type: `single` + +Answer key: `phoneQuestionnaire` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have previously completed a lung cancer risk questionnaire by phone | `#phone-questionnaire` | + +### smoker + +Question: Tobacco smoking + +Type: `single` + +Answer key: `smoker` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever smoked tobacco | `#smoker` | + +### date-of-birth + +Question: What is your date of birth? + +Type: `date` + +Answer key: `dateOfBirth` + +Validation: required; type: date + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your date of birth | `#dateOfBirth-day` | +| `invalid` | Enter a real date of birth | `#dateOfBirth-day` | + +### face-to-face-appointment + +Question: Check if you need a face-to-face appointment + +Type: `single` + +Answer key: `faceToFaceAppointment` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you need to leave the online service and ask for a face-to-face appointment | `#face-to-face-appointment` | + +### height-metric + +Question: Your height + +Type: `text` + +Answer key: `height` + +Validation: required; type: number; minimum 139.7; maximum 243.8 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your height in centimetres | `#height-metric` | +| `invalid` | Enter your height in centimetres using numbers | `#height-metric` | +| `min` | Height in centimetres must be 139.7cm or more | `#height-metric` | +| `max` | Height in centimetres must be 243.8cm or fewer | `#height-metric` | + +### height-imperial + +Question: Your height + +Type: `text_group` + +Answer key: `height` + +Validation: grouped input items: feet (required, number, whole number, min 0); inches (required, number, whole number, min 0, max 11); group total: min 55, max 96 + +| Rule | Message | Link | +| --- | --- | --- | +| `feet.required` | Enter your height in feet | `#height-imperial-feet` | +| `feet.invalid` | Enter your height in feet using numbers | `#height-imperial-feet` | +| `feet.integer` | Height in feet must be a whole number | `#height-imperial-feet` | +| `feet.min` | Height in feet must be 0ft or more | `#height-imperial-feet` | +| `inches.required` | Enter your height in inches | `#height-imperial-inches` | +| `inches.invalid` | Enter your height in inches using numbers | `#height-imperial-inches` | +| `inches.integer` | Height in inches must be a whole number | `#height-imperial-inches` | +| `inches.min` | Height in inches must be 0in or more | `#height-imperial-inches` | +| `inches.max` | Height in inches must be 11in or fewer | `#height-imperial-inches` | +| `total.min` | Height must be between 4 feet 7 inches and 8 feet | `#height-imperial-feet` | +| `total.max` | Height must be between 4 feet 7 inches and 8 feet | `#height-imperial-feet` | + +### weight-metric + +Question: Your weight + +Type: `text` + +Answer key: `weight` + +Validation: required; type: number; maximum 2 decimal places; minimum 25.4; maximum 317.5 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your weight in kilograms | `#weight-metric` | +| `invalid` | Enter your weight in kilograms using numbers | `#weight-metric` | +| `decimalPlaces` | Weight in kilograms must have 2 decimal places or fewer | `#weight-metric` | +| `min` | Weight in kilograms must be 25.4kg or more | `#weight-metric` | +| `max` | Weight in kilograms must be 317.5kg or fewer | `#weight-metric` | + +### weight-imperial + +Question: Your weight + +Type: `text_group` + +Answer key: `weight` + +Validation: grouped input items: stones (required, number, whole number, min 0); pounds (required, number, whole number, min 0, max 13); group total: min 56, max 700 + +| Rule | Message | Link | +| --- | --- | --- | +| `stones.required` | Enter your weight in stones | `#weight-imperial-stones` | +| `stones.invalid` | Enter your weight in stones using numbers | `#weight-imperial-stones` | +| `stones.integer` | Weight in stones must be a whole number | `#weight-imperial-stones` | +| `stones.min` | Weight in stones must be 0st or more | `#weight-imperial-stones` | +| `pounds.required` | Enter your weight in pounds | `#weight-imperial-pounds` | +| `pounds.invalid` | Enter your weight in pounds using numbers | `#weight-imperial-pounds` | +| `pounds.integer` | Weight in pounds must be a whole number | `#weight-imperial-pounds` | +| `pounds.min` | Weight in pounds must be 0lb or more | `#weight-imperial-pounds` | +| `pounds.max` | Weight in pounds must be 13lb or fewer | `#weight-imperial-pounds` | +| `total.min` | Weight must be between 4 stone and 50 stone | `#weight-imperial-stones` | +| `total.max` | Weight must be between 4 stone and 50 stone | `#weight-imperial-stones` | + +### gender + +Question: Your gender identity + +Type: `single` + +Answer key: `gender` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select which option best describes you | `#gender` | + +### sex + +Question: Your sex at birth + +Type: `single` + +Answer key: `sex` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select your sex at birth | `#sex` | + +### ethnicity + +Question: Your ethnic background + +Type: `single` + +Answer key: `ethnicity` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select your ethnic background | `#ethnicity` | + +### education + +Question: Your education + +Type: `single` + +Answer key: `education` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select the highest level of education you have completed | `#education` | + +### respiratory-conditions + +Question: Have you ever been diagnosed with any of the following respiratory conditions? + +Type: `multiple` + +Answer key: `respiratoryConditions` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select if you have ever been diagnosed with any respiratory conditions | `#respiratory-conditions` | + +### asbestos-at-work + +Question: Tell us if you might have been exposed to asbestos at work + +Type: `single` + +Answer key: `asbestosAtWork` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever worked in a job where you might have been exposed to asbestos | `#asbestos-at-work` | + +### asbestos-at-home + +Question: Tell us if you ever lived with anyone who worked with asbestos + +Type: `single` + +Answer key: `asbestosAtHome` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever lived with anyone who worked with asbestos | `#asbestos-at-home` | + +### cancer-diagnosis + +Question: Tell us if you have ever been diagnosed with cancer + +Type: `single` + +Answer key: `cancerDiagnosis` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever been diagnosed with cancer | `#cancer-diagnosis` | + +### cancer-diagnosis-relatives + +Question: Tell us if your parents, siblings or children have ever been diagnosed with lung cancer + +Type: `single` + +Answer key: `cancerDiagnosisRelatives` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether any of your parents, siblings or children have ever been diagnosed with lung cancer | `#cancer-diagnosis-relatives` | + +### cancer-diagnosis-relatives-age + +Question: If your relatives were under 60 when they were diagnosed + +Type: `single` + +Answer key: `cancerDiagnosisRelativesAge` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether any of your relatives were younger than 60 when they were diagnosed with lung cancer | `#cancer-diagnosis-relatives-age` | + +### age-started-smoking + +Question: How old were you when you started smoking? + +Type: `text` + +Answer key: `ageStartedSmoking` + +Validation: required; type: number; minimum 1; maximum 120 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter the age you started smoking | `#age-started-smoking` | +| `invalid` | Enter the age you started smoking using numbers | `#age-started-smoking` | +| `min` | Age you started smoking must be 1 or older | `#age-started-smoking` | +| `max` | Age you started smoking must be 120 or younger | `#age-started-smoking` | + +### age-stopped-smoking + +Question: How old were you when you stopped smoking? + +Type: `text` + +Answer key: `ageStoppedSmoking` + +Validation: required; type: number; minimum 1; maximum 120 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter the age you stopped smoking | `#age-stopped-smoking` | +| `invalid` | Enter the age you stopped smoking using numbers | `#age-stopped-smoking` | +| `min` | Age you stopped smoking must be 1 or older | `#age-stopped-smoking` | +| `max` | Age you stopped smoking must be 120 or younger | `#age-stopped-smoking` | + +### periods-stopped-smoking + +Question: Periods when you stopped smoking + +Type: `single` + +Answer key: `periodsStoppedSmoking` + +Validation: required; conditional: yes (required, number, min 1, max 80) + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever stopped smoking for periods of 1 year or longer | `#periods-stopped-smoking` | +| `invalid` | Total number of years you stopped smoking must be a number | `#years-stopped-smoking` | +| `min` | Total number of years you stopped smoking must be 1 or more | `#years-stopped-smoking` | +| `max` | Total number of years you stopped smoking must be 80 or fewer | `#years-stopped-smoking` | +| `conditional.yes.required` | Enter the total number of years you stopped smoking | `#years-stopped-smoking` | + +### smoking-type + +Question: The type of tobacco you smoke or used to smoke + +Type: `multiple` + +Answer key: `smokingType` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select the types of tobacco you have smoked | `#smoking-type` | + +Variants: `previous`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-status + +Question: Do you currently smoke? + +Type: `single` + +Answer key: `smokingStatus` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you currently smoke this type of tobacco | `#smoking-status` | + +### smoking-frequency + +Question: How often do you smoke? + +Type: `single` + +Answer key: `smokingFrequency` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select how often you smoke this type of tobacco | `#smoking-frequency` | + +### smoking-quantity + +Question: How much do you smoke? + +Type: `text` + +Answer key: `smokingQuantity` + +Validation: required; type: number; minimum 1; maximum 200 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how much you smoke | `#smoking-quantity` | +| `invalid` | Enter how much you smoke using numbers | `#smoking-quantity` | +| `min` | Amount smoked must be 1 or more | `#smoking-quantity` | +| `max` | Amount smoked must be 200 or fewer | `#smoking-quantity` | + +Variants: `rolling_tobacco`, `shisha`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-setting + +Question: Do you usually smoke shisha in a group or on your own? + +Type: `multiple` + +Answer key: `smokingSetting` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you usually smoke shisha in a group or on your own | `#smoking-setting` | + +### smoking-change + +Question: Has the amount you normally smoke changed over time? + +Type: `multiple` + +Answer key: `smokingChange` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether the amount you normally smoke has changed over time | `#smoking-change` | + +### smoking-frequency-change + +Question: How often did you smoke? + +Type: `single` + +Answer key: `smokingFrequencyChange` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select how often you smoked this type of tobacco | `#smoking-frequency-change` | + +### smoking-quantity-change + +Question: How much did you smoke? + +Type: `text` + +Answer key: `smokingQuantityChange` + +Validation: required; type: number; minimum 1; maximum 200 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how much you smoked | `#smoking-quantity-change` | +| `invalid` | Enter how much you smoked using numbers | `#smoking-quantity-change` | +| `min` | Amount smoked must be 1 or more | `#smoking-quantity-change` | +| `max` | Amount smoked must be 200 or fewer | `#smoking-quantity-change` | + +Variants: `rolling_tobacco`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-years-change + +Question: How many years did you smoke this amount? + +Type: `text` + +Answer key: `smokingYearsChange` + +Validation: required; type: number; minimum 1; maximum 80 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how many years you smoked this amount | `#smoking-years-change` | +| `invalid` | Enter how many years using numbers | `#smoking-years-change` | +| `min` | Number of years must be 1 or more | `#smoking-years-change` | +| `max` | Number of years must be 80 or fewer | `#smoking-years-change` | diff --git a/app/prototype_v4_1/docs/question-schema.md b/app/prototype_v4_1/docs/question-schema.md index 85db6b46..513e5736 100644 --- a/app/prototype_v4_1/docs/question-schema.md +++ b/app/prototype_v4_1/docs/question-schema.md @@ -314,7 +314,10 @@ Supported validation fields are: | `type: date` | Requires day, month and year to form a real date. | | `min` | Minimum numeric value. | | `max` | Maximum numeric value. | +| `integer` | Requires a numeric answer to be a whole number. | +| `decimalPlaces` | Maximum decimal places for a numeric answer. | | `items` | Per-field validation for `text_group`. | +| `total` | Combined numeric validation for `text_group` fields. | | `conditional` | Validation for conditional reveal inputs. | For numeric questions, provide all relevant messages: diff --git a/app/prototype_v4_1/lib/question-validator.js b/app/prototype_v4_1/lib/question-validator.js index 35d5ecf7..ef162476 100644 --- a/app/prototype_v4_1/lib/question-validator.js +++ b/app/prototype_v4_1/lib/question-validator.js @@ -12,8 +12,11 @@ const { getQuestion } = require('./questions') * @property {string} [type] - Type-specific validation, for example number or date. * @property {number} [min] - Minimum numeric value. * @property {number} [max] - Maximum numeric value. + * @property {boolean} [integer] - Whether a numeric value must be a whole number. + * @property {number} [decimalPlaces] - Maximum number of decimal places allowed. * @property {Object} [conditional] - Conditional reveal validation rules. * @property {Object[]} [items] - Validation rules for grouped inputs. + * @property {Object} [total] - Total validation rules for grouped numeric inputs. */ /** @@ -191,6 +194,30 @@ const mergeQuestion = (question, overrides = {}) => { } } +/** + * Check whether a submitted numeric value is written as a whole number. + * + * @param {*} value - Submitted value. + * @returns {boolean} True when the submitted value is an integer string. + */ +const isIntegerValue = (value) => { + return /^-?\d+$/.test(String(value).trim()) +} + +/** + * Check whether a submitted numeric value has no more than the allowed number + * of decimal places. + * + * @param {*} value - Submitted value. + * @param {number} decimalPlaces - Maximum decimal places allowed. + * @returns {boolean} True when the value has an allowed decimal precision. + */ +const hasMaximumDecimalPlaces = (value, decimalPlaces) => { + const match = String(value).trim().match(/^-?\d+(?:\.(\d+))?$/) + + return Boolean(match) && (!match[1] || match[1].length <= decimalPlaces) +} + /** * Validate a numeric value against invalid, min and max rules. * @@ -203,11 +230,24 @@ const mergeQuestion = (question, overrides = {}) => { const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => { const number = Number(value) - if (Number.isNaN(number)) { + if (Number.isNaN(number) || !Number.isFinite(number)) { errors.push(makeError(errorsConfig.invalid, defaultHref)) return } + if (validation.integer && !isIntegerValue(value)) { + errors.push(makeError(errorsConfig.integer || errorsConfig.invalid, defaultHref)) + return + } + + if ( + validation.decimalPlaces !== undefined && + !hasMaximumDecimalPlaces(value, validation.decimalPlaces) + ) { + errors.push(makeError(errorsConfig.decimalPlaces || errorsConfig.invalid, defaultHref)) + return + } + if (validation.min !== undefined && number < validation.min) { errors.push(makeError(errorsConfig.min, defaultHref)) } @@ -217,6 +257,61 @@ const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => } } +/** + * Validate the total value of grouped numeric inputs. + * + * @param {Object} groupValue - Submitted group values keyed by answer key. + * @param {Object} question - Normalised text_group question config. + * @param {ValidationError[]} errors - Mutable error collection. + */ +const validateGroupTotal = (groupValue, question, errors) => { + const totalValidation = question.validation?.total + + if (!totalValidation) { + return + } + + const total = (totalValidation.items || []).reduce((sum, item) => { + const multiplier = item.multiplier || 1 + + return sum + (Number(groupValue[item.answerKey]) * multiplier) + }, 0) + const totalErrors = question.errors?.total || {} + const defaultHref = `#${question.input?.items?.[0]?.id || question.id}` + + if (totalValidation.min !== undefined && total < totalValidation.min) { + errors.push(makeError(totalErrors.min, defaultHref)) + } + + if (totalValidation.max !== undefined && total > totalValidation.max) { + errors.push(makeError(totalErrors.max, defaultHref)) + } +} + +/** + * Order grouped input errors by the visual input order. + * + * @param {ValidationError[]} errors - Validation errors for a text_group. + * @param {Object} question - Normalised text_group question config. + * @returns {ValidationError[]} Errors ordered by grouped input position. + */ +const orderGroupErrors = (errors, question) => { + const inputOrder = (question.input?.items || []).reduce((map, item, index) => { + map[`#${item.id}`] = index + return map + }, {}) + + return errors + .map((error, index) => ({ error, index })) + .sort((a, b) => { + const aOrder = inputOrder[a.error.href] ?? Number.MAX_SAFE_INTEGER + const bOrder = inputOrder[b.error.href] ?? Number.MAX_SAFE_INTEGER + + return aOrder - bOrder || a.index - b.index + }) + .map(({ error }) => error) +} + /** * Validate grouped inputs, such as imperial height or weight fields. * @@ -227,6 +322,7 @@ const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => const validateInputGroup = (answers, question) => { const errors = [] const groupValue = answers[question.answerKey]?.[question.input.valueKey] || {} + let canValidateTotal = Boolean(question.validation?.total) ;(question.validation?.items || []).forEach((itemValidation) => { const value = groupValue[itemValidation.answerKey] @@ -235,15 +331,30 @@ const validateInputGroup = (answers, question) => { if (itemValidation.required && isBlank(value)) { errors.push(makeError(itemErrors.required, defaultHref)) + canValidateTotal = false return } if (itemValidation.type === 'number' && !isBlank(value)) { + const number = Number(value) + + if ( + Number.isNaN(number) || + !Number.isFinite(number) || + (itemValidation.integer && !isIntegerValue(value)) + ) { + canValidateTotal = false + } + validateNumber(value, itemValidation, itemErrors, errors, defaultHref) } }) - return errors + if (canValidateTotal) { + validateGroupTotal(groupValue, question, errors) + } + + return orderGroupErrors(errors, question) } /** diff --git a/app/prototype_v4_1/lib/tobacco-flow.js b/app/prototype_v4_1/lib/tobacco-flow.js index 490b9b3a..7786f4f2 100644 --- a/app/prototype_v4_1/lib/tobacco-flow.js +++ b/app/prototype_v4_1/lib/tobacco-flow.js @@ -302,6 +302,119 @@ const getSmokingComparisonQuantity = (type, answer) => { return getSmokingQuantity(type, answer) } +const rollingTobaccoQuantityValues = { + less_than_10: 10, + '10_to_30': 20, + '31_to_50': 40.5, + '51_to_75': 63, + '76_to_100': 88, + more_than_100: 100 +} + +const smokingFrequencyRateMultipliers = { + daily: 7, + weekly: 1, + monthly: 0.25, + yearly: 1 / 52 +} + +/** + * Check whether a value can be compared as a submitted numeric answer. + * + * @param {*} value - Submitted value. + * @returns {boolean} True when value is numeric. + */ +const isComparableNumber = (value) => { + if (value === undefined || value === null || String(value).trim() === '') { + return false + } + + const number = Number(value) + + return Number.isFinite(number) +} + +/** + * Get a normalised quantity value for cross-frequency comparisons. + * + * @param {string} type - Tobacco type key. + * @param {*} quantity - Submitted quantity answer. + * @param {string} frequency - Submitted frequency answer. + * @returns {number|undefined} Normalised quantity, when comparable. + */ +const getSmokingQuantityComparisonRate = (type, quantity, frequency) => { + const multiplier = smokingFrequencyRateMultipliers[frequency] + + if (!multiplier) { + return undefined + } + + if (type === 'rolling_tobacco') { + const value = rollingTobaccoQuantityValues[quantity] + + return value ? value * multiplier : undefined + } + + return isComparableNumber(quantity) ? Number(quantity) * multiplier : undefined +} + +/** + * Build the left side of a changed-quantity comparison error. + * + * @param {string} type - Tobacco type key. + * @returns {string} Error phrase. + */ +const getSmokingQuantityChangeErrorPhrase = (type) => { + const heading = removeQuestionMark(getSmokingTypeHeadings(type, true).quantityHeading || '') + .replace(/ in a normal (day|week|month|year)$/, '') + + return upperFirst(lowerFirst(heading) + .replace(/^(how (?:much|many) .+?) did you normally smoke$/, '$1 you normally smoked') + .replace(/^(how (?:much|many) .+?) did you smoke$/, '$1 you normally smoked')) +} + +/** + * Check whether a changed-smoking quantity contradicts the selected change direction. + * + * @param {Object} params - Comparison inputs. + * @returns {Object|undefined} Validation error when the quantities contradict. + */ +const getSmokingQuantityChangeComparisonError = ({ + page, + step, + answer, + changeAnswer, + href +}) => { + if (page !== 'smoking-quantity-change' || !step.change || !answer.smokingQuantity || !answer.smokingFrequency || !changeAnswer.quantity || !changeAnswer.frequency) { + return undefined + } + + const currentRate = getSmokingQuantityComparisonRate(step.type, answer.smokingQuantity, answer.smokingFrequency) + const changedRate = getSmokingQuantityComparisonRate(step.type, changeAnswer.quantity, changeAnswer.frequency) + + if (currentRate === undefined || changedRate === undefined) { + return undefined + } + + const contradictsChange = step.change === 'greater' + ? changedRate <= currentRate + : changedRate >= currentRate + + if (!contradictsChange) { + return undefined + } + + const comparisonText = step.type === 'rolling_tobacco' && step.change === 'fewer' + ? 'less' + : smokingChangeTypes[step.change]?.label + + return { + text: `${getSmokingQuantityChangeErrorPhrase(step.type)} must be ${comparisonText} than ${[getSmokingComparisonQuantity(step.type, answer.smokingQuantity), getSmokingFrequencyComparisonPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}`, + href + } +} + const smokingFrequencyPeriods = { daily: 'a day', weekly: 'a week', @@ -309,6 +422,20 @@ const smokingFrequencyPeriods = { yearly: 'a year' } +const smokingFrequencyComparisonPeriods = { + daily: 'per day', + weekly: 'per week', + monthly: 'per month', + yearly: 'per year' +} + +const smokingFrequencyMaxHours = { + daily: 24, + weekly: 168, + monthly: 744, + yearly: 8760 +} + /** * Convert a smoking frequency value into a period phrase. * @@ -319,6 +446,31 @@ const getSmokingFrequencyPeriod = (frequency) => { return smokingFrequencyPeriods[frequency] || '' } +/** + * Convert a smoking frequency value into a comparison phrase. + * + * @param {string} frequency - Frequency value. + * @returns {string} Comparison phrase, for example `per day`. + */ +const getSmokingFrequencyComparisonPeriod = (frequency) => { + return smokingFrequencyComparisonPeriods[frequency] || '' +} + +/** + * Get the maximum number of hours allowed for an "another amount" shisha answer. + * + * @param {string} type - Tobacco type key. + * @param {string} frequency - Selected smoking frequency. + * @returns {number} Maximum number of hours. + */ +const getSmokingQuantityOtherMaxHours = (type, frequency) => { + if (type !== 'shisha') { + return 24 + } + + return smokingFrequencyMaxHours[frequency] || 24 +} + /** * Replace the default "normal day/week/month/year" phrase in a heading. * @@ -666,6 +818,16 @@ const getQuestionItemsWithLabels = (id, labels = {}, hintOverrides = {}) => { }) } +/** + * Check whether a smoking quantity must be a whole number. + * + * @param {string} type - Tobacco type key. + * @returns {boolean} True when decimal quantities should be rejected. + */ +const requiresWholeNumberSmokingQuantity = (type) => { + return !['rolling_tobacco', 'shisha'].includes(type) +} + /** * Build runtime overrides for a quantity question. * @@ -680,6 +842,7 @@ const getSmokingQuantityQuestionOverrides = ({ name, value, conditionalValue, + frequency, smokingType }) => { const question = getQuestion(page) @@ -689,6 +852,15 @@ const getSmokingQuantityQuestionOverrides = ({ const hint = hasHintOverride ? variantInput.hint : question.input.hint const questionType = variant.type || question.type const conditionalHref = '#smoking-quantity-other' + const maxHours = getSmokingQuantityOtherMaxHours(step.type, frequency) + const validation = { + ...variant.validation + } + + if (requiresWholeNumberSmokingQuantity(step.type)) { + validation.integer = true + } + const items = variant.options ? variant.options.map((option) => { const item = toComponentItem(option) @@ -726,13 +898,13 @@ const getSmokingQuantityQuestionOverrides = ({ suffix: questionType === 'text' ? smokingType.suffix : undefined }, validation: { - ...variant.validation, + ...validation, conditional: { another_amount: { required: true, type: 'number', min: 0.5, - max: 24, + max: maxHours, answerKey: 'smokingQuantityOther', value: conditionalValue, href: conditionalHref @@ -752,12 +924,16 @@ const getSmokingQuantityQuestionOverrides = ({ text: 'Number of hours must be a number', href: conditionalHref }, + integer: { + text: `${upperFirst(getAnswerPhraseFromHeading(heading))} must be a whole number`, + href: `#${question.input.id}` + }, min: { text: 'Number of hours must be 0.5 or more', href: conditionalHref }, max: { - text: 'Number of hours must be 24 or fewer', + text: `Number of hours must be ${maxHours} or fewer`, href: conditionalHref } }, @@ -772,6 +948,10 @@ const lowerFirst = (value = '') => { return value ? `${value.charAt(0).toLowerCase()}${value.slice(1)}` : '' } +const upperFirst = (value = '') => { + return value ? `${value.charAt(0).toUpperCase()}${value.slice(1)}` : '' +} + /** * Convert a question heading into an error-message answer phrase. * @@ -785,9 +965,10 @@ const getAnswerPhraseFromHeading = (heading = '') => { .replace(/^how long did you smoke /, 'how long you smoked ') .replace(/^how long do you currently smoke /, 'how long you currently smoke ') .replace(/^how long do you smoke /, 'how long you smoke ') - .replace(/^(how (?:much|many|long) .+?) did you smoke /, '$1 you smoked ') - .replace(/^(how (?:much|many|long) .+?) do you currently smoke /, '$1 you currently smoke ') - .replace(/^(how (?:much|many|long) .+?) do you smoke /, '$1 you smoke ') + .replace(/^(how (?:much|many|long) .+?) did you normally smoke(?= |$)/, '$1 you normally smoked') + .replace(/^(how (?:much|many|long) .+?) did you smoke(?= |$)/, '$1 you smoked') + .replace(/^(how (?:much|many|long) .+?) do you currently smoke(?= |$)/, '$1 you currently smoke') + .replace(/^(how (?:much|many|long) .+?) do you smoke(?= |$)/, '$1 you smoke') } /** @@ -933,6 +1114,7 @@ const getSmokingContentQuestionOverrides = ({ : `answers[${step.type}][smokingQuantity]`, value: isSettingSpecific ? settingAnswer.smokingQuantity : answer.smokingQuantity, conditionalValue: isSettingSpecific ? settingAnswer.smokingQuantityOther : answer.smokingQuantityOther, + frequency: isSettingSpecific ? settingAnswer.smokingFrequency : answer.smokingFrequency, smokingType }) } @@ -974,6 +1156,7 @@ const getSmokingContentQuestionOverrides = ({ name: `answers[${step.type}][${smokingChange.answerKey}][quantity]`, value: changeAnswer.quantity, conditionalValue: changeAnswer.smokingQuantityOther, + frequency: answer.smokingFrequency, smokingType }) } @@ -1107,10 +1290,23 @@ const validateSmokingTypeQuestion = (req, page, step) => { } } - return validateQuestion(req.session.data.answers, page, { + const validationErrors = validateQuestion(req.session.data.answers, page, { ...overrides, errors }) + const comparisonError = getSmokingQuantityChangeComparisonError({ + page, + step, + answer, + changeAnswer, + href: `#${errorHref}` + }) + + if (comparisonError && !validationErrors.some((error) => error.href === comparisonError.href)) { + validationErrors.push(comparisonError) + } + + return validationErrors } module.exports = { diff --git a/app/prototype_v4_2/data/questions.yaml b/app/prototype_v4_2/data/questions.yaml index 9f278b67..779e114d 100644 --- a/app/prototype_v4_2/data/questions.yaml +++ b/app/prototype_v4_2/data/questions.yaml @@ -108,7 +108,7 @@ questions: label: What is your height in centimetres? valueKey: metric suffix: cm - inputmode: numeric + inputmode: decimal classes: nhsuk-input--width-4 switchUnits: text: Switch to feet and inches @@ -117,17 +117,17 @@ questions: validation: required: true type: number - min: 100 - max: 250 + min: 139.7 + max: 243.8 errors: required: text: Enter your height in centimetres invalid: text: Enter your height in centimetres using numbers min: - text: Height in centimetres must be 100 or more + text: Height in centimetres must be 139.7cm or more max: - text: Height in centimetres must be 250 or fewer + text: Height in centimetres must be 243.8cm or fewer - id: height-imperial type: text_group answerKey: height @@ -158,13 +158,21 @@ questions: - answerKey: feet required: true type: number - min: 3 - max: 8 + integer: true + min: 0 - answerKey: inches required: true type: number + integer: true min: 0 max: 11 + total: + min: 55 + max: 96 + items: + - answerKey: feet + multiplier: 12 + - answerKey: inches errors: items: feet: @@ -174,11 +182,11 @@ questions: invalid: text: Enter your height in feet using numbers href: "#height-imperial-feet" - min: - text: Height in feet must be 3 or more + integer: + text: Height in feet must be a whole number href: "#height-imperial-feet" - max: - text: Height in feet must be 8 or fewer + min: + text: Height in feet must be 0ft or more href: "#height-imperial-feet" inches: required: @@ -187,12 +195,22 @@ questions: invalid: text: Enter your height in inches using numbers href: "#height-imperial-inches" + integer: + text: Height in inches must be a whole number + href: "#height-imperial-inches" min: - text: Height in inches must be 0 or more + text: Height in inches must be 0in or more href: "#height-imperial-inches" max: - text: Height in inches must be 11 or fewer + text: Height in inches must be 11in or fewer href: "#height-imperial-inches" + total: + min: + text: Height must be between 4 feet 7 inches and 8 feet + href: "#height-imperial-feet" + max: + text: Height must be between 4 feet 7 inches and 8 feet + href: "#height-imperial-feet" - id: weight-metric type: text answerKey: weight @@ -202,7 +220,7 @@ questions: valueKey: metric label: What is your weight in kilograms? suffix: kg - inputmode: numeric + inputmode: decimal classes: nhsuk-input--width-4 switchUnits: text: Switch to stones and pounds @@ -211,17 +229,20 @@ questions: validation: required: true type: number - min: 30 - max: 250 + decimalPlaces: 2 + min: 25.4 + max: 317.5 errors: required: text: Enter your weight in kilograms invalid: text: Enter your weight in kilograms using numbers + decimalPlaces: + text: Weight in kilograms must have 2 decimal places or fewer min: - text: Weight in kilograms must be 30 or more + text: Weight in kilograms must be 25.4kg or more max: - text: Weight in kilograms must be 250 or fewer + text: Weight in kilograms must be 317.5kg or fewer - id: weight-imperial type: text_group answerKey: weight @@ -252,13 +273,21 @@ questions: - answerKey: stones required: true type: number - min: 4 - max: 40 + integer: true + min: 0 - answerKey: pounds required: true type: number + integer: true min: 0 max: 13 + total: + min: 56 + max: 700 + items: + - answerKey: stones + multiplier: 14 + - answerKey: pounds errors: items: stones: @@ -268,11 +297,11 @@ questions: invalid: text: Enter your weight in stones using numbers href: "#weight-imperial-stones" - min: - text: Weight in stones must be 4 or more + integer: + text: Weight in stones must be a whole number href: "#weight-imperial-stones" - max: - text: Weight in stones must be 40 or fewer + min: + text: Weight in stones must be 0st or more href: "#weight-imperial-stones" pounds: required: @@ -281,12 +310,22 @@ questions: invalid: text: Enter your weight in pounds using numbers href: "#weight-imperial-pounds" + integer: + text: Weight in pounds must be a whole number + href: "#weight-imperial-pounds" min: - text: Weight in pounds must be 0 or more + text: Weight in pounds must be 0lb or more href: "#weight-imperial-pounds" max: - text: Weight in pounds must be 13 or fewer + text: Weight in pounds must be 13lb or fewer href: "#weight-imperial-pounds" + total: + min: + text: Weight must be between 4 stone and 50 stone + href: "#weight-imperial-stones" + max: + text: Weight must be between 4 stone and 50 stone + href: "#weight-imperial-stones" - id: gender type: single answerKey: gender diff --git a/app/prototype_v4_2/docs/error-messages.md b/app/prototype_v4_2/docs/error-messages.md new file mode 100644 index 00000000..8f8e08be --- /dev/null +++ b/app/prototype_v4_2/docs/error-messages.md @@ -0,0 +1,609 @@ +# Error messages + +This document summarises validation logic and error messages for `prototype_v4_2`. + +Sources: + +- `data/questions.yaml` for base question content, validation rules and error text +- `data/pages.yaml` for grouped-page composition and conditional question display +- `lib/question-validator.js` for shared validation behaviour +- `lib/tobacco-flow.js` for runtime tobacco question overrides +- `controllers/question.js` for route branching and answer-clearing logic + +## Shared validation logic + +- Blank values are `undefined`, `null`, an empty string after trimming, or an empty array. +- `required` errors stop further validation for that question or field. +- Date validation only accepts real calendar dates. If no date part is supplied, the `required` message is used; if any date part is supplied but the date is not real, the `invalid` message is used. +- Number validation rejects non-finite numbers, then checks whole-number, decimal-place, minimum and maximum rules in that order. +- Grouped text inputs validate each item first. Total minimum and maximum checks only run when the grouped values are present and numerically valid. +- Conditional reveal validation only runs for the selected trigger value. If the conditional value is blank, the conditional `required` message is used; number rules then use the question-level number error messages unless a runtime override replaces them. +- Error links default to `#${question.input.id || question.id}`. Date, grouped and conditional inputs usually set explicit `href` values. + +## Flow logic + +- `phone-questionnaire`: `yes` goes to `phone-questionnaire-exit`; `no` continues to `smoker`. +- `smoker`: `no` or `yes_fewer_than_100` goes to `not-eligible-for-screening`; current or previous smokers continue to `date-of-birth`. +- `date-of-birth`: valid dates outside the eligible scan age range go to `not-eligible-for-scan`; eligible users continue to `face-to-face-appointment`. +- `face-to-face-appointment`: `yes` goes to `book-appointment`; `no` continues to height and weight. +- `height-*` and `weight-*`: metric and imperial pages are alternatives. Submitting one unit clears the other unit answer. +- `asbestos`: grouped page that validates `asbestos-at-work` and `asbestos-at-home` together. +- `cancer-diagnosis-relatives`: `yes` continues to `cancer-diagnosis-relatives-age`; `no` clears that answer and goes to `check-your-answers`. +- `smoking-duration`: grouped page. `age-stopped-smoking` is visible only when `smoker` is `yes_previous` or type-specific `smoking-status` is `no`; otherwise `ageStoppedSmoking` is cleared. If `periods-stopped-smoking` is `no`, `yearsStoppedSmoking` is cleared. +- `smoking-type`: `none` goes to `smoking-type-exit`; otherwise selected tobacco types create the tobacco sub-flow in tobacco.yaml order. Former smokers skip each type-specific `smoking-status` question. +- `tobacco-smoking`: grouped tobacco page that validates `smoking-frequency` and `smoking-quantity` together for the active tobacco type. +- `smoking-change`: selected `greater` and `fewer` options create corresponding changed-smoking follow-up steps. Unselected changed-smoking answer groups are cleared. +- `tobacco-smoking-change`: grouped tobacco page that validates `smoking-frequency-change`, `smoking-quantity-change`, and `smoking-years-change` together for the active tobacco type and change direction. +- `smoking-quantity` and `smoking-quantity-change`: if `another_amount` is not selected, the related `smokingQuantityOther` answer is cleared. + +## Page composition + +- `accept-terms`: `accept-terms` +- `phone-questionnaire`: `phone-questionnaire` +- `smoker`: `smoker` +- `date-of-birth`: `date-of-birth` +- `face-to-face-appointment`: `face-to-face-appointment` +- `height-metric`: `height-metric` +- `height-imperial`: `height-imperial` +- `weight-metric`: `weight-metric` +- `weight-imperial`: `weight-imperial` +- `gender`: `gender` +- `sex`: `sex` +- `ethnicity`: `ethnicity` +- `education`: `education` +- `respiratory-conditions`: `respiratory-conditions` +- `asbestos`: `asbestos-at-work`, `asbestos-at-home` +- `cancer-diagnosis`: `cancer-diagnosis` +- `cancer-diagnosis-relatives`: `cancer-diagnosis-relatives` +- `cancer-diagnosis-relatives-age`: `cancer-diagnosis-relatives-age` +- `smoking-duration`: `age-started-smoking`, `age-stopped-smoking` (shown when any configured condition matches), `periods-stopped-smoking` +- `smoking-type`: `smoking-type` +- `smoking-status`: `smoking-status` +- `tobacco-smoking`: `smoking-frequency`, `smoking-quantity` +- `tobacco-smoking-change`: `smoking-frequency-change`, `smoking-quantity-change`, `smoking-years-change` +- `smoking-change`: `smoking-change` + +## Runtime tobacco messages + +- Tobacco sub-flow pages use `getSmokingContentQuestionOverrides()` before validation. The validator sees the runtime heading, input name, selected value and tobacco-specific variant. +- `smoking-status`, `smoking-frequency`, `smoking-quantity`, `smoking-change`, `smoking-frequency-change`, `smoking-quantity-change`, and `smoking-years-change` can replace their base `required` text with contextual text generated from the runtime heading. +- For headings beginning `How often`, the required message is `Select ...`; for `How much`, `How many`, or `How long`, the message is `Enter ...` for text inputs and `Select ...` for single-choice inputs; yes/no headings become `Select whether ...`. +- For numeric tobacco quantity text inputs, non-rolling-tobacco and non-shisha types require whole numbers. The integer message is generated as `[answer phrase] must be a whole number`. +- For shisha `another_amount`, the conditional input messages are `Enter the number of hours`, `Number of hours must be a number`, `Number of hours must be 0.5 or more`, and `Number of hours must be [maxHours] or fewer`. `maxHours` is 24 for daily/non-shisha, 168 weekly, 744 monthly, and 8760 yearly. +- `smoking-quantity-change` adds a comparison error when a `greater` answer is not greater than the original amount, or a `fewer` answer is not fewer than the original amount after normalising by frequency. The message is generated as `[quantity phrase] must be more/fewer/less than [original quantity] [original frequency]`. + +## Question messages + +### accept-terms + +Question: Confirm you agree to the terms of use + +Type: `multiple` + +Answer key: `acceptTerms` + +Page: `accept-terms` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Confirm that you have read and agree to the terms of use | `#accept-terms` | + +### phone-questionnaire + +Question: Have you previously completed a lung cancer risk questionnaire by phone in the last 12 months? + +Type: `single` + +Answer key: `phoneQuestionnaire` + +Page: `phone-questionnaire` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have previously completed a lung cancer risk questionnaire by phone | `#phone-questionnaire` | + +### smoker + +Question: Have you ever smoked tobacco? + +Type: `single` + +Answer key: `smoker` + +Page: `smoker` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever smoked tobacco | `#smoker` | + +### date-of-birth + +Question: What is your date of birth? + +Type: `date` + +Answer key: `dateOfBirth` + +Page: `date-of-birth` + +Validation: required; type: date + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your date of birth | `#dateOfBirth-day` | +| `invalid` | Enter a real date of birth | `#dateOfBirth-day` | + +### face-to-face-appointment + +Question: Do you need to leave the online service and ask for a face-to-face appointment? + +Type: `single` + +Answer key: `faceToFaceAppointment` + +Page: `face-to-face-appointment` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you need to leave the online service and ask for a face-to-face appointment | `#face-to-face-appointment` | + +### height-metric + +Question: What is your height in centimetres? + +Type: `text` + +Answer key: `height` + +Page: `height-metric` + +Validation: required; type: number; minimum 139.7; maximum 243.8 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your height in centimetres | `#height-metric` | +| `invalid` | Enter your height in centimetres using numbers | `#height-metric` | +| `min` | Height in centimetres must be 139.7cm or more | `#height-metric` | +| `max` | Height in centimetres must be 243.8cm or fewer | `#height-metric` | + +### height-imperial + +Question: What is your height in feet and inches? + +Type: `text_group` + +Answer key: `height` + +Page: `height-imperial` + +Validation: grouped input items: feet (required, number, whole number, min 0); inches (required, number, whole number, min 0, max 11); group total: min 55, max 96 + +| Rule | Message | Link | +| --- | --- | --- | +| `feet.required` | Enter your height in feet | `#height-imperial-feet` | +| `feet.invalid` | Enter your height in feet using numbers | `#height-imperial-feet` | +| `feet.integer` | Height in feet must be a whole number | `#height-imperial-feet` | +| `feet.min` | Height in feet must be 0ft or more | `#height-imperial-feet` | +| `inches.required` | Enter your height in inches | `#height-imperial-inches` | +| `inches.invalid` | Enter your height in inches using numbers | `#height-imperial-inches` | +| `inches.integer` | Height in inches must be a whole number | `#height-imperial-inches` | +| `inches.min` | Height in inches must be 0in or more | `#height-imperial-inches` | +| `inches.max` | Height in inches must be 11in or fewer | `#height-imperial-inches` | +| `total.min` | Height must be between 4 feet 7 inches and 8 feet | `#height-imperial-feet` | +| `total.max` | Height must be between 4 feet 7 inches and 8 feet | `#height-imperial-feet` | + +### weight-metric + +Question: What is your weight in kilograms? + +Type: `text` + +Answer key: `weight` + +Page: `weight-metric` + +Validation: required; type: number; maximum 2 decimal places; minimum 25.4; maximum 317.5 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your weight in kilograms | `#weight-metric` | +| `invalid` | Enter your weight in kilograms using numbers | `#weight-metric` | +| `decimalPlaces` | Weight in kilograms must have 2 decimal places or fewer | `#weight-metric` | +| `min` | Weight in kilograms must be 25.4kg or more | `#weight-metric` | +| `max` | Weight in kilograms must be 317.5kg or fewer | `#weight-metric` | + +### weight-imperial + +Question: What is your weight in stones and pounds? + +Type: `text_group` + +Answer key: `weight` + +Page: `weight-imperial` + +Validation: grouped input items: stones (required, number, whole number, min 0); pounds (required, number, whole number, min 0, max 13); group total: min 56, max 700 + +| Rule | Message | Link | +| --- | --- | --- | +| `stones.required` | Enter your weight in stones | `#weight-imperial-stones` | +| `stones.invalid` | Enter your weight in stones using numbers | `#weight-imperial-stones` | +| `stones.integer` | Weight in stones must be a whole number | `#weight-imperial-stones` | +| `stones.min` | Weight in stones must be 0st or more | `#weight-imperial-stones` | +| `pounds.required` | Enter your weight in pounds | `#weight-imperial-pounds` | +| `pounds.invalid` | Enter your weight in pounds using numbers | `#weight-imperial-pounds` | +| `pounds.integer` | Weight in pounds must be a whole number | `#weight-imperial-pounds` | +| `pounds.min` | Weight in pounds must be 0lb or more | `#weight-imperial-pounds` | +| `pounds.max` | Weight in pounds must be 13lb or fewer | `#weight-imperial-pounds` | +| `total.min` | Weight must be between 4 stone and 50 stone | `#weight-imperial-stones` | +| `total.max` | Weight must be between 4 stone and 50 stone | `#weight-imperial-stones` | + +### gender + +Question: Which of these best describes your gender identity? + +Type: `single` + +Answer key: `gender` + +Page: `gender` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select which option best describes your gender identity | `#gender` | + +### sex + +Question: What was your sex at birth? + +Type: `single` + +Answer key: `sex` + +Page: `sex` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select your sex at birth | `#sex` | + +### ethnicity + +Question: What is your ethnic background? + +Type: `single` + +Answer key: `ethnicity` + +Page: `ethnicity` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select your ethnic background | `#ethnicity` | + +### education + +Question: What is the highest level of education you have completed? + +Type: `single` + +Answer key: `education` + +Page: `education` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select the highest level of education you have completed | `#education` | + +### respiratory-conditions + +Question: Have you ever been diagnosed with any of the following respiratory conditions? + +Type: `multiple` + +Answer key: `respiratoryConditions` + +Page: `respiratory-conditions` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select if you have ever been diagnosed with any respiratory conditions | `#respiratory-conditions` | + +### asbestos-at-work + +Question: Have you ever worked in a job where you might have been exposed to asbestos? + +Type: `single` + +Answer key: `asbestosAtWork` + +Page: `asbestos` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever worked in a job where you might have been exposed to asbestos | `#asbestos-at-work` | + +### asbestos-at-home + +Question: Have you ever lived with anyone who worked with asbestos? + +Type: `single` + +Answer key: `asbestosAtHome` + +Page: `asbestos` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever lived with anyone who worked with asbestos | `#asbestos-at-home` | + +### cancer-diagnosis + +Question: Have you ever been diagnosed with cancer? + +Type: `single` + +Answer key: `cancerDiagnosis` + +Page: `cancer-diagnosis` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever been diagnosed with cancer | `#cancer-diagnosis` | + +### cancer-diagnosis-relatives + +Question: Have any of your parents, siblings or children ever been diagnosed with lung cancer? + +Type: `single` + +Answer key: `cancerDiagnosisRelatives` + +Page: `cancer-diagnosis-relatives` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether any of your parents, siblings or children have ever been diagnosed with lung cancer | `#cancer-diagnosis-relatives` | + +### cancer-diagnosis-relatives-age + +Question: Were any of your relatives younger than 60 years old when they were diagnosed with lung cancer? + +Type: `single` + +Answer key: `cancerDiagnosisRelativesAge` + +Page: `cancer-diagnosis-relatives-age` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether any of your relatives were younger than 60 when they were diagnosed with lung cancer | `#cancer-diagnosis-relatives-age` | + +### age-started-smoking + +Question: How old were you when you started smoking? + +Type: `text` + +Answer key: `ageStartedSmoking` + +Page: `smoking-duration` + +Validation: required; type: number; minimum 1; maximum 120 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter the age you started smoking | `#age-started-smoking` | +| `invalid` | Enter the age you started smoking using numbers | `#age-started-smoking` | +| `min` | Age you started smoking must be 1 or older | `#age-started-smoking` | +| `max` | Age you started smoking must be 120 or younger | `#age-started-smoking` | + +### age-stopped-smoking + +Question: How old were you when you quit smoking? + +Type: `text` + +Answer key: `ageStoppedSmoking` + +Page: `smoking-duration` + +Validation: required; type: number; minimum 1; maximum 120 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter the age you quit smoking | `#age-stopped-smoking` | +| `invalid` | Enter the age you quit smoking using numbers | `#age-stopped-smoking` | +| `min` | Age you quit smoking must be 1 or older | `#age-stopped-smoking` | +| `max` | Age you quit smoking must be 120 or younger | `#age-stopped-smoking` | + +### periods-stopped-smoking + +Question: Have you ever stopped smoking for periods of 1 year or longer? + +Type: `single` + +Answer key: `periodsStoppedSmoking` + +Page: `smoking-duration` + +Validation: required; conditional: yes (required, number, min 1, max 80) + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever stopped smoking for periods of 1 year or longer | `#periods-stopped-smoking` | +| `invalid` | Total number of years you stopped smoking must be a number | `#years-stopped-smoking` | +| `min` | Total number of years you stopped smoking must be 1 or more | `#years-stopped-smoking` | +| `max` | Total number of years you stopped smoking must be 80 or fewer | `#years-stopped-smoking` | +| `conditional.yes.required` | Enter the total number of years you stopped smoking | `#years-stopped-smoking` | + +### smoking-type + +Question: Have you ever smoked any of the following types of tobacco? + +Type: `multiple` + +Answer key: `smokingType` + +Page: `smoking-type` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select the types of tobacco you have smoked | `#smoking-type` | + +Variants: `previous`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-status + +Question: Do you currently smoke? + +Type: `single` + +Answer key: `smokingStatus` + +Page: `smoking-status` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you currently smoke this type of tobacco | `#smoking-status` | + +### smoking-frequency + +Question: How often do you smoke? + +Type: `single` + +Answer key: `smokingFrequency` + +Page: `tobacco-smoking` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select how often you smoke this type of tobacco | `#smoking-frequency` | + +### smoking-quantity + +Question: How much do you smoke? + +Type: `text` + +Answer key: `smokingQuantity` + +Page: `tobacco-smoking` + +Validation: required; type: number; minimum 1; maximum 200 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how much you smoke | `#smoking-quantity` | +| `invalid` | Enter how much you smoke using numbers | `#smoking-quantity` | +| `min` | Amount smoked must be 1 or more | `#smoking-quantity` | +| `max` | Amount smoked must be 200 or fewer | `#smoking-quantity` | + +Variants: `rolling_tobacco`, `shisha`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-change + +Question: Has the amount you normally smoke changed over time? + +Type: `multiple` + +Answer key: `smokingChange` + +Page: `smoking-change` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether the amount you normally smoke has changed over time | `#smoking-change` | + +### smoking-frequency-change + +Question: How often did you smoke? + +Type: `single` + +Answer key: `smokingFrequencyChange` + +Page: `tobacco-smoking-change` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select how often you smoked this type of tobacco | `#smoking-frequency-change` | + +### smoking-quantity-change + +Question: How much did you smoke? + +Type: `text` + +Answer key: `smokingQuantityChange` + +Page: `tobacco-smoking-change` + +Validation: required; type: number; minimum 1; maximum 200 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how much you smoked | `#smoking-quantity-change` | +| `invalid` | Enter how much you smoked using numbers | `#smoking-quantity-change` | +| `min` | Amount smoked must be 1 or more | `#smoking-quantity-change` | +| `max` | Amount smoked must be 200 or fewer | `#smoking-quantity-change` | + +Variants: `rolling_tobacco`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-years-change + +Question: How many years did you smoke this amount? + +Type: `text` + +Answer key: `smokingYearsChange` + +Page: `tobacco-smoking-change` + +Validation: required; type: number; minimum 1; maximum 80 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how many years you smoked this amount | `#smoking-years-change` | +| `invalid` | Enter how many years using numbers | `#smoking-years-change` | +| `min` | Number of years must be 1 or more | `#smoking-years-change` | +| `max` | Number of years must be 80 or fewer | `#smoking-years-change` | + diff --git a/app/prototype_v4_2/docs/question-schema.md b/app/prototype_v4_2/docs/question-schema.md index ac82bc78..d080daab 100644 --- a/app/prototype_v4_2/docs/question-schema.md +++ b/app/prototype_v4_2/docs/question-schema.md @@ -391,7 +391,10 @@ Supported validation fields are: | `type: date` | Requires day, month and year to form a real date. | | `min` | Minimum numeric value. | | `max` | Maximum numeric value. | +| `integer` | Requires a numeric answer to be a whole number. | +| `decimalPlaces` | Maximum decimal places for a numeric answer. | | `items` | Per-field validation for `text_group`. | +| `total` | Combined numeric validation for `text_group` fields. | | `conditional` | Validation for conditional reveal inputs. | For numeric questions, provide all relevant messages: diff --git a/app/prototype_v4_2/lib/question-validator.js b/app/prototype_v4_2/lib/question-validator.js index db2953f0..9b970ee2 100644 --- a/app/prototype_v4_2/lib/question-validator.js +++ b/app/prototype_v4_2/lib/question-validator.js @@ -12,8 +12,11 @@ const { getQuestion } = require('./questions') * @property {string} [type] - Type-specific validation, for example number or date. * @property {number} [min] - Minimum numeric value. * @property {number} [max] - Maximum numeric value. + * @property {boolean} [integer] - Whether a numeric value must be a whole number. + * @property {number} [decimalPlaces] - Maximum number of decimal places allowed. * @property {Object} [conditional] - Conditional reveal validation rules. * @property {Object[]} [items] - Validation rules for grouped inputs. + * @property {Object} [total] - Total validation rules for grouped numeric inputs. */ /** @@ -191,6 +194,30 @@ const mergeQuestion = (question, overrides = {}) => { } } +/** + * Check whether a submitted numeric value is written as a whole number. + * + * @param {*} value - Submitted value. + * @returns {boolean} True when the submitted value is an integer string. + */ +const isIntegerValue = (value) => { + return /^-?\d+$/.test(String(value).trim()) +} + +/** + * Check whether a submitted numeric value has no more than the allowed number + * of decimal places. + * + * @param {*} value - Submitted value. + * @param {number} decimalPlaces - Maximum decimal places allowed. + * @returns {boolean} True when the value has an allowed decimal precision. + */ +const hasMaximumDecimalPlaces = (value, decimalPlaces) => { + const match = String(value).trim().match(/^-?\d+(?:\.(\d+))?$/) + + return Boolean(match) && (!match[1] || match[1].length <= decimalPlaces) +} + /** * Validate a numeric value against invalid, min and max rules. * @@ -203,11 +230,24 @@ const mergeQuestion = (question, overrides = {}) => { const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => { const number = Number(value) - if (Number.isNaN(number)) { + if (Number.isNaN(number) || !Number.isFinite(number)) { errors.push(makeError(errorsConfig.invalid, defaultHref)) return } + if (validation.integer && !isIntegerValue(value)) { + errors.push(makeError(errorsConfig.integer || errorsConfig.invalid, defaultHref)) + return + } + + if ( + validation.decimalPlaces !== undefined && + !hasMaximumDecimalPlaces(value, validation.decimalPlaces) + ) { + errors.push(makeError(errorsConfig.decimalPlaces || errorsConfig.invalid, defaultHref)) + return + } + if (validation.min !== undefined && number < validation.min) { errors.push(makeError(errorsConfig.min, defaultHref)) } @@ -217,6 +257,61 @@ const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => } } +/** + * Validate the total value of grouped numeric inputs. + * + * @param {Object} groupValue - Submitted group values keyed by answer key. + * @param {Object} question - Normalised text_group question config. + * @param {ValidationError[]} errors - Mutable error collection. + */ +const validateGroupTotal = (groupValue, question, errors) => { + const totalValidation = question.validation?.total + + if (!totalValidation) { + return + } + + const total = (totalValidation.items || []).reduce((sum, item) => { + const multiplier = item.multiplier || 1 + + return sum + (Number(groupValue[item.answerKey]) * multiplier) + }, 0) + const totalErrors = question.errors?.total || {} + const defaultHref = `#${question.input?.items?.[0]?.id || question.id}` + + if (totalValidation.min !== undefined && total < totalValidation.min) { + errors.push(makeError(totalErrors.min, defaultHref)) + } + + if (totalValidation.max !== undefined && total > totalValidation.max) { + errors.push(makeError(totalErrors.max, defaultHref)) + } +} + +/** + * Order grouped input errors by the visual input order. + * + * @param {ValidationError[]} errors - Validation errors for a text_group. + * @param {Object} question - Normalised text_group question config. + * @returns {ValidationError[]} Errors ordered by grouped input position. + */ +const orderGroupErrors = (errors, question) => { + const inputOrder = (question.input?.items || []).reduce((map, item, index) => { + map[`#${item.id}`] = index + return map + }, {}) + + return errors + .map((error, index) => ({ error, index })) + .sort((a, b) => { + const aOrder = inputOrder[a.error.href] ?? Number.MAX_SAFE_INTEGER + const bOrder = inputOrder[b.error.href] ?? Number.MAX_SAFE_INTEGER + + return aOrder - bOrder || a.index - b.index + }) + .map(({ error }) => error) +} + /** * Validate grouped inputs, such as imperial height or weight fields. * @@ -227,6 +322,7 @@ const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => const validateInputGroup = (answers, question) => { const errors = [] const groupValue = answers[question.answerKey]?.[question.input.valueKey] || {} + let canValidateTotal = Boolean(question.validation?.total) ;(question.validation?.items || []).forEach((itemValidation) => { const value = groupValue[itemValidation.answerKey] @@ -235,15 +331,30 @@ const validateInputGroup = (answers, question) => { if (itemValidation.required && isBlank(value)) { errors.push(makeError(itemErrors.required, defaultHref)) + canValidateTotal = false return } if (itemValidation.type === 'number' && !isBlank(value)) { + const number = Number(value) + + if ( + Number.isNaN(number) || + !Number.isFinite(number) || + (itemValidation.integer && !isIntegerValue(value)) + ) { + canValidateTotal = false + } + validateNumber(value, itemValidation, itemErrors, errors, defaultHref) } }) - return errors + if (canValidateTotal) { + validateGroupTotal(groupValue, question, errors) + } + + return orderGroupErrors(errors, question) } /** diff --git a/app/prototype_v4_2/lib/tobacco-flow.js b/app/prototype_v4_2/lib/tobacco-flow.js index e96f1a96..074bca58 100644 --- a/app/prototype_v4_2/lib/tobacco-flow.js +++ b/app/prototype_v4_2/lib/tobacco-flow.js @@ -267,6 +267,119 @@ const getSmokingComparisonQuantity = (type, answer) => { return getSmokingQuantity(type, answer) } +const rollingTobaccoQuantityValues = { + less_than_10: 10, + '10_to_30': 20, + '31_to_50': 40.5, + '51_to_75': 63, + '76_to_100': 88, + more_than_100: 100 +} + +const smokingFrequencyRateMultipliers = { + daily: 7, + weekly: 1, + monthly: 0.25, + yearly: 1 / 52 +} + +/** + * Check whether a value can be compared as a submitted numeric answer. + * + * @param {*} value - Submitted value. + * @returns {boolean} True when value is numeric. + */ +const isComparableNumber = (value) => { + if (value === undefined || value === null || String(value).trim() === '') { + return false + } + + const number = Number(value) + + return Number.isFinite(number) +} + +/** + * Get a normalised quantity value for cross-frequency comparisons. + * + * @param {string} type - Tobacco type key. + * @param {*} quantity - Submitted quantity answer. + * @param {string} frequency - Submitted frequency answer. + * @returns {number|undefined} Normalised quantity, when comparable. + */ +const getSmokingQuantityComparisonRate = (type, quantity, frequency) => { + const multiplier = smokingFrequencyRateMultipliers[frequency] + + if (!multiplier) { + return undefined + } + + if (type === 'rolling_tobacco') { + const value = rollingTobaccoQuantityValues[quantity] + + return value ? value * multiplier : undefined + } + + return isComparableNumber(quantity) ? Number(quantity) * multiplier : undefined +} + +/** + * Build the left side of a changed-quantity comparison error. + * + * @param {string} type - Tobacco type key. + * @returns {string} Error phrase. + */ +const getSmokingQuantityChangeErrorPhrase = (type) => { + const heading = removeQuestionMark(getSmokingTypeHeadings(type, true).quantityHeading || '') + .replace(/ in a normal (day|week|month|year)$/, '') + + return upperFirst(lowerFirst(heading) + .replace(/^(how (?:much|many) .+?) did you normally smoke$/, '$1 you normally smoked') + .replace(/^(how (?:much|many) .+?) did you smoke$/, '$1 you normally smoked')) +} + +/** + * Check whether a changed-smoking quantity contradicts the selected change direction. + * + * @param {Object} params - Comparison inputs. + * @returns {Object|undefined} Validation error when the quantities contradict. + */ +const getSmokingQuantityChangeComparisonError = ({ + page, + step, + answer, + changeAnswer, + href +}) => { + if (page !== 'smoking-quantity-change' || !step.change || !answer.smokingQuantity || !answer.smokingFrequency || !changeAnswer.quantity || !changeAnswer.frequency) { + return undefined + } + + const currentRate = getSmokingQuantityComparisonRate(step.type, answer.smokingQuantity, answer.smokingFrequency) + const changedRate = getSmokingQuantityComparisonRate(step.type, changeAnswer.quantity, changeAnswer.frequency) + + if (currentRate === undefined || changedRate === undefined) { + return undefined + } + + const contradictsChange = step.change === 'greater' + ? changedRate <= currentRate + : changedRate >= currentRate + + if (!contradictsChange) { + return undefined + } + + const comparisonText = step.type === 'rolling_tobacco' && step.change === 'fewer' + ? 'less' + : smokingChangeTypes[step.change]?.label + + return { + text: `${getSmokingQuantityChangeErrorPhrase(step.type)} must be ${comparisonText} than ${[getSmokingComparisonQuantity(step.type, answer.smokingQuantity), getSmokingFrequencyComparisonPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}`, + href + } +} + const smokingFrequencyPeriods = { daily: 'a day', weekly: 'a week', @@ -274,6 +387,20 @@ const smokingFrequencyPeriods = { yearly: 'a year' } +const smokingFrequencyComparisonPeriods = { + daily: 'per day', + weekly: 'per week', + monthly: 'per month', + yearly: 'per year' +} + +const smokingFrequencyMaxHours = { + daily: 24, + weekly: 168, + monthly: 744, + yearly: 8760 +} + /** * Convert a smoking frequency value into a period phrase. * @@ -284,6 +411,31 @@ const getSmokingFrequencyPeriod = (frequency) => { return smokingFrequencyPeriods[frequency] || '' } +/** + * Convert a smoking frequency value into a comparison phrase. + * + * @param {string} frequency - Frequency value. + * @returns {string} Comparison phrase, for example `per day`. + */ +const getSmokingFrequencyComparisonPeriod = (frequency) => { + return smokingFrequencyComparisonPeriods[frequency] || '' +} + +/** + * Get the maximum number of hours allowed for an "another amount" shisha answer. + * + * @param {string} type - Tobacco type key. + * @param {string} frequency - Selected smoking frequency. + * @returns {number} Maximum number of hours. + */ +const getSmokingQuantityOtherMaxHours = (type, frequency) => { + if (type !== 'shisha') { + return 24 + } + + return smokingFrequencyMaxHours[frequency] || 24 +} + /** * Replace the default "normal day/week/month/year" phrase in a heading. * @@ -537,13 +689,7 @@ const getSmokingChangeHeading = (page, type, change, changeAnswer = {}, answer = } if (page === 'smoking-years-change') { - const quantity = getSmokingQuantity(type, changeAnswer.quantity) - - if (!quantity) { - return getQuestion('smoking-years-change').input.label - } - - return `How many years did you smoke ${[quantity, getSmokingFrequencyPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}?` + return getQuestion('smoking-years-change').input.label } return '' @@ -632,6 +778,16 @@ const getQuestionItemsWithLabels = (id, labels = {}, hintOverrides = {}) => { }) } +/** + * Check whether a smoking quantity must be a whole number. + * + * @param {string} type - Tobacco type key. + * @returns {boolean} True when decimal quantities should be rejected. + */ +const requiresWholeNumberSmokingQuantity = (type) => { + return !['rolling_tobacco', 'shisha'].includes(type) +} + /** * Build runtime overrides for a quantity question. * @@ -646,6 +802,7 @@ const getSmokingQuantityQuestionOverrides = ({ name, value, conditionalValue, + frequency, smokingType }) => { const question = getQuestion(page) @@ -655,6 +812,15 @@ const getSmokingQuantityQuestionOverrides = ({ const hint = hasHintOverride ? variantInput.hint : question.input.hint const questionType = variant.type || question.type const conditionalHref = '#smoking-quantity-other' + const maxHours = getSmokingQuantityOtherMaxHours(step.type, frequency) + const validation = { + ...variant.validation + } + + if (requiresWholeNumberSmokingQuantity(step.type)) { + validation.integer = true + } + const items = variant.options ? variant.options.map((option) => { const item = toComponentItem(option) @@ -690,13 +856,13 @@ const getSmokingQuantityQuestionOverrides = ({ suffix: questionType === 'text' ? smokingType.suffix : undefined }, validation: { - ...variant.validation, + ...validation, conditional: { another_amount: { required: true, type: 'number', min: 0.5, - max: 24, + max: maxHours, answerKey: 'smokingQuantityOther', value: conditionalValue, href: conditionalHref @@ -716,12 +882,16 @@ const getSmokingQuantityQuestionOverrides = ({ text: 'Number of hours must be a number', href: conditionalHref }, + integer: { + text: `${upperFirst(getAnswerPhraseFromHeading(heading))} must be a whole number`, + href: `#${question.input.id}` + }, min: { text: 'Number of hours must be 0.5 or more', href: conditionalHref }, max: { - text: 'Number of hours must be 24 or fewer', + text: `Number of hours must be ${maxHours} or fewer`, href: conditionalHref } }, @@ -753,10 +923,10 @@ const getAnswerPhraseFromHeading = (heading = '') => { .replace(/^how long did you smoke /, 'how long you smoked ') .replace(/^how long do you currently smoke /, 'how long you currently smoke ') .replace(/^how long do you smoke /, 'how long you smoke ') - .replace(/^(how (?:much|many|long) .+?) did you normally smoke/, '$1 you normally smoked') - .replace(/^(how (?:much|many|long) .+?) did you smoke /, '$1 you smoked ') - .replace(/^(how (?:much|many|long) .+?) do you currently smoke /, '$1 you currently smoke ') - .replace(/^(how (?:much|many|long) .+?) do you smoke /, '$1 you smoke ') + .replace(/^(how (?:much|many|long) .+?) did you normally smoke(?= |$)/, '$1 you normally smoked') + .replace(/^(how (?:much|many|long) .+?) did you smoke(?= |$)/, '$1 you smoked') + .replace(/^(how (?:much|many|long) .+?) do you currently smoke(?= |$)/, '$1 you currently smoke') + .replace(/^(how (?:much|many|long) .+?) do you smoke(?= |$)/, '$1 you smoke') } /** @@ -878,6 +1048,7 @@ const getSmokingContentQuestionOverrides = ({ name: `answers[${step.type}][smokingQuantity]`, value: answer.smokingQuantity, conditionalValue: answer.smokingQuantityOther, + frequency: answer.smokingFrequency, smokingType }) } @@ -919,6 +1090,7 @@ const getSmokingContentQuestionOverrides = ({ name: `answers[${step.type}][${smokingChange.answerKey}][quantity]`, value: changeAnswer.quantity, conditionalValue: changeAnswer.smokingQuantityOther, + frequency: answer.smokingFrequency, smokingType }) } @@ -1120,10 +1292,23 @@ const validateSmokingTypeQuestion = (req, page, step) => { } } - return validateQuestion(req.session.data.answers, page, { + const validationErrors = validateQuestion(req.session.data.answers, page, { ...overrides, errors }) + const comparisonError = getSmokingQuantityChangeComparisonError({ + page, + step, + answer: context.answer, + changeAnswer: context.changeAnswer, + href: `#${errorHref}` + }) + + if (comparisonError && !validationErrors.some((error) => error.href === comparisonError.href)) { + validationErrors.push(comparisonError) + } + + return validationErrors } /** diff --git a/app/prototype_v4_3/data/questions.yaml b/app/prototype_v4_3/data/questions.yaml index 581d4235..5318e502 100644 --- a/app/prototype_v4_3/data/questions.yaml +++ b/app/prototype_v4_3/data/questions.yaml @@ -108,7 +108,7 @@ questions: label: What is your height in centimetres? valueKey: metric suffix: cm - inputmode: numeric + inputmode: decimal classes: nhsuk-input--width-4 switchUnits: text: Switch to feet and inches @@ -117,17 +117,17 @@ questions: validation: required: true type: number - min: 100 - max: 250 + min: 139.7 + max: 243.8 errors: required: text: Enter your height in centimetres invalid: text: Enter your height in centimetres using numbers min: - text: Height in centimetres must be 100 or more + text: Height in centimetres must be 139.7cm or more max: - text: Height in centimetres must be 250 or fewer + text: Height in centimetres must be 243.8cm or fewer - id: height-imperial type: text_group answerKey: height @@ -158,13 +158,21 @@ questions: - answerKey: feet required: true type: number - min: 3 - max: 8 + integer: true + min: 0 - answerKey: inches required: true type: number + integer: true min: 0 max: 11 + total: + min: 55 + max: 96 + items: + - answerKey: feet + multiplier: 12 + - answerKey: inches errors: items: feet: @@ -174,11 +182,11 @@ questions: invalid: text: Enter your height in feet using numbers href: "#height-imperial-feet" - min: - text: Height in feet must be 3 or more + integer: + text: Height in feet must be a whole number href: "#height-imperial-feet" - max: - text: Height in feet must be 8 or fewer + min: + text: Height in feet must be 0ft or more href: "#height-imperial-feet" inches: required: @@ -187,12 +195,22 @@ questions: invalid: text: Enter your height in inches using numbers href: "#height-imperial-inches" + integer: + text: Height in inches must be a whole number + href: "#height-imperial-inches" min: - text: Height in inches must be 0 or more + text: Height in inches must be 0in or more href: "#height-imperial-inches" max: - text: Height in inches must be 11 or fewer + text: Height in inches must be 11in or fewer href: "#height-imperial-inches" + total: + min: + text: Height must be between 4 feet 7 inches and 8 feet + href: "#height-imperial-feet" + max: + text: Height must be between 4 feet 7 inches and 8 feet + href: "#height-imperial-feet" - id: weight-metric type: text answerKey: weight @@ -202,7 +220,7 @@ questions: valueKey: metric label: What is your weight in kilograms? suffix: kg - inputmode: numeric + inputmode: decimal classes: nhsuk-input--width-4 switchUnits: text: Switch to stones and pounds @@ -211,17 +229,20 @@ questions: validation: required: true type: number - min: 30 - max: 250 + decimalPlaces: 2 + min: 25.4 + max: 317.5 errors: required: text: Enter your weight in kilograms invalid: text: Enter your weight in kilograms using numbers + decimalPlaces: + text: Weight in kilograms must have 2 decimal places or fewer min: - text: Weight in kilograms must be 30 or more + text: Weight in kilograms must be 25.4kg or more max: - text: Weight in kilograms must be 250 or fewer + text: Weight in kilograms must be 317.5kg or fewer - id: weight-imperial type: text_group answerKey: weight @@ -252,13 +273,21 @@ questions: - answerKey: stones required: true type: number - min: 4 - max: 40 + integer: true + min: 0 - answerKey: pounds required: true type: number + integer: true min: 0 max: 13 + total: + min: 56 + max: 700 + items: + - answerKey: stones + multiplier: 14 + - answerKey: pounds errors: items: stones: @@ -268,11 +297,11 @@ questions: invalid: text: Enter your weight in stones using numbers href: "#weight-imperial-stones" - min: - text: Weight in stones must be 4 or more + integer: + text: Weight in stones must be a whole number href: "#weight-imperial-stones" - max: - text: Weight in stones must be 40 or fewer + min: + text: Weight in stones must be 0st or more href: "#weight-imperial-stones" pounds: required: @@ -281,12 +310,22 @@ questions: invalid: text: Enter your weight in pounds using numbers href: "#weight-imperial-pounds" + integer: + text: Weight in pounds must be a whole number + href: "#weight-imperial-pounds" min: - text: Weight in pounds must be 0 or more + text: Weight in pounds must be 0lb or more href: "#weight-imperial-pounds" max: - text: Weight in pounds must be 13 or fewer + text: Weight in pounds must be 13lb or fewer href: "#weight-imperial-pounds" + total: + min: + text: Weight must be between 4 stone and 50 stone + href: "#weight-imperial-stones" + max: + text: Weight must be between 4 stone and 50 stone + href: "#weight-imperial-stones" - id: gender type: single answerKey: gender diff --git a/app/prototype_v4_3/docs/error-messages.md b/app/prototype_v4_3/docs/error-messages.md new file mode 100644 index 00000000..df970ddf --- /dev/null +++ b/app/prototype_v4_3/docs/error-messages.md @@ -0,0 +1,627 @@ +# Error messages + +This document summarises validation logic and error messages for `prototype_v4_3`. + +Sources: + +- `data/questions.yaml` for base question content, validation rules and error text +- `data/pages.yaml` for grouped-page composition and conditional question display +- `lib/question-validator.js` for shared validation behaviour +- `lib/tobacco-flow.js` for runtime tobacco question overrides +- `controllers/question.js` for route branching and answer-clearing logic + +## Shared validation logic + +- Blank values are `undefined`, `null`, an empty string after trimming, or an empty array. +- `required` errors stop further validation for that question or field. +- Date validation only accepts real calendar dates. If no date part is supplied, the `required` message is used; if any date part is supplied but the date is not real, the `invalid` message is used. +- Number validation rejects non-finite numbers, then checks whole-number, decimal-place, minimum and maximum rules in that order. +- Grouped text inputs validate each item first. Total minimum and maximum checks only run when the grouped values are present and numerically valid. +- Conditional reveal validation only runs for the selected trigger value. If the conditional value is blank, the conditional `required` message is used; number rules then use the question-level number error messages unless a runtime override replaces them. +- Error links default to `#${question.input.id || question.id}`. Date, grouped and conditional inputs usually set explicit `href` values. + +## Flow logic + +- `phone-questionnaire`: `yes` goes to `phone-questionnaire-exit`; `no` continues to `smoking-type`. +- `smoking-type`: `none` goes to `smoking-type-exit`. If multiple tobacco types are selected, the flow asks `smoking-status-current`; if one type is selected, it asks that type-specific `smoking-status`. +- `smoking-status-current`: only shown when more than one tobacco type was selected; it records which selected types are currently smoked and then continues to `date-of-birth`. +- `smoking-status`: if the answer is `less_than_lifetime_threshold`, the flow goes to `not-eligible-for-screening`; otherwise it continues through that tobacco type sub-flow. +- `date-of-birth`: valid dates outside the eligible scan age range go to `not-eligible-for-scan`; eligible users continue to `face-to-face-appointment`. +- `face-to-face-appointment`: `yes` goes to `book-appointment`; `no` continues to height and weight. +- `height-*` and `weight-*`: metric and imperial pages are alternatives. Submitting one unit clears the other unit answer. +- `asbestos`: grouped page that validates `asbestos-at-work` and `asbestos-at-home` together. +- `cancer-diagnosis-relatives`: `yes` continues to `cancer-diagnosis-relatives-age`; `no` clears that answer and goes to `check-your-answers`. +- `smoking-duration`: rendered inside the active tobacco sub-flow. It validates `age-started-smoking`, conditionally `age-stopped-smoking`, and `periods-stopped-smoking` for the active tobacco type. Current tobacco types clear `ageStoppedSmoking`; `periods-stopped-smoking` of `no` clears `yearsStoppedSmoking`. +- `tobacco-smoking`: grouped tobacco page that validates `smoking-frequency` and `smoking-quantity` together for the active tobacco type. +- `smoking-change`: selected `greater` and `fewer` options create corresponding changed-smoking follow-up steps. Unselected changed-smoking answer groups are cleared. +- `tobacco-smoking-change`: grouped tobacco page that validates `smoking-frequency-change`, `smoking-quantity-change`, and `smoking-years-change` together for the active tobacco type and change direction. +- `smoking-quantity` and `smoking-quantity-change`: if `another_amount` is not selected, the related `smokingQuantityOther` answer is cleared. + +## Page composition + +- `accept-terms`: `accept-terms` +- `phone-questionnaire`: `phone-questionnaire` +- `smoker`: `smoker` +- `date-of-birth`: `date-of-birth` +- `face-to-face-appointment`: `face-to-face-appointment` +- `height-metric`: `height-metric` +- `height-imperial`: `height-imperial` +- `weight-metric`: `weight-metric` +- `weight-imperial`: `weight-imperial` +- `gender`: `gender` +- `sex`: `sex` +- `ethnicity`: `ethnicity` +- `education`: `education` +- `respiratory-conditions`: `respiratory-conditions` +- `asbestos`: `asbestos-at-work`, `asbestos-at-home` +- `cancer-diagnosis`: `cancer-diagnosis` +- `cancer-diagnosis-relatives`: `cancer-diagnosis-relatives` +- `cancer-diagnosis-relatives-age`: `cancer-diagnosis-relatives-age` +- `smoking-duration`: `age-started-smoking`, `age-stopped-smoking` (shown when any configured condition matches), `periods-stopped-smoking` +- `smoking-type`: `smoking-type` +- `smoking-status`: `smoking-status` +- `smoking-status-current`: `smoking-status-current` +- `tobacco-smoking`: `smoking-frequency`, `smoking-quantity` +- `tobacco-smoking-change`: `smoking-frequency-change`, `smoking-quantity-change`, `smoking-years-change` +- `smoking-change`: `smoking-change` + +## Runtime tobacco messages + +- Tobacco sub-flow pages use `getSmokingContentQuestionOverrides()` before validation. The validator sees the runtime heading, input name, selected value and tobacco-specific variant. +- `age-started-smoking`, `age-stopped-smoking`, `periods-stopped-smoking`, `smoking-status`, `smoking-frequency`, `smoking-quantity`, `smoking-change`, `smoking-frequency-change`, `smoking-quantity-change`, and `smoking-years-change` can replace their base `required` text with contextual text generated from the runtime heading. +- For headings beginning `How often`, the required message is `Select ...`; for `How old`, `How much`, `How many`, or `How long`, the message is `Enter ...` for text inputs and `Select ...` for single-choice inputs; yes/no headings become `Select whether ...`. +- For numeric tobacco quantity text inputs, non-rolling-tobacco and non-shisha types require whole numbers. The integer message is generated as `[answer phrase] must be a whole number`. +- For shisha `another_amount`, the conditional input messages are `Enter the number of hours`, `Number of hours must be a number`, `Number of hours must be 0.5 or more`, and `Number of hours must be [maxHours] or fewer`. `maxHours` is 24 for daily/non-shisha, 168 weekly, 744 monthly, and 8760 yearly. +- `smoking-quantity-change` adds a comparison error when a `greater` answer is not greater than the original amount, or a `fewer` answer is not fewer than the original amount after normalising by frequency. The message is generated as `[quantity phrase] must be more/fewer/less than [original quantity] [original frequency]`. + +## Question messages + +### accept-terms + +Question: Confirm you agree to the terms of use + +Type: `multiple` + +Answer key: `acceptTerms` + +Page: `accept-terms` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Confirm that you have read and agree to the terms of use | `#accept-terms` | + +### phone-questionnaire + +Question: Have you previously completed a lung cancer risk questionnaire by phone in the last 12 months? + +Type: `single` + +Answer key: `phoneQuestionnaire` + +Page: `phone-questionnaire` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have previously completed a lung cancer risk questionnaire by phone | `#phone-questionnaire` | + +### smoker + +Question: Have you ever smoked tobacco? + +Type: `single` + +Answer key: `smoker` + +Page: `smoker` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever smoked tobacco | `#smoker` | + +### date-of-birth + +Question: What is your date of birth? + +Type: `date` + +Answer key: `dateOfBirth` + +Page: `date-of-birth` + +Validation: required; type: date + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your date of birth | `#dateOfBirth-day` | +| `invalid` | Enter a real date of birth | `#dateOfBirth-day` | + +### face-to-face-appointment + +Question: Do you need to leave the online service and ask for a face-to-face appointment? + +Type: `single` + +Answer key: `faceToFaceAppointment` + +Page: `face-to-face-appointment` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you need to leave the online service and ask for a face-to-face appointment | `#face-to-face-appointment` | + +### height-metric + +Question: What is your height in centimetres? + +Type: `text` + +Answer key: `height` + +Page: `height-metric` + +Validation: required; type: number; minimum 139.7; maximum 243.8 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your height in centimetres | `#height-metric` | +| `invalid` | Enter your height in centimetres using numbers | `#height-metric` | +| `min` | Height in centimetres must be 139.7cm or more | `#height-metric` | +| `max` | Height in centimetres must be 243.8cm or fewer | `#height-metric` | + +### height-imperial + +Question: What is your height in feet and inches? + +Type: `text_group` + +Answer key: `height` + +Page: `height-imperial` + +Validation: grouped input items: feet (required, number, whole number, min 0); inches (required, number, whole number, min 0, max 11); group total: min 55, max 96 + +| Rule | Message | Link | +| --- | --- | --- | +| `feet.required` | Enter your height in feet | `#height-imperial-feet` | +| `feet.invalid` | Enter your height in feet using numbers | `#height-imperial-feet` | +| `feet.integer` | Height in feet must be a whole number | `#height-imperial-feet` | +| `feet.min` | Height in feet must be 0ft or more | `#height-imperial-feet` | +| `inches.required` | Enter your height in inches | `#height-imperial-inches` | +| `inches.invalid` | Enter your height in inches using numbers | `#height-imperial-inches` | +| `inches.integer` | Height in inches must be a whole number | `#height-imperial-inches` | +| `inches.min` | Height in inches must be 0in or more | `#height-imperial-inches` | +| `inches.max` | Height in inches must be 11in or fewer | `#height-imperial-inches` | +| `total.min` | Height must be between 4 feet 7 inches and 8 feet | `#height-imperial-feet` | +| `total.max` | Height must be between 4 feet 7 inches and 8 feet | `#height-imperial-feet` | + +### weight-metric + +Question: What is your weight in kilograms? + +Type: `text` + +Answer key: `weight` + +Page: `weight-metric` + +Validation: required; type: number; maximum 2 decimal places; minimum 25.4; maximum 317.5 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your weight in kilograms | `#weight-metric` | +| `invalid` | Enter your weight in kilograms using numbers | `#weight-metric` | +| `decimalPlaces` | Weight in kilograms must have 2 decimal places or fewer | `#weight-metric` | +| `min` | Weight in kilograms must be 25.4kg or more | `#weight-metric` | +| `max` | Weight in kilograms must be 317.5kg or fewer | `#weight-metric` | + +### weight-imperial + +Question: What is your weight in stones and pounds? + +Type: `text_group` + +Answer key: `weight` + +Page: `weight-imperial` + +Validation: grouped input items: stones (required, number, whole number, min 0); pounds (required, number, whole number, min 0, max 13); group total: min 56, max 700 + +| Rule | Message | Link | +| --- | --- | --- | +| `stones.required` | Enter your weight in stones | `#weight-imperial-stones` | +| `stones.invalid` | Enter your weight in stones using numbers | `#weight-imperial-stones` | +| `stones.integer` | Weight in stones must be a whole number | `#weight-imperial-stones` | +| `stones.min` | Weight in stones must be 0st or more | `#weight-imperial-stones` | +| `pounds.required` | Enter your weight in pounds | `#weight-imperial-pounds` | +| `pounds.invalid` | Enter your weight in pounds using numbers | `#weight-imperial-pounds` | +| `pounds.integer` | Weight in pounds must be a whole number | `#weight-imperial-pounds` | +| `pounds.min` | Weight in pounds must be 0lb or more | `#weight-imperial-pounds` | +| `pounds.max` | Weight in pounds must be 13lb or fewer | `#weight-imperial-pounds` | +| `total.min` | Weight must be between 4 stone and 50 stone | `#weight-imperial-stones` | +| `total.max` | Weight must be between 4 stone and 50 stone | `#weight-imperial-stones` | + +### gender + +Question: Which of these best describes your gender identity? + +Type: `single` + +Answer key: `gender` + +Page: `gender` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select which option best describes your gender identity | `#gender` | + +### sex + +Question: What was your sex at birth? + +Type: `single` + +Answer key: `sex` + +Page: `sex` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select your sex at birth | `#sex` | + +### ethnicity + +Question: What is your ethnic background? + +Type: `single` + +Answer key: `ethnicity` + +Page: `ethnicity` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select your ethnic background | `#ethnicity` | + +### education + +Question: What level of education have you completed? + +Type: `multiple` + +Answer key: `education` + +Page: `education` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select what level of education you have completed | `#education` | + +### respiratory-conditions + +Question: Have you ever been diagnosed with any of the following respiratory conditions? + +Type: `multiple` + +Answer key: `respiratoryConditions` + +Page: `respiratory-conditions` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select if you have ever been diagnosed with any respiratory conditions | `#respiratory-conditions` | + +### asbestos-at-work + +Question: Have you ever worked in a job where you might have been exposed to asbestos? + +Type: `single` + +Answer key: `asbestosAtWork` + +Page: `asbestos` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever worked in a job where you might have been exposed to asbestos | `#asbestos-at-work` | + +### asbestos-at-home + +Question: Have you ever lived with anyone who worked with asbestos? + +Type: `single` + +Answer key: `asbestosAtHome` + +Page: `asbestos` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever lived with anyone who worked with asbestos | `#asbestos-at-home` | + +### cancer-diagnosis + +Question: Have you ever been diagnosed with cancer? + +Type: `single` + +Answer key: `cancerDiagnosis` + +Page: `cancer-diagnosis` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever been diagnosed with cancer | `#cancer-diagnosis` | + +### cancer-diagnosis-relatives + +Question: Have any of your parents, siblings or children ever been diagnosed with lung cancer? + +Type: `single` + +Answer key: `cancerDiagnosisRelatives` + +Page: `cancer-diagnosis-relatives` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether any of your parents, siblings or children have ever been diagnosed with lung cancer | `#cancer-diagnosis-relatives` | + +### cancer-diagnosis-relatives-age + +Question: Were any of your relatives younger than 60 years old when they were diagnosed with lung cancer? + +Type: `single` + +Answer key: `cancerDiagnosisRelativesAge` + +Page: `cancer-diagnosis-relatives-age` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether any of your relatives were younger than 60 when they were diagnosed with lung cancer | `#cancer-diagnosis-relatives-age` | + +### age-started-smoking + +Question: How old were you when you started smoking? + +Type: `text` + +Answer key: `ageStartedSmoking` + +Page: `smoking-duration` + +Validation: required; type: number; minimum 1; maximum 120 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter the age you started smoking | `#age-started-smoking` | +| `invalid` | Enter the age you started smoking using numbers | `#age-started-smoking` | +| `min` | Age you started smoking must be 1 or older | `#age-started-smoking` | +| `max` | Age you started smoking must be 120 or younger | `#age-started-smoking` | + +### age-stopped-smoking + +Question: How old were you when you quit smoking? + +Type: `text` + +Answer key: `ageStoppedSmoking` + +Page: `smoking-duration` + +Validation: required; type: number; minimum 1; maximum 120 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter the age you quit smoking | `#age-stopped-smoking` | +| `invalid` | Enter the age you quit smoking using numbers | `#age-stopped-smoking` | +| `min` | Age you quit smoking must be 1 or older | `#age-stopped-smoking` | +| `max` | Age you quit smoking must be 120 or younger | `#age-stopped-smoking` | + +### periods-stopped-smoking + +Question: Have you ever stopped smoking for periods of 1 year or longer? + +Type: `single` + +Answer key: `periodsStoppedSmoking` + +Page: `smoking-duration` + +Validation: required; conditional: yes (required, number, min 1, max 80) + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever stopped smoking for periods of 1 year or longer | `#periods-stopped-smoking` | +| `invalid` | Total number of years you stopped smoking must be a number | `#years-stopped-smoking` | +| `min` | Total number of years you stopped smoking must be 1 or more | `#years-stopped-smoking` | +| `max` | Total number of years you stopped smoking must be 80 or fewer | `#years-stopped-smoking` | +| `conditional.yes.required` | Enter the total number of years you stopped smoking | `#years-stopped-smoking` | + +### smoking-type + +Question: Have you ever smoked any of the following types of tobacco for a period of 1 year or longer? + +Type: `multiple` + +Answer key: `smokingType` + +Page: `smoking-type` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select the types of tobacco you have smoked | `#smoking-type` | + +Variants: `previous`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-status-current + +Question: Do you currently smoke any of the following types of tobacco? + +Type: `multiple` + +Answer key: `smokingStatusCurrent` + +Page: `smoking-status-current` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select the types of tobacco you currently smoke | `#smoking-status-current` | + +### smoking-status + +Question: Do you currently smoke? + +Type: `single` + +Answer key: `smokingStatus` + +Page: `smoking-status` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you currently smoke this type of tobacco | `#smoking-status` | + +### smoking-frequency + +Question: How often do you smoke? + +Type: `single` + +Answer key: `smokingFrequency` + +Page: `tobacco-smoking` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select how often you smoke this type of tobacco | `#smoking-frequency` | + +### smoking-quantity + +Question: How much do you smoke? + +Type: `text` + +Answer key: `smokingQuantity` + +Page: `tobacco-smoking` + +Validation: required; type: number; minimum 1; maximum 200 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how much you smoke | `#smoking-quantity` | +| `invalid` | Enter how much you smoke using numbers | `#smoking-quantity` | +| `min` | Amount smoked must be 1 or more | `#smoking-quantity` | +| `max` | Amount smoked must be 200 or fewer | `#smoking-quantity` | + +Variants: `rolling_tobacco`, `shisha`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-change + +Question: Has the amount you normally smoke changed over time? + +Type: `multiple` + +Answer key: `smokingChange` + +Page: `smoking-change` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether the amount you normally smoke has changed over time | `#smoking-change` | + +### smoking-frequency-change + +Question: How often did you smoke? + +Type: `single` + +Answer key: `smokingFrequencyChange` + +Page: `tobacco-smoking-change` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select how often you smoked this type of tobacco | `#smoking-frequency-change` | + +### smoking-quantity-change + +Question: How much did you smoke? + +Type: `text` + +Answer key: `smokingQuantityChange` + +Page: `tobacco-smoking-change` + +Validation: required; type: number; minimum 1; maximum 200 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how much you smoked | `#smoking-quantity-change` | +| `invalid` | Enter how much you smoked using numbers | `#smoking-quantity-change` | +| `min` | Amount smoked must be 1 or more | `#smoking-quantity-change` | +| `max` | Amount smoked must be 200 or fewer | `#smoking-quantity-change` | + +Variants: `rolling_tobacco`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-years-change + +Question: How many years did you smoke this amount? + +Type: `text` + +Answer key: `smokingYearsChange` + +Page: `tobacco-smoking-change` + +Validation: required; type: number; minimum 1; maximum 80 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how many years you smoked this amount | `#smoking-years-change` | +| `invalid` | Enter how many years using numbers | `#smoking-years-change` | +| `min` | Number of years must be 1 or more | `#smoking-years-change` | +| `max` | Number of years must be 80 or fewer | `#smoking-years-change` | + diff --git a/app/prototype_v4_3/docs/question-schema.md b/app/prototype_v4_3/docs/question-schema.md index f4aaf43e..5f972112 100644 --- a/app/prototype_v4_3/docs/question-schema.md +++ b/app/prototype_v4_3/docs/question-schema.md @@ -393,7 +393,10 @@ Supported validation fields are: | `type: date` | Requires day, month and year to form a real date. | | `min` | Minimum numeric value. | | `max` | Maximum numeric value. | +| `integer` | Requires a numeric answer to be a whole number. | +| `decimalPlaces` | Maximum decimal places for a numeric answer. | | `items` | Per-field validation for `text_group`. | +| `total` | Combined numeric validation for `text_group` fields. | | `conditional` | Validation for conditional reveal inputs. | For numeric questions, provide all relevant messages: diff --git a/app/prototype_v4_3/lib/question-validator.js b/app/prototype_v4_3/lib/question-validator.js index db2953f0..9b970ee2 100644 --- a/app/prototype_v4_3/lib/question-validator.js +++ b/app/prototype_v4_3/lib/question-validator.js @@ -12,8 +12,11 @@ const { getQuestion } = require('./questions') * @property {string} [type] - Type-specific validation, for example number or date. * @property {number} [min] - Minimum numeric value. * @property {number} [max] - Maximum numeric value. + * @property {boolean} [integer] - Whether a numeric value must be a whole number. + * @property {number} [decimalPlaces] - Maximum number of decimal places allowed. * @property {Object} [conditional] - Conditional reveal validation rules. * @property {Object[]} [items] - Validation rules for grouped inputs. + * @property {Object} [total] - Total validation rules for grouped numeric inputs. */ /** @@ -191,6 +194,30 @@ const mergeQuestion = (question, overrides = {}) => { } } +/** + * Check whether a submitted numeric value is written as a whole number. + * + * @param {*} value - Submitted value. + * @returns {boolean} True when the submitted value is an integer string. + */ +const isIntegerValue = (value) => { + return /^-?\d+$/.test(String(value).trim()) +} + +/** + * Check whether a submitted numeric value has no more than the allowed number + * of decimal places. + * + * @param {*} value - Submitted value. + * @param {number} decimalPlaces - Maximum decimal places allowed. + * @returns {boolean} True when the value has an allowed decimal precision. + */ +const hasMaximumDecimalPlaces = (value, decimalPlaces) => { + const match = String(value).trim().match(/^-?\d+(?:\.(\d+))?$/) + + return Boolean(match) && (!match[1] || match[1].length <= decimalPlaces) +} + /** * Validate a numeric value against invalid, min and max rules. * @@ -203,11 +230,24 @@ const mergeQuestion = (question, overrides = {}) => { const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => { const number = Number(value) - if (Number.isNaN(number)) { + if (Number.isNaN(number) || !Number.isFinite(number)) { errors.push(makeError(errorsConfig.invalid, defaultHref)) return } + if (validation.integer && !isIntegerValue(value)) { + errors.push(makeError(errorsConfig.integer || errorsConfig.invalid, defaultHref)) + return + } + + if ( + validation.decimalPlaces !== undefined && + !hasMaximumDecimalPlaces(value, validation.decimalPlaces) + ) { + errors.push(makeError(errorsConfig.decimalPlaces || errorsConfig.invalid, defaultHref)) + return + } + if (validation.min !== undefined && number < validation.min) { errors.push(makeError(errorsConfig.min, defaultHref)) } @@ -217,6 +257,61 @@ const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => } } +/** + * Validate the total value of grouped numeric inputs. + * + * @param {Object} groupValue - Submitted group values keyed by answer key. + * @param {Object} question - Normalised text_group question config. + * @param {ValidationError[]} errors - Mutable error collection. + */ +const validateGroupTotal = (groupValue, question, errors) => { + const totalValidation = question.validation?.total + + if (!totalValidation) { + return + } + + const total = (totalValidation.items || []).reduce((sum, item) => { + const multiplier = item.multiplier || 1 + + return sum + (Number(groupValue[item.answerKey]) * multiplier) + }, 0) + const totalErrors = question.errors?.total || {} + const defaultHref = `#${question.input?.items?.[0]?.id || question.id}` + + if (totalValidation.min !== undefined && total < totalValidation.min) { + errors.push(makeError(totalErrors.min, defaultHref)) + } + + if (totalValidation.max !== undefined && total > totalValidation.max) { + errors.push(makeError(totalErrors.max, defaultHref)) + } +} + +/** + * Order grouped input errors by the visual input order. + * + * @param {ValidationError[]} errors - Validation errors for a text_group. + * @param {Object} question - Normalised text_group question config. + * @returns {ValidationError[]} Errors ordered by grouped input position. + */ +const orderGroupErrors = (errors, question) => { + const inputOrder = (question.input?.items || []).reduce((map, item, index) => { + map[`#${item.id}`] = index + return map + }, {}) + + return errors + .map((error, index) => ({ error, index })) + .sort((a, b) => { + const aOrder = inputOrder[a.error.href] ?? Number.MAX_SAFE_INTEGER + const bOrder = inputOrder[b.error.href] ?? Number.MAX_SAFE_INTEGER + + return aOrder - bOrder || a.index - b.index + }) + .map(({ error }) => error) +} + /** * Validate grouped inputs, such as imperial height or weight fields. * @@ -227,6 +322,7 @@ const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => const validateInputGroup = (answers, question) => { const errors = [] const groupValue = answers[question.answerKey]?.[question.input.valueKey] || {} + let canValidateTotal = Boolean(question.validation?.total) ;(question.validation?.items || []).forEach((itemValidation) => { const value = groupValue[itemValidation.answerKey] @@ -235,15 +331,30 @@ const validateInputGroup = (answers, question) => { if (itemValidation.required && isBlank(value)) { errors.push(makeError(itemErrors.required, defaultHref)) + canValidateTotal = false return } if (itemValidation.type === 'number' && !isBlank(value)) { + const number = Number(value) + + if ( + Number.isNaN(number) || + !Number.isFinite(number) || + (itemValidation.integer && !isIntegerValue(value)) + ) { + canValidateTotal = false + } + validateNumber(value, itemValidation, itemErrors, errors, defaultHref) } }) - return errors + if (canValidateTotal) { + validateGroupTotal(groupValue, question, errors) + } + + return orderGroupErrors(errors, question) } /** diff --git a/app/prototype_v4_3/lib/tobacco-flow.js b/app/prototype_v4_3/lib/tobacco-flow.js index 9c317640..71da00f7 100644 --- a/app/prototype_v4_3/lib/tobacco-flow.js +++ b/app/prototype_v4_3/lib/tobacco-flow.js @@ -312,6 +312,119 @@ const getSmokingComparisonQuantity = (type, answer) => { return getSmokingQuantity(type, answer) } +const rollingTobaccoQuantityValues = { + less_than_10: 10, + '10_to_30': 20, + '31_to_50': 40.5, + '51_to_75': 63, + '76_to_100': 88, + more_than_100: 100 +} + +const smokingFrequencyRateMultipliers = { + daily: 7, + weekly: 1, + monthly: 0.25, + yearly: 1 / 52 +} + +/** + * Check whether a value can be compared as a submitted numeric answer. + * + * @param {*} value - Submitted value. + * @returns {boolean} True when value is numeric. + */ +const isComparableNumber = (value) => { + if (value === undefined || value === null || String(value).trim() === '') { + return false + } + + const number = Number(value) + + return Number.isFinite(number) +} + +/** + * Get a normalised quantity value for cross-frequency comparisons. + * + * @param {string} type - Tobacco type key. + * @param {*} quantity - Submitted quantity answer. + * @param {string} frequency - Submitted frequency answer. + * @returns {number|undefined} Normalised quantity, when comparable. + */ +const getSmokingQuantityComparisonRate = (type, quantity, frequency) => { + const multiplier = smokingFrequencyRateMultipliers[frequency] + + if (!multiplier) { + return undefined + } + + if (type === 'rolling_tobacco') { + const value = rollingTobaccoQuantityValues[quantity] + + return value ? value * multiplier : undefined + } + + return isComparableNumber(quantity) ? Number(quantity) * multiplier : undefined +} + +/** + * Build the left side of a changed-quantity comparison error. + * + * @param {string} type - Tobacco type key. + * @returns {string} Error phrase. + */ +const getSmokingQuantityChangeErrorPhrase = (type) => { + const heading = removeQuestionMark(getSmokingTypeHeadings(type, true).quantityHeading || '') + .replace(/ in a normal (day|week|month|year)$/, '') + + return upperFirst(lowerFirst(heading) + .replace(/^(how (?:much|many) .+?) did you normally smoke$/, '$1 you normally smoked') + .replace(/^(how (?:much|many) .+?) did you smoke$/, '$1 you normally smoked')) +} + +/** + * Check whether a changed-smoking quantity contradicts the selected change direction. + * + * @param {Object} params - Comparison inputs. + * @returns {Object|undefined} Validation error when the quantities contradict. + */ +const getSmokingQuantityChangeComparisonError = ({ + page, + step, + answer, + changeAnswer, + href +}) => { + if (page !== 'smoking-quantity-change' || !step.change || !answer.smokingQuantity || !answer.smokingFrequency || !changeAnswer.quantity || !changeAnswer.frequency) { + return undefined + } + + const currentRate = getSmokingQuantityComparisonRate(step.type, answer.smokingQuantity, answer.smokingFrequency) + const changedRate = getSmokingQuantityComparisonRate(step.type, changeAnswer.quantity, changeAnswer.frequency) + + if (currentRate === undefined || changedRate === undefined) { + return undefined + } + + const contradictsChange = step.change === 'greater' + ? changedRate <= currentRate + : changedRate >= currentRate + + if (!contradictsChange) { + return undefined + } + + const comparisonText = step.type === 'rolling_tobacco' && step.change === 'fewer' + ? 'less' + : smokingChangeTypes[step.change]?.label + + return { + text: `${getSmokingQuantityChangeErrorPhrase(step.type)} must be ${comparisonText} than ${[getSmokingComparisonQuantity(step.type, answer.smokingQuantity), getSmokingFrequencyComparisonPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}`, + href + } +} + const smokingFrequencyPeriods = { daily: 'a day', weekly: 'a week', @@ -319,6 +432,20 @@ const smokingFrequencyPeriods = { yearly: 'a year' } +const smokingFrequencyComparisonPeriods = { + daily: 'per day', + weekly: 'per week', + monthly: 'per month', + yearly: 'per year' +} + +const smokingFrequencyMaxHours = { + daily: 24, + weekly: 168, + monthly: 744, + yearly: 8760 +} + /** * Convert a smoking frequency value into a period phrase. * @@ -329,6 +456,31 @@ const getSmokingFrequencyPeriod = (frequency) => { return smokingFrequencyPeriods[frequency] || '' } +/** + * Convert a smoking frequency value into a comparison phrase. + * + * @param {string} frequency - Frequency value. + * @returns {string} Comparison phrase, for example `per day`. + */ +const getSmokingFrequencyComparisonPeriod = (frequency) => { + return smokingFrequencyComparisonPeriods[frequency] || '' +} + +/** + * Get the maximum number of hours allowed for an "another amount" shisha answer. + * + * @param {string} type - Tobacco type key. + * @param {string} frequency - Selected smoking frequency. + * @returns {number} Maximum number of hours. + */ +const getSmokingQuantityOtherMaxHours = (type, frequency) => { + if (type !== 'shisha') { + return 24 + } + + return smokingFrequencyMaxHours[frequency] || 24 +} + /** * Replace the default "normal day/week/month/year" phrase in a heading. * @@ -585,13 +737,7 @@ const getSmokingChangeHeading = (page, type, change, changeAnswer = {}, answer = } if (page === 'smoking-years-change') { - const quantity = getSmokingQuantity(type, changeAnswer.quantity) - - if (!quantity) { - return getQuestion('smoking-years-change').input.label - } - - return `How many years did you smoke ${[quantity, getSmokingFrequencyPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}?` + return getQuestion('smoking-years-change').input.label } return '' @@ -719,6 +865,16 @@ const getQuestionItemsWithLabels = (id, labels = {}, hintOverrides = {}) => { }) } +/** + * Check whether a smoking quantity must be a whole number. + * + * @param {string} type - Tobacco type key. + * @returns {boolean} True when decimal quantities should be rejected. + */ +const requiresWholeNumberSmokingQuantity = (type) => { + return !['rolling_tobacco', 'shisha'].includes(type) +} + /** * Build runtime overrides for a quantity question. * @@ -733,6 +889,7 @@ const getSmokingQuantityQuestionOverrides = ({ name, value, conditionalValue, + frequency, smokingType }) => { const question = getQuestion(page) @@ -742,6 +899,15 @@ const getSmokingQuantityQuestionOverrides = ({ const hint = hasHintOverride ? variantInput.hint : question.input.hint const questionType = variant.type || question.type const conditionalHref = '#smoking-quantity-other' + const maxHours = getSmokingQuantityOtherMaxHours(step.type, frequency) + const validation = { + ...variant.validation + } + + if (requiresWholeNumberSmokingQuantity(step.type)) { + validation.integer = true + } + const items = variant.options ? variant.options.map((option) => { const item = toComponentItem(option) @@ -777,13 +943,13 @@ const getSmokingQuantityQuestionOverrides = ({ suffix: questionType === 'text' ? smokingType.suffix : undefined }, validation: { - ...variant.validation, + ...validation, conditional: { another_amount: { required: true, type: 'number', min: 0.5, - max: 24, + max: maxHours, answerKey: 'smokingQuantityOther', value: conditionalValue, href: conditionalHref @@ -803,12 +969,16 @@ const getSmokingQuantityQuestionOverrides = ({ text: 'Number of hours must be a number', href: conditionalHref }, + integer: { + text: `${upperFirst(getAnswerPhraseFromHeading(heading))} must be a whole number`, + href: `#${question.input.id}` + }, min: { text: 'Number of hours must be 0.5 or more', href: conditionalHref }, max: { - text: 'Number of hours must be 24 or fewer', + text: `Number of hours must be ${maxHours} or fewer`, href: conditionalHref } }, @@ -841,9 +1011,10 @@ const getAnswerPhraseFromHeading = (heading = '') => { .replace(/^how long did you smoke /, 'how long you smoked ') .replace(/^how long do you currently smoke /, 'how long you currently smoke ') .replace(/^how long do you smoke /, 'how long you smoke ') - .replace(/^(how (?:much|many|long) .+?) did you smoke /, '$1 you smoked ') - .replace(/^(how (?:much|many|long) .+?) do you currently smoke /, '$1 you currently smoke ') - .replace(/^(how (?:much|many|long) .+?) do you smoke /, '$1 you smoke ') + .replace(/^(how (?:much|many|long) .+?) did you normally smoke(?= |$)/, '$1 you normally smoked') + .replace(/^(how (?:much|many|long) .+?) did you smoke(?= |$)/, '$1 you smoked') + .replace(/^(how (?:much|many|long) .+?) do you currently smoke(?= |$)/, '$1 you currently smoke') + .replace(/^(how (?:much|many|long) .+?) do you smoke(?= |$)/, '$1 you smoke') } /** @@ -1048,6 +1219,7 @@ const getSmokingContentQuestionOverrides = ({ name: `answers[${step.type}][smokingQuantity]`, value: answer.smokingQuantity, conditionalValue: answer.smokingQuantityOther, + frequency: answer.smokingFrequency, smokingType }) } @@ -1089,6 +1261,7 @@ const getSmokingContentQuestionOverrides = ({ name: `answers[${step.type}][${smokingChange.answerKey}][quantity]`, value: changeAnswer.quantity, conditionalValue: changeAnswer.smokingQuantityOther, + frequency: answer.smokingFrequency, smokingType }) } @@ -1323,10 +1496,23 @@ const validateSmokingTypeQuestion = (req, page, step) => { } } - return validateQuestion(req.session.data.answers, page, { + const validationErrors = validateQuestion(req.session.data.answers, page, { ...overrides, errors }) + const comparisonError = getSmokingQuantityChangeComparisonError({ + page, + step, + answer: context.answer, + changeAnswer: context.changeAnswer, + href: `#${errorHref}` + }) + + if (comparisonError && !validationErrors.some((error) => error.href === comparisonError.href)) { + validationErrors.push(comparisonError) + } + + return validationErrors } /**