diff --git a/README.md b/README.md index aafcdeff0..8d11f4f57 100644 --- a/README.md +++ b/README.md @@ -174,23 +174,22 @@ Please use a config file instead. This will give you more control over each envi The defaults can be found in [config](./src/config/index.ts). Place your config files in `runner/config` See [https://github.com/node-config/node-config#readme](https://github.com/node-config/node-config#readme) for more info. -| name | description | required | default | valid | notes | -| --------------------- | -------------------------------------------------------------------------------- | :------: | ------- | :-------------------------: | :-------------------------------------------------------------------------------------------------------------------------: | -| NODE_ENV | Node environment | no | | development,test,production | | -| PORT | Port number | no | 3009 | | | -| NOTIFY_TEMPLATE_ID | Notify api key | yes | | | Template ID required to send form payloads via [GOV.UK Notify](https://www.notifications.service.gov.uk) email service. | -| NOTIFY_API_KEY | Notify api key | yes | | | API KEY required to send form payloads via [GOV.UK Notify](https://www.notifications.service.gov.uk) email service. | -| LOG_LEVEL | Log level | no | debug | trace,debug,info,error | | -| PHASE_TAG | Tag to use for phase banner | no | beta | alpha, beta, empty string | | -| FEEDBACK_LINK | Link to display in the phase banner when asking for feedback. | no | | | Used for an anchor tag's href. To display an email link, use a 'mailto:dest@domain.com' value. Else use a standard website. | -| HTTP_PROXY | HTTP proxy to use, e.g. the one from CDP. Currently used for Hapi Wreck. | no | | | | -| HTTPS_PROXY | HTTPS proxy to use, e.g. the one from CDP. Currently used for Hapi Wreck. | no | | | | -| NO_PROXY | HTTP proxy to use, e.g. the one from CDP. Currently used for Hapi Wreck. | no | | | | -| AWS_ACCESS_KEY_ID | AWS key id | yes | dummy | | | -| AWS_SECRET_ACCESS_KEY | AWS access key | yes | dummy | | | -| SNS_ENDPOINT | Endpoint for SNS messaging | yes | | | | -| SNS_ADAPTER_TOPIC_ARN | The SNS topic for the submission adapter - in Amazon Resource Name (ARN) format. | yes | | | | -| SNS_SAVE_TOPIC_ARN | The SNS topic for the save-and-exit - in Amazon Resource Name (ARN) format. | yes | | | | +| name | description | required | default | valid | notes | +| --------------------- | -------------------------------------------------------------------------------- | :------: | ------- | :-------------------------: | :---------------------------------------------------------------------------------------------------------------------: | +| NODE_ENV | Node environment | no | | development,test,production | | +| PORT | Port number | no | 3009 | | | +| NOTIFY_TEMPLATE_ID | Notify api key | yes | | | Template ID required to send form payloads via [GOV.UK Notify](https://www.notifications.service.gov.uk) email service. | +| NOTIFY_API_KEY | Notify api key | yes | | | API KEY required to send form payloads via [GOV.UK Notify](https://www.notifications.service.gov.uk) email service. | +| LOG_LEVEL | Log level | no | debug | trace,debug,info,error | | +| PHASE_TAG | Tag to use for phase banner | no | beta | alpha, beta, empty string | | +| HTTP_PROXY | HTTP proxy to use, e.g. the one from CDP. Currently used for Hapi Wreck. | no | | | | +| HTTPS_PROXY | HTTPS proxy to use, e.g. the one from CDP. Currently used for Hapi Wreck. | no | | | | +| NO_PROXY | HTTP proxy to use, e.g. the one from CDP. Currently used for Hapi Wreck. | no | | | | +| AWS_ACCESS_KEY_ID | AWS key id | yes | dummy | | | +| AWS_SECRET_ACCESS_KEY | AWS access key | yes | dummy | | | +| SNS_ENDPOINT | Endpoint for SNS messaging | yes | | | | +| SNS_ADAPTER_TOPIC_ARN | The SNS topic for the submission adapter - in Amazon Resource Name (ARN) format. | yes | | | | +| SNS_SAVE_TOPIC_ARN | The SNS topic for the save-and-exit - in Amazon Resource Name (ARN) format. | yes | | | | For proxy options, see https://www.npmjs.com/package/proxy-from-env which is used by https://github.com/TooTallNate/proxy-agents/tree/main/packages/proxy-agent. @@ -207,7 +206,6 @@ MANAGER_URL=http://localhost:3001 BASE_URL=http://localhost:3009 NOTIFY_TEMPLATE_ID= NOTIFY_API_KEY= -FEEDBACK_LINK=http://test.com DESIGNER_URL=http://localhost:3000 SUBMISSION_URL=http://localhost:3002 UPLOADER_BUCKET_NAME=my-bucket diff --git a/jest.setup.cjs b/jest.setup.cjs index db16c6a47..b30216847 100644 --- a/jest.setup.cjs +++ b/jest.setup.cjs @@ -6,7 +6,6 @@ process.env.REDIS_USERNAME = 'dummy' process.env.SESSION_COOKIE_PASSWORD = 'test-env-session-cookie-password' process.env.NOTIFY_TEMPLATE_ID = 'dummy' process.env.NOTIFY_API_KEY = 'dummy' -process.env.FEEDBACK_LINK = 'https://test.defra.gov.uk/' process.env.DESIGNER_URL = 'https://forms-designer' process.env.SUBMISSION_URL = 'https://test-submission-api.cdp-int.defra.cloud' process.env.UPLOADER_URL = 'https://test-uploader.cdp-int.defra.cloud' diff --git a/package-lock.json b/package-lock.json index 003a218c7..b5a3f29f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.934.0", - "@defra/forms-engine-plugin": "^4.0.25", - "@defra/forms-model": "^3.0.583", + "@defra/forms-engine-plugin": "^4.0.26", + "@defra/forms-model": "^3.0.585", "@defra/hapi-tracing": "^1.29.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -34,6 +34,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", @@ -860,7 +861,6 @@ "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", @@ -2717,7 +2717,6 @@ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -2831,7 +2830,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2855,7 +2853,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2908,13 +2905,13 @@ } }, "node_modules/@defra/forms-engine-plugin": { - "version": "4.0.25", - "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-4.0.25.tgz", - "integrity": "sha512-rbhtOmNodIlkGxINFGOpPkqSQyUjpV3KbUktrWmyIy4l43PEK1HZa3Q2Tz+CxHBjyu25VrZjS1IR93rgWzwSWw==", + "version": "4.0.26", + "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-4.0.26.tgz", + "integrity": "sha512-D8Q5dPKleQB3q4Xh5+CPk//fcA6Ul9NplRo+4l5uGIbhSAsrJiQc5nqUA6aUz6Lj0QYjIhIhK2Tj2B5pluMtDA==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.584", + "@defra/forms-model": "^3.0.585", "@defra/hapi-tracing": "^1.29.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -2977,9 +2974,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", @@ -6609,7 +6606,6 @@ "integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.47.0", @@ -6640,7 +6636,6 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -7425,7 +7420,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7891,7 +7885,6 @@ "integrity": "sha512-h/tOYTkXEsAcV3//6C1/7U4ifSpKyJvb6auveAepqqNJl6TdZaPFEtKjBQNf8UxQdDP850knB2i/whq4zlsxJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/sinon": "^17.0.3", "sinon": "^18.0.1", @@ -8307,7 +8300,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -10064,7 +10056,6 @@ "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", @@ -10315,7 +10306,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10489,7 +10479,6 @@ "integrity": "sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "builtins": "^5.0.1", @@ -10553,7 +10542,6 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -12630,7 +12618,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -13718,7 +13705,6 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -13728,7 +13714,6 @@ "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", @@ -13797,7 +13782,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -15414,7 +15398,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15978,7 +15961,6 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -17227,7 +17209,6 @@ "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", @@ -18022,7 +18003,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", @@ -18758,7 +18738,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -18924,7 +18903,6 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -19059,7 +19037,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -19145,7 +19122,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -19346,7 +19322,6 @@ "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -19415,7 +19390,6 @@ "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 76bb2df06..9a0be2a68 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.934.0", - "@defra/forms-engine-plugin": "^4.0.25", - "@defra/forms-model": "^3.0.583", + "@defra/forms-engine-plugin": "^4.0.26", + "@defra/forms-model": "^3.0.585", "@defra/hapi-tracing": "^1.29.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -64,6 +64,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/config/index.ts b/src/config/index.ts index 1ef028c67..d47f83dda 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -93,12 +93,6 @@ export const config = convict({ default: null, env: 'SERVICE_VERSION' } as SchemaObj, - feedbackLink: { - doc: 'Used in your phase banner. Can be a URL or more commonly mailto mailto:feedback@department.gov.uk', - format: String, - default: null, - env: 'FEEDBACK_LINK' - } as SchemaObj, phaseTag: { format: String, default: 'beta', // Accepts "alpha" |"beta" | "" diff --git a/src/server/index.ts b/src/server/index.ts index b6271ac68..b00bffb8c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -30,6 +30,7 @@ import { requestLogger } from '~/src/server/common/helpers/logging/request-logge import { requestTracing } from '~/src/server/common/helpers/logging/request-tracing.js' import { buildRedisClient } from '~/src/server/common/helpers/redis-client.js' import { FORM_PREFIX } from '~/src/server/constants.js' +import { FeedbackPageController } from '~/src/server/plugins/FeedbackPageController.js' import { SummaryPageWithConfirmationEmailController } from '~/src/server/plugins/SummaryPageWithConfirmationEmailController.js' import { configureBlankiePlugin } from '~/src/server/plugins/blankie.js' import { configureCrumbPlugin } from '~/src/server/plugins/crumb.js' @@ -133,7 +134,8 @@ export const configureEnginePlugin = async ({ model = new FormModel(definition, { basePath: initialBasePath }, services, { // Custom controllers - SummaryPageWithConfirmationEmailController + SummaryPageWithConfirmationEmailController, + FeedbackPageController }) } @@ -169,7 +171,8 @@ export const configureEnginePlugin = async ({ }, controllers: { // Custom controllers - SummaryPageWithConfirmationEmailController + SummaryPageWithConfirmationEmailController, + FeedbackPageController }, ordnanceSurveyApiKey: config.get('ordnanceSurveyApiKey') } diff --git a/src/server/plugins/FeedbackPageController.test.ts b/src/server/plugins/FeedbackPageController.test.ts new file mode 100644 index 000000000..03c259dcf --- /dev/null +++ b/src/server/plugins/FeedbackPageController.test.ts @@ -0,0 +1,96 @@ +import { type QuestionPageController } from '@defra/forms-engine-plugin/controllers/QuestionPageController.js' +import { FormModel } from '@defra/forms-engine-plugin/engine/models/FormModel.js' +import { buildFormRequest } from '@defra/forms-engine-plugin/engine/pageControllers/__stubs__/request.js' +import { type FormSubmissionState } from '@defra/forms-engine-plugin/engine/types.js' +import { + type FormContext, + type FormRequest, + type FormRequestPayload, + type FormResponseToolkit +} from '@defra/forms-engine-plugin/types' +import { type PageQuestion } from '@defra/forms-model' + +import { FeedbackPageController } from '~/src/server/plugins/FeedbackPageController.js' +import definition from '~/test/form/definitions/user-feedback.js' + +describe('FeedbackPageController', () => { + let model: FormModel + let controller: FeedbackPageController + let requestPage: FormRequest + + const response = { + code: jest.fn().mockImplementation(() => response) + } + const h: FormResponseToolkit = { + redirect: jest.fn().mockReturnValue(response), + view: jest.fn() + } + + beforeEach(() => { + model = new FormModel(definition, { + basePath: 'test' + }) + + // Create a mock page for FeedbackPageController + const mockPage = { + ...definition.pages[0], + controller: 'FeedbackPageController' + } as unknown as PageQuestion + + controller = new FeedbackPageController(model, mockPage) + + requestPage = buildFormRequest({ + method: 'get', + url: new URL('http://example.com/test/give-feedback'), + path: '/test/give-feedback', + params: { + path: 'give-feedback', + slug: 'test' + }, + query: {}, + app: { model } + } as FormRequest) + }) + + describe('handle errors', () => { + it('should display errors including summary', async () => { + const state: FormSubmissionState = { + $$__referenceNumber: 'foobar', + formId: 365 + } + const request = { + ...requestPage, + method: 'post', + payload: { invalid: '123', action: 'send' } + } as unknown as FormRequestPayload + + // Add error + const context = { + ...model.getFormContext(request, state), + errors: [ + { + name: 'PMPyjg', + path: '/feedback', + text: 'Select how you feel about this service' + } + ] + } as FormContext + + jest + .spyOn(controller as unknown as QuestionPageController, 'getState') + .mockResolvedValue({}) + jest + .spyOn(controller as unknown as QuestionPageController, 'setState') + .mockResolvedValue(state) + + const postHandler = controller.makePostRouteHandler() + await postHandler(request, context, h) + + const viewModel = controller.getViewModel(request, context) + + expect(viewModel.errors).toHaveLength(1) + const errorText = viewModel.errors ? viewModel.errors[0].text : '' + expect(errorText).toBe('Select how you feel about this service') + }) + }) +}) diff --git a/src/server/plugins/FeedbackPageController.ts b/src/server/plugins/FeedbackPageController.ts new file mode 100644 index 000000000..9d125f726 --- /dev/null +++ b/src/server/plugins/FeedbackPageController.ts @@ -0,0 +1,73 @@ +import { type PageController } from '@defra/forms-engine-plugin/controllers/PageController.js' +import { QuestionPageController } from '@defra/forms-engine-plugin/controllers/QuestionPageController.js' +import { SummaryPageController } from '@defra/forms-engine-plugin/controllers/SummaryPageController.js' +import { + type FormContext, + type FormContextRequest +} from '@defra/forms-engine-plugin/engine/types.js' +import { + type FormPageViewModel, + type FormRequestPayload, + type FormResponseToolkit +} from '@defra/forms-engine-plugin/types' + +export class FeedbackPageController extends QuestionPageController { + allowSaveAndExit = false + + getViewModel( + request: FormContextRequest, + context: FormContext + ): FormPageViewModel { + const viewModel = super.getViewModel(request, context) + return { + ...viewModel, + hidePhaseBanner: true, + submitButtonText: 'Send feedback', + name: context.state.formName + } + } + + /** + * Returns an async function. This is called in plugin.ts when there is a POST request at `/{id}/{path*}`. + * If a form is incomplete, a user will be redirected to the start page. + */ + makePostRouteHandler() { + return async ( + request: FormRequestPayload, + context: FormContext, + h: FormResponseToolkit + ) => { + // Should not have to coerce the type - ticket to resolve later https://eaflood.atlassian.net/browse/DF-555 + const { viewName, model } = this as unknown as PageController + const { collection } = this + const { isForceAccess, state, evaluationState } = context + + /** + * If there are any errors, render the page with the parsed errors + * @todo Refactor to match POST REDIRECT GET pattern + */ + if (context.errors || isForceAccess) { + const viewModel = this.getViewModel(request, context) + viewModel.errors = collection.getViewErrors(viewModel.errors) + + // Filter our components based on their conditions using our evaluated state + viewModel.components = this.filterConditionalComponents( + viewModel, + model, + evaluationState + ) + + return h.view(viewName, viewModel) + } + + // Save state + await this.setState(request, state) + + const summary = new SummaryPageController( + model, + context.pageMap.get(context.paths[0]) + ) + return summary.handleFormSubmit(request, context, h) + } + } +} diff --git a/src/server/plugins/error-preview/error-preview.test.js b/src/server/plugins/error-preview/error-preview.test.js index 5312ce78c..c28283d9b 100644 --- a/src/server/plugins/error-preview/error-preview.test.js +++ b/src/server/plugins/error-preview/error-preview.test.js @@ -47,17 +47,17 @@ describe('Error preview route', () => { 'govuk-error-summary__title govuk-!-margin-bottom-2' ) - expect($links[4].textContent).toBe('Enter [short description]') + expect($links[3].textContent).toBe('Enter [short description]') expect($headings[3].textContent.trim()).toBe('If you set answer limits') expect($headings[3]).toHaveClass( 'govuk-error-summary__title govuk-!-margin-bottom-2' ) - expect($links[5].textContent).toBe( + expect($links[4].textContent).toBe( '[short description] must be [min length] characters or more' ) - expect($links[6].textContent).toBe( + expect($links[5].textContent).toBe( '[short description] must be [max length] characters or less' ) }) diff --git a/src/server/plugins/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index c748c6bc6..529fa01eb 100644 --- a/src/server/plugins/nunjucks/context.js +++ b/src/server/plugins/nunjucks/context.js @@ -1,10 +1,7 @@ import { readFileSync } from 'node:fs' import { basename, join } from 'node:path' -import { - checkFormStatus, - encodeUrl -} from '@defra/forms-engine-plugin/engine/helpers.js' +import { checkFormStatus } from '@defra/forms-engine-plugin/engine/helpers.js' import Boom from '@hapi/boom' import { StatusCodes } from 'http-status-codes' @@ -52,7 +49,6 @@ export function context(request) { config: { cdpEnvironment: config.get('cdpEnvironment'), designerUrl: config.get('designerUrl'), - feedbackLink: encodeUrl(config.get('feedbackLink')), phaseTag: config.get('phaseTag'), serviceBannerText: config.get('serviceBannerText'), serviceName: config.get('serviceName'), diff --git a/src/server/plugins/nunjucks/context.test.js b/src/server/plugins/nunjucks/context.test.js index bcd8870ce..b1d6c1ee6 100644 --- a/src/server/plugins/nunjucks/context.test.js +++ b/src/server/plugins/nunjucks/context.test.js @@ -1,7 +1,5 @@ import { tmpdir } from 'node:os' -import { encodeUrl } from '@defra/forms-engine-plugin/engine/helpers.js' - import { config } from '~/src/config/index.js' import { context } from '~/src/server/plugins/nunjucks/context.js' @@ -63,7 +61,6 @@ describe('Nunjucks context', () => { expect(ctx.config).toEqual( expect.objectContaining({ cdpEnvironment: config.get('cdpEnvironment'), - feedbackLink: encodeUrl(config.get('feedbackLink')), googleAnalyticsTrackingId: config.get('googleAnalyticsTrackingId'), phaseTag: config.get('phaseTag'), serviceBannerText: config.get('serviceBannerText'), diff --git a/src/server/plugins/nunjucks/types.js b/src/server/plugins/nunjucks/types.js index c82db7eef..f72a977f8 100644 --- a/src/server/plugins/nunjucks/types.js +++ b/src/server/plugins/nunjucks/types.js @@ -20,6 +20,7 @@ * @property {string} [currentPath] - Current path * @property {string} [previewMode] - Preview mode * @property {string} [slug] - Form slug + * @property {string} [formId] - Form id * @property {(asset?: string) => string} getAssetPath - Asset path resolver * @property {FormContext} [context] - the current form context */ diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index 2713525f7..f79dc0a04 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -1,4 +1,5 @@ import { + getCacheService, handleLegacyRedirect, isPathRelative } from '@defra/forms-engine-plugin/engine/helpers.js' @@ -146,16 +147,23 @@ export default { server.route<{ Params: { slug: string } }>({ method: 'get', path: '/help/cookies/{slug}', - handler(_request, h) { + async handler(request, h) { const sessionTimeout = config.get('sessionTimeout') const sessionDurationPretty = humanizeDuration(sessionTimeout) + const cacheService = getCacheService(request.server) + + const state = await cacheService.getState(request) + + const formId = state?.formId ?? '' + return h.view('help/cookies', { googleAnalyticsContainerId: config .get('googleAnalyticsTrackingId') .replace(/^G-/, ''), - sessionDurationPretty + sessionDurationPretty, + feedbackLink: `/form/feedback?formId=${formId}` }) }, options diff --git a/src/server/views/confirmation.html b/src/server/views/confirmation.html index fac814235..161070c4b 100644 --- a/src/server/views/confirmation.html +++ b/src/server/views/confirmation.html @@ -4,16 +4,42 @@ {% set mainClasses = "govuk-main-wrapper--l" %} +{% set subTitle = "What happens next" %} + +{% set controllers = "" %} +{% for page in page.model.def.pages %} + {% set controllers = controllers + page.controller + ", " %} +{% endfor %} + +{% set phaseTag = phaseTag or config.phaseTag %} + +{# Override values if this is a submission from a feedback form i.e. it uses FeedbackPageController #} +{% if "FeedbackPageController," in controllers %} + {% set pageTitle = "Feedback submitted"%} + {% set submissionGuidance = "Thank you for leaving feedback.\n\nYou can close this screen now."%} + {% set subTitle = undefined %} + {% set feedbackLink = undefined %} + {% set name = formName if formName else "Submit a form to Defra" %} + {% set hidePhaseBanner = true %} +{% endif %} + {% block content %}
{{ govukPanel({ titleText: pageTitle }) }} -

What happens next

+ {% if subTitle %} +

{{ subTitle }}

+ {% endif %}
{{ submissionGuidance | markdown(3) | safe }}
+ {% if feedbackLink %} +

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

+ {% endif %}
{% endblock %} diff --git a/src/server/views/layout.html b/src/server/views/layout.html index 28e3ef758..216b52979 100644 --- a/src/server/views/layout.html +++ b/src/server/views/layout.html @@ -45,6 +45,8 @@ }) }} {% endblock %} +{% set phaseTag = phaseTag or config.phaseTag %} + {% block header %} {% if config.googleAnalyticsTrackingId and slug %}
@@ -131,17 +133,11 @@ }) }} {% endblock %} {% block beforeContent %} - {% set feedbackLink = feedbackLink or config.feedbackLink -%} - {% set phaseTag = phaseTag or config.phaseTag -%} - - {% if phaseTag %} + {% set feedbackLink = feedbackLink or config.feedbackLink %} + {% if phaseTag and not hidePhaseBanner %} {% set feedbackLinkHtml -%} - {%- if "mailto:" in feedbackLink -%} - give your feedback by email - {%- else -%} - give your feedback (opens in new tab) - {%- endif -%} + give your feedback (opens in new tab) {%- endset %} diff --git a/test/form/definitions/user-feedback-with-custom-controller.js b/test/form/definitions/user-feedback-with-custom-controller.js new file mode 100644 index 000000000..2e8a46732 --- /dev/null +++ b/test/form/definitions/user-feedback-with-custom-controller.js @@ -0,0 +1,97 @@ +import { ComponentType, Engine, SchemaVersion } from '@defra/forms-model' + +export default /** @satisfies {FormDefinition} */ ({ + name: 'User feedback', + engine: Engine.V2, + schema: SchemaVersion.V2, + startPage: '/give-feedback', + pages: [ + { + title: 'Give feedback', + path: '/give-feedback', + components: [ + { + type: ComponentType.RadiosField, + title: + 'Overall, how do you feel about the service you received today?', + name: 'PMPyjg', + shortDescription: 'How you feel about the service', + hint: '', + options: { + required: true + }, + list: '7acae132-d54d-4663-929d-04a1ed4d35d3', + id: 'b5c525db-068e-4f05-818a-7ef57303f8b5' + }, + { + type: ComponentType.MultilineTextField, + title: 'How could we improve this service?', + name: 'AiBocn', + shortDescription: 'How we could improve this service', + hint: '', + options: { + required: false + }, + schema: { + max: 1200 + }, + id: '57c2b549-e152-469f-9250-865ce5a4de23' + }, + { + type: ComponentType.HiddenField, + title: 'Hidden form id', + name: 'formId', + options: { + required: false + }, + id: '5ab73ef3-bce4-41a6-a8ae-9525160c36ed' + } + ], + next: [], + id: '4af5213f-0373-43b9-b32f-0ee822d37860', + // @ts-expect-error - custom controller name + controller: 'FeedbackPageController' + } + ], + conditions: [], + sections: [], + lists: [ + { + name: 'hgNvNj', + title: 'List for question PMPyjg', + type: 'string', + items: [ + { + id: '24c89938-25e6-4cfa-99c6-f84609dd1bd2', + text: 'Very satisfied', + value: 'Very satisfied' + }, + { + id: '631bcf2f-ba78-4627-9e1b-e9f1030dc0e0', + text: 'Satisfied', + value: 'Satisfied' + }, + { + id: 'ee4db6ec-7246-430e-8dd2-e24cf4c34376', + text: 'Neither satisfied not dissatisfied', + value: 'Neither satisfied not dissatisfied' + }, + { + id: '3b720c19-e580-405d-81ca-189452e9d873', + text: 'Dissatisfied', + value: 'Dissatisfied' + }, + { + id: '4f4c7e11-f27a-470d-a366-48dd9a25f24c', + text: 'Very dissatisfied', + value: 'Very dissatisfied' + } + ], + id: '7acae132-d54d-4663-929d-04a1ed4d35d3' + } + ] +}) + +/** + * @import { FormDefinition } from '@defra/forms-model' + */ diff --git a/test/form/definitions/user-feedback.js b/test/form/definitions/user-feedback.js new file mode 100644 index 000000000..834d981a2 --- /dev/null +++ b/test/form/definitions/user-feedback.js @@ -0,0 +1,106 @@ +import { + ComponentType, + ControllerType, + Engine, + SchemaVersion +} from '@defra/forms-model' + +export default /** @satisfies {FormDefinition} */ ({ + name: 'User feedback', + engine: Engine.V2, + schema: SchemaVersion.V2, + startPage: '/give-feedback', + pages: [ + { + title: 'Give feedback', + path: '/give-feedback', + components: [ + { + type: ComponentType.RadiosField, + title: + 'Overall, how do you feel about the service you received today?', + name: 'PMPyjg', + shortDescription: 'How you feel about the service', + hint: '', + options: { + required: true + }, + list: '7acae132-d54d-4663-929d-04a1ed4d35d3', + id: 'b5c525db-068e-4f05-818a-7ef57303f8b5' + }, + { + type: ComponentType.MultilineTextField, + title: 'How could we improve this service?', + name: 'AiBocn', + shortDescription: 'How we could improve this service', + hint: '', + options: { + required: false + }, + schema: { + max: 1200 + }, + id: '57c2b549-e152-469f-9250-865ce5a4de23' + }, + { + type: ComponentType.HiddenField, + title: 'Hidden form id', + name: 'formId', + options: { + required: false + }, + id: '5ab73ef3-bce4-41a6-a8ae-9525160c36ed' + } + ], + next: [], + id: '4af5213f-0373-43b9-b32f-0ee822d37860' + }, + { + id: '449a45f6-4541-4a46-91bd-8b8931b07b50', + title: '', + path: '/summary', + controller: ControllerType.Summary + } + ], + conditions: [], + sections: [], + lists: [ + { + name: 'hgNvNj', + title: 'List for question PMPyjg', + type: 'string', + items: [ + { + id: '24c89938-25e6-4cfa-99c6-f84609dd1bd2', + text: 'Very satisfied', + value: 'Very satisfied' + }, + { + id: '631bcf2f-ba78-4627-9e1b-e9f1030dc0e0', + text: 'Satisfied', + value: 'Satisfied' + }, + { + id: 'ee4db6ec-7246-430e-8dd2-e24cf4c34376', + text: 'Neither satisfied not dissatisfied', + value: 'Neither satisfied not dissatisfied' + }, + { + id: '3b720c19-e580-405d-81ca-189452e9d873', + text: 'Dissatisfied', + value: 'Dissatisfied' + }, + { + id: '4f4c7e11-f27a-470d-a366-48dd9a25f24c', + text: 'Very dissatisfied', + value: 'Very dissatisfied' + } + ], + id: '7acae132-d54d-4663-929d-04a1ed4d35d3' + } + ] +}) + +/** + * @import { FormDefinition } from '@defra/forms-model' + */ diff --git a/test/form/feedback.test.js b/test/form/feedback.test.js index b451ad0eb..0e504972e 100644 --- a/test/form/feedback.test.js +++ b/test/form/feedback.test.js @@ -1,15 +1,17 @@ import { join } from 'node:path' -import { FORM_PREFIX } from '~/src/server/constants.js' +import { + checkFormStatus, + getCacheService +} from '@defra/forms-engine-plugin/engine/helpers.js' + import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' -const { FEEDBACK_LINK } = process.env -const basePath = `${FORM_PREFIX}/feedback` - jest.mock('~/src/server/services/formsService.js') +jest.mock('@defra/forms-engine-plugin/engine/helpers.js') describe('Feedback link', () => { /** @type {Server} */ @@ -33,24 +35,23 @@ describe('Feedback link', () => { await server.stop() }) - it.each([ - { - url: `${FORM_PREFIX}/help/cookies`, - name: 'give your feedback (opens in new tab)', - href: FEEDBACK_LINK - }, - { - // Email address from feedback.json - url: `${basePath}/uk-passport`, - name: 'give your feedback by email', - href: 'mailto:test@feedback.cat' - } - ])("should match route '$url'", async ({ url, name, href }) => { + it('should match route', async () => { + // @ts-expect-error - not all method mocked + jest.mocked(getCacheService).mockImplementationOnce(() => ({ + setState: jest.fn(), + getState: jest + .fn() + .mockResolvedValue({ formId: '661e4ca5039739ef2902b214' }) + })) + jest.mocked(checkFormStatus).mockReturnValue({ isPreview: true, state: {} }) const { container } = await renderResponse(server, { method: 'GET', - url + url: '/help/cookies/feedback' }) + const name = 'give your feedback (opens in new tab)' + const href = '/form/feedback?formId=661e4ca5039739ef2902b214' + const $phaseBanner = document.querySelector('.govuk-phase-banner') const $link = container.getByRole('link', { name }) diff --git a/test/form/user-feedback.test.js b/test/form/user-feedback.test.js new file mode 100644 index 000000000..c92281a8d --- /dev/null +++ b/test/form/user-feedback.test.js @@ -0,0 +1,245 @@ +import { join } from 'node:path' + +import { SummaryPageController } from '@defra/forms-engine-plugin/controllers/SummaryPageController.js' +import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' +import { within } from '@testing-library/dom' +import { StatusCodes } from 'http-status-codes' + +import { FORM_PREFIX } from '~/src/server/constants.js' +import { createServer } from '~/src/server/index.js' +import { + getFormMetadata, + getFormMetadataById +} from '~/src/server/services/formsService.js' +import * as fixtures from '~/test/fixtures/index.js' +import { renderResponse } from '~/test/helpers/component-helpers.js' +import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' + +jest.mock('~/src/server/services/formsService.js') +jest.mock('~/src/server/messaging/publish.js') +jest.mock('@defra/forms-engine-plugin/services/formSubmissionService.js') +jest.mock('@defra/forms-engine-plugin/controllers/SummaryPageController.js') + +const basePath = `${FORM_PREFIX}/user-feedback-with-custom-controller` + +const metadata = { + ...fixtures.form.metadata, + notificationEmail: undefined +} + +describe('User feedback journey', () => { + const journey = [ + /** + * Pre-page + */ + { + // No title/name yet as URL saves param state and forwards to proper start page + + paths: { + current: '/give-feedback?formId=some-form-id' + } + }, + + /** + * Question page 1 + */ + { + formName: 'Test form', + + heading1: 'Give feedback', + + paths: { + current: '/give-feedback', + next: '/status' + }, + + fields: [ + { + title: + 'Overall, how do you feel about the service you received today?', + text: 'How do you feel about the service', + payload: { + empty: { PMPyjg: '', AiBocn: '', formId: '' }, + valid: { + PMPyjg: 'Very satisfied', + AiBocn: 'some extra text', + formId: '123' + } + }, + + errors: { + empty: 'Select how you feel about the service' + } + } + ] + }, + + /** + * Submitted + */ + { + formName: 'Test form', + + heading1: 'Feedback submitted', + + paths: { + current: '/status', + previous: '/give-feedback' + } + } + ] + + /** @type {Server} */ + let server + + /** @type {string} */ + let csrfToken + + /** @type {ReturnType} */ + let headers + + /** @type {BoundFunctions} */ + let container + + // Create server before each test + beforeAll(async () => { + jest.mocked(getFormMetadataById).mockResolvedValue(metadata) + jest.mocked(getFormMetadata).mockResolvedValue(metadata) + server = await createServer({ + formFileName: 'user-feedback-with-custom-controller.js', + formFilePath: join(import.meta.dirname, 'definitions'), + enforceCsrf: true + }) + + await server.initialize() + + // Navigate to start + const response = await server.inject({ + url: `${basePath}${journey[0].paths.current}` + }) + + // Extract the session cookie + csrfToken = getCookie(response, 'crumb') + headers = getCookieHeader(response, ['session', 'crumb']) + }) + + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(getFormMetadata).mockResolvedValue(metadata) + jest.mocked(getFormMetadataById).mockResolvedValue(metadata) + jest + .spyOn(SummaryPageController.prototype, 'handleFormSubmit') + .mockImplementation((request, _context, h) => { + const cacheService = getCacheService(request.server) + // Should be able to void this but linter still doesnt like it + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const dummy = Promise.resolve( + cacheService.setConfirmationState(request, { + confirmed: true, + formId: 'dummyId' + }) + ) + return Promise.resolve(h.redirect(`${basePath}/status`)) + }) + }) + + afterAll(async () => { + await server.stop() + }) + + describe.each(journey)( + 'Page: $paths.current', + ({ formName, heading1, paths, fields = [] }) => { + beforeEach(async () => { + ;({ container } = await renderResponse(server, { + url: `${basePath}${paths.current}`, + headers + })) + }) + + it('dummy test in case no activated tests in this beforeEach loop', () => { + expect(paths.current).toBeDefined() + }) + + if (heading1) { + it('should render the page heading', () => { + const $heading = container.getByRole('heading', { + name: heading1, + level: 1 + }) + + expect($heading).toBeInTheDocument() + }) + } + + if (formName) { + it('should render the form title', () => { + const $title = container.getByRole('link', { + name: formName + }) + + expect($title).toBeInTheDocument() + }) + } + + if (paths.next) { + it('should show errors when invalid on submit', async () => { + const payload = {} + + for (const field of fields) { + Object.assign(payload, field.payload.empty) + } + + // Submit form with empty values + const { container, response } = await renderResponse(server, { + url: `${basePath}${paths.current}`, + method: 'POST', + headers, + payload: { ...payload, crumb: csrfToken } + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + expect(response.headers.location).toBeUndefined() + + const $errorSummary = container.getByRole('alert') + const $errorItems = within($errorSummary).getAllByRole('listitem') + + const $heading = within($errorSummary).getByRole('heading', { + name: 'There is a problem', + level: 2 + }) + + expect($heading).toBeInTheDocument() + + for (const [index, { errors }] of fields.entries()) { + expect($errorItems[index]).toHaveTextContent(errors.empty) + } + }) + + it('should redirect to the next page on submit', async () => { + const payload = {} + + for (const field of fields) { + Object.assign(payload, field.payload.valid) + } + + // Submit form with populated values + const response = await server.inject({ + url: `${basePath}${paths.current}`, + method: 'POST', + headers, + payload: { ...payload, crumb: csrfToken } + }) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe(`${basePath}${paths.next}`) + }) + } + } + ) +}) + +/** + * @import { Server } from '@hapi/hapi' + * @import { BoundFunctions, queries } from '@testing-library/dom' + */