From 86385b5b86112542212a4ac23efef6f196428096 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 19 Nov 2025 16:30:08 +0000 Subject: [PATCH 01/15] Copies url params to state --- .../engine/components/ComponentCollection.ts | 1 + .../pageControllers/QuestionPageController.ts | 14 +++++++++++++- .../pageControllers/StatusPageController.ts | 4 +++- src/server/plugins/engine/routes/index.ts | 19 +++++++++++++++++++ .../plugins/engine/views/confirmation.html | 3 +++ 5 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index df0ae3f48..c09d21e32 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -248,6 +248,7 @@ export class ComponentCollection { return { type, isFormComponent, model } }) + console.log('component collection view model', result) return result } diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 20ef943ae..8d0dd3f8b 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -268,9 +268,19 @@ export class QuestionPageController extends PageController { // Form payload from state const payload = collection.getFormDataFromState(state) + // External state items all start with a specific prefix + const externalState = Object.keys(state) + .filter(key => key.startsWith('_ext_')) + .reduce((obj: Record, key) => { + obj[key] = state[key] + return obj + }, {}) + + console.log('externalState', externalState) return { ...params, - ...payload + ...payload, + ...externalState } } @@ -310,6 +320,8 @@ export class QuestionPageController extends PageController { const cacheService = getCacheService(request.server) + console.log('got state', await cacheService.getState(request)) + return cacheService.getState(request) } diff --git a/src/server/plugins/engine/pageControllers/StatusPageController.ts b/src/server/plugins/engine/pageControllers/StatusPageController.ts index 9f2f72d7e..adef2f52c 100644 --- a/src/server/plugins/engine/pageControllers/StatusPageController.ts +++ b/src/server/plugins/engine/pageControllers/StatusPageController.ts @@ -47,7 +47,9 @@ export class StatusPageController extends QuestionPageController { return h.view(viewName, { ...viewModel, - submissionGuidance + submissionGuidance, + feedbackFormSlug: 'csat', + feedbackSourceSlug: slug // TODO - allow override from JSON if defined }) } } diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 9cb681c2c..18113d062 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -108,6 +108,22 @@ export async function redirectOrMakeHandler( return proceed(request, h, page.getHref(relevantPath)) } +async function prefillStateFromQueryParameters(request: AnyFormRequest, model: FormModel): Promise { + const q = Object.keys(request.query).length ? request.query : {} + const params = Object.entries(q).reduce((acc, [key, value]) => { + if (value === undefined) return acc + + const normalized = Array.isArray(value) ? value.join(',') : String(value) + ;(acc as Record)[`_ext_${key}`] = normalized + + return acc + }, {}) + + const page = model.pages[0] // Any page will do so just take the first one + const formData = await page.getState(request) + await page.mergeState(request, formData, params) +} + async function importExternalComponentState( request: AnyFormRequest, page: PageControllerClass, @@ -249,6 +265,9 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { controllers ) + // Copy any URL params into the form state + await prefillStateFromQueryParameters(request, model) + // Create new item and add it to the item cache item = { model, updatedAt: state.updatedAt } server.app.models.set(key, item) diff --git a/src/server/plugins/engine/views/confirmation.html b/src/server/plugins/engine/views/confirmation.html index 7c9fbb1a7..1907433e0 100644 --- a/src/server/plugins/engine/views/confirmation.html +++ b/src/server/plugins/engine/views/confirmation.html @@ -14,6 +14,9 @@

What happens next

{{ submissionGuidance | markdown | safe }}
+

+ What do you think of this service? (takes 30 seconds) +

{% endblock %} From e47560fdfcfb42981d674f50756e58832876b541 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 21 Nov 2025 14:21:35 +0000 Subject: [PATCH 02/15] Pre-populates params into state --- .../engine/components/ComponentCollection.ts | 1 - .../plugins/engine/components/HiddenField.ts | 73 +++++++++++++++++++ .../engine/components/helpers/components.ts | 5 ++ src/server/plugins/engine/components/index.ts | 1 + src/server/plugins/engine/models/FormModel.ts | 3 + .../pageControllers/QuestionPageController.ts | 14 +--- .../pageControllers/StatusPageController.ts | 3 +- src/server/plugins/engine/routes/index.ts | 53 +++++++++++--- .../plugins/engine/services/formsService.js | 10 +++ src/server/plugins/engine/types.ts | 1 + .../engine/views/components/hiddenfield.html | 3 + .../plugins/engine/views/confirmation.html | 2 +- src/server/types.ts | 1 + src/server/utils/file-form-service.js | 26 +++++++ 14 files changed, 168 insertions(+), 28 deletions(-) create mode 100644 src/server/plugins/engine/components/HiddenField.ts create mode 100644 src/server/plugins/engine/views/components/hiddenfield.html diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index c09d21e32..df0ae3f48 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -248,7 +248,6 @@ export class ComponentCollection { return { type, isFormComponent, model } }) - console.log('component collection view model', result) return result } diff --git a/src/server/plugins/engine/components/HiddenField.ts b/src/server/plugins/engine/components/HiddenField.ts new file mode 100644 index 000000000..68b189687 --- /dev/null +++ b/src/server/plugins/engine/components/HiddenField.ts @@ -0,0 +1,73 @@ +import { + type HiddenFieldComponent, + type TextFieldComponent +} from '@defra/forms-model' +import joi, { type StringSchema } from 'joi' + +import { + FormComponent, + isFormValue +} 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[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]) + } + + getFormValue(value?: FormStateValue | FormState) { + return this.isValue(value) ? value : undefined + } + + 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 TextField.getAllPossibleErrors() + } + + /** + * Static version of getAllPossibleErrors that doesn't require a component instance. + */ + static getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [{ type: 'required', template: messageTemplate.required }], + advancedSettingsErrors: [] + } + } + + static isText(value?: FormStateValue | FormState): value is string { + return isFormValue(value) && typeof value === 'string' + } +} diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index 880df9812..d307e6517 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -34,6 +34,7 @@ export type Field = InstanceType< | typeof Components.TextField | typeof Components.UkAddressField | typeof Components.FileUploadField + | typeof Components.HiddenField > // Guidance component instances only @@ -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') { diff --git a/src/server/plugins/engine/components/index.ts b/src/server/plugins/engine/components/index.ts index 43c342e26..da04c5a62 100644 --- a/src/server/plugins/engine/components/index.ts +++ b/src/server/plugins/engine/components/index.ts @@ -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' diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 936aa6568..42cfcad80 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -74,6 +74,7 @@ export class FormModel { lists: FormDefinition['lists'] sections: FormDefinition['sections'] = [] name: string + formId: string values: FormDefinition basePath: string versionNumber?: number @@ -100,6 +101,7 @@ export class FormModel { basePath: string versionNumber?: number ordnanceSurveyApiKey?: string + formId?: string }, services: Services = defaultServices, controllers?: Record @@ -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 diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 8d0dd3f8b..20ef943ae 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -268,19 +268,9 @@ export class QuestionPageController extends PageController { // Form payload from state const payload = collection.getFormDataFromState(state) - // External state items all start with a specific prefix - const externalState = Object.keys(state) - .filter(key => key.startsWith('_ext_')) - .reduce((obj: Record, key) => { - obj[key] = state[key] - return obj - }, {}) - - console.log('externalState', externalState) return { ...params, - ...payload, - ...externalState + ...payload } } @@ -320,8 +310,6 @@ export class QuestionPageController extends PageController { const cacheService = getCacheService(request.server) - console.log('got state', await cacheService.getState(request)) - return cacheService.getState(request) } diff --git a/src/server/plugins/engine/pageControllers/StatusPageController.ts b/src/server/plugins/engine/pageControllers/StatusPageController.ts index adef2f52c..833e4168c 100644 --- a/src/server/plugins/engine/pageControllers/StatusPageController.ts +++ b/src/server/plugins/engine/pageControllers/StatusPageController.ts @@ -48,8 +48,7 @@ export class StatusPageController extends QuestionPageController { return h.view(viewName, { ...viewModel, submissionGuidance, - feedbackFormSlug: 'csat', - feedbackSourceSlug: slug // TODO - allow override from JSON if defined + feedbackFormSlug: 'csat' // TODO - allow override from JSON if defined }) } } diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 18113d062..a6401b84e 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -1,3 +1,4 @@ +import { getHiddenFields } from '@defra/forms-model' import Boom from '@hapi/boom' import { type ResponseObject, @@ -33,6 +34,7 @@ import { type ExternalStateAppendage, type FormContext, type FormPayload, + type FormStateValue, type FormSubmissionState, type OnRequestCallback, type PluginOptions @@ -41,6 +43,7 @@ import { type FormRequest, type FormResponseToolkit } from '~/src/server/routes/types.js' +import { type Services } from '~/src/server/types.js' export async function redirectOrMakeHandler( request: AnyFormRequest, @@ -108,16 +111,44 @@ export async function redirectOrMakeHandler( return proceed(request, h, page.getHref(relevantPath)) } -async function prefillStateFromQueryParameters(request: AnyFormRequest, model: FormModel): Promise { - const q = Object.keys(request.query).length ? request.query : {} - const params = Object.entries(q).reduce((acc, [key, value]) => { - if (value === undefined) return acc - - const normalized = Array.isArray(value) ? value.join(',') : String(value) - ;(acc as Record)[`_ext_${key}`] = normalized - - return acc - }, {}) +/** + * A series of functions that can transform a pre-fill input parameter e.g lookup a form title based on firm id + */ +const paramLookupFunctions = { + formId: async (val: string, services: Services) => { + const meta = await services.formsService.getFormMetadataById(val) + return meta.title + } +} as Partial< + Record< + string, + (val: string, services: Services) => Promise + > +> + +/** + * Any hidden parameters defined in the FormDefinition may be pre-filled by URL parameter values. + * Other parameters are ignored for security reasons. + * @param request + * @param model + */ +async function prefillStateFromQueryParameters( + request: AnyFormRequest, + model: FormModel +): Promise { + const hiddenFieldNames = getHiddenFields(model.def).map((field) => field.name) + const query = Object.keys(request.query).length ? request.query : {} + const params = {} as Record + + for (const [key, value = ''] of Object.entries(query)) { + if (hiddenFieldNames.includes(key)) { + const lookupFunc = paramLookupFunctions[key] + const resValue = lookupFunc + ? await lookupFunc(value, model.services) + : value + params[key] = resValue + } + } const page = model.pages[0] // Any page will do so just take the first one const formData = await page.getState(request) @@ -260,7 +291,7 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { // Construct the form model const model = new FormModel( definition, - { basePath, versionNumber, ordnanceSurveyApiKey }, + { basePath, versionNumber, ordnanceSurveyApiKey, formId: id }, services, controllers ) diff --git a/src/server/plugins/engine/services/formsService.js b/src/server/plugins/engine/services/formsService.js index cb766daa7..79a538d2e 100644 --- a/src/server/plugins/engine/services/formsService.js +++ b/src/server/plugins/engine/services/formsService.js @@ -12,6 +12,16 @@ export function getFormMetadata(_slug) { throw error } +// eslint-disable-next-line jsdoc/require-returns-check +/** + * Dummy function to get form metadata. + * @param {string} _id - the id of the form + * @returns {Promise} + */ +export function getFormMetadataById(_id) { + throw error +} + // eslint-disable-next-line jsdoc/require-returns-check /** * Dummy function to get form metadata. diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 61af993be..01f0f00b3 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -299,6 +299,7 @@ export type SummaryListAction = ComponentText & { export interface PageViewModelBase extends Partial { page: PageController name?: string + formId?: string pageTitle: string sectionTitle?: string showTitle: boolean diff --git a/src/server/plugins/engine/views/components/hiddenfield.html b/src/server/plugins/engine/views/components/hiddenfield.html new file mode 100644 index 000000000..88f5f864f --- /dev/null +++ b/src/server/plugins/engine/views/components/hiddenfield.html @@ -0,0 +1,3 @@ +{% macro HiddenField(component) %} + +{% endmacro %} diff --git a/src/server/plugins/engine/views/confirmation.html b/src/server/plugins/engine/views/confirmation.html index 1907433e0..394fdf8da 100644 --- a/src/server/plugins/engine/views/confirmation.html +++ b/src/server/plugins/engine/views/confirmation.html @@ -15,7 +15,7 @@

