Skip to content

Commit d945cb8

Browse files
authored
Merge pull request #268 from DEFRA/feat/DF-553-csat-lint
feat/DF-553: CSAT feedback
2 parents 9549d48 + ddb8681 commit d945cb8

29 files changed

Lines changed: 873 additions & 45 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
layout: default
3+
title: Pre-populate state
4+
parent: Code-based Features
5+
grand_parent: Features
6+
render_with_liquid: false
7+
---
8+
9+
# Pre-populate state
10+
11+
The forms engine supports the ability to pre-populate form state using query string parameters. This feature enables applications to support passing specific parameter values through the form and on to the submission without the user having to enter these values.
12+
13+
The feature uses the HiddenField component to prevent against rogue state injection. Only query string parameters whose names exist as HiddenField components will be copied into state.
14+
15+
The parameter values get copied on first load of the form, and are simple key/value parameters e.g.:
16+
17+
```
18+
?paramname1=paramval1,paramname2=paramval2
19+
```
20+
21+
There is no limit set on the number of parameters. The keys and values get copied as-is (no case changes get applied).

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
},
7171
"license": "SEE LICENSE IN LICENSE",
7272
"dependencies": {
73-
"@defra/forms-model": "^3.0.584",
73+
"@defra/forms-model": "^3.0.585",
7474
"@defra/hapi-tracing": "^1.29.0",
7575
"@elastic/ecs-pino-format": "^1.5.0",
7676
"@hapi/boom": "^10.0.1",
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { ComponentType, type HiddenFieldComponent } from '@defra/forms-model'
2+
3+
import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
4+
import {
5+
getAnswer,
6+
type Field
7+
} from '~/src/server/plugins/engine/components/helpers/components.js'
8+
import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
9+
import definition from '~/test/form/definitions/blank.js'
10+
import { getFormData, getFormState } from '~/test/helpers/component-helpers.js'
11+
12+
describe('HiddenField', () => {
13+
let model: FormModel
14+
15+
beforeEach(() => {
16+
model = new FormModel(definition, {
17+
basePath: 'test'
18+
})
19+
})
20+
21+
describe('Defaults', () => {
22+
let def: HiddenFieldComponent
23+
let collection: ComponentCollection
24+
let field: Field
25+
26+
beforeEach(() => {
27+
def = {
28+
title: 'Hidden field',
29+
name: 'myComponent',
30+
type: ComponentType.HiddenField,
31+
options: {}
32+
} satisfies HiddenFieldComponent
33+
34+
collection = new ComponentCollection([def], { model })
35+
field = collection.fields[0]
36+
})
37+
38+
describe('Schema', () => {
39+
it('uses component title as label as default', () => {
40+
const { formSchema } = collection
41+
const { keys } = formSchema.describe()
42+
43+
expect(keys).toHaveProperty(
44+
'myComponent',
45+
expect.objectContaining({
46+
flags: expect.objectContaining({
47+
label: 'Hidden field'
48+
})
49+
})
50+
)
51+
})
52+
53+
it('uses component name as keys', () => {
54+
const { formSchema } = collection
55+
const { keys } = formSchema.describe()
56+
57+
expect(field.keys).toEqual(['myComponent'])
58+
expect(field.collection).toBeUndefined()
59+
60+
for (const key of field.keys) {
61+
expect(keys).toHaveProperty(key)
62+
}
63+
})
64+
65+
it('is required by default', () => {
66+
const { formSchema } = collection
67+
const { keys } = formSchema.describe()
68+
69+
expect(keys).toHaveProperty(
70+
'myComponent',
71+
expect.objectContaining({
72+
flags: expect.objectContaining({
73+
presence: 'required'
74+
})
75+
})
76+
)
77+
})
78+
it('accepts valid values', () => {
79+
const result1 = collection.validate(getFormData('Hidden value'))
80+
const result2 = collection.validate(getFormData('Hidden value 2'))
81+
82+
expect(result1.errors).toBeUndefined()
83+
expect(result2.errors).toBeUndefined()
84+
})
85+
86+
it('adds errors for empty value', () => {
87+
const result = collection.validate(getFormData(''))
88+
89+
expect(result.errors).toEqual([
90+
expect.objectContaining({
91+
text: 'Enter hidden field'
92+
})
93+
])
94+
})
95+
96+
it('adds errors for invalid values', () => {
97+
const result1 = collection.validate(getFormData(['invalid']))
98+
const result2 = collection.validate(
99+
// @ts-expect-error - Allow invalid param for test
100+
getFormData({ unknown: 'invalid' })
101+
)
102+
103+
expect(result1.errors).toBeTruthy()
104+
expect(result2.errors).toBeTruthy()
105+
})
106+
})
107+
108+
describe('State', () => {
109+
it('returns text from state', () => {
110+
const state1 = getFormState('Hidden field')
111+
const state2 = getFormState(null)
112+
113+
const answer1 = getAnswer(field, state1)
114+
const answer2 = getAnswer(field, state2)
115+
116+
expect(answer1).toBe('Hidden field')
117+
expect(answer2).toBe('')
118+
})
119+
120+
it('returns payload from state', () => {
121+
const state1 = getFormState('Hidden field')
122+
const state2 = getFormState(null)
123+
124+
const payload1 = field.getFormDataFromState(state1)
125+
const payload2 = field.getFormDataFromState(state2)
126+
127+
expect(payload1).toEqual(getFormData('Hidden field'))
128+
expect(payload2).toEqual(getFormData())
129+
})
130+
131+
it('returns value from state', () => {
132+
const state1 = getFormState('Hidden field')
133+
const state2 = getFormState(null)
134+
135+
const value1 = field.getFormValueFromState(state1)
136+
const value2 = field.getFormValueFromState(state2)
137+
138+
expect(value1).toBe('Hidden field')
139+
expect(value2).toBeUndefined()
140+
})
141+
142+
it('returns context for conditions and form submission', () => {
143+
const state1 = getFormState('Hidden field')
144+
const state2 = getFormState(null)
145+
146+
const value1 = field.getContextValueFromState(state1)
147+
const value2 = field.getContextValueFromState(state2)
148+
149+
expect(value1).toBe('Hidden field')
150+
expect(value2).toBeNull()
151+
})
152+
153+
it('returns state from payload', () => {
154+
const payload1 = getFormData('Hidden field')
155+
const payload2 = getFormData()
156+
157+
const value1 = field.getStateFromValidForm(payload1)
158+
const value2 = field.getStateFromValidForm(payload2)
159+
160+
expect(value1).toEqual(getFormState('Hidden field'))
161+
expect(value2).toEqual(getFormState(null))
162+
})
163+
})
164+
165+
describe('View model', () => {
166+
it('sets Nunjucks component defaults', () => {
167+
const viewModel = field.getViewModel(getFormData('Hidden field'))
168+
169+
expect(viewModel).toEqual(
170+
expect.objectContaining({
171+
label: { text: def.title },
172+
name: 'myComponent',
173+
id: 'myComponent',
174+
value: 'Hidden field'
175+
})
176+
)
177+
})
178+
})
179+
180+
describe('AllPossibleErrors', () => {
181+
it('should return errors', () => {
182+
const errors = field.getAllPossibleErrors()
183+
expect(errors.baseErrors).not.toBeEmpty()
184+
expect(errors.advancedSettingsErrors).toBeEmpty()
185+
})
186+
})
187+
})
188+
})
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import {
2+
type HiddenFieldComponent,
3+
type TextFieldComponent
4+
} from '@defra/forms-model'
5+
import joi, { type StringSchema } from 'joi'
6+
7+
import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
8+
import { TextField } from '~/src/server/plugins/engine/components/TextField.js'
9+
import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js'
10+
import {
11+
type ErrorMessageTemplateList,
12+
type FormState,
13+
type FormStateValue,
14+
type FormSubmissionState
15+
} from '~/src/server/plugins/engine/types.js'
16+
17+
export class HiddenField extends FormComponent {
18+
declare formSchema: StringSchema
19+
declare stateSchema: StringSchema
20+
declare schema: TextFieldComponent['schema']
21+
declare options: TextFieldComponent['options']
22+
23+
constructor(
24+
def: HiddenFieldComponent,
25+
props: ConstructorParameters<typeof FormComponent>[1]
26+
) {
27+
super(def, props)
28+
29+
const { options } = def
30+
31+
let formSchema = joi.string().trim().label(this.label).required()
32+
33+
if (options.required === false) {
34+
formSchema = formSchema.allow('')
35+
}
36+
37+
this.formSchema = formSchema.default('')
38+
this.stateSchema = formSchema.default(null).allow(null)
39+
this.schema = {}
40+
this.options = {}
41+
}
42+
43+
getFormValueFromState(state: FormSubmissionState) {
44+
const { name } = this
45+
return this.getFormValue(state[name])
46+
}
47+
48+
isValue(value?: FormStateValue | FormState): value is string {
49+
return TextField.isText(value)
50+
}
51+
52+
/**
53+
* For error preview page that shows all possible errors on a component
54+
*/
55+
getAllPossibleErrors(): ErrorMessageTemplateList {
56+
return HiddenField.getAllPossibleErrors()
57+
}
58+
59+
/**
60+
* Static version of getAllPossibleErrors that doesn't require a component instance.
61+
*/
62+
static getAllPossibleErrors(): ErrorMessageTemplateList {
63+
return {
64+
baseErrors: [{ type: 'required', template: messageTemplate.required }],
65+
advancedSettingsErrors: []
66+
}
67+
}
68+
}

src/server/plugins/engine/components/helpers/components.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type Field = InstanceType<
3434
| typeof Components.TextField
3535
| typeof Components.UkAddressField
3636
| typeof Components.FileUploadField
37+
| typeof Components.HiddenField
3738
>
3839

3940
// Guidance component instances only
@@ -186,6 +187,10 @@ export function createComponent(
186187
case ComponentType.LatLongField:
187188
component = new Components.LatLongField(def, options)
188189
break
190+
191+
case ComponentType.HiddenField:
192+
component = new Components.HiddenField(def, options)
193+
break
189194
}
190195

191196
if (typeof component === 'undefined') {

src/server/plugins/engine/components/helpers/helpers.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ComponentType, type ComponentDef } from '@defra/forms-model'
22

33
import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js'
44
import { EastingNorthingField } from '~/src/server/plugins/engine/components/EastingNorthingField.js'
5+
import { HiddenField } from '~/src/server/plugins/engine/components/HiddenField.js'
56
import { LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js'
67
import { NationalGridFieldNumberField } from '~/src/server/plugins/engine/components/NationalGridFieldNumberField.js'
78
import { OsGridRefField } from '~/src/server/plugins/engine/components/OsGridRefField.js'
@@ -96,6 +97,22 @@ describe('helpers tests', () => {
9697
expect(component.name).toBe('testField')
9798
expect(component.title).toBe('Test National Grid')
9899
})
100+
101+
test('should create HiddenField component', () => {
102+
const component = createComponent(
103+
{
104+
type: ComponentType.HiddenField,
105+
name: 'hiddenField',
106+
title: 'Hidden field',
107+
options: {}
108+
},
109+
{ model: formModel }
110+
)
111+
112+
expect(component).toBeInstanceOf(HiddenField)
113+
expect(component.name).toBe('hiddenField')
114+
expect(component.title).toBe('Hidden field')
115+
})
99116
})
100117

