Skip to content
Closed
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
21 changes: 21 additions & 0 deletions docs/features/code-based/PRE_POPULATE_STATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
layout: default
title: Pre-populate state
parent: Code-based Features
grand_parent: Features
render_with_liquid: false
---

# Pre-populate state

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.

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.

The parameter values get copied on first load of the form, and are simple key/value parameters e.g.:

```
?paramname1=paramval1,paramname2=paramname2
```

There is no limit set on the number of parameters. The keys and values get copied as-is (no case changes get applied).
285 changes: 137 additions & 148 deletions package-lock.json

Large diffs are not rendered by default.

188 changes: 188 additions & 0 deletions src/server/plugins/engine/components/HiddenField.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { ComponentType, type HiddenFieldComponent } from '@defra/forms-model'

import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
import {
getAnswer,
type Field
} from '~/src/server/plugins/engine/components/helpers/components.js'
import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
import definition from '~/test/form/definitions/blank.js'
import { getFormData, getFormState } from '~/test/helpers/component-helpers.js'

describe('HiddenField', () => {
let model: FormModel

beforeEach(() => {
model = new FormModel(definition, {
basePath: 'test'
})
})

describe('Defaults', () => {
let def: HiddenFieldComponent
let collection: ComponentCollection
let field: Field

beforeEach(() => {
def = {
title: 'Hidden field',
name: 'myComponent',
type: ComponentType.HiddenField,
options: {}
} satisfies HiddenFieldComponent

collection = new ComponentCollection([def], { model })
field = collection.fields[0]
})

describe('Schema', () => {
it('uses component title as label as default', () => {
const { formSchema } = collection
const { keys } = formSchema.describe()

expect(keys).toHaveProperty(
'myComponent',
expect.objectContaining({
flags: expect.objectContaining({
label: 'Hidden field'
})
})
)
})

it('uses component name as keys', () => {
const { formSchema } = collection
const { keys } = formSchema.describe()

expect(field.keys).toEqual(['myComponent'])
expect(field.collection).toBeUndefined()

for (const key of field.keys) {
expect(keys).toHaveProperty(key)
}
})

it('is required by default', () => {
const { formSchema } = collection
const { keys } = formSchema.describe()

expect(keys).toHaveProperty(
'myComponent',
expect.objectContaining({
flags: expect.objectContaining({
presence: 'required'
})
})
)
})
it('accepts valid values', () => {
const result1 = collection.validate(getFormData('Hidden value'))
const result2 = collection.validate(getFormData('Hidden value 2'))

expect(result1.errors).toBeUndefined()
expect(result2.errors).toBeUndefined()
})

it('adds errors for empty value', () => {
const result = collection.validate(getFormData(''))

expect(result.errors).toEqual([
expect.objectContaining({
text: 'Enter hidden field'
})
])
})

it('adds errors for invalid values', () => {
const result1 = collection.validate(getFormData(['invalid']))
const result2 = collection.validate(
// @ts-expect-error - Allow invalid param for test
getFormData({ unknown: 'invalid' })
)

expect(result1.errors).toBeTruthy()
expect(result2.errors).toBeTruthy()
})
})

describe('State', () => {
it('returns text from state', () => {
const state1 = getFormState('Hidden field')
const state2 = getFormState(null)

const answer1 = getAnswer(field, state1)
const answer2 = getAnswer(field, state2)

expect(answer1).toBe('Hidden field')
expect(answer2).toBe('')
})

it('returns payload from state', () => {
const state1 = getFormState('Hidden field')
const state2 = getFormState(null)

const payload1 = field.getFormDataFromState(state1)
const payload2 = field.getFormDataFromState(state2)

expect(payload1).toEqual(getFormData('Hidden field'))
expect(payload2).toEqual(getFormData())
})

it('returns value from state', () => {
const state1 = getFormState('Hidden field')
const state2 = getFormState(null)

const value1 = field.getFormValueFromState(state1)
const value2 = field.getFormValueFromState(state2)

expect(value1).toBe('Hidden field')
expect(value2).toBeUndefined()
})

it('returns context for conditions and form submission', () => {
const state1 = getFormState('Hidden field')
const state2 = getFormState(null)

const value1 = field.getContextValueFromState(state1)
const value2 = field.getContextValueFromState(state2)

expect(value1).toBe('Hidden field')
expect(value2).toBeNull()
})

it('returns state from payload', () => {
const payload1 = getFormData('Hidden field')
const payload2 = getFormData()

const value1 = field.getStateFromValidForm(payload1)
const value2 = field.getStateFromValidForm(payload2)

expect(value1).toEqual(getFormState('Hidden field'))
expect(value2).toEqual(getFormState(null))
})
})

describe('View model', () => {
it('sets Nunjucks component defaults', () => {
const viewModel = field.getViewModel(getFormData('Hidden field'))

expect(viewModel).toEqual(
expect.objectContaining({
label: { text: def.title },
name: 'myComponent',
id: 'myComponent',
value: 'Hidden field'
})
)
})
})

describe('AllPossibleErrors', () => {
it('should return errors', () => {
const errors = field.getAllPossibleErrors()
expect(errors.baseErrors).not.toBeEmpty()
expect(errors.advancedSettingsErrors).toBeEmpty()
})
})
})
})
62 changes: 62 additions & 0 deletions src/server/plugins/engine/components/HiddenField.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {
type HiddenFieldComponent,
type TextFieldComponent
} from '@defra/forms-model'
import joi, { type StringSchema } from 'joi'