What happens next

{{ submissionGuidance | markdown | safe }}

- What do you think of this service? (takes 30 seconds) + What do you think of this service? (takes 30 seconds)

diff --git a/src/server/types.ts b/src/server/types.ts index b20881f28..87823c0cd 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -23,6 +23,7 @@ import { type CacheService } from '~/src/server/services/cacheService.js' export interface FormsService { getFormMetadata: (slug: string) => Promise + getFormMetadataById: (id: string) => Promise getFormDefinition: ( id: string, state: FormStatus diff --git a/src/server/utils/file-form-service.js b/src/server/utils/file-form-service.js index f8306bc4d..6813f289c 100644 --- a/src/server/utils/file-form-service.js +++ b/src/server/utils/file-form-service.js @@ -97,6 +97,23 @@ export class FileFormService { return metadata } + /** + * Get the form metadata by form id + * @param {string} id - the form id + * @returns {FormMetadata} + */ + getFormMetadataById(id) { + const metadata = Array.from(this.#metadata.values()).find( + (form) => form.id === id + ) + + if (!metadata) { + throw new Error(`Form metadata id '${id}' not found`) + } + + return metadata + } + /** * Get the form defintion by id * @param {string} id - the form id @@ -127,6 +144,15 @@ export class FileFormService { return Promise.resolve(this.getFormMetadata(slug)) }, + /** + * Get the form metadata by form id + * @param {string} id + * @returns {Promise} + */ + getFormMetadataById: (id) => { + return Promise.resolve(this.getFormMetadataById(id)) + }, + /** * Get the form defintion by id * @param {string} id From 13e0859faf6e1489a6bb14434b5d19dffb8b2d41 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 21 Nov 2025 14:43:22 +0000 Subject: [PATCH 03/15] Bumped model version --- package-lock.json | 26 +++++++++++++------------- package.json | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8f07ee6ac..a0754831b 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.580", + "@defra/forms-model": "^3.0.582", "@defra/hapi-tracing": "^1.29.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -2213,9 +2213,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.580", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.580.tgz", - "integrity": "sha512-t96bN6V0wmLkIHPHMf5XDEbzDl5ARj3cPqBo09h5lSzQzP6qDtME6PVx4acZNmnFUBgkRkgYsY/xuwK5Gz2Cgw==", + "version": "3.0.582", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.582.tgz", + "integrity": "sha512-OFedFpTv+j/uUInNAQNxc6qn8Bs2CrXbFOFaVVxVgTz7JfzLwTVe8F3bmsCTLHioxLeEPuHkRrGXEg1XJW7cCA==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", @@ -3929,9 +3929,9 @@ } }, "node_modules/@jest/reporters/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -11401,9 +11401,9 @@ } }, "node_modules/jest-config/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -11971,9 +11971,9 @@ } }, "node_modules/jest-runtime/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 4ff0c294e..0384e9e8d 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.580", + "@defra/forms-model": "^3.0.582", "@defra/hapi-tracing": "^1.29.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", From 864c96baad366db09078963bbfc0ac518770e67f Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 21 Nov 2025 15:17:52 +0000 Subject: [PATCH 04/15] Extra coverage --- .../engine/components/HiddenField.test.ts | 188 ++++++++++++++++++ .../plugins/engine/components/HiddenField.ts | 15 +- .../engine/components/helpers/helpers.test.ts | 17 ++ src/server/plugins/engine/routes/index.ts | 6 +- 4 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 src/server/plugins/engine/components/HiddenField.test.ts diff --git a/src/server/plugins/engine/components/HiddenField.test.ts b/src/server/plugins/engine/components/HiddenField.test.ts new file mode 100644 index 000000000..bf02c3802 --- /dev/null +++ b/src/server/plugins/engine/components/HiddenField.test.ts @@ -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() + }) + }) + }) +}) diff --git a/src/server/plugins/engine/components/HiddenField.ts b/src/server/plugins/engine/components/HiddenField.ts index 68b189687..227652ab8 100644 --- a/src/server/plugins/engine/components/HiddenField.ts +++ b/src/server/plugins/engine/components/HiddenField.ts @@ -4,10 +4,7 @@ import { } from '@defra/forms-model' import joi, { type StringSchema } from 'joi' -import { - FormComponent, - isFormValue -} from '~/src/server/plugins/engine/components/FormComponent.js' +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 { @@ -42,10 +39,6 @@ export class HiddenField extends FormComponent { return this.getFormValue(state[name]) } - getFormValue(value?: FormStateValue | FormState) { - return this.isValue(value) ? value : undefined - } - isValue(value?: FormStateValue | FormState): value is string { return TextField.isText(value) } @@ -54,7 +47,7 @@ export class HiddenField extends FormComponent { * For error preview page that shows all possible errors on a component */ getAllPossibleErrors(): ErrorMessageTemplateList { - return TextField.getAllPossibleErrors() + return HiddenField.getAllPossibleErrors() } /** @@ -66,8 +59,4 @@ export class HiddenField extends FormComponent { advancedSettingsErrors: [] } } - - static isText(value?: FormStateValue | FormState): value is string { - return isFormValue(value) && typeof value === 'string' - } } diff --git a/src/server/plugins/engine/components/helpers/helpers.test.ts b/src/server/plugins/engine/components/helpers/helpers.test.ts index ab4a4762a..494618e62 100644 --- a/src/server/plugins/engine/components/helpers/helpers.test.ts +++ b/src/server/plugins/engine/components/helpers/helpers.test.ts @@ -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' @@ -92,6 +93,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', () => { diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index a6401b84e..83b5b37b5 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -136,12 +136,14 @@ async function prefillStateFromQueryParameters( request: AnyFormRequest, model: FormModel ): Promise { - const hiddenFieldNames = getHiddenFields(model.def).map((field) => field.name) + const hiddenFieldNames = new Set( + getHiddenFields(model.def).map((field) => field.name) + ) const query = Object.keys(request.query).length ? request.query : {} const params = {} as Record for (const [key, value = ''] of Object.entries(query)) { - if (hiddenFieldNames.includes(key)) { + if (hiddenFieldNames.has(key)) { const lookupFunc = paramLookupFunctions[key] const resValue = lookupFunc ? await lookupFunc(value, model.services) From 916cbc46308e4a574057d15b3b096abcc54e37a2 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 21 Nov 2025 16:00:47 +0000 Subject: [PATCH 05/15] Extra coverage --- src/server/plugins/engine/routes/index.ts | 10 ++- .../engine/services/formsService.test.ts | 21 +++++ src/server/utils/file-form-service.test.js | 83 +++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 src/server/plugins/engine/services/formsService.test.ts create mode 100644 src/server/utils/file-form-service.test.js diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 83b5b37b5..cb3fe56a0 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -139,7 +139,12 @@ async function prefillStateFromQueryParameters( const hiddenFieldNames = new Set( getHiddenFields(model.def).map((field) => field.name) ) - const query = Object.keys(request.query).length ? request.query : {} + const query = Object.keys(request.query).length ? request.query : undefined + + if (!query) { + return + } + const params = {} as Record for (const [key, value = ''] of Object.entries(query)) { @@ -234,6 +239,9 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { if (server.app.model) { request.app.model = server.app.model + // Copy any URL params into the form state + await prefillStateFromQueryParameters(request, request.app.model) + return h.continue } diff --git a/src/server/plugins/engine/services/formsService.test.ts b/src/server/plugins/engine/services/formsService.test.ts new file mode 100644 index 000000000..e7ae06ccc --- /dev/null +++ b/src/server/plugins/engine/services/formsService.test.ts @@ -0,0 +1,21 @@ +import { FormStatus } from '@defra/forms-model' + +import { + getFormDefinition, + getFormMetadata, + getFormMetadataById +} from '~/src/server/plugins/engine/services/formsService.js' + +describe('formsService', () => { + it('getFormMetadata should throw error', () => { + expect(() => getFormMetadata('slug')).toThrow() + }) + + it('getFormMetadataById should throw error', () => { + expect(() => getFormMetadataById('id')).toThrow() + }) + + it('getFormDefinition should throw error', () => { + expect(() => getFormDefinition('id', FormStatus.Draft)).toThrow() + }) +}) diff --git a/src/server/utils/file-form-service.test.js b/src/server/utils/file-form-service.test.js new file mode 100644 index 000000000..f9e3a047f --- /dev/null +++ b/src/server/utils/file-form-service.test.js @@ -0,0 +1,83 @@ +import { join } from 'node:path' + +import { FileFormService } from '~/src/server/utils/file-form-service.js' + +describe('File-form-service', () => { + /** @type {FileFormService} */ + let service + beforeEach(async () => { + const now = new Date() + const user = { id: 'user', displayName: 'Username' } + const author = { + createdAt: now, + createdBy: user, + updatedAt: now, + updatedBy: user + } + service = new FileFormService() + const metadata = { + organisation: 'Defra', + teamName: 'Team name', + teamEmail: 'team@defra.gov.uk', + submissionGuidance: "Thanks for your submission, we'll be in touch", + notificationEmail: 'email@domain.com', + ...author, + live: author + } + await service.addForm( + `${join(import.meta.dirname, '../../../test/form/definitions')}/components.json`, + { + ...metadata, + id: '95e92559-968d-44ae-8666-2b1ad3dffd31', + title: 'Form test', + slug: 'form-test' + } + ) + }) + + describe('metadata by slug', () => { + it('should get form metadata by slug', () => { + const meta = service.getFormMetadata('form-test') + expect(meta.id).toBe('95e92559-968d-44ae-8666-2b1ad3dffd31') + expect(meta.title).toBe('Form test') + }) + + it('should throw if not found', () => { + expect(() => service.getFormMetadata('form-test-missing')).toThrow( + "Form metadata 'form-test-missing' not found" + ) + }) + }) + + describe('metadata by id', () => { + it('should get form metadata by id', () => { + const meta = service.getFormMetadataById( + '95e92559-968d-44ae-8666-2b1ad3dffd31' + ) + expect(meta.id).toBe('95e92559-968d-44ae-8666-2b1ad3dffd31') + expect(meta.title).toBe('Form test') + }) + + it('should throw if not found', () => { + expect(() => service.getFormMetadataById('id-missing')).toThrow( + "Form metadata id 'id-missing' not found" + ) + }) + }) + + describe('definition by id', () => { + it('should get form definition by id', () => { + const form = service.getFormDefinition( + '95e92559-968d-44ae-8666-2b1ad3dffd31' + ) + expect(form.name).toBe('All components') + expect(form.startPage).toBe('/all-components') + }) + + it('should throw if not found', () => { + expect(() => service.getFormDefinition('id-missing')).toThrow( + "Form definition 'id-missing' not found" + ) + }) + }) +}) From 40fc5e49f67f4db58d224d5426a094fc73449914 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 24 Nov 2025 09:28:08 +0000 Subject: [PATCH 06/15] Fixed tests Fixed type lint --- .../plugins/engine/routes/index.test.ts | 113 ++++++++++++++++-- src/server/plugins/engine/routes/index.ts | 2 +- src/server/utils/file-form-service.js | 5 +- src/server/utils/file-form-service.test.js | 31 +++++ 4 files changed, 136 insertions(+), 15 deletions(-) diff --git a/src/server/plugins/engine/routes/index.test.ts b/src/server/plugins/engine/routes/index.test.ts index 45cb49128..513777617 100644 --- a/src/server/plugins/engine/routes/index.test.ts +++ b/src/server/plugins/engine/routes/index.test.ts @@ -1,3 +1,4 @@ +import { ComponentType, type Page } from '@defra/forms-model' import Boom from '@hapi/boom' import { type ResponseObject, type ResponseToolkit } from '@hapi/hapi' @@ -9,7 +10,10 @@ import { } from '~/src/server/plugins/engine/helpers.js' import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' -import { redirectOrMakeHandler } from '~/src/server/plugins/engine/routes/index.js' +import { + prefillStateFromQueryParameters, + redirectOrMakeHandler +} from '~/src/server/plugins/engine/routes/index.js' import { type AnyFormRequest, type OnRequestCallback @@ -18,6 +22,25 @@ import { type FormResponseToolkit } from '~/src/server/routes/types.js' jest.mock('~/src/server/plugins/engine/helpers') +function buildMockModel( + pagesOverride = [] as Page[], + pagesControllerOverride = [] as PageControllerClass[] +) { + return { + def: { + metadata: { + submission: { code: 'TEST-CODE' } + } as { submission: { code: string } }, + pages: pagesOverride + }, + getFormContext: jest.fn().mockReturnValue({ + isForceAccess: false, + data: {} + }), + pages: pagesControllerOverride + } as unknown as FormModel +} + describe('redirectOrMakeHandler', () => { const mockServer = {} as unknown as Parameters< typeof redirectOrMakeHandler @@ -38,17 +61,7 @@ describe('redirectOrMakeHandler', () => { let mockPage: PageControllerClass - const mockModel: FormModel = { - def: { - metadata: { - submission: { code: 'TEST-CODE' } - } as { submission: { code: string } } - }, - getFormContext: jest.fn().mockReturnValue({ - isForceAccess: false, - data: {} - }) - } as unknown as FormModel + const mockModel = buildMockModel() const mockMakeHandler = jest .fn() @@ -314,4 +327,80 @@ describe('redirectOrMakeHandler', () => { expect(proceed).toHaveBeenCalledWith(mockRequest, mockH, '/test-href') }) }) + + describe('prefillStateFromQueryParameters', () => { + const mockGetState = jest.fn() + const mockMergeState = jest.fn() + const mockRequestPrefill: AnyFormRequest = { + server: mockServer, + app: {}, + yar: { flash: () => [] }, + params: { path: 'test-path' }, + query: {} + } as unknown as AnyFormRequest + + it('should not add any state if no params', async () => { + const mockModelPrefill = buildMockModel( + [], + [ + { + getState: mockGetState, + mergeState: mockMergeState + } as unknown as PageControllerClass + ] + ) + + await prefillStateFromQueryParameters( + mockRequestPrefill, + mockModelPrefill + ) + expect(mockMergeState).not.toHaveBeenCalled() + }) + + it('should only add state where param names match hidden field names', async () => { + const mockRequest2 = { + ...mockRequest, + query: { + param1: 'val1', + param2: 'val2', + param3: 'val3', + param4: 'val4' + } + } as unknown as AnyFormRequest + + const mockModel = buildMockModel( + [ + { + components: [ + { + type: ComponentType.HiddenField, + name: 'param2' + }, + { + type: ComponentType.HiddenField, + name: 'param4' + } + ], + next: [] + } as unknown as Page + ], + [ + { + getState: mockGetState.mockResolvedValue({}), + mergeState: mockMergeState + } as unknown as PageControllerClass + ] + ) + + await prefillStateFromQueryParameters(mockRequest2, mockModel) + expect(mockMergeState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { + param2: 'val2', + param4: 'val4' + } + ) + }) + }) }) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index cb3fe56a0..eeb948ef6 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -132,7 +132,7 @@ const paramLookupFunctions = { * @param request * @param model */ -async function prefillStateFromQueryParameters( +export async function prefillStateFromQueryParameters( request: AnyFormRequest, model: FormModel ): Promise { diff --git a/src/server/utils/file-form-service.js b/src/server/utils/file-form-service.js index 6813f289c..fa58f22f8 100644 --- a/src/server/utils/file-form-service.js +++ b/src/server/utils/file-form-service.js @@ -156,9 +156,10 @@ export class FileFormService { /** * Get the form defintion by id * @param {string} id + * @param {FormStatus} _state * @returns {Promise} */ - getFormDefinition: (id) => { + getFormDefinition: (id, _state) => { return Promise.resolve(this.getFormDefinition(id)) } } @@ -166,5 +167,5 @@ export class FileFormService { } /** - * @import { FormMetadata, FormDefinition } from '@defra/forms-model' + * @import { FormMetadata, FormDefinition, FormStatus } from '@defra/forms-model' */ diff --git a/src/server/utils/file-form-service.test.js b/src/server/utils/file-form-service.test.js index f9e3a047f..266a00288 100644 --- a/src/server/utils/file-form-service.test.js +++ b/src/server/utils/file-form-service.test.js @@ -1,5 +1,6 @@ import { join } from 'node:path' +import { FormStatus } from '~/src/server/routes/types.js' import { FileFormService } from '~/src/server/utils/file-form-service.js' describe('File-form-service', () => { @@ -80,4 +81,34 @@ describe('File-form-service', () => { ) }) }) + + describe('toFormsService', () => { + it('should create interface', async () => { + const interfaceImpl = service.toFormsService() + const res1 = await interfaceImpl.getFormMetadata('form-test') + expect(res1.id).toBe('95e92559-968d-44ae-8666-2b1ad3dffd31') + expect(res1.title).toBe('Form test') + + const res2 = await interfaceImpl.getFormMetadataById( + '95e92559-968d-44ae-8666-2b1ad3dffd31' + ) + expect(res2.id).toBe('95e92559-968d-44ae-8666-2b1ad3dffd31') + expect(res2.title).toBe('Form test') + + const res3 = await interfaceImpl.getFormDefinition( + '95e92559-968d-44ae-8666-2b1ad3dffd31', + FormStatus.Draft + ) + expect(res3?.name).toBe('All components') + expect(res3?.startPage).toBe('/all-components') + }) + }) + + describe('readForm', () => { + it('should throw if invalid extension', async () => { + await expect( + service.readForm('/some-folder/some-file.bad') + ).rejects.toThrow("Invalid file extension '.bad'") + }) + }) }) From 6daf951b0f2430004ee1a2ef69638dd70d7a76ca Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 24 Nov 2025 11:04:37 +0000 Subject: [PATCH 07/15] Stores original and looked-up value --- .../plugins/engine/routes/index.test.ts | 55 ++++++++++++++++++- src/server/plugins/engine/routes/index.ts | 22 ++++++-- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/server/plugins/engine/routes/index.test.ts b/src/server/plugins/engine/routes/index.test.ts index 513777617..a5b0f01ab 100644 --- a/src/server/plugins/engine/routes/index.test.ts +++ b/src/server/plugins/engine/routes/index.test.ts @@ -19,12 +19,14 @@ import { type OnRequestCallback } from '~/src/server/plugins/engine/types.js' import { type FormResponseToolkit } from '~/src/server/routes/types.js' +import { type FormsService, type Services } from '~/src/server/types.js' jest.mock('~/src/server/plugins/engine/helpers') function buildMockModel( pagesOverride = [] as Page[], - pagesControllerOverride = [] as PageControllerClass[] + pagesControllerOverride = [] as PageControllerClass[], + servicesOverride = {} as Services ) { return { def: { @@ -37,7 +39,8 @@ function buildMockModel( isForceAccess: false, data: {} }), - pages: pagesControllerOverride + pages: pagesControllerOverride, + services: servicesOverride } as unknown as FormModel } @@ -402,5 +405,53 @@ describe('redirectOrMakeHandler', () => { } ) }) + + it('should call lookup function for formId', async () => { + const mockRequest3 = { + ...mockRequest, + query: { + formId: 'c644804b-2f23-4c96-a2fc-ad4975974723' + } + } as unknown as AnyFormRequest + + const mockModel = buildMockModel( + [ + { + components: [ + { + type: ComponentType.HiddenField, + name: 'formId' + } + ], + next: [] + } as unknown as Page + ], + [ + { + getState: mockGetState.mockResolvedValue({}), + mergeState: mockMergeState + } as unknown as PageControllerClass + ], + { + formsService: { + getFormMetadata: jest.fn(), + getFormMetadataById: jest + .fn() + .mockResolvedValue({ title: 'My looked-up form name' }), + getFormDefinition: jest.fn() + } as unknown as FormsService + } as Services + ) + + await prefillStateFromQueryParameters(mockRequest3, mockModel) + expect(mockMergeState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { + formId: 'c644804b-2f23-4c96-a2fc-ad4975974723', + formName: 'My looked-up form name' + } + ) + }) }) }) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index eeb948ef6..b73f40651 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -117,12 +117,18 @@ export async function redirectOrMakeHandler( const paramLookupFunctions = { formId: async (val: string, services: Services) => { const meta = await services.formsService.getFormMetadataById(val) - return meta.title + return { + key: 'formName', + value: meta.title + } } } as Partial< Record< string, - (val: string, services: Services) => Promise + ( + val: string, + services: Services + ) => Promise<{ key: string; value: string | undefined }> > > @@ -150,10 +156,14 @@ export async function prefillStateFromQueryParameters( for (const [key, value = ''] of Object.entries(query)) { if (hiddenFieldNames.has(key)) { const lookupFunc = paramLookupFunctions[key] - const resValue = lookupFunc - ? await lookupFunc(value, model.services) - : value - params[key] = resValue + if (lookupFunc) { + const res = await lookupFunc(value, model.services) + // Store original value and result + params[key] = value + params[res.key] = res.value + } else { + params[key] = value + } } } From 26781b907c18a23102e16eac6505489822f9cce5 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 24 Nov 2025 11:44:20 +0000 Subject: [PATCH 08/15] Customisable button Phase banner can be overridden --- .../plugins/engine/pageControllers/PageController.ts | 3 ++- src/server/plugins/engine/views/partials/form.html | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/PageController.ts b/src/server/plugins/engine/pageControllers/PageController.ts index 0ba533749..bf2d37cab 100644 --- a/src/server/plugins/engine/pageControllers/PageController.ts +++ b/src/server/plugins/engine/pageControllers/PageController.ts @@ -8,6 +8,7 @@ import { import Boom from '@hapi/boom' import { type Lifecycle, type RouteOptions, type Server } from '@hapi/hapi' +import { config } from '~/src/config/index.js' import { type ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { encodeUrl, @@ -131,7 +132,7 @@ export class PageController { get phaseTag() { const { def } = this - return def.phaseBanner?.phase + return def.phaseBanner?.phase ?? config.get('phaseTag') } getHref(path: string): string { diff --git a/src/server/plugins/engine/views/partials/form.html b/src/server/plugins/engine/views/partials/form.html index c7a68d32e..a2732ba39 100644 --- a/src/server/plugins/engine/views/partials/form.html +++ b/src/server/plugins/engine/views/partials/form.html @@ -6,9 +6,17 @@ {{ componentList(components) }} + {% if isStartPage %} + {% set buttonText = "Start now" %} + {% elif submitButtonText %} + {% set buttonText = submitButtonText %} + {% else %} + {% set buttonText = "Continue" %} + {% endif %} +
{{ govukButton({ - text: "Start now" if isStartPage else "Continue", + text: buttonText, isStartButton: isStartPage, preventDoubleClick: true }) }} From 4e04f7dc65bd1dfd818074b0f511cde77144351b Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 24 Nov 2025 11:47:44 +0000 Subject: [PATCH 09/15] Fixed test --- .../plugins/engine/pageControllers/PageController.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/engine/pageControllers/PageController.test.ts b/src/server/plugins/engine/pageControllers/PageController.test.ts index 10d5c52c8..d119994da 100644 --- a/src/server/plugins/engine/pageControllers/PageController.test.ts +++ b/src/server/plugins/engine/pageControllers/PageController.test.ts @@ -77,7 +77,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' From 44ee8cc63661f937b88001793eb928c5fd43a9a1 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 24 Nov 2025 12:01:33 +0000 Subject: [PATCH 10/15] Added README --- .../features/code-based/PRE_POPULATE_STATE.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/features/code-based/PRE_POPULATE_STATE.md diff --git a/docs/features/code-based/PRE_POPULATE_STATE.md b/docs/features/code-based/PRE_POPULATE_STATE.md new file mode 100644 index 000000000..658e1afcc --- /dev/null +++ b/docs/features/code-based/PRE_POPULATE_STATE.md @@ -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). From 16d3a12ca9ce9b4e7010f566290f63f3b1735a4a Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 24 Nov 2025 16:41:27 +0000 Subject: [PATCH 11/15] STash --- .../plugins/engine/pageControllers/PageController.ts | 5 ++++- .../engine/pageControllers/StatusPageController.ts | 3 +-- src/server/plugins/engine/routes/index.ts | 1 + src/server/plugins/engine/views/confirmation.html | 8 +++++--- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/PageController.ts b/src/server/plugins/engine/pageControllers/PageController.ts index bf2d37cab..73a0052c5 100644 --- a/src/server/plugins/engine/pageControllers/PageController.ts +++ b/src/server/plugins/engine/pageControllers/PageController.ts @@ -122,11 +122,14 @@ export class PageController { get feedbackLink() { const { def } = this - // setting the feedbackLink to undefined here for feedback forms prevents the feedback link from being shown + // Use the feedbackLink if defined, otherwise use default CSAT link const feedbackLink = def.feedback?.emailAddress ? `mailto:${def.feedback.emailAddress}` : def.feedback?.url + if (!feedbackLink) { + return `/form/csat?formId=${this.model.formId}` + } return encodeUrl(feedbackLink) } diff --git a/src/server/plugins/engine/pageControllers/StatusPageController.ts b/src/server/plugins/engine/pageControllers/StatusPageController.ts index 833e4168c..9f2f72d7e 100644 --- a/src/server/plugins/engine/pageControllers/StatusPageController.ts +++ b/src/server/plugins/engine/pageControllers/StatusPageController.ts @@ -47,8 +47,7 @@ export class StatusPageController extends QuestionPageController { return h.view(viewName, { ...viewModel, - submissionGuidance, - feedbackFormSlug: 'csat' // TODO - allow override from JSON if defined + submissionGuidance }) } } diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index b73f40651..0a861d7c5 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -167,6 +167,7 @@ export async function prefillStateFromQueryParameters( } } + // console.log('Adding to state', params) const page = model.pages[0] // Any page will do so just take the first one const formData = await page.getState(request) await page.mergeState(request, formData, params) diff --git a/src/server/plugins/engine/views/confirmation.html b/src/server/plugins/engine/views/confirmation.html index 394fdf8da..a7aaf3257 100644 --- a/src/server/plugins/engine/views/confirmation.html +++ b/src/server/plugins/engine/views/confirmation.html @@ -14,9 +14,11 @@

What happens next

{{ submissionGuidance | markdown | safe }}
-

- What do you think of this service? (takes 30 seconds) -

+ {% if feedbackLink %} +

+ What do you think of this service? (takes 30 seconds) +

+ {% endif %}
{% endblock %} From b4fb4c07136a7b3e1e1cf595f0de87946d939ff4 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 25 Nov 2025 10:11:26 +0000 Subject: [PATCH 12/15] Fixed tests --- package-lock.json | 43 ++++++++++++------- package.json | 1 + .../pageControllers/PageController.test.ts | 10 +++-- 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index a0754831b..595a48f20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "blankie": "^5.0.0", "blipp": "^4.0.2", "btoa": "^1.2.1", + "chokidar": "^3.6.0", "convict": "^6.2.4", "date-fns": "^4.1.0", "dotenv": "^17.2.3", @@ -227,6 +228,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2046,6 +2048,7 @@ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -2138,6 +2141,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2161,6 +2165,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -5238,6 +5243,7 @@ "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.4", @@ -5268,6 +5274,7 @@ "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.4", "@typescript-eslint/types": "8.46.4", @@ -6010,6 +6017,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6165,7 +6173,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "devOptional": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -6739,7 +6746,6 @@ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "license": "MIT", - "optional": true, "engines": { "node": ">=8" }, @@ -6805,7 +6811,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "devOptional": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -6834,6 +6839,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6953,6 +6959,7 @@ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -7092,7 +7099,7 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "license": "MIT", - "optional": true, + "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -8594,6 +8601,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8829,6 +8837,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -9005,6 +9014,7 @@ "integrity": "sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "builtins": "^5.0.1", @@ -9081,6 +9091,7 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -9435,7 +9446,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "devOptional": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -9598,7 +9608,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -9824,7 +9833,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "devOptional": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -10540,7 +10548,6 @@ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "license": "MIT", - "optional": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -10671,7 +10678,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10740,7 +10746,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "devOptional": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -10786,7 +10791,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -11189,6 +11193,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -12238,6 +12243,7 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -12247,6 +12253,7 @@ "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", @@ -12315,6 +12322,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -13291,7 +13299,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13805,7 +13812,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -14121,6 +14127,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14697,6 +14704,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -14977,7 +14985,6 @@ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "license": "MIT", - "optional": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -15950,6 +15957,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -16713,6 +16721,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", @@ -17451,6 +17460,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17489,7 +17499,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "devOptional": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -17595,6 +17604,7 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -17729,6 +17739,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17988,6 +17999,7 @@ "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -18056,6 +18068,7 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", diff --git a/package.json b/package.json index 0384e9e8d..45d250876 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "blankie": "^5.0.0", "blipp": "^4.0.2", "btoa": "^1.2.1", + "chokidar": "^3.6.0", "convict": "^6.2.4", "date-fns": "^4.1.0", "dotenv": "^17.2.3", diff --git a/src/server/plugins/engine/pageControllers/PageController.test.ts b/src/server/plugins/engine/pageControllers/PageController.test.ts index d119994da..13d867d90 100644 --- a/src/server/plugins/engine/pageControllers/PageController.test.ts +++ b/src/server/plugins/engine/pageControllers/PageController.test.ts @@ -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) @@ -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' From c1aea8c9168d2d21353ef82277392137df7c0975 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 25 Nov 2025 14:53:10 +0000 Subject: [PATCH 13/15] Handles CSAT submission and confirmation --- .../engine/pageControllers/StatusPageController.ts | 11 +++++++++-- .../engine/pageControllers/SummaryPageController.ts | 5 ++++- src/server/plugins/engine/routes/index.ts | 7 +++---- src/server/services/cacheService.ts | 4 ++-- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/StatusPageController.ts b/src/server/plugins/engine/pageControllers/StatusPageController.ts index 9f2f72d7e..08a57fac5 100644 --- a/src/server/plugins/engine/pageControllers/StatusPageController.ts +++ b/src/server/plugins/engine/pageControllers/StatusPageController.ts @@ -41,13 +41,20 @@ export class StatusPageController extends QuestionPageController { const slug = request.params.slug const { formsService } = this.model.services - const { getFormMetadata } = formsService + const { getFormMetadata, getFormMetadataById } = formsService const { submissionGuidance } = await getFormMetadata(slug) + // Re-read form name if overriding display (for example, in a feedback form) + const storedFormId = confirmationState.formId + const formName = storedFormId + ? (await getFormMetadataById(storedFormId)).title + : undefined + return h.view(viewName, { ...viewModel, - submissionGuidance + submissionGuidance, + formName }) } } diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index aac9d3b55..0da4396eb 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -152,7 +152,10 @@ export class SummaryPageController extends QuestionPageController { ) } - await cacheService.setConfirmationState(request, { confirmed: true }) + await cacheService.setConfirmationState(request, { + confirmed: true, + formId: context.state.formId as string | undefined + }) // Clear all form data await cacheService.clearState(request) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 0a861d7c5..7e3f9e00e 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -167,7 +167,6 @@ export async function prefillStateFromQueryParameters( } } - // console.log('Adding to state', params) const page = model.pages[0] // Any page will do so just take the first one const formData = await page.getState(request) await page.mergeState(request, formData, params) @@ -317,14 +316,14 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { controllers ) - // Copy any URL params into the form state - await prefillStateFromQueryParameters(request, model) - // Create new item and add it to the item cache item = { model, updatedAt: state.updatedAt } server.app.models.set(key, item) } + // Copy any URL params into the form state + await prefillStateFromQueryParameters(request, item.model) + // Assign the model to the request data // for use in the downstream handler request.app.model = item.model diff --git a/src/server/services/cacheService.ts b/src/server/services/cacheService.ts index 42d5c818d..63ac6b08c 100644 --- a/src/server/services/cacheService.ts +++ b/src/server/services/cacheService.ts @@ -55,7 +55,7 @@ export class CacheService { async getConfirmationState( request: AnyFormRequest - ): Promise<{ confirmed?: true }> { + ): Promise<{ confirmed?: true; formId?: string }> { const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation) const value = await this.cache.get(key) @@ -64,7 +64,7 @@ export class CacheService { async setConfirmationState( request: AnyFormRequest, - confirmationState: { confirmed?: true } + confirmationState: { confirmed?: true; formId?: string } ) { const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation) const ttl = config.get('confirmationSessionTimeout') From 5b04bc996c28fc59a69d804c105f335af53fa9f9 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 25 Nov 2025 16:39:13 +0000 Subject: [PATCH 14/15] Avoids merging state if nothing to merge --- src/server/plugins/engine/routes/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 7e3f9e00e..7abb98a2b 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -167,6 +167,10 @@ export async function prefillStateFromQueryParameters( } } + if (!Object.keys(params).length) { + return + } + const page = model.pages[0] // Any page will do so just take the first one const formData = await page.getState(request) await page.mergeState(request, formData, params) From a765b7f6372e4b051a7675eb828dfe4d28d4d28c Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 25 Nov 2025 17:17:43 +0000 Subject: [PATCH 15/15] Tidied package-lock --- package-lock.json | 267 ++++++++++++++++++++++------------------------ 1 file changed, 128 insertions(+), 139 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1d0237d22..e9c5fa0ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2012,28 +2012,15 @@ "keyv": "^5.5.4" } }, - "node_modules/@cacheable/memory/node_modules/@cacheable/utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.0.tgz", - "integrity": "sha512-qznqu6bpEei96zojGW+/IX1VXTOihznnVOK/kzyQWcqgn7SqkC3216nsX7M4BQfGwQgnxUXZ1xX7xiUoedqLPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hashery": "^1.2.0" - }, - "peerDependencies": { - "keyv": "^5.5.4" - } - }, "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.2.0.tgz", - "integrity": "sha512-4Lme8NejkyetZ9oJ6u8NSf0iJEFFt7I+tyDI48wZlaFmbhDEh4nZg7bEPFPwCWkpIuL50/ukWBC9AHQTmdJLUA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.0.tgz", + "integrity": "sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==", "dev": true, "license": "MIT", "dependencies": { "hashery": "^1.2.0", - "hookified": "^1.12.2" + "hookified": "^1.13.0" }, "engines": { "node": ">= 18" @@ -2053,6 +2040,27 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/@cacheable/utils": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.1.tgz", + "integrity": "sha512-38NJXjIr4W1Sghun8ju+uYWD8h2c61B4dKwfnQHVDFpAJ9oS28RpfqZQJ6Dgd3RceGkILDY9YT+72HJR3LoeSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.2.0", + "keyv": "^5.5.4" + } + }, + "node_modules/@cacheable/utils/node_modules/keyv": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.4.tgz", + "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -2218,9 +2226,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.584", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.584.tgz", - "integrity": "sha512-hghONjBY8ho7Z7I/QRwYT70Ot5BStPO5rbtaSsGMMhjOsiXM93+jGmclr8rw1A3w5DTiqmaagbtX1lgDojcSjg==", + "version": "3.0.585", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.585.tgz", + "integrity": "sha512-lSJzQu0xTk+VqSjLcjt7zwPS88J7MVYl5kdKfKCQsqbnVTmBHi9a3MHvCa6xlZRu1GZgoEjwQWY+QKzTKfN8FA==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", @@ -5095,9 +5103,9 @@ "license": "MIT" }, "node_modules/@types/lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", "dev": true, "license": "MIT" }, @@ -5238,18 +5246,18 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", - "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", + "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/type-utils": "8.46.4", - "@typescript-eslint/utils": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/type-utils": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -5263,23 +5271,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.4", + "@typescript-eslint/parser": "^8.48.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", - "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", + "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4" }, "engines": { @@ -5295,14 +5303,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", - "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", + "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.4", - "@typescript-eslint/types": "^8.46.4", + "@typescript-eslint/tsconfig-utils": "^8.48.0", + "@typescript-eslint/types": "^8.48.0", "debug": "^4.3.4" }, "engines": { @@ -5317,14 +5325,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", - "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", + "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4" + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5335,9 +5343,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", - "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", + "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", "dev": true, "license": "MIT", "engines": { @@ -5352,15 +5360,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz", - "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", + "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/utils": "8.46.4", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -5377,9 +5385,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", - "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", "dev": true, "license": "MIT", "engines": { @@ -5391,21 +5399,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", - "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", + "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.4", - "@typescript-eslint/tsconfig-utils": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/project-service": "8.48.0", + "@typescript-eslint/tsconfig-utils": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", + "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "engines": { @@ -5433,16 +5440,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", - "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", + "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4" + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5457,13 +5464,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", - "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", + "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/types": "8.48.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -6723,9 +6730,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.29", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.29.tgz", - "integrity": "sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==", + "version": "2.8.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", + "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6940,26 +6947,12 @@ "qified": "^0.5.2" } }, - "node_modules/cacheable/node_modules/@cacheable/utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.0.tgz", - "integrity": "sha512-qznqu6bpEei96zojGW+/IX1VXTOihznnVOK/kzyQWcqgn7SqkC3216nsX7M4BQfGwQgnxUXZ1xX7xiUoedqLPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hashery": "^1.2.0" - }, - "peerDependencies": { - "keyv": "^5.5.4" - } - }, "node_modules/cacheable/node_modules/keyv": { "version": "5.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.4.tgz", "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -7048,9 +7041,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001755", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", - "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", "dev": true, "funding": [ { @@ -7505,9 +7498,9 @@ } }, "node_modules/core-js": { - "version": "3.46.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz", - "integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==", + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", + "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7517,13 +7510,13 @@ } }, "node_modules/core-js-compat": { - "version": "3.46.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz", - "integrity": "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==", + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", + "integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.26.3" + "browserslist": "^4.28.0" }, "funding": { "type": "opencollective", @@ -8244,9 +8237,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.254", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.254.tgz", - "integrity": "sha512-DcUsWpVhv9svsKRxnSCZ86SjD+sp32SGidNB37KpqXJncp1mfUgKbHvBomE89WJDbfVKw1mdv5+ikrvd43r+Bg==", + "version": "1.5.260", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.260.tgz", + "integrity": "sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==", "dev": true, "license": "ISC" }, @@ -11930,17 +11923,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runner/node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/jest-runtime": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", @@ -16344,9 +16326,9 @@ } }, "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", "dev": true, "license": "MIT", "dependencies": { @@ -16706,9 +16688,9 @@ } }, "node_modules/stylelint": { - "version": "16.25.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.25.0.tgz", - "integrity": "sha512-Li0avYWV4nfv1zPbdnxLYBGq4z8DVZxbRgx4Kn6V+Uftz1rMoF1qiEI3oL4kgWqyYgCgs7gT5maHNZ82Gk03vQ==", + "version": "16.26.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.0.tgz", + "integrity": "sha512-Y/3AVBefrkqqapVYH3LBF5TSDZ1kw+0XpdKN2KchfuhMK6lQ85S4XOG4lIZLcrcS4PWBmvcY6eS2kCQFz0jukQ==", "dev": true, "funding": [ { @@ -16736,7 +16718,7 @@ "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", - "file-entry-cache": "^10.1.4", + "file-entry-cache": "^11.1.0", "global-modules": "^2.0.0", "globby": "^11.1.0", "globjoin": "^0.1.4", @@ -16941,13 +16923,13 @@ "license": "MIT" }, "node_modules/stylelint/node_modules/file-entry-cache": { - "version": "10.1.4", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-10.1.4.tgz", - "integrity": "sha512-5XRUFc0WTtUbjfGzEwXc42tiGxQHBmtbUG1h9L2apu4SulCGN3Hqm//9D6FAolf8MYNL7f/YlJl9vy08pj5JuA==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.1.tgz", + "integrity": "sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^6.1.13" + "flat-cache": "^6.1.19" } }, "node_modules/stylelint/node_modules/flat-cache": { @@ -17019,10 +17001,6 @@ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, "engines": { "node": ">=8" } @@ -17368,6 +17346,17 @@ "dev": true, "license": "MIT" }, + "node_modules/terser/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -17998,9 +17987,9 @@ } }, "node_modules/webpack": { - "version": "5.102.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", - "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", + "version": "5.103.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.103.0.tgz", + "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", "dev": true, "license": "MIT", "peer": true, @@ -18022,7 +18011,7 @@ "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3",