Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tidy up validation #337

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions server/middleware/nunjucks/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,22 @@ export const localeInterpolation = (locale: object, replacements: Record<string,
return interpolateObject(locale)
}

export const toFormattedError = (errors: any, locale: any, fieldName: string) => {
const fieldErrors = errors?.body?.[fieldName]
export const getFormattedError = (errors: any, locale: any, fieldName: string) => {
const localePath = errors?.body?.[fieldName]?.messages?.[0]

if (errors?.body?.[fieldName]) {
const errorType = Object.keys(fieldErrors)[0]
if (!localePath) {
return false
}

if (errorType) {
return {
text: locale.errors[fieldName][errorType],
href: `#${fieldName}`,
}
}
const errorMessage = localePath
.replace(/^locale\./, '')
.split('.')
.reduce((obj, key) => obj?.[key], locale)

return {
text: errorMessage ?? localePath,
href: `#${fieldName}`,
}
return false
}

export const formatDate = (date: string, format: 'iso' | 'simple') => {
Expand Down
4 changes: 2 additions & 2 deletions server/middleware/nunjucks/nunjucksSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import config from '../../config'
import { initialiseName, mergeDeep, convertToTitleCase } from '../../utils/utils'
import commonLocale from '../../utils/commonLocale.json'
import { sentenceLength } from '../../utils/assessmentUtils'
import { formatDate, localeInterpolation, merge, toFormattedError } from './helpers'
import { formatDate, getFormattedError, localeInterpolation, merge } from './helpers'

const production = process.env.NODE_ENV === 'production'

Expand Down Expand Up @@ -54,7 +54,7 @@ export default function nunjucksSetup(app: express.Express, applicationInfo: App
njkEnv.addGlobal('merge', merge)
njkEnv.addGlobal('sentenceLength', sentenceLength)
njkEnv.addGlobal('interpolate', localeInterpolation)
njkEnv.addGlobal('getFormattedError', toFormattedError)
njkEnv.addGlobal('getFormattedError', getFormattedError)

app.use((req, res, next) => {
res.render = new Proxy(res.render, {
Expand Down
45 changes: 25 additions & 20 deletions server/middleware/validationMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,37 @@ import { NextFunction, Request, Response } from 'express'
import RequestDataSources from '../@types/RequestDataSources'
import 'reflect-metadata'

export function getValidationErrors(data: object) {
function buildPrettyError(errorsInner: ValidationError[], path: string = ''): Record<string, any> {
return errorsInner.reduce((accumulator, error) => {
const currentPath = path ? `${path}.${error.property}` : error.property

const updatedAccumulator = {
...accumulator,
...(error.constraints && {
[currentPath]: Object.keys(error.constraints).reduce(
(constraintAccumulator, key) => ({ ...constraintAccumulator, [key]: true }),
{} as Record<string, boolean>,
),
}),
interface FlatValidationError {
constraintsNotMet: string[]
messages: string[]
}

function flattenValidationErrors(errors: ValidationError[], path = ''): Record<string, FlatValidationError> {
return errors.reduce(
(acc, error) => {
const propertyPath = path ? `${path}.${error.property}` : error.property

if (error.constraints) {
const { constraints } = error
acc[propertyPath] = {
constraintsNotMet: Object.keys(constraints),
messages: Object.values(constraints),
}
}

if (error.children && error.children.length > 0) {
const childErrors = buildPrettyError(error.children, currentPath)
return { ...updatedAccumulator, ...childErrors }
const childMap = flattenValidationErrors(error.children, propertyPath)
Object.assign(acc, childMap)
}
return acc
},
{} as Record<string, FlatValidationError>,
)
}

return updatedAccumulator
}, {})
}

export function getValidationErrors(data: object) {
const errors = validateSync(data)
return buildPrettyError(errors)
return flattenValidationErrors(errors)
}

export default function validateRequest() {
Expand Down
22 changes: 10 additions & 12 deletions server/routes/add-steps/locale.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
"description": {
"label": "What should they do to achieve the goal?",
"hint": "Enter one step at a time.",
"autocompleteHint": "Select a suggested step or enter your own."
"autocompleteHint": "Select a suggested step or enter your own.",
"errors": {
"isEmpty": "Select or enter what they should do to achieve the goal.",
"maxLength": "What they should do to achieve the goal must be 4,000 characters or less"
}
},
"actor": {
"label": "Who will do this step?",
Expand All @@ -25,21 +29,15 @@
"Partnership agency",
"Commissioned rehabilitative services (CRS) provider",
"Someone else (include who in the step)"
]
],
"errors": {
"isEmpty": "Select who will do the step"
}
}
},
"saveButtonText": "Save and continue",
"addAnotherButton": "Add another step",
"removeStepLinkButton": "Remove",
"clearStepLinkButton": "Clear",
"errors": {
"step-description": {
"isNotEmpty": "Select or enter what they should do to achieve the goal.",
"maxLength": "What they should do to achieve the goal must be 4,000 characters or less"
},
"step-actor": {
"notContains": "Select who will do the step"
}
}
"clearStepLinkButton": "Clear"
}
}
16 changes: 12 additions & 4 deletions server/routes/add-steps/models/AddStepsPostModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@ import { Expose, plainToInstance, Transform } from 'class-transformer'
import { StepStatus } from '../../../@types/StepType'

export class StepModel {
@IsNotEmpty()
@NotContains('Choose someone')
@IsNotEmpty({
message: 'locale.step.actor.errors.isEmpty',
})
@NotContains('Choose someone', {
message: 'locale.step.actor.errors.isEmpty',
})
actor: string

@IsNotEmpty()
@MaxLength(4000)
@IsNotEmpty({
message: 'locale.step.description.errors.isEmpty',
})
@MaxLength(4000, {
message: 'locale.step.description.errors.maxLength',
})
description: string

@IsEnum(StepStatus)
Expand Down
52 changes: 26 additions & 26 deletions server/routes/createGoal/locale.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,36 @@
"goalSelection": {
"label": "What goal should {{ subject.givenName }} try to achieve?",
"hint": "Search for a suggested goal or enter your own. Add one goal at a time.",
"autocompleteHint": "Search for a suggested goal or enter your own."
"autocompleteHint": "Search for a suggested goal or enter your own.",
"errors": {
"isEmpty": "Select or enter what goal they should try to achieve",
"maxLength": "Goal must be 4,000 characters or less"
}
},
"relatedAreaOfNeed": {
"label": "Is this goal related to any other area of need?",
"hint": "Select all that apply.",
"errors": {
"isEmpty": "Select all related areas"
}
},
"relatedAreaOfNeedRadio": {
"label": "Is this goal related to any other area of need?",
"options": {
"yes": "Yes",
"no": "No"
},
"errors": {
"isEmpty": "Select yes if this goal is related to any other area of need"
}
},
"startWorking": {
"label": "Can {{ subject.givenName }} start working on this goal now?",
"options": {
"yes": "Yes",
"no": "No, it is a future goal"
},
"errors": {
"isEmpty": "Select yes if they can start working on this goal now"
}
},
"dateSelection": {
Expand All @@ -35,35 +50,20 @@
"sixMonths": "In 6 months ({{ dateOptions.sixMonths }})",
"twelveMonths": "In 12 months ({{ dateOptions.twelveMonths }})",
"custom": "Set another date"
},
"errors": {
"isEmpty": "Select when they should aim to achieve this goal"
}
},
"datePicker": {
"label": "Select a date",
"hint": "For example, 31/3/2023."
"hint": "For example, 31/3/2023.",
"errors": {
"isEmpty": "Select a date",
"goalDateMustBeTodayOrFuture": "Date must be today or in the future"
}
},
"addStepsButton": "Add steps",
"saveWithoutStepsButton": "Save without steps",
"errors": {
"related-area-of-need-radio": {
"isNotEmpty": "Select yes if this goal is related to any other area of need"
},
"goal-input-autocomplete": {
"isNotEmpty": "Select or enter what goal they should try to achieve",
"maxLength": "Goal must be 4,000 characters or less"
},
"related-area-of-need": {
"isNotEmpty": "Select all related areas"
},
"start-working-goal-radio": {
"isNotEmpty": "Select yes if they can start working on this goal now"
},
"date-selection-radio": {
"isNotEmpty": "Select when they should aim to achieve this goal"
},
"date-selection-custom": {
"isNotEmpty": "Select a date",
"GoalDateMustBeTodayOrFuture": "Date must be today or in the future"
}
}
"saveWithoutStepsButton": "Save without steps"
}
}
32 changes: 24 additions & 8 deletions server/routes/createGoal/models/CreateGoalPostModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,46 @@ import { Expose, Transform } from 'class-transformer'
import GoalDateMustBeTodayOrFuture from '../../validators/GoalDateMustBeTodayOrFuture'

export default class CreateGoalPostModel {
@IsNotEmpty()
@MaxLength(4000)
@IsNotEmpty({
message: 'locale.goalSelection.errors.isEmpty',
})
@MaxLength(4000, {
message: 'locale.goalSelection.errors.maxLength',
})
'goal-input-autocomplete': string

@IsNotEmpty()
@IsNotEmpty({
message: 'locale.relatedAreaOfNeedRadio.errors.isEmpty',
})
'related-area-of-need-radio': string

@ValidateIf(o => o['related-area-of-need-radio'] === 'yes')
@IsNotEmpty()
@IsNotEmpty({
message: 'locale.relatedAreaOfNeed.errors.isNotEmpty',
})
@Transform(({ obj }) => {
return typeof obj['related-area-of-need'] === 'string' ? [obj['related-area-of-need']] : obj['related-area-of-need']
})
'related-area-of-need': string[]

@IsNotEmpty()
@IsNotEmpty({
message: 'locale.startWorking.errors.isEmpty',
})
'start-working-goal-radio': string

@ValidateIf(o => o['start-working-goal-radio'] === 'yes')
@IsNotEmpty()
@IsNotEmpty({
message: 'locale.dateSelection.errors.isEmpty',
})
'date-selection-radio': string

@ValidateIf(o => o['date-selection-radio'] === 'custom' && o['start-working-goal-radio'] === 'yes')
@IsNotEmpty()
@Validate(GoalDateMustBeTodayOrFuture, { message: 'Date must be today or in the future' })
@IsNotEmpty({
message: 'locale.datePicker.errors.isEmpty',
})
@Validate(GoalDateMustBeTodayOrFuture, {
message: 'locale.datePicker.errors.goalDateMustBeTodayOrFuture',
})
@Expose()
'date-selection-custom': string
}
28 changes: 3 additions & 25 deletions server/views/pages/add-steps.njk
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,6 @@
}) %}
{% endfor %}

{% set errorMessage = false %}

{% if errors.body['steps.' + loop.index0 + '.actor' ].notContains %}
{% set errorMessage = {
'text': locale.errors['step-actor'].notContains
} %}
{% endif %}

{{ govukSelect({
id: "step-actor-" + loop.index,
name: "step-actor-" + loop.index,
Expand All @@ -93,7 +85,7 @@
classes: 'govuk-visually-hidden--desktop'
},
items: actorItemsList,
errorMessage: errorMessage
errorMessage: getFormattedError(errors, locale, 'steps.' + loop.index0 + '.actor')
}) }}
</dt>
<dt class="govuk-summary-list__value">
Expand All @@ -102,20 +94,6 @@
name="step-status-{{ loop.index }}"
value="{{ step.status | default('NOT_STARTED') }}"/>

{% set errorMessage = false %}

{% if errors.body['steps.' + loop.index0 + '.description' ].isNotEmpty %}
{% set errorMessage = {
'text': locale.errors['step-description'].isNotEmpty
} %}
{% endif %}

{% if errors.body['steps.' + loop.index0 + '.description' ].maxLength %}
{% set errorMessage = {
'text': locale.errors['step-description'].maxLength
} %}
{% endif %}

{{ govukInput({
formGroup: {
classes: "step-description-" + loop.index + "-autocomplete-wrapper"
Expand All @@ -128,7 +106,7 @@
text: locale.step.description.hint,
classes: 'govuk-visually-hidden--desktop'
},
errorMessage: errorMessage,
errorMessage: getFormattedError(errors, locale, 'steps.' + loop.index0 + '.description'),
id: "step-description-" + loop.index,
name: "step-description-" + loop.index,
value: step.description
Expand Down Expand Up @@ -171,4 +149,4 @@
</div>
</div>
</form>
{% endblock %}
{% endblock %}
Loading