diff --git a/package-lock.json b/package-lock.json index 2ff8e96c4..2db45e9e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "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", @@ -115,7 +115,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", @@ -3697,9 +3697,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.704", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.704.tgz", - "integrity": "sha512-+yxRe8TxWMoBwlZl9FSobXWEJoqgTip1uq5GBoBylXUxG/r33oB+Jk2tdX5VqpEEZN7wYkfzOFqACd7Wf2kzfQ==", + "version": "3.0.708", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.708.tgz", + "integrity": "sha512-yQkH6rQmo2l2AcIRRDxLlDqoXaIaAkPQm3t7A0Ml/NXQo7nB2EQeoSBCsiClYmBi6aE/Zn2g+efOyRz7KopCvQ==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", @@ -17774,9 +17774,9 @@ } }, "node_modules/editorconfig-checker": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/editorconfig-checker/-/editorconfig-checker-6.1.1.tgz", - "integrity": "sha512-kiOb6qaWpMNt7Z/43ba0Pa1Inhr2/t9nKbvEKtCeXJ5AesztoM9AgLOOQVB4QUv/nGjgz3xkbx4pcogVRD2NWw==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/editorconfig-checker/-/editorconfig-checker-6.2.0.tgz", + "integrity": "sha512-5zrNwlxUWyOvAcrSK4mPIGNYrf1KK5pH2D3VXDB1AcDsDdBfLNPOjBL5jHgYh+JykOIuXlQHMeYvXPrZwGNmHA==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 74048435d..7c400b446 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/scripts/component-metadata.json b/scripts/component-metadata.json index f45dd7219..fdb218eeb 100644 --- a/scripts/component-metadata.json +++ b/scripts/component-metadata.json @@ -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.", @@ -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." + ], "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." ], diff --git a/scripts/component-preview-fixtures.js b/scripts/component-preview-fixtures.js index 7c70ab270..0e73fbc3f 100644 --- a/scripts/component-preview-fixtures.js +++ b/scripts/component-preview-fixtures.js @@ -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: diff --git a/src/server/forms/components.json b/src/server/forms/components.json index b77878eb5..0b3f1d9e9 100644 --- a/src/server/forms/components.json +++ b/src/server/forms/components.json @@ -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", diff --git a/src/server/forms/register-as-a-unicorn-breeder.yaml b/src/server/forms/register-as-a-unicorn-breeder.yaml index fa653641d..6999b60a9 100644 --- a/src/server/forms/register-as-a-unicorn-breeder.yaml +++ b/src/server/forms/register-as-a-unicorn-breeder.yaml @@ -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 diff --git a/src/server/plugins/engine/components/TrackingField.test.ts b/src/server/plugins/engine/components/TrackingField.test.ts new file mode 100644 index 000000000..d94f4b613 --- /dev/null +++ b/src/server/plugins/engine/components/TrackingField.test.ts @@ -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() + }) + }) + }) +}) diff --git a/src/server/plugins/engine/components/TrackingField.ts b/src/server/plugins/engine/components/TrackingField.ts new file mode 100644 index 000000000..086b6920e --- /dev/null +++ b/src/server/plugins/engine/components/TrackingField.ts @@ -0,0 +1,64 @@ +import { type TrackingFieldComponent } from '@defra/forms-model' +import joi, { type BooleanSchema } from 'joi' + +import { + FormComponent, + isFormValue +} from '~/src/server/plugins/engine/components/FormComponent.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 TrackingField extends FormComponent { + declare formSchema: BooleanSchema + declare stateSchema: BooleanSchema + declare options: TrackingFieldComponent['options'] + + constructor( + def: TrackingFieldComponent, + props: ConstructorParameters[1] + ) { + super(def, props) + + const formSchema = joi.boolean().valid(true).label(this.label).required() + + this.formSchema = formSchema.default(false) + 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 true { + return TrackingField.isTracking(value) + } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return TrackingField.getAllPossibleErrors() + } + + static isTracking(value?: FormStateValue | FormState): value is true { + return isFormValue(value) && typeof value === 'boolean' + } + + /** + * Static version of getAllPossibleErrors that doesn't require a component instance. + */ + static getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [{ type: 'required', template: messageTemplate.required }], + advancedSettingsErrors: [] + } + } +} diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index 728fc8616..7fdd2414c 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -37,6 +37,7 @@ export type Field = InstanceType< | typeof Components.UkAddressField | typeof Components.FileUploadField | typeof Components.HiddenField + | typeof Components.TrackingField | typeof Components.PaymentField | typeof Components.GeospatialField > @@ -201,6 +202,10 @@ export function createComponent( component = new Components.HiddenField(def, options) break + case ComponentType.TrackingField: + component = new Components.TrackingField(def, options) + break + case ComponentType.PaymentField: component = new Components.PaymentField(def, options) break diff --git a/src/server/plugins/engine/components/helpers/helpers.test.ts b/src/server/plugins/engine/components/helpers/helpers.test.ts index 3f21ffce2..3220cf667 100644 --- a/src/server/plugins/engine/components/helpers/helpers.test.ts +++ b/src/server/plugins/engine/components/helpers/helpers.test.ts @@ -13,6 +13,7 @@ import { lowerFirstExpressionOptions, lowerFirstPreserveProperNouns } from '~/src/server/plugins/engine/components/helpers/index.js' +import { TrackingField } from '~/src/server/plugins/engine/components/index.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import definition from '~/test/form/definitions/basic.js' @@ -117,6 +118,22 @@ describe('helpers tests', () => { expect(component.name).toBe('hiddenField') expect(component.title).toBe('Hidden field') }) + + test('should create TrackingField component', () => { + const component = createComponent( + { + type: ComponentType.TrackingField, + name: 'trackingField', + title: 'Tracking field', + options: {} + }, + { model: formModel } + ) + + expect(component).toBeInstanceOf(TrackingField) + expect(component.name).toBe('trackingField') + expect(component.title).toBe('Tracking field') + }) }) describe('ComponentBase tests', () => { diff --git a/src/server/plugins/engine/components/index.ts b/src/server/plugins/engine/components/index.ts index 8662e8ad2..2d8333c35 100644 --- a/src/server/plugins/engine/components/index.ts +++ b/src/server/plugins/engine/components/index.ts @@ -30,5 +30,6 @@ export { OsGridRefField } from '~/src/server/plugins/engine/components/OsGridRef 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' +export { TrackingField } from '~/src/server/plugins/engine/components/TrackingField.js' export { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js' export { GeospatialField } from '~/src/server/plugins/engine/components/GeospatialField.js' diff --git a/src/server/plugins/engine/views/components/trackingfield.html b/src/server/plugins/engine/views/components/trackingfield.html new file mode 100644 index 000000000..b743d40ab --- /dev/null +++ b/src/server/plugins/engine/views/components/trackingfield.html @@ -0,0 +1,3 @@ +{% macro TrackingField(component) %} + +{% endmacro %}