import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
import { TextField } from '~/src/server/plugins/engine/components/TextField.js'
import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js'
import {
type ErrorMessageTemplateList,
type FormState,
type FormStateValue,
type FormSubmissionState
} from '~/src/server/plugins/engine/types.js'

export class HiddenField extends FormComponent {
declare formSchema: StringSchema
declare stateSchema: StringSchema
declare schema: TextFieldComponent['schema']
declare options: TextFieldComponent['options']

constructor(
def: HiddenFieldComponent,
props: ConstructorParameters<typeof FormComponent>[1]
) {
super(def, props)

const formSchema = joi.string().trim().label(this.label).required()

this.formSchema = formSchema.default('')
this.stateSchema = formSchema.default(null).allow(null)
this.schema = {}
this.options = {}
}

getFormValueFromState(state: FormSubmissionState) {
const { name } = this
return this.getFormValue(state[name])
}

isValue(value?: FormStateValue | FormState): value is string {
return TextField.isText(value)
}

/**
* For error preview page that shows all possible errors on a component
*/
getAllPossibleErrors(): ErrorMessageTemplateList {
return HiddenField.getAllPossibleErrors()
}

/**
* Static version of getAllPossibleErrors that doesn't require a component instance.
*/
static getAllPossibleErrors(): ErrorMessageTemplateList {
return {
baseErrors: [{ type: 'required', template: messageTemplate.required }],
advancedSettingsErrors: []
}
}
}
5 changes: 5 additions & 0 deletions src/server/plugins/engine/components/helpers/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type Field = InstanceType<
| typeof Components.TextField
| typeof Components.UkAddressField
| typeof Components.FileUploadField
| typeof Components.HiddenField
>

// Guidance component instances only
Expand Down Expand Up @@ -186,6 +187,10 @@ export function createComponent(
case ComponentType.LatLongField:
component = new Components.LatLongField(def, options)
break

case ComponentType.HiddenField:
component = new Components.HiddenField(def, options)
break
}

if (typeof component === 'undefined') {
Expand Down
17 changes: 17 additions & 0 deletions src/server/plugins/engine/components/helpers/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ComponentType, type ComponentDef } from '@defra/forms-model'

import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js'
import { EastingNorthingField } from '~/src/server/plugins/engine/components/EastingNorthingField.js'
import { HiddenField } from '~/src/server/plugins/engine/components/HiddenField.js'
import { LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js'
import { NationalGridFieldNumberField } from '~/src/server/plugins/engine/components/NationalGridFieldNumberField.js'
import { OsGridRefField } from '~/src/server/plugins/engine/components/OsGridRefField.js'
Expand Down Expand Up @@ -96,6 +97,22 @@ describe('helpers tests', () => {
expect(component.name).toBe('testField')
expect(component.title).toBe('Test National Grid')
})

test('should create HiddenField component', () => {
const component = createComponent(
{
type: ComponentType.HiddenField,
name: 'hiddenField',
title: 'Hidden field',
options: {}
},
{ model: formModel }
)

expect(component).toBeInstanceOf(HiddenField)
expect(component.name).toBe('hiddenField')
expect(component.title).toBe('Hidden field')
})
})

describe('ComponentBase tests', () => {
Expand Down
1 change: 1 addition & 0 deletions src/server/plugins/engine/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ export { EastingNorthingField } from '~/src/server/plugins/engine/components/Eas
export { OsGridRefField } from '~/src/server/plugins/engine/components/OsGridRefField.js'
export { NationalGridFieldNumberField } from '~/src/server/plugins/engine/components/NationalGridFieldNumberField.js'
export { LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js'
export { HiddenField } from '~/src/server/plugins/engine/components/HiddenField.js'
3 changes: 3 additions & 0 deletions src/server/plugins/engine/models/FormModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export class FormModel {
lists: FormDefinition['lists']
sections: FormDefinition['sections'] = []
name: string
formId: string
values: FormDefinition
basePath: string
versionNumber?: number
Expand All @@ -100,6 +101,7 @@ export class FormModel {
basePath: string
versionNumber?: number
ordnanceSurveyApiKey?: string
formId?: string
},
services: Services = defaultServices,
controllers?: Record<string, typeof PageController>
Expand Down Expand Up @@ -152,6 +154,7 @@ export class FormModel {
this.lists = def.lists
this.sections = def.sections
this.name = def.name ?? ''
this.formId = options.formId ?? ''
this.values = result.value
this.basePath = options.basePath
this.versionNumber = options.versionNumber
Expand Down
12 changes: 8 additions & 4 deletions src/server/plugins/engine/pageControllers/PageController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ describe('PageController', () => {
const page2 = pages[1]

model = new FormModel(definition, {
basePath: testBasePath
basePath: testBasePath,
formId: 'form-id'
})

controller1 = new PageController(model, page1)
Expand Down Expand Up @@ -61,8 +62,11 @@ describe('PageController', () => {
})
})

it('returns feedback link (from form definition)', () => {
expect(controller1).toHaveProperty('feedbackLink', undefined)
it('returns feedback link default', () => {
expect(controller1).toHaveProperty(
'feedbackLink',
'/form/csat?formId=form-id'
)

const emailAddress = 'test@feedback.cat'

Expand All @@ -77,7 +81,7 @@ describe('PageController', () => {
})

it('returns phase tag (from form definition)', () => {
expect(controller1).toHaveProperty('phaseTag', undefined)
expect(controller1).toHaveProperty('phaseTag', 'beta')

model.def.phaseBanner = {
phase: 'alpha'
Expand Down
Loading
Loading