Skip to content
Merged
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
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
},
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@defra/forms-model": "^3.0.704",
"@defra/forms-model": "^3.0.708",
"@defra/hapi-tracing": "^1.29.0",
"@defra/interactive-map": "0.0.33-alpha",
"@elastic/ecs-pino-format": "^1.5.0",
Expand Down Expand Up @@ -197,7 +197,7 @@
"core-js": "^3.46.0",
"cssnano": "^7.1.2",
"cssnano-preset-default": "^7.0.10",
"editorconfig-checker": "^6.1.1",
"editorconfig-checker": "^6.2.0",
"eslint": "^9.39.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jest": "^28.14.0",
Expand Down
4 changes: 4 additions & 0 deletions scripts/component-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"FileUploadField": "File manager component that displays uploaded files from session state.",
"DeclarationField": "A checkbox the user must tick to confirm a declaration before proceeding.",
"HiddenField": "A non-visible field that stores a fixed value in form state without user input.",
"TrackingField": "A field that can track the user's progress through the form.",
"PaymentField": "Redirects the user to GOV.UK Pay to collect a payment before proceeding.",
"Html": "Display static HTML content on a page without collecting user input.",
"Markdown": "Display content authored in Markdown, rendered to HTML at runtime.",
Expand Down Expand Up @@ -99,6 +100,9 @@
"HiddenField": [
"Hidden fields can be pre-populated from query string parameters — see [Pre-populating state](../code-based/pre-populate-state.md) for details."
],
"TrackingField": [
"Tracking fields can track the user's progress through the form. They are hidden fields that are set to `true` when the user reaches a certain point in the form."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'...when the user reaches a certain point in the form...' - suggest '...when the user reaches the page the tracking field is on..'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I nearly went with this but then reverted. It's only once the user passes then page (through a POST) that it's set to true (i.e. not on the GET).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about 'They are hidden fields that are set to true when the user passes the page in the form.'

],
"EastingNorthingField": [
"This component renders an inline Ordnance Survey map that lets users click a location to auto-populate the coordinate inputs. The map requires the `ordnanceSurveyApiKey` and `ordnanceSurveyApiSecret` [plugin options](../../plugin-options.md#geospatial-map) to be set — without them the component falls back to plain text inputs."
],
Expand Down
11 changes: 11 additions & 0 deletions scripts/component-preview-fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,17 @@ export const fixtures = {
model: null,
payload: {}
},
[ComponentType.TrackingField]: {
jsLevel: 3,
def: {
type: ComponentType.TrackingField,
name: 'tracking',
title: 'Tracking field',
options: {}
},
model: null,
payload: {}
},
[ComponentType.LatLongField]: {
jsLevel: 2,
jsNotice:
Expand Down
8 changes: 8 additions & 0 deletions src/server/forms/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
"path": "/all-components",
"title": "All Components",
"components": [
{
"type": "TrackingField",
"name": "trackingField",
"title": "Tracking field",
"hint": "Help text",
"options": {},
"schema": {}
},
{
"type": "TextField",
"name": "textField",
Expand Down
4 changes: 4 additions & 0 deletions src/server/forms/register-as-a-unicorn-breeder.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ pages:
- path: '/whats-your-name'
title: What's your name?
components:
- type: TrackingField
name: hasSeenNamePage
title: Tracks if the user has seen the name page
options: {}
- type: TextField
name: textField
title: Name
Expand Down
211 changes: 211 additions & 0 deletions src/server/plugins/engine/components/TrackingField.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { ComponentType, type TrackingFieldComponent } 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 { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js'
import definition from '~/test/form/definitions/blank.js'
import { getFormData, getFormState } from '~/test/helpers/component-helpers.js'

const translator = new FormModel(definition, {
basePath: '/'
}).createTranslator()

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

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

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

beforeEach(() => {
def = {
title: 'Tracking field',
name: 'myComponent',
type: ComponentType.TrackingField,
options: {}
} satisfies TrackingFieldComponent

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: 'Tracking 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(true))

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

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

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

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

expect(result.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
text: 'Select tracking field'
}),
expect.objectContaining({
text: 'Tracking field must be a boolean'
})
])
)
})

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(true)
const state2 = getFormState(null)

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

expect(answer1).toBe('true')
expect(answer2).toBe('')
})

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

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

expect(payload1).toEqual(getFormData(true))
expect(payload2).toEqual(getFormData())
})

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

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

expect(value1).toBe(true)
expect(value2).toBeUndefined()
})

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

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

expect(value1).toBe(true)
expect(value2).toBeNull()
})

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

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

expect(value1).toEqual(getFormState(true))
expect(value2).toEqual(getFormState(null))
})
})

describe('View model', () => {
it('sets Nunjucks component defaults', () => {
const viewModel = field.getViewModel({
payload: getFormData(true),
errors: undefined,
translator: stubTranslator
})

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

describe('AllPossibleErrors', () => {
it('should return errors', () => {
const errors = field.getAllPossibleErrors()
expect(errors.baseErrors).not.toBeEmpty()
expect(errors.advancedSettingsErrors).toBeEmpty()
})
})
})
})
Loading
Loading