101118
describe('ComponentBase tests', () => {

src/server/plugins/engine/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ export { EastingNorthingField } from '~/src/server/plugins/engine/components/Eas
2828
export { OsGridRefField } from '~/src/server/plugins/engine/components/OsGridRefField.js'
2929
export { NationalGridFieldNumberField } from '~/src/server/plugins/engine/components/NationalGridFieldNumberField.js'
3030
export { LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js'
31+
export { HiddenField } from '~/src/server/plugins/engine/components/HiddenField.js'

src/server/plugins/engine/helpers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from '~/src/server/plugins/engine/components/helpers/components.js'
2323
import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
2424
import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
25+
import { stripParam } from '~/src/server/plugins/engine/pageControllers/helpers/state.js'
2526
import {
2627
type AnyFormRequest,
2728
type FormContext,
@@ -133,7 +134,7 @@ export function proceed(
133134
const response =
134135
isReturnAllowed && isPathRelative(returnUrl)
135136
? h.redirect(returnUrl)
136-
: h.redirect(redirectPath(nextUrl))
137+
: h.redirect(redirectPath(nextUrl, stripParam(query, 'returnUrl')))
137138

138139
// Redirect POST to GET to avoid resubmission
139140
return method === 'post'

0 commit comments

Comments
 (0)