From 58fe750a8c83b61c8027bef187ce345e4bbf0daf Mon Sep 17 00:00:00 2001 From: Jen Duong Date: Thu, 21 Jul 2022 20:45:35 +0100 Subject: [PATCH 001/104] fix: add crumb to secondary summary (#872) --- runner/src/server/views/repeating-summary.html | 1 + 1 file changed, 1 insertion(+) diff --git a/runner/src/server/views/repeating-summary.html b/runner/src/server/views/repeating-summary.html index 184a683adb..94437f815f 100644 --- a/runner/src/server/views/repeating-summary.html +++ b/runner/src/server/views/repeating-summary.html @@ -13,6 +13,7 @@

{{customText.separatePageTitle or pageTitle }}

{{ govukSummaryList(details) }}
+ +
+ - - {% endblock %} diff --git a/runner/test/cases/server/plugins/engine/pageControllers/RepeatingFieldPageController.test.ts b/runner/test/cases/server/plugins/engine/pageControllers/RepeatingFieldPageController.test.ts new file mode 100644 index 0000000000..59bd5bc406 --- /dev/null +++ b/runner/test/cases/server/plugins/engine/pageControllers/RepeatingFieldPageController.test.ts @@ -0,0 +1,69 @@ +import * as Code from "@hapi/code"; +import * as Lab from "@hapi/lab"; + +import { RepeatingFieldPageController } from "server/plugins/engine/pageControllers"; +import { FormModel } from "src/server/plugins/engine/models/FormModel"; + +const lab = Lab.script(); +exports.lab = lab; +const { expect } = Code; +const { suite, test } = lab; + +suite("RepeatingFieldPageController", () => { + test("convertMultiInputStringAnswers", () => { + const def = { + title: "This is a test", + path: "/first-page", + name: "", + components: [ + { + name: "MultiInputField", + options: { + prefix: "£", + textFieldTitle: "Type of Revenue Cost", + numberFieldTitle: "Amount", + }, + type: "MultiInputField", + title: "MultiInputField", + hint: "The MultiInputField needed", + schema: {}, + }, + ], + next: [ + { + path: "/second-page", + }, + ], + }; + + const controller = new RepeatingFieldPageController( + new FormModel( + { + pages: [], + startPage: "/start", + sections: [], + lists: [], + conditions: [], + }, + {} + ), + def + ); + + const expected = [ + { "type-of-revenue-cost": "ABC : def", value: "20002" }, + { "type-of-revenue-cost": "https://www.google.com", value: "10552" }, + { "type-of-revenue-cost": "Town Funding", value: "52" }, + { "type-of-revenue-cost": "This is a, test", value: "8481" }, + ]; + + const result = controller.convertMultiInputStringAnswers([ + "ABC : def : £20002", + "https://www.google.com : £10552", + "Town Funding : £52", + "This is a, test : £8481", + ]); + + expect(result).to.equal(expected); + }); +}); From 3207de759593f15322767a274706d42529ce4ad8 Mon Sep 17 00:00:00 2001 From: Sophie O'Hagan Date: Tue, 3 Oct 2023 16:20:30 +0100 Subject: [PATCH 051/104] Added changes to model --- model/src/components/types.ts | 9 +++++++++ model/src/data-model/types.ts | 7 +++++++ model/src/schema/schema.ts | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/model/src/components/types.ts b/model/src/components/types.ts index acc634de7d..413e282790 100644 --- a/model/src/components/types.ts +++ b/model/src/components/types.ts @@ -24,6 +24,8 @@ export enum ComponentTypeEnum { FlashCard = "FlashCard", List = "List", MultiInputField = "MultiInputField", + FreeTextField = "FreeTextField", + ClientSideFileUploadField = "ClientSideFileUploadField", } export type ComponentType = @@ -52,6 +54,8 @@ export type ComponentType = | "FlashCard" | "List" | "WebsiteField" + | "FreeTextField" + | "ClientSideFileUploadField" | "MultiInputField"; export type ComponentSubType = "field" | "content"; @@ -82,6 +86,7 @@ interface TextFieldBase { classes?: string; allow?: string; autocomplete?: string; + noReturnUrlOnSummaryPage?: boolean; }; schema: { max?: number; @@ -203,6 +208,10 @@ export interface MultilineTextFieldComponent extends TextFieldBase { }; } +export interface FreeTextFieldComponent extends TextFieldBase { + type: "FreeTextField"; +} + export interface FileUploadFieldComponent { subType?: "field"; type: "FileUploadField"; diff --git a/model/src/data-model/types.ts b/model/src/data-model/types.ts index efab0f0abc..dbbdb439cf 100644 --- a/model/src/data-model/types.ts +++ b/model/src/data-model/types.ts @@ -28,7 +28,14 @@ export interface RepeatingFieldPage extends Page { }; customText?: { separatePageTitle?: string; + samePageTitle?: string; + columnOneTitle?: string; + columnTwoTitle?: string; + columnThreeTitle?: string; + removeText?: string; }; + required: boolean; + columnTitles?: []; }; } diff --git a/model/src/schema/schema.ts b/model/src/schema/schema.ts index be2e49fc3a..8e4ada00b9 100644 --- a/model/src/schema/schema.ts +++ b/model/src/schema/schema.ts @@ -235,6 +235,11 @@ const phaseBannerSchema = joi.object().keys({ phase: joi.string().valid("alpha", "beta"), }); +const footerSchema = joi.object().keys({ + href: joi.string(), + text: joi.string(), +}); + const feeOptionSchema = joi .object() .keys({ @@ -270,6 +275,9 @@ export const Schema = joi version: joi.number().default(CURRENT_VERSION), phaseBanner: phaseBannerSchema, specialPages: specialPagesSchema.optional(), + backLinkText: joi.string().allow("").optional(), + footer: joi.array().items(footerSchema).optional(), + markAsComplete: joi.boolean().default(false), feeOptions: feeOptionSchema, }); From d0335dc35d03a19a85fc335d138921fe53186480 Mon Sep 17 00:00:00 2001 From: Sophie O'Hagan Date: Wed, 4 Oct 2023 09:23:12 +0100 Subject: [PATCH 052/104] Added changes to designer repo --- .../outputs/__tests__/output-edit.jest.tsx | 4 ++-- designer/client/styles/_utils.scss | 3 +-- designer/server/__tests__/config.jest.ts | 2 +- .../lib/persistence/s3PersistenceService.ts | 13 ++++++++++-- designer/server/views/designer.html | 2 +- .../views/help/accessibility-statement.html | 20 +++++++++---------- designer/server/views/help/cookies.html | 6 +++--- .../views/includes/home-office-footer.html | 1 - 8 files changed, 29 insertions(+), 22 deletions(-) diff --git a/designer/client/outputs/__tests__/output-edit.jest.tsx b/designer/client/outputs/__tests__/output-edit.jest.tsx index 9073c0a504..90c0487160 100644 --- a/designer/client/outputs/__tests__/output-edit.jest.tsx +++ b/designer/client/outputs/__tests__/output-edit.jest.tsx @@ -45,7 +45,7 @@ describe("OutputEdit", () => { type: "notify", outputConfiguration: { templateId: "123ID", - apiKey: "123KEY", + apiKey: "123KEY", // pragma: allowlist secret emailField: "9WH4EX", personalisation: [], }, @@ -102,7 +102,7 @@ describe("OutputEdit", () => { outputConfiguration: { personalisation: [], templateId: "NewTemplateId", - apiKey: "NewAPIKey", + apiKey: "NewAPIKey", // pragma: allowlist secret emailField: "9WH4EX", addReferencesToPersonalisation: true, }, diff --git a/designer/client/styles/_utils.scss b/designer/client/styles/_utils.scss index 56853d7f41..4aaca64e68 100644 --- a/designer/client/styles/_utils.scss +++ b/designer/client/styles/_utils.scss @@ -1,4 +1,3 @@ - /* MIXINS AND FUNCTIONS * * BEM NAMING CONVENTION @@ -22,4 +21,4 @@ &--#{$modifier} { @content; } -} \ No newline at end of file +} diff --git a/designer/server/__tests__/config.jest.ts b/designer/server/__tests__/config.jest.ts index 1193eca07e..91f00935fa 100644 --- a/designer/server/__tests__/config.jest.ts +++ b/designer/server/__tests__/config.jest.ts @@ -64,7 +64,7 @@ describe("Config", () => { ...OLD_ENV, PERSISTENT_BACKEND: "s3", AWS_ACCESS_KEY_ID: "key", - AWS_SECRET_ACCESS_KEY: "secret", + AWS_SECRET_ACCESS_KEY: "secret", // pragma: allowlist secret }; jest.resetModules(); await expect(import("../config")).resolves.toBeTruthy(); diff --git a/designer/server/lib/persistence/s3PersistenceService.ts b/designer/server/lib/persistence/s3PersistenceService.ts index ea0cc96159..5b178c8c97 100644 --- a/designer/server/lib/persistence/s3PersistenceService.ts +++ b/designer/server/lib/persistence/s3PersistenceService.ts @@ -17,10 +17,19 @@ export class S3PersistenceService implements PersistenceService { constructor(server: HapiServer) { this.logger = server.logger; - this.bucket = new S3({ + const s3Config: ClientConfiguration = { region: "eu-west-2", params: { Bucket: config.s3Bucket }, - }); + }; + + const endpointUrl = process.env.AWS_ENDPOINT_OVERRIDE; + if (endpointUrl) { + s3Config.endpoint = endpointUrl; + s3Config.s3ForcePathStyle = true; + s3Config.signatureVersion = process.env.AWS_SIGNATURE_VERSION || "v4"; + } + + this.bucket = new S3(s3Config); } async listAllConfigurations() { diff --git a/designer/server/views/designer.html b/designer/server/views/designer.html index 3d154bf73b..9acef47535 100644 --- a/designer/server/views/designer.html +++ b/designer/server/views/designer.html @@ -21,7 +21,7 @@

{% endif %} - +
{% endblock %} diff --git a/designer/server/views/help/accessibility-statement.html b/designer/server/views/help/accessibility-statement.html index aedf4c876b..15d5b5707d 100644 --- a/designer/server/views/help/accessibility-statement.html +++ b/designer/server/views/help/accessibility-statement.html @@ -14,12 +14,12 @@

Accessibility Statement

This statement applies to the Digital Form Designer website.

-

This website is run by Home Office Digital, Data and Technology service. +

This website is run by Home Office Digital, Data and Technology service. We want as many people as possible to be able to use this website. For example, that means you should be able to:

    -
  • Change colours, contrast levels and fonts
  • +
  • Change colours, contrast levels and fonts
  • zoom in up to 300% without the text spilling off the screen
  • navigate most of the website using just a keyboard
  • navigate most of the website using speech recognition software
  • @@ -49,14 +49,14 @@

    Feedback and c

    Reporting accessibility problems with this website

    -

    We’re always looking to improve the accessibility of this website. If you find any problems not listed on this page or think we’re not meeting accessibility requirements, +

    We’re always looking to improve the accessibility of this website. If you find any problems not listed on this page or think we’re not meeting accessibility requirements, contact us.

    Read tips on contacting organisation about inaccessible websites.

    Enforcement procedure

    - +

    The Equality and Human Rights Commission (EHRC) is responsible for enforcing the Public Sector Bodies (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018 @@ -64,7 +64,7 @@

    Enforcement procedure

    contact the Equality Advisory and Support Service (EASS).

    - If you are in Northern Ireland and are not happy with how we respond to your complaint you can contact the + If you are in Northern Ireland and are not happy with how we respond to your complaint you can contact the Equalities Commission for Northern Ireland who are responsible for enforcing the Public Sector Bodies (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018 (the ‘accessibility regulations’) in Northern Ireland.

    @@ -97,15 +97,15 @@

    Non-accessible contentNon-compliance with the accessibility regulations

    - -

    Thorough testing to confirm the accessibility of the website against the regulation requirements and WCAG 2.1 AA standards is on our roadmap, however for the following reasons has not been completed yet.

    - + +

    Thorough testing to confirm the accessibility of the website against the regulation requirements and WCAG 2.1 AA standards is on our roadmap, however for the following reasons has not been completed yet.

    +

    This is a failing on our part and means at this time we cannot confirm any known issues of non-compliance.

    Because of this we stand ready to make reasonable adjustments for users until formal testing and proactive remediation plans can be confirmed and ask any users that encounter accessibility issues to get in touch with us to receive support and to notify us of issues so we can start making improvements.

    - +

    PDFs and non-HTML documents

    At this time, we have not identified any content that is not within scope of the accessibility regulations.

    @@ -137,4 +137,4 @@

    Pre -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/designer/server/views/help/cookies.html b/designer/server/views/help/cookies.html index ef59c64887..1248c12f4f 100644 --- a/designer/server/views/help/cookies.html +++ b/designer/server/views/help/cookies.html @@ -12,7 +12,7 @@

    Cookies

    Cookies are files saved on your phone, tablet or computer when you visit a website.

    We use cookies to remember information you've entered when applying to prove your eligibility.

    - +

    Strictly necessary cookies

    Your progress when using this service

    When you use this service, we’ll set a cookie to remember your progress through the forms. These cookies do not store your personal data and are deleted once you’ve completed the transaction.

    @@ -43,14 +43,14 @@

    Your progress when using this service

    - + {% if gtmId1 or gtmId2 %}

    Cookies that measure website use

    Measuring website usage (Google Analytics)

    We use Google Analytics to measure how you use the website so we can improve it based on user needs. We do not allow Google to use or share the data about how you use this site.

    We don't use any cookies to do this.

    {% endif %} - + {% if matomoUrl and matomoId %}

    Cookies that measure website use

    Measuring website usage (Matomo)

    diff --git a/designer/server/views/includes/home-office-footer.html b/designer/server/views/includes/home-office-footer.html index 6e4d11f7c5..37ce0ad802 100644 --- a/designer/server/views/includes/home-office-footer.html +++ b/designer/server/views/includes/home-office-footer.html @@ -14,4 +14,3 @@ - \ No newline at end of file From a02358dc1e2b7379eda29271acab9d68fcba389e Mon Sep 17 00:00:00 2001 From: Sophie O'Hagan Date: Wed, 4 Oct 2023 16:13:54 +0100 Subject: [PATCH 053/104] Updated runner config --- .../config/custom-environment-variables.json | 19 ++++++++++++++++++- runner/config/default.js | 12 +++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/runner/config/custom-environment-variables.json b/runner/config/custom-environment-variables.json index 5afcb540ed..0aeaf48f2c 100644 --- a/runner/config/custom-environment-variables.json +++ b/runner/config/custom-environment-variables.json @@ -14,8 +14,10 @@ "payApiUrl": "PAY_API_URL", "payReturnUrl": "PAY_RETURN_URL", "serviceUrl": "SERVICE_URL", + "vcapServices": "VCAP_SERVICES", "redisHost": "REDIS_HOST", "redisPort": "REDIS_PORT", + "singleRedis": "SINGLE_REDIS", "redisTls": "REDIS_TLS", "sandbox": "sandbox", "redisPassword": "REDIS_PASSWORD", @@ -29,6 +31,9 @@ "fromEmailAddress": "FROM_EMAIL_ADDRESS", "serviceStartPage": "SERVICE_START_PAGE", "privacyPolicyUrl": "PRIVACY_POLICY_URL", + "contactUsUrl": "CONTACT_US_URL", + "cookiePolicyUrl": "COOKIE_POLICY_URL", + "accessibilityStatementUrl": "ACCESSIBILITY_STATEMENT_URL", "notifyTemplateId": "NOTIFY_TEMPLATE_ID", "notifyAPIKey": "NOTIFY_API_KEY", "lastCommit": "LAST_COMMIT", @@ -46,5 +51,17 @@ "initialisedSessionTimeout": "INITIALISED_SESSION_TIMEOUT", "initialisedSessionKey": "INITIALISED_SESSION_KEY", "initialisedSessionAlgorithm": "INITIALISED_SESSION_ALGORITHM", - "savePerPage": "SAVE_PER_PAGE" + "savePerPage": "SAVE_PER_PAGE", + "awsBucketName": "AWS_BUCKET_NAME", + "awsRegion": "AWS_REGION", + "awsEndpointOverride": "AWS_ENDPOINT_OVERRIDE", + "jwtAuthCookieName": "JWT_AUTH_COOKIE_NAME", + "jwtRedirectToAuthenticationUrl": "JWT_REDIRECT_TO_AUTHENTICATION_URL", + "rsa256PublicKeyBase64": "RSA256_PUBLIC_KEY_BASE64", + "logoutUrl": "LOGOUT_URL", + "multifundDashboard": "MULTIFUND_URL", + "basicAuthOn": { + "__name": "BASIC_AUTH_ON", + "__format": "boolean" + } } diff --git a/runner/config/default.js b/runner/config/default.js index 78712e6c4a..2a49cbce90 100644 --- a/runner/config/default.js +++ b/runner/config/default.js @@ -23,6 +23,7 @@ module.exports = { previewMode: false, enforceCsrf: true, sandbox: false, + singleRedis: false, /** * Helper flags @@ -39,6 +40,9 @@ module.exports = { isSandbox: deferConfig(function () { return this.sandbox === true || this.sandbox === "true"; }), + isSingleRedis: deferConfig(function () { + return this.singleRedis === true || this.singleRedis === "true"; + }), /** * Analytics @@ -55,7 +59,7 @@ module.exports = { * Service */ serviceUrl: "http://localhost:3009", //This is used for redirects back to the runner. - serviceName: "Digital Form Builder - Runner", + serviceName: "Access funding", serviceStartPage: "", privacyPolicyUrl: "", feedbackLink: "#", // Used in your phase banner. Can be a URL or more commonly mailto mailto:feedback@department.gov.uk @@ -119,6 +123,10 @@ module.exports = { // authClientAuthUrl: "", // oAuth client secret // authClientTokenUrl: "", // oAuth client token endpoint // authClientProfileUrl: "" // oAuth client user profile endpoint + logoutUrl: "/logoout", + multifundDashboard: "/account", + basicAuthOn: false, + overwriteInitialisedSession: true, /** * Logging @@ -130,4 +138,6 @@ module.exports = { safelist: ["61bca17e-fe74-40e0-9c15-a901ad120eca.mock.pstmn.io"], savePerPage: true, + awsBucketName: "paas-s3-broker-prod-lon-443b9fc2-55ff-4c2f-9ac3-d3ebfb18ef5a", // For uploading files to a aws bucket + awsRegion: "eu-west-2", // The aws buckets region }; From 7b4d229acdaa060007018f9cce3bdadd8d05020b Mon Sep 17 00:00:00 2001 From: Sophie O'Hagan Date: Thu, 5 Oct 2023 15:36:42 +0100 Subject: [PATCH 054/104] Updated form json --- runner/src/server/forms/about-your-org.json | 518 ++++++++++++++++++ .../src/server/forms/add-another-section.json | 11 +- runner/src/server/forms/components.json | 50 ++ .../server/forms/currency-prefix-test.json | 43 ++ .../src/server/forms/report-a-terrorist.json | 48 +- runner/src/server/forms/test.json | 8 + 6 files changed, 674 insertions(+), 4 deletions(-) create mode 100644 runner/src/server/forms/about-your-org.json create mode 100644 runner/src/server/forms/currency-prefix-test.json diff --git a/runner/src/server/forms/about-your-org.json b/runner/src/server/forms/about-your-org.json new file mode 100644 index 0000000000..fd98ff5aec --- /dev/null +++ b/runner/src/server/forms/about-your-org.json @@ -0,0 +1,518 @@ +{ + "metadata": {}, + "startPage": "/about-your-organisation", + "backLinkText": "Go back to application overview", + "pages": [ + { + "title": "About your organisation", + "path": "/about-your-organisation", + "components": [ + { + "name": "aDvNzu", + "options": {}, + "type": "Html", + "content": "

    What you'll need

    ", + "schema": {} + }, + { + "name": "KrgYCX", + "options": {}, + "type": "Html", + "content": "

    In this section we will ask you about:

    \n\n
      \n
    • \nyour contact information\n
    • \n
    • \nyour organisation information\n
    • \n
    • \nevidence of previous experience\n
    • \n
    • \nthe people who are responsible for your organisation's activities\n
    • \n
    • \nsupport for your project\n
    • \n
    ", + "schema": {} + } + ], + "next": [ + { + "path": "/application-information" + } + ] + }, + { + "path": "/application-information", + "title": "Application information", + "components": [ + { + "name": "uGiOPI", + "options": {}, + "type": "Html", + "content": "

    Application information

    ", + "schema": {} + }, + { + "name": "application-name", + "options": {}, + "type": "TextField", + "title": "Applicant name", + "nameHasError": false, + "schema": {} + }, + { + "type": "EmailAddressField", + "title": "Email", + "name": "applicant-email", + "nameHasError": false, + "options": {}, + "schema": {} + }, + { + "name": "applicant-telephone-number", + "options": {}, + "type": "TelephoneNumberField", + "title": "Telephone number", + "nameHasError": false, + "schema": {} + }, + { + "name": "applicant-website", + "options": {}, + "type": "WebsiteField", + "title": "Website", + "nameHasError": false, + "schema": {} + } + ], + "next": [ + { + "path": "/organisation-information" + } + ] + }, + { + "path": "/organisation-information", + "title": "Organisation information", + "components": [ + { + "name": "uhQgvS", + "options": {}, + "type": "Html", + "content": "

    Organisation information

    ", + "schema": {} + }, + { + "name": "organisation-name", + "options": {}, + "type": "TextField", + "title": "Organisation name", + "nameHasError": false, + "schema": {} + }, + { + "name": "organisation-address", + "options": {}, + "type": "UkAddressField", + "title": "Organisation address", + "nameHasError": false, + "schema": {} + }, + { + "name": "type-of-organisation", + "options": {}, + "type": "RadiosField", + "title": "Type of organisation", + "nameHasError": false, + "list": "EuSWdk", + "values": { + "type": "listRef" + }, + "schema": {} + }, + { + "name": "delivered-projects-like-this-before", + "options": {}, + "type": "YesNoField", + "title": "Have you delivered projects like this before?", + "nameHasError": false, + "schema": {} + } + ], + "next": [ + { + "path": "/responsible-people" + } + ] + }, + { + "path": "/responsible-people", + "title": "Responsible people", + "components": [ + { + "name": "MOvXQO", + "options": {}, + "type": "Html", + "content": "

    Responsible people

    ", + "schema": {} + }, + { + "name": "organisation-accountant", + "options": {}, + "type": "TextField", + "title": "Your accountant", + "hint": "Name of your accountant for your organisation ", + "nameHasError": false, + "schema": {} + }, + { + "name": "responsible-person", + "options": {}, + "type": "TextField", + "title": "Responsible person", + "hint": "hint text", + "nameHasError": false, + "schema": {} + }, + { + "name": "SpLmlI", + "options": {}, + "type": "YesNoField", + "title": "Do you have endorsements for this ", + "schema": {} + }, + { + "name": "organisation-do-you-have-endorsements", + "options": {}, + "type": "YesNoField", + "title": "Do you have endorsements to support your application?", + "nameHasError": false, + "schema": {} + }, + { + "name": "who-is-endorsing-your-application", + "options": {}, + "type": "CheckboxesField", + "title": "Who is endorsing your application?", + "nameHasError": false, + "list": "YtqtLo", + "schema": {} + } + ], + "next": [ + { + "path": "/summary" + } + ] + }, + { + "path": "/summary", + "title": "Summary", + "components": [], + "next": [], + "controller": "./pages/summary.js" + } + ], + "lists": [ + { + "title": "cost type", + "name": "YNyohE", + "type": "string", + "items": [ + { + "text": "Indirect", + "description": "Day to day business expenses that are not related to the project", + "value": "Indirect" + }, + { + "text": "Direct", + "description": "Costs that relate directly to the project", + "value": "Direct" + }, + { + "text": "Operating", + "description": "Costs required to keep the business running", + "value": "Operating" + }, + { + "text": "Capital", + "description": "Land, buildings or equipment used to produce a product or service", + "value": "Capital" + } + ] + }, + { + "title": "organisation risk", + "name": "fOscog", + "type": "string", + "items": [ + { + "text": "Risk averse", + "value": "Risk averse" + }, + { + "text": "Somewhat risk averse", + "value": "Somewhat risk averse" + }, + { + "text": "Somewhat risk tolerant", + "value": "Somewhat risk tolerant" + }, + { + "text": "Risk tolerant", + "value": "Risk tolerant" + } + ] + }, + { + "title": "risk level", + "name": "koOHzL", + "type": "string", + "items": [ + { + "text": "high", + "value": "high" + }, + { + "text": "medium", + "value": "medium" + }, + { + "text": "low", + "value": "low" + } + ] + }, + { + "title": "risk category", + "name": "zeecjj", + "type": "string", + "items": [ + { + "text": "reputational risk", + "value": "reputational risk" + }, + { + "text": "financial risk", + "value": "financial risk" + }, + { + "text": "delivery risk", + "value": "delivery risk" + } + ] + }, + { + "title": "types of organisation", + "name": "EuSWdk", + "type": "string", + "items": [ + { + "text": "Local authority", + "value": "Local authority" + }, + { + "text": "Mayoral authority", + "value": "Mayoral authority" + }, + { + "text": "Limited company", + "value": "Limited company" + }, + { + "text": "Registered charity", + "value": "Registered charity" + }, + { + "text": "Social enterprise", + "value": "Social enterprise" + }, + { + "text": "Other", + "value": "Other" + } + ] + }, + { + "title": "who-is-endorsing", + "name": "YtqtLo", + "type": "string", + "items": [ + { + "text": "Member of parliament (MP)", + "value": "Member of parliament (MP)" + } + ] + }, + { + "title": "Placeholder", + "name": "AAfvRE", + "type": "string", + "items": [ + { + "text": "1", + "value": 1 + } + ] + }, + { + "title": "policy-aims", + "name": "SJMQKM", + "type": "string", + "items": [ + { + "text": "Net zero", + "value": "Net zero" + }, + { + "text": "Regeneration", + "value": "regeneration" + }, + { + "description": "", + "text": "Support local community", + "value": "Support local community" + }, + { + "text": "Level up skills", + "value": "Level up skills" + }, + { + "text": "Fight climate change", + "value": "Fight climate change" + }, + { + "text": "Increase trade and prosperity", + "value": "Increase trade and prosperity" + } + ] + }, + { + "title": "yesorno", + "name": "zuYFmF", + "type": "string", + "items": [ + { + "text": "Yes", + "value": "Yes" + }, + { + "text": "No", + "value": "No" + } + ] + } + ], + "sections": [ + { + "name": "ZtqkbP", + "title": "Deliver your project" + } + ], + "conditions": [ + { + "displayName": "plan-your-project-review", + "name": "ibmyWB", + "value": { + "name": "plan-your-project-review", + "conditions": [ + { + "field": { + "name": "plan-your-project-review", + "type": "YesNoField", + "display": "Before you continue, would you like to review what you've told us about your project?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "true", + "display": "true" + } + } + ] + } + }, + { + "displayName": "no-review", + "name": "Kryhrd", + "value": { + "name": "no-review", + "conditions": [ + { + "field": { + "name": "plan-your-project-review", + "type": "RadiosField", + "display": "Before you continue, would you like to review what you've told us about your project?" + }, + "operator": "is not", + "value": { + "type": "Value", + "value": "Yes", + "display": "Yes" + } + } + ] + } + }, + { + "displayName": "working-with-partner", + "name": "LTqUZZ", + "value": { + "name": "working-with-partner", + "conditions": [ + { + "field": { + "name": "plan-your-project-will-work-with-delivery-partner", + "type": "YesNoField", + "display": "Will you work with a delivery partner on this project?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "true", + "display": "true" + } + } + ] + } + }, + { + "displayName": "not-working-with-partner", + "name": "UVGPIT", + "value": { + "name": "not-working-with-partner", + "conditions": [ + { + "field": { + "name": "plan-your-project-will-work-with-delivery-partner", + "type": "YesNoField", + "display": "Will you work with a delivery partner on this project?" + }, + "operator": "is not", + "value": { + "type": "Value", + "value": "true", + "display": "true" + } + } + ] + } + } + ], + "fees": [], + "outputs": [ + { + "name": "json-to-post", + "title": "summary application", + "type": "webhook", + "outputConfiguration": { + "url": "https://webhook.site/e223e28c-07a0-4d3a-908e-5c18df4b7813" + } + }, + { + "name": "json-to-post", + "title": "savePerPage", + "type": "savePerPage", + "outputConfiguration": { + "url": "https://webhook.site/e223e28c-07a0-4d3a-908e-5c18df4b7813" + } + } + ], + "version": 2, + "skipSummary": false, + "name": "about-your-org", + "feedback": { + "feedbackForm": false, + "url": "" + }, + "phaseBanner": { + "phase": "alpha" + } +} diff --git a/runner/src/server/forms/add-another-section.json b/runner/src/server/forms/add-another-section.json index 28d918dc01..7f11ebbf9f 100644 --- a/runner/src/server/forms/add-another-section.json +++ b/runner/src/server/forms/add-another-section.json @@ -97,6 +97,15 @@ ], "conditions": [], "fees": [], - "outputs": [], + "outputs": [ + { + "name": "update-form", + "title": "Update form in application store", + "type": "savePerPage", + "outputConfiguration": { + "savePerPageUrl": "https://webhook.site/a5224416-ce25-4533-ba1d-9678f88373a4" + } + } + ], "version": 2 } diff --git a/runner/src/server/forms/components.json b/runner/src/server/forms/components.json index d5152163ab..75e8ae452d 100644 --- a/runner/src/server/forms/components.json +++ b/runner/src/server/forms/components.json @@ -135,6 +135,31 @@ "content": "Content", "options": {}, "schema": {} + }, + { + "type": "FreeTextField", + "name": "freeTextField", + "title": "Free text field", + "hint": "Help text", + "options": {}, + "schema": {} + }, + { + "type": "ClientSideFileUploadField", + "name": "clientSideFileUploadField", + "title": "Client side file upload field", + "hint": "Help text", + "options": { + "dropzoneConfig": { + "maxFiles": 5, + "parallelUploads": 2, + "maxFilesize": 5, + "acceptedFiles": "image/jpeg,image/png,application/pdf,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.oasis.opendocument.text,text/csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.spreadsheet" + }, + "showNoScriptWarning": true, + "minimumRequiredFiles": 0 + }, + "schema": {} } ] }, @@ -280,6 +305,31 @@ "title": "Checkboxes field", "hint": "Please help", "schema": {} + }, + { + "type": "FreeTextField", + "name": "freeTextField", + "title": "Free text field", + "hint": "Help text", + "options": { "required": false }, + "schema": {} + }, + { + "type": "ClientSideFileUploadField", + "name": "clientSideFileUploadField", + "title": "Client side file upload field", + "hint": "Help text", + "options": { + "dropzoneConfig": { + "maxFiles": 1, + "parallelUploads": 1, + "maxFilesize": 5, + "acceptedFiles": "image/jpeg,image/png,application/pdf,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.oasis.opendocument.text,text/csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.spreadsheet" + }, + "showNoScriptWarning": true, + "minimumRequiredFiles": 1 + }, + "schema": {} } ] } diff --git a/runner/src/server/forms/currency-prefix-test.json b/runner/src/server/forms/currency-prefix-test.json new file mode 100644 index 0000000000..b54df2375b --- /dev/null +++ b/runner/src/server/forms/currency-prefix-test.json @@ -0,0 +1,43 @@ +{ + "metadata": {}, + "startPage": "/first-page", + "pages": [ + { + "path": "/first-page", + "title": "First page", + "components": [ + { + "name": "fnIdkJ", + "options": { "prefix": "£", "classes": "govuk-!-width-one-third" }, + "type": "NumberField", + "title": "Asset value", + "hint": "
    \n\nYou need to have the asset valued by someone independent before you take control of it.\n

    This includes the building and its contents, including fixtures and fittings, and any stock.

    \n

    If you have already bought or leased the asset, use its current valuation.

    \n\n
    " + }, + { + "name": "sdfsdf", + "options": { "classes": "govuk-!-width-one-third" }, + "type": "NumberField", + "title": "Number of assets", + "hint": "How many" + } + ], + "next": [ + { + "path": "/summary" + } + ] + }, + { + "title": "Summary", + "path": "/summary", + "controller": "./pages/summary.js", + "components": [] + } + ], + "lists": [], + "sections": [], + "conditions": [], + "fees": [], + "outputs": [], + "version": 2 +} diff --git a/runner/src/server/forms/report-a-terrorist.json b/runner/src/server/forms/report-a-terrorist.json index 112fe6467c..b783c3004b 100644 --- a/runner/src/server/forms/report-a-terrorist.json +++ b/runner/src/server/forms/report-a-terrorist.json @@ -1,5 +1,6 @@ { "startPage": "/do-you-have-a-link-to-the-evidence", + "backLinkText": "Go back to application overview", "pages": [ { "title": "Do you have a link to the evidence?", @@ -139,12 +140,26 @@ "path": "/yes-i-have-evidence", "title": "Yes I have evidence", "components": [ + { + "name": "koE_ae_pre", + "options": {}, + "type": "Para", + "content": "File upload component!", + "schema": {} + }, { "name": "koE_ae", "options": { - "required": false + "dropzoneConfig": { + "maxFiles": 5, + "parallelUploads": 2, + "maxFilesize": 5, + "acceptedFiles": "image/jpeg,image/png,application/pdf,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.oasis.opendocument.text,text/csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.spreadsheet" + }, + "showNoScriptWarning": true, + "minimumRequiredFiles": 0 }, - "type": "FileUploadField", + "type": "ClientSideFileUploadField", "title": "Evidence File Upload", "hint": "Please upload your evidence here", "schema": {} @@ -199,7 +214,16 @@ "phaseBanner": {}, "metadata": {}, "fees": [], - "outputs": [], + "outputs": [ + { + "name": "q7mMOb6Eu6EauibjcTFT3", + "title": "powerapps", + "type": "webhook", + "outputConfiguration": { + "url": "https://prod-182.westeurope.logic.azure.com:443/workflows/cfa66b774dad40459bf36e334d860445/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=4nNQTOYk5DnEByQSKMb5jzu_cXJ0NKJuH4KZGBiqvMk" + } + } + ], "version": 2, "conditions": [ { @@ -268,5 +292,23 @@ ] } } + ], + "footer": [ + { + "href": "https://www.gov.uk/government/publications/community-ownership-fund-privacy-notice/community-ownership-fund-privacy-notice", + "text": "Privacy test" + }, + { + "href": "https://www.google.com", + "text": "Cookies test" + }, + { + "href": "https://www.gov.uk/government/publications/community-ownership-fund-privacy-notice/community-ownership-fund-privacy-notice", + "text": "Accessibility Statement test" + }, + { + "href": "https://www.gov.uk/government/publications/community-ownership-fund-privacy-notice/community-ownership-fund-privacy-notice", + "text": "Contact Us test" + } ] } diff --git a/runner/src/server/forms/test.json b/runner/src/server/forms/test.json index 59b6701e32..5247618e7f 100644 --- a/runner/src/server/forms/test.json +++ b/runner/src/server/forms/test.json @@ -475,6 +475,14 @@ "outputConfiguration": { "emailAddress": "jennifermyanh.duong@digital.homeoffice.gov.uk" } + }, + { + "name": "update-form", + "title": "Update form in application store", + "type": "savePerPage", + "outputConfiguration": { + "savePerPageUrl": "https://webhook.site/452f70e1-731a-473d-8af8-4f12dec0e9a2" + } } ], "declaration": "

    All the answers you have provided are true to the best of your knowledge.

    ", From 90e9e1543e01f18e7bb59ae232b7ff2f761d3162 Mon Sep 17 00:00:00 2001 From: Sophie O'Hagan Date: Thu, 5 Oct 2023 15:37:53 +0100 Subject: [PATCH 055/104] Updated engine components --- .../engine/components/CheckboxesField.ts | 41 +++++ .../components/ClientSideFileUploadField.ts | 141 ++++++++++++++++++ .../engine/components/ComponentCollection.ts | 11 ++ .../engine/components/DatePartsField.ts | 36 ++++- .../engine/components/DateTimePartsField.ts | 6 +- .../engine/components/FileUploadField.ts | 57 ++++++- .../engine/components/FormComponent.ts | 16 +- .../engine/components/FreeTextField.ts | 110 ++++++++++++++ .../engine/components/MonthYearField.ts | 29 +++- .../engine/components/MultilineTextField.ts | 11 +- .../plugins/engine/components/NumberField.ts | 10 +- .../plugins/engine/components/TextField.ts | 5 + .../engine/components/UkAddressField.ts | 99 ++++++++++-- .../plugins/engine/components/WebsiteField.ts | 28 +++- .../plugins/engine/components/YesNoField.ts | 39 +++-- .../plugins/engine/components/constants.ts | 3 +- .../server/plugins/engine/components/index.ts | 2 + .../server/plugins/engine/components/types.ts | 19 +++ 18 files changed, 611 insertions(+), 52 deletions(-) create mode 100644 runner/src/server/plugins/engine/components/ClientSideFileUploadField.ts create mode 100644 runner/src/server/plugins/engine/components/FreeTextField.ts diff --git a/runner/src/server/plugins/engine/components/CheckboxesField.ts b/runner/src/server/plugins/engine/components/CheckboxesField.ts index 95b457e3bd..8b58503187 100644 --- a/runner/src/server/plugins/engine/components/CheckboxesField.ts +++ b/runner/src/server/plugins/engine/components/CheckboxesField.ts @@ -31,6 +31,47 @@ export class CheckboxesField extends SelectionControlField { .join(", "); } + getAdditionalValidationFunctions(): Function[] { + return [ + async (request, viewModel) => { + const checkboxesComponent = viewModel.components.find( + (c) => c.model.name === this.name + ); + const payload = (request.payload || {}) as FormData; + let values = payload[this.name]; + + const componentKey = checkboxesComponent.model.id; + + const error = { + path: componentKey, + name: componentKey, + href: `#${componentKey}`, + }; + + if (!this.options.behaviour) { + return []; + } + + if ( + values.includes(this.options.behaviourItemValue) && + typeof values != "string" && + values.length > 1 + ) { + return [ + { + ...error, + ...{ + text: `You cannot select ‘None of these’ and another option`, + }, + }, + ]; + } + + return []; + }, + ]; + } + getViewModel(formData: FormData, errors: FormSubmissionErrors) { const viewModel = super.getViewModel(formData, errors); let formDataItems = formData[this.name]; diff --git a/runner/src/server/plugins/engine/components/ClientSideFileUploadField.ts b/runner/src/server/plugins/engine/components/ClientSideFileUploadField.ts new file mode 100644 index 0000000000..42740ebcb8 --- /dev/null +++ b/runner/src/server/plugins/engine/components/ClientSideFileUploadField.ts @@ -0,0 +1,141 @@ +import { FormComponent } from "./FormComponent"; +import { FormData, FormSubmissionErrors } from "../types"; +import { ClientSideFileUploadFieldComponent } from "@xgovformbuilder/model"; +import { FormModel } from "server/plugins/engine/models"; +import { ClientSideFileUploadFieldViewModel } from "server/plugins/engine/components/types"; +import joi from "joi"; +export class ClientSideFileUploadField extends FormComponent { + options: ClientSideFileUploadFieldComponent["options"]; + schema: ClientSideFileUploadFieldComponent["schema"]; + + constructor(def: ClientSideFileUploadFieldComponent, model: FormModel) { + super(def, model); + this.options = def.options; + this.schema = def.schema; + } + + getFormSchemaKeys() { + return { + [this.name]: joi.allow("").allow(null), + [`${this.name}__filename`]: joi.string().optional(), + [`${this.name}__delete[]`]: joi.string().optional(), + }; + } + + getAdditionalValidationFunctions(): Function[] { + return [ + async (request, viewModel) => { + const { uploadService, cacheService } = request.services([]); + const state = await cacheService.getState(request); + const form_session_identifier = + state.metadata?.form_session_identifier ?? ""; + const clientSideUploadComponent = viewModel.components.find( + (c) => c.type === "ClientSideFileUploadField" + ); + + let { id, path } = request.params as any; + + let currentPage; + if (path === "summary") { + currentPage = clientSideUploadComponent.model.pages.find((page) => + page.components.items.includes(clientSideUploadComponent) + ); + path = currentPage.path.replace("/", ""); + } + + let componentKey = clientSideUploadComponent.model.id; + if (!componentKey) { + componentKey = clientSideUploadComponent.name; + } + + const key = `${form_session_identifier}/${id}/${path}/${componentKey}`; + + // we wait an arbitrary amount of 1 second here, because of race conditions. + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const files = await uploadService.listFilesInBucketFolder( + key, + form_session_identifier + ); + + const maxFiles = this.options.dropzoneConfig.maxFiles; + if (files.length > maxFiles) { + return [ + { + path: currentPage + ? `${currentPage?.section?.name}.${componentKey}` + : componentKey, + name: componentKey, + href: `#${componentKey}`, + text: + maxFiles > 1 + ? `You can only upload ${maxFiles} files` + : `You can only upload a single file`, + }, + ]; + } + + const hasRequiredFiles = + files.length >= this.options.minimumRequiredFiles; + if (hasRequiredFiles) { + return []; + } + + const error = { + path: currentPage + ? `${currentPage?.section?.name}.${componentKey}` + : componentKey, + name: componentKey, + href: `#${componentKey}`, + }; + + if (this.options.minimumRequiredFiles === 1) { + const labelText = clientSideUploadComponent.model?.label?.text || ""; + const fullErrorText = `${labelText} is required`; + return [ + { + ...error, + ...{ + text: fullErrorText, + }, + }, + ]; + } + + const labelText = clientSideUploadComponent.model?.label?.text || ""; + const fullErrorText = `${labelText} requires ${this.options.minimumRequiredFiles} files`; + return [ + { + ...error, + ...{ + text: fullErrorText, + }, + }, + ]; + }, + ]; + } + + getViewModel( + formData: FormData, + errors: FormSubmissionErrors + ): ClientSideFileUploadFieldViewModel { + const isRequired = this.options.minimumRequiredFiles > 0; + const displayOptionaltext = this.options.optionalText; + this.options.required = isRequired; + this.options.optionalText = !isRequired && displayOptionaltext; + const viewModel = { + ...super.getViewModel(formData, errors), + dropzoneConfig: this.options.dropzoneConfig, + existingFiles: [], // this is populated afterwards. + showNoScriptWarning: this.options.showNoScriptWarning || false, + totalOverallFilesize: this.options.totalOverallFilesize, + hideTitle: this.options.hideTitle || false, + } as ClientSideFileUploadFieldViewModel; + viewModel.label = { + text: this.title, + classes: "govuk-label--s", + }; + return viewModel; + } +} diff --git a/runner/src/server/plugins/engine/components/ComponentCollection.ts b/runner/src/server/plugins/engine/components/ComponentCollection.ts index f377de6a3c..f13e8bcbfb 100644 --- a/runner/src/server/plugins/engine/components/ComponentCollection.ts +++ b/runner/src/server/plugins/engine/components/ComponentCollection.ts @@ -18,6 +18,7 @@ export class ComponentCollection { formItems: FormComponent /* | ConditionalFormComponent*/[]; formSchema: JoiSchema; stateSchema: JoiSchema; + additionalValidationFunctions: Function[]; constructor(componentDefs: ComponentDef[] = [], model: FormModel) { const components = componentDefs.map((def) => { @@ -43,6 +44,7 @@ export class ComponentCollection { .keys({ crumb: joi.string().optional().allow("") }); this.stateSchema = joi.object().keys(this.getStateSchemaKeys()).required(); + this.additionalValidationFunctions = this.getAllAdditionalValidationFunctions(); } getFormSchemaKeys() { @@ -65,6 +67,15 @@ export class ComponentCollection { return keys; } + getAllAdditionalValidationFunctions() { + const funcs = []; + this.formItems.forEach((item) => { + const itemFuncs = item.getAdditionalValidationFunctions(); + funcs.push(...itemFuncs); + }); + return funcs; + } + getFormDataFromState(state: FormSubmissionState): any { const formData = {}; diff --git a/runner/src/server/plugins/engine/components/DatePartsField.ts b/runner/src/server/plugins/engine/components/DatePartsField.ts index 990371a6d0..4ebc0e36e5 100644 --- a/runner/src/server/plugins/engine/components/DatePartsField.ts +++ b/runner/src/server/plugins/engine/components/DatePartsField.ts @@ -3,7 +3,8 @@ import { InputFieldsComponentsDef } from "@xgovformbuilder/model"; import { FormComponent } from "./FormComponent"; import { ComponentCollection } from "./ComponentCollection"; -import { optionalText } from "./constants"; +import { optionalTextEnglish } from "./constants"; +import { optionalTextCymraeg } from "./constants"; import * as helpers from "./helpers"; import { FormData, @@ -24,13 +25,26 @@ export class DatePartsField extends FormComponent { const { name, options } = this; const isRequired = "required" in options && options.required === false ? false : true; - const optionalText = "optionalText" in options && options.optionalText; + const optionalText = model?.def?.metadata?.isWelsh + ? optionalTextCymraeg + : optionalTextEnglish; + + let dayTitle = "Day"; + let monthTitle = "Month"; + let yearTitle = "Year"; + + if (model?.def?.metadata?.isWelsh) { + dayTitle = "Diwrnod"; + monthTitle = "mis"; + yearTitle = "blwyddyn"; + } + this.children = new ComponentCollection( [ { type: "NumberField", name: `${name}__day`, - title: "Day", + title: dayTitle, schema: { min: 1, max: 31 }, options: { required: isRequired, @@ -42,7 +56,7 @@ export class DatePartsField extends FormComponent { { type: "NumberField", name: `${name}__month`, - title: "Month", + title: monthTitle, schema: { min: 1, max: 12 }, options: { required: isRequired, @@ -54,7 +68,7 @@ export class DatePartsField extends FormComponent { { type: "NumberField", name: `${name}__year`, - title: "Year", + title: yearTitle, schema: { min: 1000, max: 3000 }, options: { required: isRequired, @@ -123,6 +137,9 @@ export class DatePartsField extends FormComponent { // @ts-ignore - eslint does not report this as an error, only tsc getViewModel(formData: FormData, errors: FormSubmissionErrors) { const viewModel = super.getViewModel(formData, errors); + const optionalText = this.model?.def?.metadata?.isWelsh + ? optionalTextCymraeg + : optionalTextEnglish; // Use the component collection to generate the subitems const componentViewModels = this.children @@ -141,8 +158,13 @@ export class DatePartsField extends FormComponent { } }); - const firstError = errors?.errorList?.[0]; - const errorMessage = firstError && { text: firstError?.text }; + let errorMessage; + errors?.errorList?.find((value) => { + if (value.name.includes(this.name)) { + const firstError = errors?.errorList?.[0]; + errorMessage = firstError && { text: firstError?.text }; + } + }); return { ...viewModel, diff --git a/runner/src/server/plugins/engine/components/DateTimePartsField.ts b/runner/src/server/plugins/engine/components/DateTimePartsField.ts index a57dbce3b7..6e47e43b14 100644 --- a/runner/src/server/plugins/engine/components/DateTimePartsField.ts +++ b/runner/src/server/plugins/engine/components/DateTimePartsField.ts @@ -3,7 +3,8 @@ import { Schema } from "joi"; import { InputFieldsComponentsDef } from "@xgovformbuilder/model"; import * as helpers from "./helpers"; -import { optionalText } from "./constants"; +import { optionalTextEnglish } from "./constants"; +import { optionalTextCymraeg } from "./constants"; import { FormComponent } from "./FormComponent"; import { ComponentCollection } from "./ComponentCollection"; import { @@ -141,6 +142,9 @@ export class DateTimePartsField extends FormComponent { // @ts-ignore - eslint does not report this as an error, only tsc getViewModel(formData: FormData, errors: FormSubmissionErrors) { const viewModel = super.getViewModel(formData, errors); + const optionalText = this.model?.def?.metadata?.isWelsh + ? optionalTextCymraeg + : optionalTextEnglish; // Use the component collection to generate the subitems const componentViewModels = this.children diff --git a/runner/src/server/plugins/engine/components/FileUploadField.ts b/runner/src/server/plugins/engine/components/FileUploadField.ts index dd179b47cc..e45d963756 100644 --- a/runner/src/server/plugins/engine/components/FileUploadField.ts +++ b/runner/src/server/plugins/engine/components/FileUploadField.ts @@ -1,32 +1,83 @@ +import { FileUploadFieldComponent } from "@xgovformbuilder/model"; +import { FormModel } from "../models"; import { FormData, FormSubmissionErrors } from "../types"; import { FormComponent } from "./FormComponent"; import * as helpers from "./helpers"; import { DataType, ViewModel } from "./types"; +import joi, { Schema } from "joi"; export class FileUploadField extends FormComponent { dataType = "file" as DataType; getFormSchemaKeys() { - return helpers.getFormSchemaKeys(this.name, "string", this); + const { options } = this; + let componentSchema = joi.string().required(); + if (options.required === false) { + componentSchema = componentSchema.allow("").allow(null).optional(); + } + componentSchema = componentSchema.label(this.title); + return { + [this.name]: componentSchema.when(`${this.name}__filename`, { + is: joi.string().required(), + then: joi.optional().allow(""), + }), + [`${this.name}__filename`]: joi.string().optional(), + }; } getStateSchemaKeys() { - return helpers.getStateSchemaKeys(this.name, "string", this); + const { options } = this; + let componentSchema = joi.string().required(); + if (options.required === false) { + componentSchema = componentSchema.allow("").allow(null).optional(); + } + componentSchema = componentSchema.label(this.title); + return { + [this.name]: componentSchema.when(`${this.name}__filename`, { + is: joi.string().required(), + then: joi.optional().allow(""), + }), + [`${this.name}__filename`]: joi.string().optional(), + }; } get attributes() { return { - accept: "image/jpeg,image/gif,image/png,application/pdf", + accept: + "image/jpeg,image/gif,image/png,application/pdf,text/csv,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.oasis.opendocument.spreadsheet,application/vnd.oasis.opendocument.text,text/plain,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }; } + getFileNameFromState(state: FormData) { + let fileName = state[this.name]; + if (typeof fileName !== "undefined") { + let value = fileName.split("/"); + fileName = value[value.length - 1]; + } + return fileName; + } + getViewModel(formData: FormData, errors: FormSubmissionErrors) { const { options } = this; + const fileName = this.getFileNameFromState(formData) ?? ""; + let existingFileText = "You have already provided this file, " + fileName; + let content = + "Your file will upload when you select Save and continue.\n It may take some time so please wait a few seconds."; + + if (this.model?.def?.metadata?.isWelsh) { + existingFileText = "Rydych eisoes wedi darparu'r ffeil hwn" + fileName; + content = + "Bydd eich ffeil yn uwchlwytho pan fyddwch yn dewis Cadw a pharhau.\n Gall gymryd peth amser felly arhoswch ychydig eiliadau"; + } const viewModel: ViewModel = { ...super.getViewModel(formData, errors), attributes: this.attributes, + filename: this.getFileNameFromState(formData) ?? "", + existingFileText: existingFileText, }; + viewModel.content = content; + if ("multiple" in options && options.multiple) { viewModel.attributes.multiple = "multiple"; } diff --git a/runner/src/server/plugins/engine/components/FormComponent.ts b/runner/src/server/plugins/engine/components/FormComponent.ts index 1a64f48e67..7ee1179600 100644 --- a/runner/src/server/plugins/engine/components/FormComponent.ts +++ b/runner/src/server/plugins/engine/components/FormComponent.ts @@ -1,7 +1,8 @@ import joi, { Schema } from "joi"; import { ComponentBase } from "./ComponentBase"; -import { optionalText } from "./constants"; +import { optionalTextEnglish } from "./constants"; +import { optionalTextCymraeg } from "./constants"; import { FormSubmissionState, @@ -61,6 +62,12 @@ export class FormComponent extends ComponentBase { getStateValueFromValidForm(payload: FormPayload): any { const name = this.name; + if (this.type === "FileUploadField" && payload[name] === "") { + return name in payload && payload[name + "__filename"] !== "" + ? payload[name + "__filename"] + : null; + } + return name in payload && payload[name] !== "" ? payload[name] : null; } @@ -79,6 +86,9 @@ export class FormComponent extends ComponentBase { getViewModel(formData: FormData, errors: FormSubmissionErrors) { const options: any = this.options; const isOptional = options.required === false; + const optionalText = this.model?.def?.metadata?.isWelsh + ? optionalTextCymraeg + : optionalTextEnglish; const optionalPostfix = isOptional && options.optionalText !== false ? optionalText : ""; this.lang = formData.lang; @@ -132,6 +142,10 @@ export class FormComponent extends ComponentBase { return { [this.name]: joi.any() }; } + getAdditionalValidationFunctions(): Function[] { + return []; + } + getDisplayStringFromState(state) { return state[this.name]; } diff --git a/runner/src/server/plugins/engine/components/FreeTextField.ts b/runner/src/server/plugins/engine/components/FreeTextField.ts new file mode 100644 index 0000000000..698b32d3ec --- /dev/null +++ b/runner/src/server/plugins/engine/components/FreeTextField.ts @@ -0,0 +1,110 @@ +import { FormComponent } from "./FormComponent"; +import { FormData, FormSubmissionErrors } from "../types"; +import Joi, { Schema, StringSchema } from "joi"; +import { FreeTextFieldComponent } from "@xgovformbuilder/model"; +import { FormModel } from "server/plugins/engine/models"; +import { FreeTextFieldViewModel } from "server/plugins/engine/components/types"; +import { DataType } from "./types"; + +// this must match the front-end, or we'll have discrepancies +// runner\src\server\plugins\engine\views\components\freetextfield.html +function inputIsOverWordCount(input, maxWords) { + /** + * This validation is copied from the govuk-frontend library to match their client side behaviour + * the {@link https://github.com/alphagov/govuk-frontend/blob/e1612b13771fb7ca9a58ee85393aec94a1849335/src/govuk/components/character-count/character-count.js#L91 | govuk-frontend} library + */ + // This peice of regex will remove all the html elements and entitys to get a accurate word count + input = input.replace(/<[^>]*>/g, "").replace(/&[^;]+;/g, ""); + const maxWordCount = parseInt(maxWords); + const wordCount = (input.match(/\S+/g) || []).length; + return wordCount > maxWordCount; +} + +export class FreeTextField extends FormComponent { + formSchema: StringSchema; + options: FreeTextFieldComponent["options"]; + schema: FreeTextFieldComponent["schema"]; + customValidationMessage?: string; + dataType = "freeText" as DataType; + isCharacterOrWordCount: boolean = false; + + constructor(def: FreeTextFieldComponent, model: FormModel) { + super(def, model); + this.options = def.options; + this.schema = def.schema; + this.formSchema = Joi.string(); + this.formSchema = this.formSchema.label(def.title); + const { maxWords, customValidationMessage } = def.options; + const isRequired = def.options.required ?? true; + + if (isRequired) { + this.formSchema = this.formSchema.required(); + } else { + this.formSchema = this.formSchema.allow("").allow(null); + } + this.formSchema = this.formSchema.ruleset; + + if (def.schema.max) { + this.formSchema = this.formSchema.max(def.schema.max); + this.isCharacterOrWordCount = true; + } + + if (def.schema.min) { + this.formSchema = this.formSchema.min(def.schema.min); + } + + if (maxWords ?? false) { + this.formSchema = this.formSchema.custom((value, helpers) => { + if (inputIsOverWordCount(value, maxWords)) { + return helpers.error("string.maxWords", { limit: maxWords }); + } + return value; + }, "max words validation"); + this.isCharacterOrWordCount = true; + } + + if (customValidationMessage) { + this.formSchema = this.formSchema.rule({ + message: customValidationMessage, + }); + } + } + + getFormSchemaKeys() { + return { [this.name]: this.formSchema as Schema }; + } + + getStateSchemaKeys() { + return { [this.name]: this.formSchema as Schema }; + } + + getViewModel( + formData: FormData, + errors: FormSubmissionErrors + ): FreeTextFieldViewModel { + const schema = this.schema; + const options = this.options; + + const viewModel = super.getViewModel( + formData, + errors + ) as FreeTextFieldViewModel; + viewModel.isCharacterOrWordCount = this.isCharacterOrWordCount; + + if (schema.max ?? false) { + viewModel.maxlength = schema.max; + } + + if (options.rows ?? false) { + viewModel.rows = options.rows; + } + + if (options.maxWords ?? false) { + viewModel.maxWords = options.maxWords; + } + if (options.hideTitle) { + viewModel.label = { text: "", html: viewModel.hint?.html!, classes: "" }; + } + return viewModel; + } +} diff --git a/runner/src/server/plugins/engine/components/MonthYearField.ts b/runner/src/server/plugins/engine/components/MonthYearField.ts index 576e7ca7ea..fa1b883224 100644 --- a/runner/src/server/plugins/engine/components/MonthYearField.ts +++ b/runner/src/server/plugins/engine/components/MonthYearField.ts @@ -1,5 +1,6 @@ import { InputFieldsComponentsDef } from "@xgovformbuilder/model"; -import { optionalText } from "./constants"; +import { optionalTextEnglish } from "./constants"; +import { optionalTextCymraeg } from "./constants"; import { FormComponent } from "./FormComponent"; import { ComponentCollection } from "./ComponentCollection"; import { @@ -9,7 +10,7 @@ import { FormSubmissionState, } from "../types"; import { FormModel } from "../models"; -import { Schema } from "joi"; +import joi, { Schema } from "joi"; import { DataType } from "./types"; export class MonthYearField extends FormComponent { @@ -53,13 +54,30 @@ export class MonthYearField extends FormComponent { } getStateSchemaKeys() { + let schema = joi.object(this.children.getStateSchemaKeys()) as Schema; + if (this.options?.required) { + schema = schema.required(); + } return { - [this.name]: this.children.getStateSchemaKeys() as Schema, + [this.name]: schema, }; } getFormDataFromState(state: FormSubmissionState) { - return this.children.getFormDataFromState(state); + if (state && state[this.name]) { + const result = state[this.name]; + if (typeof result === "string") { + const [year, month] = result.split("-"); + return { + [`${this.name}__month`]: month, + [`${this.name}__year`]: year, + }; + } + + return this.children.getFormDataFromState(state[this.name]); + } + + return {}; } getStateValueFromValidForm(payload: FormPayload) { @@ -84,6 +102,9 @@ export class MonthYearField extends FormComponent { // @ts-ignore - eslint does not report this as an error, only tsc getViewModel(formData: FormData, errors: FormSubmissionErrors) { const viewModel = super.getViewModel(formData, errors); + const optionalText = this.model?.def?.metadata?.isWelsh + ? optionalTextCymraeg + : optionalTextEnglish; // Use the component collection to generate the subitems const componentViewModels = this.children diff --git a/runner/src/server/plugins/engine/components/MultilineTextField.ts b/runner/src/server/plugins/engine/components/MultilineTextField.ts index 38c30890a0..177f651202 100644 --- a/runner/src/server/plugins/engine/components/MultilineTextField.ts +++ b/runner/src/server/plugins/engine/components/MultilineTextField.ts @@ -10,8 +10,9 @@ function inputIsOverWordCount(input, maxWords) { * This validation is copied from the govuk-frontend library to match their client side behaviour * the {@link https://github.com/alphagov/govuk-frontend/blob/e1612b13771fb7ca9a58ee85393aec94a1849335/src/govuk/components/character-count/character-count.js#L91 | govuk-frontend} library */ + var maxWordCount = parseInt(maxWords); const wordCount = input.match(/\S+/g).length || 0; - return maxWords > wordCount; + return wordCount > maxWordCount; } export class MultilineTextField extends FormComponent { @@ -49,7 +50,7 @@ export class MultilineTextField extends FormComponent { if (maxWords ?? false) { this.formSchema = this.formSchema.custom((value, helpers) => { if (inputIsOverWordCount(value, maxWords)) { - helpers.error("string.maxWords"); + return helpers.error("string.maxWords", { limit: maxWords }); } return value; }, "max words validation"); @@ -94,6 +95,12 @@ export class MultilineTextField extends FormComponent { if (options.maxWords ?? false) { viewModel.maxwords = options.maxWords; } + + if (options.hideTitle) { + viewModel.label = { text: "", html: viewModel.hint?.html!, classes: "" }; + viewModel.hint = undefined; + } + return viewModel; } } diff --git a/runner/src/server/plugins/engine/components/NumberField.ts b/runner/src/server/plugins/engine/components/NumberField.ts index 747114c773..6c4531de9e 100644 --- a/runner/src/server/plugins/engine/components/NumberField.ts +++ b/runner/src/server/plugins/engine/components/NumberField.ts @@ -16,6 +16,11 @@ export class NumberField extends FormComponent { schema = schema.label(def.title); + const isOptional = def.options.required == false; + if (!isOptional) { + schema = schema.required(); + } + if (def.schema?.min && def.schema?.max) { schema = schema.$; } @@ -31,7 +36,7 @@ export class NumberField extends FormComponent { schema = schema.rule({ message: def.options.customValidationMessage }); } - if (def.options.required === false) { + if (isOptional) { const optionalSchema = joi .alternatives() .try(joi.string().allow(null).allow("").default("").optional(), schema); @@ -65,7 +70,8 @@ export class NumberField extends FormComponent { }; if (this.schemaOptions.precision) { - viewModel.attributes.step = "0." + "1".padStart(schema.precision, "0"); + viewModel.attributes.step = + "0." + "1".padStart(this.schemaOptions.precision, "0"); } return viewModel; diff --git a/runner/src/server/plugins/engine/components/TextField.ts b/runner/src/server/plugins/engine/components/TextField.ts index 2c50edf6c3..bb78db727a 100644 --- a/runner/src/server/plugins/engine/components/TextField.ts +++ b/runner/src/server/plugins/engine/components/TextField.ts @@ -73,6 +73,11 @@ export class TextField extends FormComponent { viewModel.autocomplete = options.autocomplete; } + if (options.hideTitle) { + viewModel.label = { text: "", html: viewModel.hint?.html!, classes: "" }; + viewModel.hint = undefined; + } + return viewModel; } } diff --git a/runner/src/server/plugins/engine/components/UkAddressField.ts b/runner/src/server/plugins/engine/components/UkAddressField.ts index 6ac0769a38..e712739ae8 100644 --- a/runner/src/server/plugins/engine/components/UkAddressField.ts +++ b/runner/src/server/plugins/engine/components/UkAddressField.ts @@ -12,6 +12,7 @@ import { FormSubmissionState, } from "../types"; import { FormModel } from "../models"; +import { i18n } from "../../i18n"; export class UkAddressField extends FormComponent { formChildren: ComponentCollection; @@ -24,34 +25,72 @@ export class UkAddressField extends FormComponent { const isRequired = "required" in options && options.required === false ? false : true; + let addressLine1Title = "Address line 1"; + let addressLine2Title = "Address line 2"; + let townCityText = "Town or city"; + let county = "County"; + let postcode = "Postcode"; + + if (model.def.metadata?.isWelsh) { + addressLine1Title = "Llinell cyfeiriad 1"; + addressLine2Title = "Llinell cyfeiriad 2"; + townCityText = "Tref neu ddinas"; + county = "Sir"; + postcode = "Cod post"; + } + const childrenList: any = [ { type: "TextField", name: "addressLine1", - title: "Address line 1", + title: addressLine1Title, schema: { max: 100 }, - options: { required: isRequired }, + options: { + required: isRequired, + classes: "govuk-!-width-full", + optionalText: false, + }, }, { type: "TextField", name: "addressLine2", - title: "Address line 2", + title: addressLine2Title, schema: { max: 100, allow: "" }, - options: { required: false }, + options: { required: false, classes: "govuk-!-width-full" }, }, { type: "TextField", name: "town", - title: "Town or city", + title: townCityText, + schema: { max: 100 }, + options: { + required: isRequired, + classes: "govuk-!-width-two-thirds", + optionalText: false, + }, + }, + { + type: "TextField", + name: "county", + title: county, schema: { max: 100 }, - options: { required: isRequired }, + options: { required: false, classes: "govuk-!-width-two-thirds" }, }, { type: "TextField", name: "postcode", - title: "Postcode", - schema: { max: 10 }, - options: { required: isRequired }, + title: postcode, + schema: { + max: 10, + regex: + "^([A-Za-z][A-Ha-hJ-Yj-y]?[0-9][A-Za-z0-9]? ?[0-9][A-Za-z]{2}|[Gg][Ii][Rr] ?0[Aa]{2})$", + }, + options: { + required: isRequired, + customValidationMessage: "Enter a valid postcode", + classes: "govuk-!-width-one-half", + optionalText: false, + }, }, ]; @@ -94,10 +133,14 @@ export class UkAddressField extends FormComponent { const name = this.name; const value = state[name]; + if (typeof value === "string") { + return this.convertStringAnswers(name, value); + } return { [`${name}__addressLine1`]: value && value.addressLine1, [`${name}__addressLine2`]: value && value.addressLine2, [`${name}__town`]: value && value.town, + [`${name}__county`]: value && value.county, [`${name}__postcode`]: value && value.postcode, }; } @@ -109,6 +152,7 @@ export class UkAddressField extends FormComponent { addressLine1: payload[`${name}__addressLine1`], addressLine2: payload[`${name}__addressLine2`], town: payload[`${name}__town`], + county: payload[`${name}__county`], postcode: payload[`${name}__postcode`], } : null; @@ -118,8 +162,28 @@ export class UkAddressField extends FormComponent { const name = this.name; const value = state[name]; + if ( + typeof value !== "string" && + typeof value !== "undefined" && + value !== null + ) { + value.addressLine2 = + value.addressLine2 === "" ? "null" : value.addressLine2; + value.county = value.county === "" ? "null" : value.county; + } + + if (typeof value === "string") { + return value; + } + return value - ? [value.addressLine1, value.addressLine2, value.town, value.postcode] + ? [ + value.addressLine1, + value.addressLine2, + value.town, + value.county, + value.postcode, + ] .filter((p) => { return !!p; }) @@ -148,4 +212,19 @@ export class UkAddressField extends FormComponent { return viewModel; } + + // This method is used to solve the issue of the address fields appearing blank when + // returning to a completed section of a form. + convertStringAnswers(name: string, value: any) { + const address = value.split(", "); + + return { + [`${name}__addressLine1`]: value && address[0], + [`${name}__addressLine2`]: + value && address[1] === "null" ? "" : address[1], + [`${name}__town`]: value && address[2], + [`${name}__county`]: value && address[3] === "null" ? "" : address[3], + [`${name}__postcode`]: value && address[4], + }; + } } diff --git a/runner/src/server/plugins/engine/components/WebsiteField.ts b/runner/src/server/plugins/engine/components/WebsiteField.ts index 92233fa3d2..27834784ed 100644 --- a/runner/src/server/plugins/engine/components/WebsiteField.ts +++ b/runner/src/server/plugins/engine/components/WebsiteField.ts @@ -3,9 +3,11 @@ import Joi, { StringSchema } from "joi"; import { FormModel } from "../models"; import { TextField } from "./TextField"; import { addClassOptionIfNone } from "./helpers"; +import { FormData, FormSubmissionErrors } from "../types"; export class WebsiteField extends TextField { - private defaultMessage = "Enter website address in the correct format"; + private defaultMessage = + "Enter website address in the correct format, starting with 'https://'"; formSchema: StringSchema; options: WebsiteFieldComponent["options"]; @@ -14,8 +16,9 @@ export class WebsiteField extends TextField { constructor(def: WebsiteFieldComponent, model: FormModel) { super(def, model); + const { schema = {} } = def; this.options = def.options; - this.schema = def.schema; + this.schema = schema; this.formSchema = Joi.string(); const isRequired = def.options.required ?? true; @@ -31,12 +34,12 @@ export class WebsiteField extends TextField { .uri() .message(def.options?.customValidationMessage ?? this.defaultMessage); - if (def.schema.max) { - this.formSchema = this.formSchema.max(def.schema.max); + if (schema.max) { + this.formSchema = this.formSchema.max(schema.max); } - if (def.schema.min) { - this.formSchema = this.formSchema.min(def.schema.min); + if (schema.min) { + this.formSchema = this.formSchema.min(schema.min); } addClassOptionIfNone(this.options, "govuk-input--width-10"); @@ -53,4 +56,17 @@ export class WebsiteField extends TextField { [this.name]: this.formSchema, }; } + + getViewModel(formData: FormData, errors: FormSubmissionErrors) { + const options: any = this.options; + const schema: any = this.schema; + const viewModel = super.getViewModel(formData, errors); + + if (options.hideTitle) { + viewModel.label = { text: "", html: viewModel.hint?.html!, classes: "" }; + viewModel.hint = { html: this.localisedString(this.hint) }; + } + + return viewModel; + } } diff --git a/runner/src/server/plugins/engine/components/YesNoField.ts b/runner/src/server/plugins/engine/components/YesNoField.ts index 5a4c7e0117..97c297db14 100644 --- a/runner/src/server/plugins/engine/components/YesNoField.ts +++ b/runner/src/server/plugins/engine/components/YesNoField.ts @@ -11,21 +11,7 @@ import { List } from "@xgovformbuilder/model"; * YesNoField is a radiosField with predefined values. */ export class YesNoField extends ListFormComponent { - list: List = { - name: "__yesNo", - title: "Yes/No", - type: "boolean", - items: [ - { - text: "Yes", - value: true, - }, - { - text: "No", - value: false, - }, - ], - }; + list; itemsSchema = joi.boolean(); get items() { return this.list?.items ?? []; @@ -39,6 +25,29 @@ export class YesNoField extends ListFormComponent { super(def, model); const { options } = this; + let yesText = "Yes"; + let noText = "No"; + + if (model.def?.metadata?.isWelsh) { + yesText = "Oes"; + noText = "Nac Oes"; + } + + this.list = { + name: "__yesNo", + title: "Yes/No", + type: "boolean", + items: [ + { + text: yesText, + value: true, + }, + { + text: noText, + value: false, + }, + ], + }; this.formSchema = helpers .buildFormSchema("boolean", this, options?.required !== false) diff --git a/runner/src/server/plugins/engine/components/constants.ts b/runner/src/server/plugins/engine/components/constants.ts index 87965f0f94..92c222392f 100644 --- a/runner/src/server/plugins/engine/components/constants.ts +++ b/runner/src/server/plugins/engine/components/constants.ts @@ -1 +1,2 @@ -export const optionalText = " (Optional)"; +export const optionalTextEnglish = " (optional)"; +export const optionalTextCymraeg = " (dewisol)"; diff --git a/runner/src/server/plugins/engine/components/index.ts b/runner/src/server/plugins/engine/components/index.ts index b73119858c..9cb3ba4eab 100644 --- a/runner/src/server/plugins/engine/components/index.ts +++ b/runner/src/server/plugins/engine/components/index.ts @@ -34,3 +34,5 @@ export { WebsiteField } from "./WebsiteField"; export { YesNoField } from "./YesNoField"; export { MonthYearField } from "./MonthYearField"; export { MultiInputField } from "./MultiInputField"; +export { FreeTextField } from "./FreeTextField"; +export { ClientSideFileUploadField } from "./ClientSideFileUploadField"; diff --git a/runner/src/server/plugins/engine/components/types.ts b/runner/src/server/plugins/engine/components/types.ts index e83865953c..1cfacc0c41 100644 --- a/runner/src/server/plugins/engine/components/types.ts +++ b/runner/src/server/plugins/engine/components/types.ts @@ -1,3 +1,5 @@ +import { S3Object } from "server/services/uploadService"; + export type Label = { text: string; classes: string; @@ -58,6 +60,9 @@ export type ViewModel = { }; children?: ComponentCollectionViewModel; autocomplete?: string; + filename: string; + existingFileText: string; + componentType?: string; }; export type MultilineTextFieldViewModel = { @@ -72,6 +77,20 @@ export type ComponentCollectionViewModel = { model: ViewModel; }[]; +export type FreeTextFieldViewModel = { + maxlength?: number; + isCharacterOrWordCount: boolean; + maxWords?: number; +} & ViewModel; + +export type ClientSideFileUploadFieldViewModel = { + dropzoneConfig: object; + existingFiles: S3Object[]; + showNoScriptWarning?: boolean; + totalOverallFilesize?: number; + hideTitle?: boolean; +} & ViewModel; + export type DataType = | "list" | "text" From 8dabffeae41e0fa79144a7eb9c0e8280c9ad3a23 Mon Sep 17 00:00:00 2001 From: Sophie O'Hagan Date: Thu, 5 Oct 2023 16:11:29 +0100 Subject: [PATCH 056/104] Updated engine models --- .../server/plugins/engine/models/FormModel.ts | 7 ++++-- .../plugins/engine/models/SummaryViewModel.ts | 2 ++ .../src/server/plugins/engine/models/types.ts | 1 + .../server/plugins/engine/models/viewModel.ts | 25 ++++++++++++++++++- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/runner/src/server/plugins/engine/models/FormModel.ts b/runner/src/server/plugins/engine/models/FormModel.ts index 493f093739..0d41c5d4f3 100644 --- a/runner/src/server/plugins/engine/models/FormModel.ts +++ b/runner/src/server/plugins/engine/models/FormModel.ts @@ -56,10 +56,13 @@ export class FormModel { }; constructor(def, options) { - const result = Schema.validate(def, { abortEarly: false }); + let result = Schema.validate(def, { abortEarly: false }); if (result.error) { - throw result.error; + result = Schema.validate(def.values, { abortEarly: false }); + if (result.error) { + throw result.error; + } } // Make a clone of the shallow copy returned diff --git a/runner/src/server/plugins/engine/models/SummaryViewModel.ts b/runner/src/server/plugins/engine/models/SummaryViewModel.ts index d141704d9f..bef40d4fc0 100644 --- a/runner/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/runner/src/server/plugins/engine/models/SummaryViewModel.ts @@ -62,6 +62,8 @@ export class SummaryViewModel extends ViewModel { this.result = result; this.value = result.value; this.name = model.name; + this.markAsCompleteComponent = state.callback?.markAsCompleteComponent; + this.markAsComplete = state.markAsComplete; } private processErrors(result, details) { diff --git a/runner/src/server/plugins/engine/models/types.ts b/runner/src/server/plugins/engine/models/types.ts index e1f265d11d..6838ba4e06 100644 --- a/runner/src/server/plugins/engine/models/types.ts +++ b/runner/src/server/plugins/engine/models/types.ts @@ -8,6 +8,7 @@ export type Fields = { title: string; type: string; answer: string | number | boolean; + columnTitles?: any; }[]; export type Question = { diff --git a/runner/src/server/plugins/engine/models/viewModel.ts b/runner/src/server/plugins/engine/models/viewModel.ts index fe65efb4e6..e52ff204f8 100644 --- a/runner/src/server/plugins/engine/models/viewModel.ts +++ b/runner/src/server/plugins/engine/models/viewModel.ts @@ -22,6 +22,8 @@ import { reach } from "hoek"; export class ViewModel { pageTitle: string; declaration: any; // TODO + markAsComplete: boolean | undefined; + markAsCompleteComponent: boolean | undefined; skipSummary: boolean; endPage: any; // TODO result: any; @@ -35,6 +37,7 @@ export class ViewModel { privacyPolicyUrl: string | undefined; phaseTag: string | undefined; declarationError: any; // TODO + markAsCompleteError: any; errors: | { path: string; @@ -297,6 +300,21 @@ export class ViewModel { }); } + addMarkAsCompleteAsQuestion(markAsComplete: boolean) { + this._webhookData?.questions?.push({ + category: null, + question: "MarkAsComplete", + fields: [ + { + key: "markAsComplete", + title: "Do you want to mark this section as complete?", + type: "boolean", + answer: markAsComplete, + }, + ], + }); + } + protected addFeedbackSourceDataToWebhook( webhookData, model: FormModel, @@ -375,11 +393,16 @@ function Item( }); } + let componentValue = component.getDisplayStringFromState(sectionState); + + if (componentValue == "" && component.options.required == false) { + componentValue = null; + } return { name: component.name, path: page.path, label: component.localisedString(component.title), - value: component.getDisplayStringFromState(sectionState), + value: componentValue, rawValue: sectionState[component.name], url: redirectUrl(request, `/${model.basePath}${page.path}`, params), pageId: `/${model.basePath}${page.path}`, From d37a8a6af6c953b12ea505ccd68327fdf115ad1a Mon Sep 17 00:00:00 2001 From: Sophie O'Hagan Date: Thu, 5 Oct 2023 17:20:31 +0100 Subject: [PATCH 057/104] Updated Page Controllers --- .../pageControllers/PageControllerBase.ts | 236 +++++++++++++--- .../pageControllers/SummaryPageController.ts | 251 +++++++++++++++++- 2 files changed, 452 insertions(+), 35 deletions(-) diff --git a/runner/src/server/plugins/engine/pageControllers/PageControllerBase.ts b/runner/src/server/plugins/engine/pageControllers/PageControllerBase.ts index 60addc1c14..afce71792e 100644 --- a/runner/src/server/plugins/engine/pageControllers/PageControllerBase.ts +++ b/runner/src/server/plugins/engine/pageControllers/PageControllerBase.ts @@ -27,6 +27,9 @@ import config from "server/config"; const FORM_SCHEMA = Symbol("FORM_SCHEMA"); const STATE_SCHEMA = Symbol("STATE_SCHEMA"); +const ADDITIONAL_VALIDATION_FUNCTIONS = Symbol( + "ADDITIONAL_VALIDATION_FUNCTIONS" +); export class PageControllerBase { /** @@ -42,6 +45,7 @@ export class PageControllerBase { phaseBanner?: { phase?: string; }; + footer: any; }; name: string; model: FormModel; @@ -54,6 +58,8 @@ export class PageControllerBase { components: ComponentCollection; hasFormComponents: boolean; hasConditionalFormComponents: boolean; + saveAndContinueText: string; + continueText: string; backLinkFallback?: string; // TODO: pageDef type @@ -89,6 +95,14 @@ export class PageControllerBase { this[FORM_SCHEMA] = this.components.formSchema; this[STATE_SCHEMA] = this.components.stateSchema; + + this.saveAndContinueText = "Save and continue"; + this.continueText = "Continue"; + + if (model?.def?.metadata?.isWelsh) { + this.saveAndContinueText = "Cadw a pharhau"; + this.continueText = "Parhau"; + } } /** @@ -110,36 +124,39 @@ export class PageControllerBase { isStartPage: boolean; startPage?: HapiResponseObject; backLink?: string; + backLinkText?: string; phaseTag?: string | undefined; + footer?: any; } { let showTitle = true; let pageTitle = this.title; let sectionTitle = this.section?.title; - if (sectionTitle && iteration !== undefined) { + if (sectionTitle && iteration !== undefined && iteration !== "null") { sectionTitle = `${sectionTitle} ${iteration}`; } const components = this.components.getViewModel(formData, errors); - const formComponents = components.filter((c) => c.isFormComponent); - const hasSingleFormComponent = formComponents.length === 1; - const singleFormComponent = hasSingleFormComponent - ? formComponents[0] - : null; - const singleFormComponentIsFirst = - singleFormComponent && singleFormComponent === components[0]; + //TODO im not eniterly sure why this is in here, i dont want to remove it completely as maybe we can add a option to skip it + // const formComponents = components.filter((c) => c.isFormComponent); + // const hasSingleFormComponent = formComponents.length === 1; + // const singleFormComponent = hasSingleFormComponent + // ? formComponents[0] + // : null; + // const singleFormComponentIsFirst = + // singleFormComponent && singleFormComponent === components[0]; - if (singleFormComponent && singleFormComponentIsFirst) { - const label: any = singleFormComponent.model.label; + // if (singleFormComponent && singleFormComponentIsFirst) { + // const label: any = singleFormComponent.model.label; - if (pageTitle) { - label.text = pageTitle; - } + // if (pageTitle) { + // label.text = pageTitle; + // } - label.isPageHeading = true; - label.classes = "govuk-label--l"; - pageTitle = pageTitle || label.text; - showTitle = false; - } + // label.isPageHeading = true; + // label.classes = "govuk-label--l"; + // pageTitle = pageTitle || label.text; + // showTitle = false; + // } return { page: this, @@ -150,6 +167,7 @@ export class PageControllerBase { components, errors, isStartPage: false, + footer: this.def.footer, }; } @@ -212,7 +230,9 @@ export class PageControllerBase { const nextLink = this.next.find((link) => { const { condition } = link; if (condition) { - return this.model.conditions[condition]?.fn?.(state); + this.model.conditions[condition] && + (this.model.conditions[condition].fn(state) || + this.model.conditions[condition].fn(state[this.section?.name])); } defaultLink = link; return false; @@ -334,6 +354,15 @@ export class PageControllerBase { return this.validate(newState, this.stateSchema); } + async validateComponentFunctions(request, viewModel) { + let errors = []; + for (let func of this.additionalValidationFunctions) { + const errorList = await func(request, viewModel); + errors.push(...errorList); + } + return errors; + } + /** * returns the language set in a user's browser. Can be used for localisable strings */ @@ -385,14 +414,14 @@ export class PageControllerBase { makeGetRouteHandler() { return async (request: HapiRequest, h: HapiResponseToolkit) => { - const { cacheService } = request.services([]); + const { cacheService, uploadService } = request.services([]); const lang = this.langFromRequest(request); const state = await cacheService.getState(request); const progress = state.progress || []; const { num } = request.query; const currentPath = `/${this.model.basePath}${this.path}${request.url.search}`; const startPage = this.model.def.startPage; - const formData = this.getFormDataFromState(state, num - 1); + let formData = this.getFormDataFromState(state, num - 1); const isStartPage = this.path === `${startPage}`; const isInitialisedSession = !!state.callback; @@ -417,18 +446,50 @@ export class PageControllerBase { if (originalFilenames) { Object.entries(formData).forEach(([key, value]) => { if (value && value === (originalFilenames[key] || {}).location) { - formData[key] = originalFilenames[key].originalFilename; + formData[key] = originalFilenames[key].location; } }); } const viewModel = this.getViewModel(formData, num); + + const form_session_identifier = + state.metadata?.form_session_identifier ?? ""; + if (form_session_identifier) { + const comp = viewModel.components.find( + (c) => c.type === "ClientSideFileUploadField" + ); + if (comp) { + const pageAndForm = currentPath.includes("?") + ? currentPath.split("?")[0] + : currentPath; + const folderPath = `${form_session_identifier}${pageAndForm}/${comp.model.id}`; + const files = await uploadService.listFilesInBucketFolder( + folderPath, + form_session_identifier + ); + comp.model.existingFiles.push(...files); + } + } + + const fileUploadFields = viewModel.components + .filter((component) => component.type === "FileUploadField") + .map((component) => component.model); + + for (let fileUploadField of fileUploadFields) { + state[fileUploadField.name + "__filename"] = + state[fileUploadField.name]; + } + await cacheService.mergeState(request, { ...state }); + viewModel.startPage = startPage!.startsWith("http") ? redirectTo(request, h, startPage!) : redirectTo(request, h, `/${this.model.basePath}${startPage!}`); this.setPhaseTag(viewModel); this.setFeedbackDetails(viewModel, request); + this.setContactUsDetails(viewModel, request); + this.setPrivacyDetails(viewModel, request); /** * Content components can be hidden based on a condition. If the condition evaluates to true, it is safe to be kept, otherwise discard it @@ -489,7 +550,9 @@ export class PageControllerBase { await cacheService.mergeState(request, { progress }); viewModel.backLink = - progress[progress.length - 2] ?? this.backLinkFallback; + state.callback?.returnUrl ?? progress[progress.length - 2]; + viewModel.backLinkText = + this.model.def?.backLinkText ?? "Go back to application overview"; return h.view(this.viewName, viewModel); }; } @@ -513,16 +576,37 @@ export class PageControllerBase { const { cacheService } = request.services([]); const hasFilesizeError = request.payload === null; const preHandlerErrors = request.pre.errors; - const payload = (request.payload || {}) as FormData; - const formResult: any = this.validateForm(payload); const state = await cacheService.getState(request); + const payload = (request.payload || {}) as FormData; + let formResult: any = this.validateForm(payload); const originalFilenames = (state || {}).originalFilenames || {}; - const fileFields = this.getViewModel(formResult) - .components.filter((component) => component.type === "FileUploadField") + const viewModel = this.getViewModel(formResult); + const fileFields = viewModel.components + .filter((component) => component.type === "FileUploadField") .map((component) => component.model); const progress = state.progress || []; const { num } = request.query; + for (let file of fileFields) { + let fileName = file.name + "__filename"; + if (!payload.hasOwnProperty(fileName)) { + payload[fileName] = state[fileName]; + } + } + + formResult = this.validateForm(payload); + + const additionalValidationErrors = await this.validateComponentFunctions( + request, + viewModel + ); + if (additionalValidationErrors.length > 0) { + formResult.errors = { + titleText: "There is a problem", + errorList: additionalValidationErrors, + }; + } + if (state.metadata === undefined) { state["metadata"] = {}; } @@ -585,26 +669,28 @@ export class PageControllerBase { if (formResult.errors) { //TODO:- refactor to match POST REDIRECT GET pattern. - return this.renderWithErrors( + return await this.renderWithErrors( request, h, payload, num, progress, - formResult.errors + formResult.errors, + state.callback?.returnUrl ); } const newState = this.getStateFromValidForm(formResult.value); const stateResult = this.validateState(newState); if (stateResult.errors) { - return this.renderWithErrors( + return await this.renderWithErrors( request, h, payload, num, progress, - stateResult.errors + stateResult.errors, + state.callback?.returnUrl ); } @@ -674,6 +760,40 @@ export class PageControllerBase { }; } + setPrivacyDetails(viewModel, request) { + let privacyPolicyUrl: string; + if (request.query.form_session_identifier) { + privacyPolicyUrl = + this.getConfiguredPrivacyLink() + + "?application_id=" + + request.query.form_session_identifier; + } else { + privacyPolicyUrl = this.getConfiguredPrivacyLink(); + } + viewModel.privacyPolicyUrl = privacyPolicyUrl; + } + + getConfiguredPrivacyLink() { + return config.privacyPolicyUrl; + } + + setContactUsDetails(viewModel, request) { + let contactUsUrl: string; + if (request.query.form_session_identifier) { + contactUsUrl = + this.getConfiguredContactUsLink() + + "?application_id=" + + request.query.form_session_identifier; + } else { + contactUsUrl = this.getConfiguredContactUsLink(); + } + viewModel.contactUsUrl = contactUsUrl; + } + + getConfiguredContactUsLink() { + return config.contactUsUrl; + } + setFeedbackDetails(viewModel, request) { const feedbackContextInfo = this.getFeedbackContextInfo(request); if (feedbackContextInfo) { @@ -686,6 +806,18 @@ export class PageControllerBase { if (this.def.feedback?.emailAddress) { viewModel.feedbackLink = `mailto:${this.def.feedback.emailAddress}`; } + if (!viewModel.feedbackLink) { + let feedbackLink: string; + if (request.query.form_session_identifier) { + feedbackLink = + this.getConfiguredFeedbackLink() + + "?application_id=" + + request.query.form_session_identifier; + } else { + feedbackLink = this.getConfiguredFeedbackLink(); + } + viewModel.feedbackLink = feedbackLink; + } } getFeedbackContextInfo(request: HapiRequest) { @@ -802,6 +934,10 @@ export class PageControllerBase { this[STATE_SCHEMA] = value; } + get additionalValidationFunctions() { + return this[ADDITIONAL_VALIDATION_FUNCTIONS]; + } + private objLength(object: {}) { return Object.keys(object).length; } @@ -814,12 +950,46 @@ export class PageControllerBase { } } - private renderWithErrors(request, h, payload, num, progress, errors) { + private async renderWithErrors( + request, + h, + payload, + num, + progress, + errors, + returnUrl + ) { const viewModel = this.getViewModel(payload, num, errors); + const { cacheService, uploadService } = request.services([]); + const state = await cacheService.getState(request); + const currentPath = `/${this.model.basePath}${this.path}${request.url.search}`; + + const form_session_identifier = + state.metadata?.form_session_identifier ?? ""; + if (form_session_identifier) { + const comp = viewModel.components.find( + (c) => c.type === "ClientSideFileUploadField" + ); + if (comp) { + const pageAndForm = currentPath.includes("?") + ? currentPath.split("?")[0] + : currentPath; + const folderPath = `${form_session_identifier}${pageAndForm}/${comp.model.id}`; + const files = await uploadService.listFilesInBucketFolder( + folderPath, + form_session_identifier + ); + comp.model.existingFiles.push(...files); + } + } - viewModel.backLink = progress[progress.length - 2] ?? this.backLinkFallback; + viewModel.backLink = returnUrl ?? progress[progress.length - 2]; + viewModel.backLinkText = + this.model.def?.backLinkText ?? "Go back to application overview"; this.setPhaseTag(viewModel); this.setFeedbackDetails(viewModel, request); + this.setContactUsDetails(viewModel, request); + this.setPrivacyDetails(viewModel, request); return h.view(this.viewName, viewModel); } diff --git a/runner/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/runner/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 7fe9f24696..e719a10455 100644 --- a/runner/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/runner/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -21,7 +21,7 @@ export class SummaryPageController extends PageController { return async (request: HapiRequest, h: HapiResponseToolkit) => { this.langFromRequest(request); - const { cacheService } = request.services([]); + const { cacheService, uploadService } = request.services([]); const model = this.model; // @ts-ignore - ignoring so docs can be generated. Remove when properly typed @@ -31,6 +31,83 @@ export class SummaryPageController extends PageController { const state = await cacheService.getState(request); const viewModel = new SummaryViewModel(this.title, model, state, request); + const { relevantPages } = SummaryViewModel.getRelevantPages(model, state); + const clientSideUploadComponents = relevantPages + .filter( + (page) => page.components.additionalValidationFunctions.length > 0 + ) + .flatMap((page) => + page.components.items + .filter((item) => item.type == "ClientSideFileUploadField") + .flatMap((x) => x) + ); + + const errorPromises = clientSideUploadComponents.map((component) => { + const funcs = component.getAdditionalValidationFunctions(); + return Promise.all( + funcs.map((func) => func(request, { components: [component] })) + ); + }); + const nestedErrors = await Promise.all(errorPromises); + const errors = nestedErrors.flat(Infinity); + if (errors.length > 0) { + const restructuredErrors = errors.map(({ name, path, text }) => { + return { + path: path, + name: name, + message: text, + }; + }); + const allErrorsUnsorted = [ + ...(viewModel.errors || []), + ...restructuredErrors, + ]; + + const sortedPathNames = relevantPages + .filter( + (page) => page.section?.name && page.components.items.length > 0 + ) + .map((page) => [ + page.section?.name, + page.components.items.map((item) => item.name), + ]) + .flatMap(([prefix, suffixes]) => + suffixes.map((suffix) => `${prefix}.${suffix}`) + ); + + const sortedErrors = [...allErrorsUnsorted].sort((a, b) => { + const indexA = sortedPathNames.indexOf(a.path); + const indexB = sortedPathNames.indexOf(b.path); + + return indexA - indexB; + }); + + viewModel.errors = sortedErrors; + } + + viewModel.footer = this.def.footer; + + const form_session_identifier = + state.metadata?.form_session_identifier ?? ""; + if (form_session_identifier) { + for (const detail of viewModel.details) { + const comps = detail.items.filter( + (c) => c.type === "ClientSideFileUploadField" + ); + for (const comp of comps) { + const folderPath = `${comp.pageId}/${comp.name}`; + const files = await uploadService.listFilesInBucketFolder( + `${form_session_identifier}${folderPath}`, + form_session_identifier + ); + comp.value = { + folderPath, + files, + }; + } + } + } + if (viewModel.endPage) { return redirectTo( request, @@ -89,6 +166,32 @@ export class SummaryPageController extends PageController { if (declarationError.length) { viewModel.declarationError = declarationError[0]; } + + const markAsCompleteError = request.yar.flash("markAsCompleteError"); + if (markAsCompleteError.length) { + viewModel.markAsCompleteError = markAsCompleteError[0]; + } + + viewModel.details.find((value, index) => { + viewModel.containsFileType = value.items.some( + (item) => item.type === "FileUploadField" + ); + }); + + viewModel.details.find((value, index) => { + for (let item of value.items) { + if (item.type === "UkAddressField") { + if (item.value != null) { + item.value = item.value.replace(/, null/g, ""); + } + } + // New lines wont render on the summary page + if (item.type === "FreeTextField" && item.value) { + item.value = item.value.replace(/\r\n/g, ""); + } + } + }); + return h.view("summary", viewModel); }; } @@ -99,7 +202,7 @@ export class SummaryPageController extends PageController { */ makePostRouteHandler() { return async (request: HapiRequest, h: HapiResponseToolkit) => { - const { payService, cacheService } = request.services([]); + const { payService, cacheService, uploadService } = request.services([]); const model = this.model; const state = await cacheService.getState(request); state.metadata.isSummaryPageSubmit = true; @@ -111,7 +214,85 @@ export class SummaryPageController extends PageController { state, request ); + + const { relevantPages } = SummaryViewModel.getRelevantPages(model, state); + const clientSideUploadComponents = relevantPages + .filter( + (page) => page.components.additionalValidationFunctions.length > 0 + ) + .flatMap((page) => + page.components.items + .filter((item) => item.type == "ClientSideFileUploadField") + .flatMap((x) => x) + ); + + const errorPromises = clientSideUploadComponents.map((component) => { + const funcs = component.getAdditionalValidationFunctions(); + return Promise.all( + funcs.map((func) => func(request, { components: [component] })) + ); + }); + const nestedErrors = await Promise.all(errorPromises); + const errors = nestedErrors.flat(Infinity); + if (errors.length > 0) { + const restructuredErrors = errors.map(({ name, path, text }) => { + return { + path: path, + name: name, + message: text, + }; + }); + const allErrorsUnsorted = [ + ...(summaryViewModel.errors || []), + ...restructuredErrors, + ]; + + const sortedPathNames = model.pages + .filter( + (page) => page.section?.name && page.components.items.length > 0 + ) + .map((page) => [ + page.section?.name, + page.components.items.map((item) => item.name), + ]) + .flatMap(([prefix, suffixes]) => + suffixes.map((suffix) => `${prefix}.${suffix}`) + ); + + const sortedErrors = [...allErrorsUnsorted].sort((a, b) => { + const indexA = sortedPathNames.indexOf(a.path); + const indexB = sortedPathNames.indexOf(b.path); + + return indexA - indexB; + }); + + summaryViewModel.errors = sortedErrors; + } + + const form_session_identifier = + state.metadata?.form_session_identifier ?? ""; + if (form_session_identifier) { + for (const detail of summaryViewModel.details) { + const comps = detail.items.filter( + (c) => c.type === "ClientSideFileUploadField" + ); + for (const comp of comps) { + const folderPath = `${comp.pageId}/${comp.name}`; + const files = await uploadService.listFilesInBucketFolder( + `${form_session_identifier}${folderPath}`, + form_session_identifier + ); + comp.value = { + folderPath, + files, + }; + } + } + } + this.setFeedbackDetails(summaryViewModel, request); + this.setContactUsDetails(summaryViewModel, request); + this.setPrivacyDetails(summaryViewModel, request); // redirect user to start page if there are incomplete form errors if (summaryViewModel.result.error) { @@ -162,6 +343,22 @@ export class SummaryPageController extends PageController { summaryViewModel.addDeclarationAsQuestion(); } + if (summaryViewModel.markAsCompleteComponent) { + const { markAsComplete } = request.payload as { markAsComplete?: any }; + + if (!markAsComplete) { + request.yar.flash("markAsCompleteError", "You must select yes or no"); + return redirectTo( + request, + h, + `${request.headers.referer}#markAsComplete` + ); + } + summaryViewModel.addMarkAsCompleteAsQuestion( + markAsComplete.toLowerCase() === "true" + ); + } + await cacheService.mergeState(request, { outputs: summaryViewModel.outputs, userCompletedSummary: true, @@ -230,6 +427,40 @@ export class SummaryPageController extends PageController { }; } + setPrivacyDetails(viewModel: SummaryViewModel, request: HapiRequest) { + let privacyPolicyUrl: string; + if (request.query.form_session_identifier) { + privacyPolicyUrl = + this.getConfiguredPrivacyLink() + + "?application_id=" + + request.query.form_session_identifier; + } else { + privacyPolicyUrl = this.getConfiguredPrivacyLink(); + } + viewModel.privacyPolicyUrl = privacyPolicyUrl; + } + + getConfiguredPrivacyLink() { + return config.privacyPolicyUrl; + } + + setContactUsDetails(viewModel: SummaryViewModel, request: HapiRequest) { + let contactUsUrl: string; + if (request.query.form_session_identifier) { + contactUsUrl = + this.getConfiguredContactUsLink() + + "?application_id=" + + request.query.form_session_identifier; + } else { + contactUsUrl = this.getConfiguredContactUsLink(); + } + viewModel.contactUsUrl = contactUsUrl; + } + + getConfiguredContactUsLink() { + return config.contactUsUrl; + } + setFeedbackDetails(viewModel: SummaryViewModel, request: HapiRequest) { const feedbackContextInfo = this.getFeedbackContextInfo(request); @@ -240,6 +471,22 @@ export class SummaryPageController extends PageController { // setting the feedbackLink to undefined here for feedback forms prevents the feedback link from being shown viewModel.feedbackLink = this.feedbackUrlFromRequest(request); + if (!viewModel.feedbackLink) { + let feedbackLink: string; + if (request.query.form_session_identifier) { + feedbackLink = + this.getConfiguredFeedbackLink() + + "?application_id=" + + request.query.form_session_identifier; + } else { + feedbackLink = this.getConfiguredFeedbackLink(); + } + viewModel.feedbackLink = feedbackLink; + } + } + + getConfiguredFeedbackLink() { + return config.feedbackLink; } getFeedbackContextInfo(request: HapiRequest) { From 17dc195f7ae4437ce00c0c4bfe02c536d417613b Mon Sep 17 00:00:00 2001 From: Sophie O'Hagan Date: Fri, 6 Oct 2023 14:01:17 +0100 Subject: [PATCH 058/104] Engine views updated --- .../components/clientsidefileuploadfield.html | 393 ++++++++++++++++++ .../engine/views/components/details.html | 3 +- .../views/components/fileuploadfield.html | 10 +- .../views/components/freetextfield.html | 89 ++++ .../plugins/engine/views/components/list.html | 3 +- .../views/components/multiinputfield.html | 1 + .../views/components/multilinetextfield.html | 4 +- .../engine/views/components/selectfield.html | 3 +- .../plugins/engine/views/partials/form.html | 6 +- 9 files changed, 495 insertions(+), 17 deletions(-) create mode 100644 runner/src/server/plugins/engine/views/components/clientsidefileuploadfield.html create mode 100644 runner/src/server/plugins/engine/views/components/freetextfield.html diff --git a/runner/src/server/plugins/engine/views/components/clientsidefileuploadfield.html b/runner/src/server/plugins/engine/views/components/clientsidefileuploadfield.html new file mode 100644 index 0000000000..cc5a4bf32f --- /dev/null +++ b/runner/src/server/plugins/engine/views/components/clientsidefileuploadfield.html @@ -0,0 +1,393 @@ +{% from "label/macro.njk" import govukLabel %} +{% from "hint/macro.njk" import govukHint %} + +{% macro formatBytes(bytes) %} + {% if bytes < 1048576 %} + {{ (bytes / 1024)|round(2) }} KB + {% elif bytes < 1073741824 %} + {{ (bytes / 1048576)|round(2) }} MB + {% else %} + {{ (bytes / 1073741824)|round(2) }} GB + {% endif %} +{% endmacro %} + + +{% macro ClientSideFileUploadField(component) %} + + + + + + + + + +{% set is_single_file_upload = component.model.dropzoneConfig.maxFiles === 1 %} +{% set total_overall_filesize = component.model.totalOverallFilesize %} + +
    + + {% if component.model.hideTitle == false %} + {{ govukLabel(component.model.label) }} + {% endif %} + + {% if component.model.hint %} + {{ govukHint(component.model.hint) }} + {% endif %} + +

    + + {{ component.model.errorMessage.text | safe }} + +

    + + {% if component.model.showNoScriptWarning %} + + {% endif %} + +
    +
    +
    +
    + {% if is_single_file_upload %} + Choose file + {% else %} + Choose files + {% endif %} +
    +
    +
    File name
    +
    File size
    +
    Progress
    +
    Action
    +
    +
    +
    +
    + + + +
    + + + +{% endmacro %} diff --git a/runner/src/server/plugins/engine/views/components/details.html b/runner/src/server/plugins/engine/views/components/details.html index b89e2abd6d..fb289e5c8e 100644 --- a/runner/src/server/plugins/engine/views/components/details.html +++ b/runner/src/server/plugins/engine/views/components/details.html @@ -3,5 +3,4 @@ {% macro Details(component) %} {# {{ getContext(component) | dump | safe }} #} {{ govukDetails(component.model)}} -{% endmacro %} - +{% endmacro %} \ No newline at end of file diff --git a/runner/src/server/plugins/engine/views/components/fileuploadfield.html b/runner/src/server/plugins/engine/views/components/fileuploadfield.html index fab533da45..c510ac2ce7 100644 --- a/runner/src/server/plugins/engine/views/components/fileuploadfield.html +++ b/runner/src/server/plugins/engine/views/components/fileuploadfield.html @@ -2,11 +2,11 @@ {% macro FileUploadField(component) %} {{ govukFileUpload(component.model) }} {% if component.model.value %} -

    You have already provided this file.

    +

    {{component.model.existingFileText}}

    {% endif %} -
{% endif %} -{% endmacro %} - +{% endmacro %} \ No newline at end of file diff --git a/runner/src/server/plugins/engine/views/components/multiinputfield.html b/runner/src/server/plugins/engine/views/components/multiinputfield.html index 02e211fb9e..e9ec9d1273 100644 --- a/runner/src/server/plugins/engine/views/components/multiinputfield.html +++ b/runner/src/server/plugins/engine/views/components/multiinputfield.html @@ -31,6 +31,7 @@ {% elif item.componentType == "WebsiteField" %} {{ govukInput(item) }} {% elif item.componentType == "UkAddressField" %} +

{{ item.label.text }}

{{ componentList(item.children) }} {%endif%} diff --git a/runner/src/server/plugins/engine/views/components/multilinetextfield.html b/runner/src/server/plugins/engine/views/components/multilinetextfield.html index 61115994f4..5bd39c07ad 100644 --- a/runner/src/server/plugins/engine/views/components/multilinetextfield.html +++ b/runner/src/server/plugins/engine/views/components/multilinetextfield.html @@ -7,6 +7,4 @@ {% else %} {{ govukTextarea(component.model) }} {% endif %} -{% endmacro %} - - +{% endmacro %} \ No newline at end of file diff --git a/runner/src/server/plugins/engine/views/components/selectfield.html b/runner/src/server/plugins/engine/views/components/selectfield.html index 26c8785f92..8bbef0ff15 100644 --- a/runner/src/server/plugins/engine/views/components/selectfield.html +++ b/runner/src/server/plugins/engine/views/components/selectfield.html @@ -2,5 +2,4 @@ {% macro SelectField(component) %} {{ govukSelect(component.model) }} -{% endmacro %} - +{% endmacro %} \ No newline at end of file diff --git a/runner/src/server/plugins/engine/views/partials/form.html b/runner/src/server/plugins/engine/views/partials/form.html index 22b674c31d..e165e12930 100644 --- a/runner/src/server/plugins/engine/views/partials/form.html +++ b/runner/src/server/plugins/engine/views/partials/form.html @@ -2,10 +2,10 @@ {% from "summary-list/macro.njk" import govukSummaryList -%}
- + {% if page.isRepeatingFieldPageController and details %} - {{ componentList(components) }} - {{ govukButton({ + {{ componentList(components) }} + {{ govukButton({ attributes: { id: "add-another"}, classes: "govuk-button govuk-button--secondary", text: page.saveText From 9b107d7e195340ecad951bf083febad8fa241c31 Mon Sep 17 00:00:00 2001 From: Sophie O'Hagan Date: Fri, 6 Oct 2023 14:51:51 +0100 Subject: [PATCH 059/104] Finish engine updates --- runner/src/server/plugins/engine/helpers.ts | 11 +++++- runner/src/server/plugins/engine/plugin.ts | 43 ++++++++++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/runner/src/server/plugins/engine/helpers.ts b/runner/src/server/plugins/engine/helpers.ts index 4bc1a20245..70e9cca1b4 100644 --- a/runner/src/server/plugins/engine/helpers.ts +++ b/runner/src/server/plugins/engine/helpers.ts @@ -11,9 +11,14 @@ export function proceed( nextUrl: string ) { const returnUrl = request.query.returnUrl; + let form_session_identifier = ""; + + if (request.query.form_session_identifier) { + form_session_identifier = `?form_session_identifier=${request.query.form_session_identifier}`; + } if (typeof returnUrl === "string" && returnUrl.startsWith("/")) { - return h.redirect(returnUrl); + return h.redirect(`${returnUrl}${form_session_identifier}`); } else { return redirectTo(request, h, nextUrl); } @@ -71,7 +76,9 @@ export function redirectTo( if (targetUrl.startsWith("http")) { return h.redirect(targetUrl); } - + if (request.query.form_session_identifier) { + params.form_session_identifier = request.query.form_session_identifier; + } const url = redirectUrl(request, targetUrl, params); return h.redirect(url); } diff --git a/runner/src/server/plugins/engine/plugin.ts b/runner/src/server/plugins/engine/plugin.ts index 8e4023282b..d944206fa5 100644 --- a/runner/src/server/plugins/engine/plugin.ts +++ b/runner/src/server/plugins/engine/plugin.ts @@ -10,6 +10,15 @@ import { PluginSpecificConfiguration } from "@hapi/hapi"; import { FormPayload } from "./types"; import { shouldLogin } from "server/plugins/auth"; import config from "config"; +import { + jwtAuthIsActivated, + jwtAuthStrategyName, + jwtStrategyOptions, +} from "server/plugins/jwtAuth"; +import { + basicAuthStrategyName, + basicAuthStrategyOptions, +} from "server/plugins/basicAuth"; configure([ // Configure Nunjucks to allow rendering of content that is revealed conditionally. @@ -50,6 +59,7 @@ type PluginOptions = { previewMode: boolean; }; +export let authStrategy; export const plugin = { name: "@xgovformbuilder/runner/engine", dependencies: "@hapi/vision", @@ -69,6 +79,32 @@ export const plugin = { const disabledRouteDetailString = "A request was made however previewing is disabled. See environment variable details in runner/README.md if this error is not expected."; + const jwtAuthStrategyIsActive = jwtAuthIsActivated( + config.jwtAuthCookieName, + config.jwtRedirectToAuthenticationUrl, + config.rsa256PublicKeyBase64 + ); + + if (config.basicAuthOn) { + server.auth.strategy( + basicAuthStrategyName, + "basic", + basicAuthStrategyOptions + ); + } else if (jwtAuthStrategyIsActive) { + server.auth.strategy( + jwtAuthStrategyName, + "jwt", + jwtStrategyOptions(config.jwtAuthCookieName) + ); + } + + authStrategy = config.basicAuthOn + ? basicAuthStrategyName + : jwtAuthStrategyIsActive + ? jwtAuthStrategyName + : options.auth; + /** * The following publish endpoints (/publish, /published/{id}, /published) * are used from the designer for operating in 'preview' mode. @@ -197,6 +233,9 @@ export const plugin = { server.route({ method: "get", path: "/{id}/{path*}", + options: { + auth: authStrategy, + }, handler: (request: HapiRequest, h: HapiResponseToolkit) => { const { path, id } = request.params; const model = forms[id]; @@ -224,7 +263,8 @@ export const plugin = { const { uploadService } = server.services([]); const handleFiles = (request: HapiRequest, h: HapiResponseToolkit) => { - return uploadService.handleUploadRequest(request, h); + const { id } = request.params; + return uploadService.handleUploadRequest(request, h, forms[id]); }; const postHandler = async ( @@ -256,6 +296,7 @@ export const plugin = { userPathLimit: 10, }, }, + auth: authStrategy, payload: { output: "stream", parse: true, From 23571392f5da1f62883db4e8a8150f74f254c7fb Mon Sep 17 00:00:00 2001 From: Sophie O'Hagan Date: Fri, 6 Oct 2023 14:55:12 +0100 Subject: [PATCH 060/104] Update initialise session --- runner/src/server/plugins/initialiseSession/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/runner/src/server/plugins/initialiseSession/types.ts b/runner/src/server/plugins/initialiseSession/types.ts index 531cf57570..6100a354df 100644 --- a/runner/src/server/plugins/initialiseSession/types.ts +++ b/runner/src/server/plugins/initialiseSession/types.ts @@ -8,6 +8,7 @@ export type InitialiseSessionOptions = { callbackUrl: string; redirectPath?: string; message?: string; + returnUrl?: string; htmlMessage?: string; title?: string; skipSummary?: { From 8659320fe357c7eae3998b4f5e4be774ade84aa1 Mon Sep 17 00:00:00 2001 From: Sophie O'Hagan Date: Fri, 6 Oct 2023 15:07:09 +0100 Subject: [PATCH 061/104] Finished updating plugins --- .../src/server/plugins/applicationStatus.ts | 116 ++++++++++++++++++ runner/src/server/plugins/basicAuth.ts | 17 +++ runner/src/server/plugins/clientSideUpload.ts | 95 ++++++++++++++ runner/src/server/plugins/errorPages.ts | 15 ++- runner/src/server/plugins/jwtAuth.ts | 69 +++++++++++ runner/src/server/plugins/router.ts | 10 -- runner/src/server/plugins/views.ts | 8 +- 7 files changed, 318 insertions(+), 12 deletions(-) create mode 100644 runner/src/server/plugins/applicationStatus.ts create mode 100644 runner/src/server/plugins/basicAuth.ts create mode 100644 runner/src/server/plugins/clientSideUpload.ts create mode 100644 runner/src/server/plugins/jwtAuth.ts diff --git a/runner/src/server/plugins/applicationStatus.ts b/runner/src/server/plugins/applicationStatus.ts new file mode 100644 index 0000000000..adfab6248e --- /dev/null +++ b/runner/src/server/plugins/applicationStatus.ts @@ -0,0 +1,116 @@ +import { redirectTo } from "./engine"; +import { HapiRequest, HapiResponseToolkit } from "../types"; + +const applicationStatus = { + plugin: { + name: "applicationStatus", + dependencies: "@hapi/vision", + multiple: true, + register: (server) => { + server.route({ + method: "get", + path: "/{id}/status", + options: { + pre: [ + { + method: (request) => { + const { statusService } = request.services([]); + return statusService.shouldRetryPay(request); + }, + assign: "shouldRetryPay", + }, + { + method: (request) => { + const { cacheService } = request.services([]); + return cacheService.getConfirmationState(request); + }, + assign: "confirmationViewModel", + }, + ], + handler: async (request: HapiRequest, h: HapiResponseToolkit) => { + const { statusService, cacheService } = request.services([]); + const { params } = request; + const form = server.app.forms[params.id]; + const state = await cacheService.getState(request); + + const { + reference: newReference, + } = await statusService.outputRequests(request); + + if ( + !!request.pre.confirmationViewModel?.confirmation && + !state.callback?.returnUrl + ) { + request.logger.info( + [`/${params.id}/status`], + `${request.yar.id} confirmationViewModel found for user` + ); + return h.view( + "confirmation", + request.pre.confirmationViewModel.confirmation + ); + } + + if (request.pre.shouldRetryPay) { + return h.view("pay-error", { + errorList: ["there was a problem with your payment"], + }); + } + + if (state?.userCompletedSummary !== true) { + request.logger.error( + [`/${params.id}/status`], + `${request.yar.id} user has incomplete state` + ); + return h.redirect(`/${params.id}/summary`); + } + + const viewModel = statusService.getViewModel( + state, + form, + newReference + ); + + await cacheService.setConfirmationState(request, { + confirmation: viewModel, + }); + await cacheService.clearState(request); + if (state.callback?.returnUrl) { + return h.redirect(state.callback?.returnUrl); + } + return h.view("confirmation", viewModel); + }, + }, + }); + + server.route({ + method: "post", + path: "/{id}/status", + handler: async (request: HapiRequest, h: HapiResponseToolkit) => { + const { payService, cacheService } = request.services([]); + const { pay } = await cacheService.getState(request); + const { meta } = pay; + meta.attempts++; + const res = await payService.retryPayRequest(pay); + + await cacheService.mergeState(request, { + webhookData: { + fees: { + paymentReference: res.reference, + }, + }, + pay: { + payId: res.payment_id, + reference: res.reference, + self: res._links.self.href, + meta, + }, + }); + return redirectTo(request, h, res._links.next_url.href); + }, + }); + }, + }, +}; + +export default applicationStatus; diff --git a/runner/src/server/plugins/basicAuth.ts b/runner/src/server/plugins/basicAuth.ts new file mode 100644 index 0000000000..40676bbe3d --- /dev/null +++ b/runner/src/server/plugins/basicAuth.ts @@ -0,0 +1,17 @@ +import config from "config"; + +// validate() +// Checks validity of user credentials +const validate = async (request, username, password) => { + // This runs if a basic auth authorization header is found on the request + // It must return an object with an 'isValid' boolean property, + // this allows the user to continue if true or raises a 401 if false + const credentials = {}; + return { isValid: true, credentials }; +}; + +// Log JWT Authentication activation status +console.log("Basic Auth Enabled: " + config.basicAuthOn.toString()); + +export const basicAuthStrategyName = "basic_auth"; +export const basicAuthStrategyOptions = { validate }; diff --git a/runner/src/server/plugins/clientSideUpload.ts b/runner/src/server/plugins/clientSideUpload.ts new file mode 100644 index 0000000000..66268d5425 --- /dev/null +++ b/runner/src/server/plugins/clientSideUpload.ts @@ -0,0 +1,95 @@ +import { HapiRequest, HapiResponseToolkit } from "../types"; +import { authStrategy } from "server/plugins/engine/plugin"; + +export default { + plugin: { + name: "clientSideUpload", + register: (server) => { + server.route({ + method: "POST", + path: "/s3/{id}/{pageKey}/{componentKey}/create-pre-signed-url", + options: { + auth: authStrategy, + }, + handler: async (request: HapiRequest, h: HapiResponseToolkit) => { + const { uploadService, cacheService } = request.services([]); + const state = await cacheService.getState(request); + const form_session_identifier = + state.metadata?.form_session_identifier ?? ""; + if (!form_session_identifier) { + return h.response({ ok: false }).code(401); + } + const { id, pageKey, componentKey } = request.params as any; + const { filename } = request.payload; + + const form = request.server.app.forms[id]; + const page = form?.pages.find( + (p) => + uploadService.normalisePath(p.path) === + uploadService.normalisePath(pageKey) + ); + + const metaData = { + page: encodeURI(page.title), + section: encodeURI(page.section?.title ?? ""), + componentName: componentKey, + }; + + const key = `${form_session_identifier}/${id}/${pageKey}/${componentKey}/${filename}`; + const url = await uploadService.getPreSignedUrlS3(key, metaData); + return { url }; + }, + }); + + server.route({ + method: "GET", + path: "/s3/{id}/{pageKey}/{componentKey}/download-file", + options: { + auth: authStrategy, + }, + handler: async (request: HapiRequest, h: HapiResponseToolkit) => { + const { uploadService, cacheService } = request.services([]); + const state = await cacheService.getState(request); + const form_session_identifier = + state.metadata?.form_session_identifier ?? ""; + if (!form_session_identifier) { + return h.response({ ok: false }).code(401); + } + const { id, pageKey, componentKey } = request.params as any; + const { filename } = request.query; + + const key = `${form_session_identifier}/${id}/${pageKey}/${componentKey}/${filename}`; + const url = await uploadService.getFileDownloadUrlS3(key); + return h.redirect(url); + }, + }); + + server.route({ + method: "DELETE", + path: "/s3/{id}/{pageKey}/{componentKey}/delete-file-by-key", + options: { + auth: authStrategy, + }, + handler: async (request, h) => { + const { uploadService, cacheService } = request.services([]); + const state = await cacheService.getState(request); + const form_session_identifier = + state.metadata?.form_session_identifier ?? ""; + if (!form_session_identifier) { + return h.response({ ok: false }).code(401); + } + const { id, pageKey, componentKey } = request.params as any; + const { filename } = request.payload; + + const key = `${form_session_identifier}/${id}/${pageKey}/${componentKey}/${filename}`; + const wasDeleted = uploadService.deleteFileS3(key); + if (wasDeleted) { + return h.response("File deleted from S3").code(200); + } else { + return h.response("Error deleting file from S3").code(500); + } + }, + }); + }, + }, +}; diff --git a/runner/src/server/plugins/errorPages.ts b/runner/src/server/plugins/errorPages.ts index ebe4b27cbd..bad69671ff 100644 --- a/runner/src/server/plugins/errorPages.ts +++ b/runner/src/server/plugins/errorPages.ts @@ -1,4 +1,5 @@ import { HapiRequest, HapiResponseToolkit } from "../types"; +import config from "server/config"; /* * Add an `onPreResponse` listener to return error pages @@ -16,6 +17,7 @@ export default { // An error was raised during // processing the request const statusCode = response.output.statusCode; + const errorMessage = `${response.message}\n${response.stack || ""}`; // In the event of 404 // return the `404` view @@ -23,10 +25,21 @@ export default { return h.view("404").code(statusCode); } + // In the event of a 401 + // redirect to authentication url + if (statusCode === 401) { + request.logger.error(errorMessage); + return h.redirect( + config.jwtRedirectToAuthenticationUrl + + "?referrer=" + + request.url + ); + } + request.log("error", { statusCode: statusCode, data: response.data, - message: response.message, + message: errorMessage, }); // The return the `500` view diff --git a/runner/src/server/plugins/jwtAuth.ts b/runner/src/server/plugins/jwtAuth.ts new file mode 100644 index 0000000000..b0e29ce15c --- /dev/null +++ b/runner/src/server/plugins/jwtAuth.ts @@ -0,0 +1,69 @@ +import config from "config"; + +// jwtAuthIsActivated() +// Checks if all required config variables are set +export function jwtAuthIsActivated( + jwtAuthCookieName, + jwtRedirectToAuthenticationUrl, + rsa256PublicKeyBase64 +) { + return !( + !jwtAuthCookieName || + !jwtRedirectToAuthenticationUrl || + !rsa256PublicKeyBase64 + ); +} + +// keyFunc returns the key and any additonal context required to +// passed to validate function (below) to validate signature +// this is normally used to look up keys from list in a multi-tenant scenario +const keyFunc = async function (decoded) { + const key = Buffer.from(config.rsa256PublicKeyBase64 ?? "", "base64"); + return { key, additional: decoded }; +}; + +// validate() +// Checks validity of user credentials +const validate = async function (decoded, request, h) { + // This runs if the jwt signature is verified + // It must return an object with an 'isValid' boolean property, + // this allows the user to continue if true or raises a 401 if false + const credentials = decoded; + if (request.plugins["hapi-auth-jwt2"]) { + credentials.extraInfo = request.plugins["hapi-auth-jwt2"].extraInfo; + } + if (!decoded.accountId) { + request.logger.error( + "JWT token has no accountID in jwt: " + credentials.extraInfo.toString() + ); + return { isValid: false }; + } else { + return { isValid: true, credentials }; + } +}; + +// rsa256Options() +// Returns configuration options for rsa256 auth strategy +export function rsa256Options(jwtAuthCookieName) { + return { + key: keyFunc, + validate, + verifyOptions: { + algorithms: ["RS256"], + }, + urlKey: false, + cookieKey: jwtAuthCookieName, + }; +} + +// Check if global config vars are set +const isActivated = jwtAuthIsActivated( + config.jwtAuthCookieName, + config.jwtRedirectToAuthenticationUrl, + config.rsa256PublicKeyBase64 +); +// Log JWT Authentication activation status +console.log("JWT Authentication Enabled: " + isActivated.toString()); + +export const jwtAuthStrategyName = "jwt_auth"; +export const jwtStrategyOptions = rsa256Options; diff --git a/runner/src/server/plugins/router.ts b/runner/src/server/plugins/router.ts index c747c4fea5..607a266932 100644 --- a/runner/src/server/plugins/router.ts +++ b/runner/src/server/plugins/router.ts @@ -27,16 +27,6 @@ export default { register: (server) => { server.route(routes); server.route([ - { - method: "get", - path: "/help/privacy", - handler: async (_request: HapiRequest, h: HapiResponseToolkit) => { - if (config.privacyPolicyUrl) { - return h.redirect(config.privacyPolicyUrl); - } - return h.view("help/privacy"); - }, - }, { method: "get", path: "/help/cookies", diff --git a/runner/src/server/plugins/views.ts b/runner/src/server/plugins/views.ts index 92544d2212..a642c60e0b 100644 --- a/runner/src/server/plugins/views.ts +++ b/runner/src/server/plugins/views.ts @@ -77,9 +77,15 @@ export default { skipTimeoutWarning: false, serviceStartPage: config.serviceStartPage || "#", privacyPolicyUrl: config.privacyPolicyUrl || "/help/privacy", + contactUsUrl: config.contactUsUrl, + cookiePolicyUrl: config.cookiePolicyUrl, + accessibilityStatementUrl: config.accessibilityStatementUrl, phaseTag: config.phaseTag, navigation: request?.auth.isAuthenticated - ? [{ text: "Sign out", href: "/logout" }] + ? [ + { text: "View all applications", href: config.multifundDashboard }, + { text: "Sign out", href: config.logoutUrl }, + ] : null, }), }, From d9fc98ebcf40d6667ce776b44e51f38e6ddb178a Mon Sep 17 00:00:00 2001 From: Sophie O'Hagan Date: Fri, 6 Oct 2023 15:59:42 +0100 Subject: [PATCH 062/104] Updated server routes --- runner/src/server/routes/public.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/runner/src/server/routes/public.ts b/runner/src/server/routes/public.ts index 97f3527535..0f97d30be2 100644 --- a/runner/src/server/routes/public.ts +++ b/runner/src/server/routes/public.ts @@ -15,6 +15,8 @@ export default [ path.join(runnerFolder, "public", "build"), path.join(rootNodeModules, "govuk-frontend", "govuk"), path.join(rootNodeModules, "govuk-frontend", "govuk", "assets"), + path.join(rootNodeModules, "tinymce"), + path.join(rootNodeModules, "dropzone", "dist", "min"), path.join( runnerFolder, "node_modules", From 9bc86c573c2de620f6f08f23b3a452e56f798653 Mon Sep 17 00:00:00 2001 From: Sophie O'Hagan Date: Fri, 6 Oct 2023 16:27:28 +0100 Subject: [PATCH 063/104] Updated server schemas --- runner/src/server/schemas/types.ts | 1 + runner/src/server/schemas/webhookSchema.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/runner/src/server/schemas/types.ts b/runner/src/server/schemas/types.ts index d2781ba662..27d6c4ea77 100644 --- a/runner/src/server/schemas/types.ts +++ b/runner/src/server/schemas/types.ts @@ -33,6 +33,7 @@ export type InitialiseSessionSchema = { redirectPath?: string; customText: ConfirmationPage["customText"]; components: ConfirmationPage["components"]; + returnUrl?: string; }; questions: InitialiseSessionQuestion[]; } & Pick; diff --git a/runner/src/server/schemas/webhookSchema.ts b/runner/src/server/schemas/webhookSchema.ts index e490291e88..d54979e41e 100644 --- a/runner/src/server/schemas/webhookSchema.ts +++ b/runner/src/server/schemas/webhookSchema.ts @@ -81,6 +81,7 @@ const optionsSchema: joi.ObjectSchema< }) .optional(), components: joi.array().items(componentSchema), + returnUrl: joi.string().required().note("The url to return to"), }); //TODO:- make this work with initialiseSession POST endpoint, so the endpoint can be auto-documented From f92bf01799f116a8ae325a10d77238320d4d40dd Mon Sep 17 00:00:00 2001 From: Sophie O'Hagan Date: Fri, 6 Oct 2023 16:54:13 +0100 Subject: [PATCH 064/104] Updated services --- .../config/custom-environment-variables.json | 1 - runner/src/server/services/cacheService.ts | 64 +++-- runner/src/server/services/uploadService.ts | 247 ++++++++++++++++-- 3 files changed, 277 insertions(+), 35 deletions(-) diff --git a/runner/config/custom-environment-variables.json b/runner/config/custom-environment-variables.json index 0aeaf48f2c..f0a053422c 100644 --- a/runner/config/custom-environment-variables.json +++ b/runner/config/custom-environment-variables.json @@ -19,7 +19,6 @@ "redisPort": "REDIS_PORT", "singleRedis": "SINGLE_REDIS", "redisTls": "REDIS_TLS", - "sandbox": "sandbox", "redisPassword": "REDIS_PASSWORD", "serviceName": "SERVICE_NAME", "documentUploadApiUrl": "DOCUMENT_UPLOAD_API_URL", diff --git a/runner/src/server/services/cacheService.ts b/runner/src/server/services/cacheService.ts index 6b3705b406..cfd905444f 100644 --- a/runner/src/server/services/cacheService.ts +++ b/runner/src/server/services/cacheService.ts @@ -18,17 +18,26 @@ const { redisPort, redisPassword, redisTls, - isSandbox, + vcapServices, + isSingleRedis, sessionTimeout, confirmationSessionTimeout, paymentSessionTimeout, } = config; +let redisUri; const partition = "cache"; enum ADDITIONAL_IDENTIFIER { Confirmation = ":confirmation", } +if (vcapServices) { + const vcapJson = JSON.parse(vcapServices); + if ("redis" in vcapJson) { + redisUri = vcapJson.redis[0].credentials.uri; + } +} + export class CacheService { /** * This service is responsible for getting, storing or deleting a user's session data in the cache. This service has been registered by {@link createServer} @@ -101,15 +110,31 @@ export class CacheService { const initialisedSession = await this.cache.get(this.JWTKey(jwt)); - const currentSession = await this.cache.get(userSessionKey); - const mergedSession = { - ...currentSession, - ...initialisedSession, - }; - this.cache.set(userSessionKey, mergedSession, sessionTimeout); + const form_session_identifier = + initialisedSession.metadata?.form_session_identifier; + let redirectPath = initialisedSession?.callback?.redirectPath ?? ""; + + if (form_session_identifier) { + userSessionKey.id = `${userSessionKey.id}:${form_session_identifier}`; + redirectPath = `${redirectPath}?form_session_identifier=${form_session_identifier}`; + } + + if (config.overwriteInitialisedSession) { + request.logger.info("Replacing user session with initialisedSession"); + this.cache.set(userSessionKey, initialisedSession, sessionTimeout); + } else { + const currentSession = await this.cache.get(userSessionKey); + const mergedSession = { + ...currentSession, + ...initialisedSession, + }; + request.logger.info("Merging user session with initialisedSession"); + this.cache.set(userSessionKey, mergedSession, sessionTimeout); + } + await this.cache.drop(this.JWTKey(jwt)); return { - redirectPath: initialisedSession?.callback?.redirectPath ?? "", + redirectPath: redirectPath, }; } @@ -127,12 +152,15 @@ export class CacheService { * @param additionalIdentifier - appended to the id */ Key(request: HapiRequest, additionalIdentifier?: ADDITIONAL_IDENTIFIER) { - if (!request?.yar?.id) { - throw Error("No session ID found"); + let id = `${request.yar.id}:${request.params.id}`; + + if (request.query.form_session_identifier) { + id = `${id}:${request.query.form_session_identifier}`; } + return { segment: partition, - id: `${request.yar.id}:${request.params.id}${additionalIdentifier ?? ""}`, + id: `${id}${additionalIdentifier ?? ""}`, }; } @@ -150,11 +178,11 @@ export const catboxProvider = () => { * More information at {@link https://hapi.dev/module/catbox/api} */ const provider = { - constructor: redisHost ? CatboxRedis : CatboxMemory, + constructor: redisHost || redisUri ? CatboxRedis : CatboxMemory, options: {}, }; - if (redisHost) { + if (redisHost || redisUri) { const redisOptions: { password?: string; tls?: {}; @@ -168,8 +196,14 @@ export const catboxProvider = () => { redisOptions.tls = {}; } - const client = isSandbox - ? new Redis({ host: redisHost, port: redisPort, password: redisPassword }) + const client = isSingleRedis + ? new Redis( + redisUri ?? { + host: redisHost, + port: redisPort, + password: redisPassword, + } + ) : new Redis.Cluster( [ { diff --git a/runner/src/server/services/uploadService.ts b/runner/src/server/services/uploadService.ts index c1550df1d2..5b109c6f6c 100644 --- a/runner/src/server/services/uploadService.ts +++ b/runner/src/server/services/uploadService.ts @@ -1,12 +1,35 @@ import http from "http"; -import FormData from "form-data"; - import config from "../config"; -import { get, post } from "./httpService"; +import { get } from "./httpService"; import { HapiRequest, HapiResponseToolkit, HapiServer } from "../types"; +const S3 = require("aws-sdk/clients/s3"); + type Payload = HapiRequest["payload"]; +export let bucketName = config.awsBucketName; +export const region = config.awsRegion; + +if (process.env.VCAP_SERVICES) { + const vcap = process.env.VCAP_SERVICES; + const vcapJson = JSON.parse(vcap); + if ("aws-s3-bucket" in vcapJson) { + const s3Credentials = vcapJson["aws-s3-bucket"][0].credentials; + process.env.AWS_ACCESS_KEY_ID = s3Credentials.aws_access_key_id; + process.env.AWS_SECRET_ACCESS_KEY = s3Credentials.aws_secret_access_key; + bucketName = s3Credentials.bucket_name; + } +} + +const awsConfig = { region }; +let endpointUrl = process.env.AWS_ENDPOINT_OVERRIDE; +if (endpointUrl) { + awsConfig.endpoint = endpointUrl; + awsConfig.S3ForcePathStyle = true; + awsConfig.signatureVersion = process.env.AWS_SIGNATURE_VERSION || "v4"; +} +const s3 = new S3(awsConfig); + const parsedError = (key: string, error?: string) => { return { path: key, @@ -16,6 +39,11 @@ const parsedError = (key: string, error?: string) => { }; }; +export interface S3Object { + Key: string; + Size: number; +} + export class UploadService { /** * Service responsible for uploading files via the FileUploadField. This service has been registered by {@link #createServer} @@ -30,8 +58,34 @@ export class UploadService { return 5 * 1024 * 1024; // 5mb } - get validFiletypes(): ["jpg", "jpeg", "png", "pdf"] { - return ["jpg", "jpeg", "png", "pdf"]; + get validFiletypes(): [ + "jpg", + "jpeg", + "png", + "pdf", + "txt", + "doc", + "docx", + "odt", + "csv", + "xls", + "xlsx", + "ods" + ] { + return [ + "jpg", + "jpeg", + "png", + "pdf", + "txt", + "doc", + "docx", + "odt", + "csv", + "xls", + "xlsx", + "ods", + ]; } fileStreamsFromPayload(payload: Payload) { @@ -46,18 +100,20 @@ export class UploadService { }); } - async uploadDocuments(locations: any[]) { - const form = new FormData(); - for (const location of locations) { - form.append("files", location, { - filename: location.hapi.filename, - contentType: location.hapi.headers["content-type"], - }); - } + async uploadDocuments(locations: any[], prefix: string, metadata) { + let error: string | undefined; + let location: string | undefined; - const data = { headers: form.getHeaders(), payload: form }; - const { res } = await post(`${config.documentUploadApiUrl}/v1/files`, data); - return this.parsedDocumentUploadResponse(res); + await this.uploadFilesS3(locations, prefix, metadata).then((result) => { + result.forEach((doc) => { + if (typeof doc.error !== "undefined") { + error = "Failed to upload file to server: " + doc.error; + } else { + location = `${prefix}/${locations[0].hapi.filename}`; + } + }); + }); + return { location, error }; } parsedDocumentUploadResponse(res: http.IncomingMessage) { @@ -92,17 +148,52 @@ export class UploadService { return h.continue; } - async handleUploadRequest(request: HapiRequest, h: HapiResponseToolkit) { + async handleUploadRequest( + request: HapiRequest, + h: HapiResponseToolkit, + form?: any + ) { const { cacheService } = request.services([]); const state = await cacheService.getState(request); const originalFilenames = state?.originalFilenames ?? {}; + const form_session_identifier = + state.metadata?.form_session_identifier ?? ""; + const applicationId = state.metadata?.applicationId ?? ""; + + const { path } = request.params; + const page = form?.pages.find( + (page) => this.normalisePath(page.path) === this.normalisePath(path) + ); + let files: [string, any][] = []; if (request.payload !== null) { files = this.fileStreamsFromPayload(request.payload); } + const clientSideUploadComponent = page.components.items.find( + (c) => c.type === "ClientSideFileUploadField" + ); + if ( + clientSideUploadComponent && + form_session_identifier && + request.payload + ) { + const { id, path } = request.params; + const delPath = `${form_session_identifier}/${id}/${path}/${clientSideUploadComponent.name}`; + const filesToDelete = + request.payload[`${clientSideUploadComponent.name}__delete[]`] || []; + + if (Array.isArray(filesToDelete)) { + for (const fileKeyToDelete of filesToDelete) { + await this.deleteFileS3(`${delPath}/${fileKeyToDelete}`); + } + } else { + await this.deleteFileS3(`${delPath}/${filesToDelete}`); + } + } + /** * If there are no valid file(buffer)s, reassign any empty buffers with empty string * allows bypassing of file upload for whatever reason it doesn't work. @@ -168,12 +259,36 @@ export class UploadService { ) ).filter((value) => !!value); + let pageTitle = page.title; + let sectionTitle = page.section?.title ?? ""; + + if (page.def.metadata.isWelsh) { + pageTitle = encodeURI(pageTitle); + sectionTitle = encodeURI(sectionTitle); + } + + const metaData = { + page: pageTitle, + section: sectionTitle, + componentName: key, + }; + if (validFiles.length === values.length) { + let prefix = applicationId; + if (clientSideUploadComponent) { + const { id, path } = request.params; + prefix = `${form_session_identifier}/${id}/${path}/${clientSideUploadComponent.name}`; + } + try { - const { error, location } = await this.uploadDocuments(validFiles); + const { error, location } = await this.uploadDocuments( + validFiles, + prefix, + metaData + ); if (location) { originalFilenames[key] = { location }; - request.payload[key] = location; + request.payload[`${key}__filename`] = location; } if (error) { request.pre.errors = [ @@ -224,4 +339,98 @@ export class UploadService { const promises = paths.map((path) => get(path, {})); return Promise.all(promises); } + + async uploadFilesS3(files, filePrefix, metadata) { + let response = new Array(); + + for (const file of files) { + const uploadParams = { + Bucket: bucketName, + Body: file, + Key: `${filePrefix}/${file.hapi.filename}`, + ContentType: file.hapi.headers["content-type"], + Metadata: metadata, + }; + + await s3 + .upload(uploadParams) + .promise() + .then(function (data) { + response.push({ location: data.Location, error: undefined }); + }) + .catch((err) => { + response.push({ + location: undefined, + error: `${err.code}: ${err.message}`, + }); + this.logger.error(`File upload Error`, err); + }); + } + return response; + } + + normalisePath(path: string) { + return path.replace(/^\//, "").replace(/\/$/, ""); + } + + async listFilesInBucketFolder( + folderPath: string, + formSessionId: string + ): Promise { + const params = { + Bucket: bucketName, + Prefix: `${folderPath}/`, + }; + + const response = await s3.listObjectsV2(params).promise(); + + if (!response.Contents || response.Contents.length === 0) { + return []; + } + + const files = response.Contents.filter((obj) => !obj.Key.endsWith("/")).map( + (obj) => ({ + FormSessionId: formSessionId, + Key: obj.Key!.replace(`${folderPath}/`, ""), + Size: obj.Size!, + }) + ); + + return files; + } + + async getFileDownloadUrlS3(key: string) { + const params = { + Bucket: bucketName, + Key: key, + }; + const url = s3.getSignedUrl("getObject", params); + return url; + } + + async getPreSignedUrlS3(key: string) { + const params = { + Bucket: bucketName, + Key: key, + Expires: 60 * 60, + }; + + return await s3.getSignedUrlPromise("putObject", params); + } + + async deleteFileS3(key: string) { + const params = { + Bucket: bucketName, + Key: key, + }; + + try { + await s3.deleteObject(params).promise(); + return true; + } catch (err) { + console.error(`Issue when deleting file with key: ${key}`); + console.error(err); + return false; + } + } } From 363ac56232a44c1ab2cd96edfac72432495b844b Mon Sep 17 00:00:00 2001 From: Sophie O'Hagan Date: Fri, 6 Oct 2023 17:01:24 +0100 Subject: [PATCH 065/104] Updated server utils --- runner/src/server/utils/configSchema.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/runner/src/server/utils/configSchema.ts b/runner/src/server/utils/configSchema.ts index 7de43ddad9..546e67a5e4 100644 --- a/runner/src/server/utils/configSchema.ts +++ b/runner/src/server/utils/configSchema.ts @@ -33,6 +33,7 @@ export const configSchema = Joi.object({ payApiUrl: Joi.string().custom(secureUrl), payReturnUrl: Joi.string().custom(secureUrl), serviceUrl: Joi.string().optional(), + vcapServices: Joi.string(), redisHost: Joi.string().optional(), redisPort: Joi.number().optional(), redisPassword: Joi.string().optional(), @@ -49,6 +50,9 @@ export const configSchema = Joi.object({ fromEmailAddress: Joi.string().optional().allow(""), serviceStartPage: Joi.string().optional().allow(""), privacyPolicyUrl: Joi.string().optional().allow(""), + contactUsUrl: Joi.string().optional().allow(""), + cookiePolicyUrl: Joi.string().optional().allow(""), + accessibilityStatementUrl: Joi.string().optional().allow(""), notifyTemplateId: Joi.string().optional().allow(""), notifyAPIKey: Joi.string().optional().allow(""), lastCommit: Joi.string(), @@ -102,6 +106,15 @@ export const configSchema = Joi.object({ ) .default("HS512"), savePerPage: Joi.boolean().optional(), + awsBucketName: Joi.string().optional(), + awsRegion: Joi.string().optional(), + jwtAuthCookieName: Joi.string().optional(), + jwtRedirectToAuthenticationUrl: Joi.string().optional(), + rsa256PublicKeyBase64: Joi.string().optional(), + logoutUrl: Joi.string().optional(), + multifundDashboard: Joi.string(), + basicAuthOn: Joi.boolean().optional(), + overwriteInitialisedSession: Joi.boolean().optional(), }); export function buildConfig(config) { From 89bd8c4ff2372ec01749c6ba4131244fa2598c6f Mon Sep 17 00:00:00 2001 From: Sophie O'Hagan Date: Fri, 6 Oct 2023 17:27:27 +0100 Subject: [PATCH 066/104] Updated server views --- runner/src/server/views/404.html | 34 ++++++------ runner/src/server/views/500.html | 3 +- runner/src/server/views/confirmation.html | 2 +- runner/src/server/views/layout.html | 54 +++++++++++-------- .../server/views/partials/modal-dialog.html | 5 +- .../server/views/partials/summary-detail.html | 2 +- .../server/views/partials/summary-row.html | 46 ++++++++++++---- .../src/server/views/repeating-summary.html | 4 +- runner/src/server/views/summary.html | 47 +++++++++++++++- 9 files changed, 139 insertions(+), 58 deletions(-) diff --git a/runner/src/server/views/404.html b/runner/src/server/views/404.html index 83c3c68002..ade37318dc 100755 --- a/runner/src/server/views/404.html +++ b/runner/src/server/views/404.html @@ -5,27 +5,25 @@
-

Page not found

-

We have reported this to the team that manages the service and they will fix it as soon as possible.

-

Contact us if you need to speak to someone.

-

Telephone: -
- 0808 1234 1234 +

Page not found

+

If you typed the web address, check it is correct.

+

If you pasted the web address, check you copied the entire address.

+

If the web address is correct or you selected a link or a button, contact + the Community Ownership Fund to speak to an adviser.

+

Email:
+ fsd.support@levellingup.gov.uk

-

Textphone: -
- 0808 1234 1234 +

Do not send any applications or attachments by email. We'll only accept applications + through this service.
+

+ Opening times:
+ Monday to Friday: 9am to 5pm

-

Outside UK: -
- +44 0808 1234 1234 +

+ Closed: Easter Sunday, Christmas Day, Boxing Day and New Year’s Day.

-

Opening times: -
- Monday to Friday: 8am to 8pm -

-

Closed Easter Sunday, Christmas Day, Boxing Day and New Year’s Day.

-
+ +
diff --git a/runner/src/server/views/500.html b/runner/src/server/views/500.html index d57926c5c2..118aa0f594 100755 --- a/runner/src/server/views/500.html +++ b/runner/src/server/views/500.html @@ -6,7 +6,8 @@

Sorry, there is a problem with the service

-

Contact your closest consulate.

+

Try again later.

+

If the problem persists email fsd.support@levellingup.gov.uk

diff --git a/runner/src/server/views/confirmation.html b/runner/src/server/views/confirmation.html index 410351788d..e937b93376 100644 --- a/runner/src/server/views/confirmation.html +++ b/runner/src/server/views/confirmation.html @@ -9,7 +9,7 @@ tag: { text: "beta" }, - html: 'This is a new service – your feedback will help us to improve it.' + html: "This is a new service." }) }} {% endblock %} diff --git a/runner/src/server/views/layout.html b/runner/src/server/views/layout.html index c58690af5e..6eefc27bf4 100644 --- a/runner/src/server/views/layout.html +++ b/runner/src/server/views/layout.html @@ -4,7 +4,6 @@ {% from "footer/macro.njk" import govukFooter -%} {% from "phase-banner/macro.njk" import govukPhaseBanner %} {% from "skip-link/macro.njk" import govukSkipLink -%} -{% from "cookie-banner/macro.njk" import govukCookieBanner %} {% block head %} @@ -68,6 +67,7 @@ + {% endblock %} {% block pageTitle %} @@ -94,9 +94,6 @@ {% endblock %} {% block bodyStart %} - {% if not cookiesPolicy.isSet %} - {% include "partials/cookie-banner.html" %} - {% endif %} {% if gtmId1 %}