diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index a96d754e0..ed6b21b6b 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -92,9 +92,35 @@ await server.register({ } }) +const viewPaths = [join(config.get('appDir'), 'views')] + // Register the `forms-engine-plugin` await server.register({ - plugin + plugin, + options: { + cacheName: 'session', // must match a session you've instantiated in your hapi server config + /** + * Options that DXT uses to render Nunjucks templates + */ + nunjucks: { + basePageLayout: 'your-base-layout.html', // the base page layout. Usually based off https://design-system.service.gov.uk/styles/page-template/ + viewPaths // list of directories DXT should use to render your views. Must contain basePageLayout. + }, + /** + * Services is what DXT uses to interact with external APIs + */ + services: { + formsService, // where your forms should be downloaded from. + formSubmissionService, // handles temporary storage of file uploads + outputService // where your form should be submitted to + }, + /** + * View context attributes made available to your pages. Returns an object containing an arbitrary set of key-value pairs. + */ + viewContext: (request) => { + "example": "hello world" // available to render on a nunjucks page as {{ example }} + } + } }) await server.start() @@ -102,6 +128,8 @@ await server.start() ## Step 3: Handling static assets +TODO: CSS will be updated with a proper build process using SASS. + 1. [Update webpack to bundle the DXT application assets (CSS, JavaScript, etc)](https://github.com/DEFRA/forms-engine-plugin-example-ui/pull/1/files#diff-1fb26bc12ac780c7ad7325730ed09fc4c2c3d757c276c3dacc44bfe20faf166f) 2. [Serve the newly bundled assets from your web server](https://github.com/DEFRA/forms-engine-plugin-example-ui/pull/1/files#diff-e5b183306056f90c7f606b526dbc0d0b7e17bccd703945703a0811b6e6bb3503) @@ -116,8 +144,7 @@ Blocks marked with `# FEATURE: ` are optional and can be omitted if the fe FEEDBACK_LINK=http://test.com # END FEATURE: Phase banner -# START FEATURE: DXT -- used if using DXT's infrastructure to store your forms and file uploads -MANAGER_URL=http://localhost:3001 +# START FEATURE: DXT -- used if using DXT's infrastructure for file uploads DESIGNER_URL=http://localhost:3000 SUBMISSION_URL=http://localhost:3002 diff --git a/jest.setup.cjs b/jest.setup.cjs index caf72de33..d3c1ccc8c 100644 --- a/jest.setup.cjs +++ b/jest.setup.cjs @@ -1,4 +1,3 @@ -process.env.MANAGER_URL = 'http://localhost:3001' process.env.REDIS_HOST = 'dummy' process.env.REDIS_KEY_PREFIX = 'forms-designer' process.env.REDIS_PASSWORD = 'dummy' diff --git a/package.json b/package.json index 0f2ab4ee0..0f113311e 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "./file-form-service.js": "./.server/server/utils/file-form-service.js", "./controllers/*": "./.server/server/plugins/engine/pageControllers/*", "./services/*": "./.server/server/plugins/engine/services/*", + "./engine/*": "./.server/server/plugins/engine/*", "./package.json": "./package.json" }, "scripts": { diff --git a/src/client/stylesheets/application.scss b/src/client/stylesheets/application.scss index b406a0855..45e41c5b1 100644 --- a/src/client/stylesheets/application.scss +++ b/src/client/stylesheets/application.scss @@ -12,3 +12,13 @@ .autocomplete__option { @include govuk-typography-common; } + +// An example of some user-supplied styling +// Not great practice but it illustrates the point +.govuk-header { + background: #008531; +} + +.govuk-header__container { + border-bottom: 10px solid #003d16; +} diff --git a/src/common/cookies.js b/src/common/cookies.js deleted file mode 100644 index c8f52dfa4..000000000 --- a/src/common/cookies.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - @type {CookieConsent} - */ -export const defaultConsent = { - analytics: null, - dismissed: false -} - -/** - * Parses the cookie consent policy - * @param {string} value - */ -export function parseCookieConsent(value) { - /** @type {CookieConsent} */ - let cookieConsent - - try { - const encodedValue = decodeURIComponent(value) - - // eslint-disable-next-line -- Allow JSON type 'any' - const decodedValue = JSON.parse(encodedValue) - - if (isValidConsent(decodedValue)) { - cookieConsent = decodedValue - } else { - cookieConsent = defaultConsent - } - } catch { - cookieConsent = defaultConsent - } - - return cookieConsent -} - -/** - * Serialises the cookie consent policy - * @param {CookieConsent} consent - * @returns {string} cookie value - */ -export function serialiseCookieConsent(consent) { - return encodeURIComponent(JSON.stringify(consent)) -} - -/** - * @param {unknown} consent - * @returns {consent is CookieConsent} - */ -function isValidConsent(consent) { - if (consent === null || Array.isArray(consent)) { - return false - } - - return typeof consent === 'object' && 'analytics' in consent -} - -/** - * @import {CookieConsent} from '~/src/common/types.js' - */ diff --git a/src/common/cookies.test.js b/src/common/cookies.test.js deleted file mode 100644 index 8a7403c63..000000000 --- a/src/common/cookies.test.js +++ /dev/null @@ -1,23 +0,0 @@ -import { parseCookieConsent } from '~/src/common/cookies.js' - -describe('cookies', () => { - it('parses a valid policy', () => { - expect(parseCookieConsent('{"analytics":true}')).toEqual({ - analytics: true - }) - }) - - it.each([ - "['not', 'an', 'object']", - '{{ not: "an object" }}', - '{ additional: AAA }', - '{ marketing: 100 }', - '', - 'null' - ])('converts a malformed policy to the default', (value) => { - expect(parseCookieConsent(value)).toEqual({ - analytics: null, - dismissed: false - }) - }) -}) diff --git a/src/common/types.js b/src/common/types.js deleted file mode 100644 index 9200ad2d0..000000000 --- a/src/common/types.js +++ /dev/null @@ -1,5 +0,0 @@ -/** - * @typedef CookieConsent - * @property {boolean | null} analytics - whether analytics cookies are allowed - * @property {boolean} dismissed - whether cookie banner has been dismissed - */ diff --git a/src/config/index.ts b/src/config/index.ts index 7bd0e277f..efdcb2c1b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -179,12 +179,6 @@ export const config = convict({ /** * API integrations */ - managerUrl: { - format: String, - default: 'http://localhost:3001', - env: 'MANAGER_URL' - } as SchemaObj, - designerUrl: { format: String, default: 'http://localhost:3000', @@ -253,19 +247,12 @@ export const config = convict({ env: 'STAGING_PREFIX' }, - serviceBannerText: { - doc: 'Service banner text used to show a maintenance message on all pages when set', - format: String, - default: '', - env: 'SERVICE_BANNER_TEXT' - }, - - googleAnalyticsTrackingId: { - doc: 'Google analytics tracking ID to be used when a user has opted in to additional cookies', + submissionEmailAddress: { + doc: 'Email address to send the form to (local devtool only)', format: String, default: '', - env: 'GOOGLE_ANALYTICS_TRACKING_ID' - } + env: 'SUBMISSION_EMAIL_ADDRESS' + } as SchemaObj }) config.validate({ allowed: 'strict' }) diff --git a/src/server/devserver/dxt-devtool-baselayout.html b/src/server/devserver/dxt-devtool-baselayout.html new file mode 100644 index 000000000..382a655bd --- /dev/null +++ b/src/server/devserver/dxt-devtool-baselayout.html @@ -0,0 +1,71 @@ +{% extends "govuk/template.njk" %} + +{% from "govuk/components/back-link/macro.njk" import govukBackLink -%} +{% from "govuk/components/footer/macro.njk" import govukFooter -%} +{% from "govuk/components/phase-banner/macro.njk" import govukPhaseBanner -%} +{% from "govuk/components/skip-link/macro.njk" import govukSkipLink -%} +{% from "govuk/macros/attributes.njk" import govukAttributes -%} +{% from "components/service-banner/macro.njk" import appServiceBanner -%} +{% from "components/tag-env/macro.njk" import appTagEnv -%} +{% from "govuk/components/cookie-banner/macro.njk" import govukCookieBanner -%} +{% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner -%} + +{% set productName %} + {{ appTagEnv({ env: "devtool" }) }} +{% endset %} + +{% block head %} + + + +{% endblock %} + +{% block pageTitle -%} + {{ "Error: " if errors | length }}{{ pageTitle | evaluate }} - {{ name if name else config.serviceName }} - GOV.UK +{%- endblock %} + +{% block skipLink %} + {{ govukSkipLink({ + href: '#main-content', + text: 'Skip to main content' + }) }} +{% endblock %} + +{% block header %} + {{ govukHeader({ + homepageUrl: currentPath if context.isForceAccess else "https://defra.github.io/forms-engine-plugin/", + containerClasses: "govuk-width-container", + productName: productName | safe | trim, + serviceName: "Digital Express Toolkit", + serviceUrl: currentPath if context.isForceAccess else serviceUrl + }) }} +{% endblock %} + +{% block beforeContent %} + {% if backLink %} + {{ govukBackLink(backLink) }} + {% endif %} +{% endblock %} + +{% block content %} +

Default page template

+{% endblock %} + +{% block bodyEnd %} + +{% endblock %} + +{% block footer %} + {% set meta = { + items: [ + { + href: 'https://defra.github.io/forms-engine-plugin/', + text: 'DXT documentation' + } + ] + } if slug %} + + {% if not context.isForceAccess %} + {{ govukFooter({ meta: meta }) }} + {% endif %} +{% endblock %} diff --git a/src/server/forms/README.md b/src/server/forms/README.md deleted file mode 100644 index 3453d74c3..000000000 --- a/src/server/forms/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Pre-configured Forms - -This folder holds pre-configured form definitions that can be loaded by the runner: - -```js -const server = await createServer({ - formFileName: 'example.js', - formFilePath: join(cwd(), 'server/forms'), -}) -``` diff --git a/src/server/forms/register-as-a-unicorn-breeder.json b/src/server/forms/register-as-a-unicorn-breeder.json new file mode 100644 index 000000000..9f9d92fe6 --- /dev/null +++ b/src/server/forms/register-as-a-unicorn-breeder.json @@ -0,0 +1,393 @@ +{ + "name": "Register as a unicorn breeder", + "pages": [ + { + "path": "/whats-your-name", + "title": "What's your name?", + "components": [ + { + "type": "TextField", + "name": "textField", + "title": "Name", + "hint": "This is a single line text box. We use it to ask for information that's likely to be 1 sentence", + "options": { + "required": true + }, + "schema": {} + } + ], + "next": [ + { + "path": "/whats-your-email-address" + } + ], + "section": "section" + }, + { + "title": "Summary", + "path": "/summary", + "controller": "./pages/summary.js", + "components": [], + "next": [] + }, + { + "path": "/whats-your-email-address", + "title": "What's your email address?", + "components": [ + { + "name": "MaTzaT", + "options": { + "required": true + }, + "type": "EmailAddressField", + "title": "Email adress", + "schema": {}, + "hint": "This is an email address. An email address must contain an at sign @" + } + ], + "next": [ + { + "path": "/whats-your-phone-number" + } + ], + "section": "section" + }, + { + "path": "/whats-your-phone-number", + "title": "What's your phone number?", + "components": [ + { + "name": "BdKgCe", + "options": { + "required": true + }, + "type": "TelephoneNumberField", + "title": "Phone number", + "schema": {}, + "hint": "This is a telephone number. This field can only contain numbers and the + symbol" + } + ], + "next": [ + { + "path": "/whats-your-address" + } + ], + "section": "section" + }, + { + "path": "/whats-your-address", + "title": "What's your address?", + "components": [ + { + "name": "wZLWPy", + "options": { + "required": true + }, + "type": "UkAddressField", + "title": "Address", + "schema": {}, + "hint": "This is a UK address. Users must enter address line 1, town and a postcode" + } + ], + "next": [ + { + "path": "/do-you-want-your-unicorn-breeder-certificate-sent-to-this-address" + } + ], + "section": "section" + }, + { + "path": "/do-you-want-your-unicorn-breeder-certificate-sent-to-this-address", + "title": "Do you want your unicorn breeder certificate sent to this address?", + "components": [ + { + "name": "dBfuID", + "options": {}, + "type": "YesNoField", + "title": "Send certificate to same address", + "schema": {}, + "hint": "This is a yes or no question. We can branch to different questions based on the answer", + "values": { + "type": "listRef" + } + } + ], + "next": [ + { + "path": "/what-address-do-you-want-the-certificate-sent-to", + "condition": "oyGPwP" + }, + { + "path": "/when-does-your-unicorn-insurance-policy-start", + "condition": "" + } + ], + "section": "section" + }, + { + "path": "/what-address-do-you-want-the-certificate-sent-to", + "title": "What address do you want the certificate sent to?", + "components": [ + { + "name": "AegFro", + "options": {}, + "type": "UkAddressField", + "title": "Address to send certificate", + "schema": {}, + "hint": "This is a simple branch to an extra question - it's shown to users who select 'no' when asked if this is the address where the certificate should be sent" + } + ], + "next": [ + { + "path": "/when-does-your-unicorn-insurance-policy-start" + } + ], + "section": "section" + }, + { + "title": "When does your unicorn insurance policy start?", + "path": "/when-does-your-unicorn-insurance-policy-start", + "section": "Regnsa", + "next": [ + { + "path": "/upload-your-insurance-certificate" + } + ], + "components": [ + { + "name": "mjAccr", + "options": {}, + "type": "DatePartsField", + "title": "Unicorn insurance policy start date", + "schema": {}, + "hint": "This is a date. We can add custom validation to the field based on your requirements. For example, the date entered must be before or after a certain date" + } + ] + }, + { + "title": "How many unicorns do you expect to breed each year?", + "path": "/how-many-unicorns-do-you-expect-to-breed-each-year", + "section": "susaYr", + "next": [ + { + "path": "/what-type-of-unicorns-will-you-breed" + } + ], + "components": [ + { + "name": "aitzzV", + "options": {}, + "type": "RadiosField", + "list": "IeFOkf", + "title": "Number of unicorns", + "schema": {}, + "hint": "This is a radio button. Users can only select one option from the list", + "values": { + "type": "listRef" + } + } + ] + }, + { + "title": "What type of unicorns will you breed?", + "path": "/what-type-of-unicorns-will-you-breed", + "section": "susaYr", + "next": [ + { + "path": "/where-will-you-keep-the-unicorns" + } + ], + "components": [ + { + "name": "DyfjJC", + "options": {}, + "type": "CheckboxesField", + "list": "fXiZrL", + "title": "Type of unicorn", + "schema": {}, + "hint": "This is a check box. Users can select more than one option", + "values": { + "type": "listRef" + } + } + ] + }, + { + "title": "Where will you keep the unicorns?", + "path": "/where-will-you-keep-the-unicorns", + "section": "susaYr", + "next": [ + { + "path": "/how-many-members-of-staff-will-look-after-the-unicorns" + } + ], + "components": [ + { + "name": "bClCvo", + "options": {}, + "schema": {}, + "type": "MultilineTextField", + "title": "Where you keep the unicorn", + "hint": "This is a multi-line text box. We use it when you expect the response to be more than 1 sentence long" + } + ] + }, + { + "title": "How many members of staff will look after the unicorns?", + "path": "/how-many-members-of-staff-will-look-after-the-unicorns", + "section": "susaYr", + "next": [ + { + "path": "/summary" + } + ], + "components": [ + { + "name": "zhJMaM", + "options": { + "classes": "govuk-!-width-one-quarter" + }, + "type": "NumberField", + "title": "Number of staff", + "schema": {}, + "hint": "This is a number field. The answer must be a number. We can use custom validation to set decimal places, minimum and maximum values" + } + ] + }, + { + "title": "Upload your insurance certificate", + "path": "/upload-your-insurance-certificate", + "controller": "FileUploadPageController", + "section": "Regnsa", + "next": [ + { + "path": "/how-many-unicorns-do-you-expect-to-breed-each-year" + } + ], + "components": [ + { + "name": "dLzALM", + "title": "Documents", + "type": "FileUploadField", + "hint": "We can specify the format and number of uploaded files", + "options": { + "required": false + }, + "schema": { + "min": 1, + "max": 3 + } + } + ] + } + ], + "conditions": [ + { + "displayName": "Address is different", + "name": "IrVmYz", + "value": { + "name": "Address is different", + "conditions": [ + { + "field": { + "name": "dBfuID", + "type": "YesNoField", + "display": "Contact details: Send certificate to same address" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "false", + "display": "false" + } + } + ] + } + }, + { + "displayName": "Address is not the same", + "name": "oyGPwP", + "value": { + "name": "Address is not the same", + "conditions": [ + { + "field": { + "name": "dBfuID", + "type": "YesNoField", + "display": "Contact details: Send certificate to same address" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "false", + "display": "No" + } + } + ] + } + } + ], + "sections": [ + { + "name": "section", + "title": "Contact details", + "hideTitle": false + }, + { + "title": "Unicorn details", + "name": "susaYr", + "hideTitle": false + }, + { + "title": "Insurance details", + "name": "Regnsa", + "hideTitle": false + } + ], + "lists": [ + { + "title": "number of unicorns", + "name": "IeFOkf", + "type": "string", + "items": [ + { + "text": "1 to 5", + "value": "1 to 5" + }, + { + "text": "6 to 10", + "value": "6 to 10" + }, + { + "text": "11 or more", + "value": "11 or more" + } + ] + }, + { + "title": "Type of unicorn", + "name": "fXiZrL", + "type": "string", + "items": [ + { + "text": "Flying", + "value": "Flying" + }, + { + "text": "Fire breathing", + "value": "Fire breathing" + }, + { + "text": "Aquatic", + "value": "Aquatic" + }, + { + "text": "Rainbow", + "value": "Rainbow" + } + ] + } + ], + "outputEmail": "defraforms@defra.gov.uk", + "startPage": "/whats-your-name" +} diff --git a/src/server/forms/register-as-a-unicorn-breeder.yaml b/src/server/forms/register-as-a-unicorn-breeder.yaml new file mode 100644 index 000000000..345dd4930 --- /dev/null +++ b/src/server/forms/register-as-a-unicorn-breeder.yaml @@ -0,0 +1,251 @@ +--- +name: Register as a unicorn breeder +pages: + - path: '/whats-your-name' + title: What's your name? + components: + - type: TextField + name: textField + title: Name + hint: + This is a single line text box. We use it to ask for information that's + likely to be 1 sentence + options: + required: true + schema: {} + next: + - path: '/whats-your-email-address' + section: section + - title: Summary + path: '/summary' + controller: './pages/summary.js' + components: [] + next: [] + - path: '/whats-your-email-address' + title: What's your email address? + components: + - name: MaTzaT + options: + required: true + type: EmailAddressField + title: Email adress + schema: {} + hint: This is an email address. An email address must contain an at sign @ + next: + - path: '/whats-your-phone-number' + section: section + - path: '/whats-your-phone-number' + title: What's your phone number? + components: + - name: BdKgCe + options: + required: true + type: TelephoneNumberField + title: Phone number + schema: {} + hint: + This is a telephone number. This field can only contain numbers and the + + symbol + next: + - path: '/whats-your-address' + section: section + - path: '/whats-your-address' + title: What's your address? + components: + - name: wZLWPy + options: + required: true + type: UkAddressField + title: Address + schema: {} + hint: This is a UK address. Users must enter address line 1, town and a postcode + next: + - path: '/do-you-want-your-unicorn-breeder-certificate-sent-to-this-address' + section: section + - path: '/do-you-want-your-unicorn-breeder-certificate-sent-to-this-address' + title: Do you want your unicorn breeder certificate sent to this address? + components: + - name: dBfuID + options: {} + type: YesNoField + title: Send certificate to same address + schema: {} + hint: + This is a yes or no question. We can branch to different questions based + on the answer + values: + type: listRef + next: + - path: '/what-address-do-you-want-the-certificate-sent-to' + condition: oyGPwP + - path: '/when-does-your-unicorn-insurance-policy-start' + condition: '' + section: section + - path: '/what-address-do-you-want-the-certificate-sent-to' + title: What address do you want the certificate sent to? + components: + - name: AegFro + options: {} + type: UkAddressField + title: Address to send certificate + schema: {} + hint: + This is a simple branch to an extra question - it's shown to users who select + 'no' when asked if this is the address where the certificate should be sent + next: + - path: '/when-does-your-unicorn-insurance-policy-start' + section: section + - title: When does your unicorn insurance policy start? + path: '/when-does-your-unicorn-insurance-policy-start' + section: Regnsa + next: + - path: '/upload-your-insurance-certificate' + components: + - name: mjAccr + options: {} + type: DatePartsField + title: Unicorn insurance policy start date + schema: {} + hint: + This is a date. We can add custom validation to the field based on your + requirements. For example, the date entered must be before or after a certain + date + - title: How many unicorns do you expect to breed each year? + path: '/how-many-unicorns-do-you-expect-to-breed-each-year' + section: susaYr + next: + - path: '/what-type-of-unicorns-will-you-breed' + components: + - name: aitzzV + options: {} + type: RadiosField + list: IeFOkf + title: Number of unicorns + schema: {} + hint: This is a radio button. Users can only select one option from the list + values: + type: listRef + - title: What type of unicorns will you breed? + path: '/what-type-of-unicorns-will-you-breed' + section: susaYr + next: + - path: '/where-will-you-keep-the-unicorns' + components: + - name: DyfjJC + options: {} + type: CheckboxesField + list: fXiZrL + title: Type of unicorn + schema: {} + hint: This is a check box. Users can select more than one option + values: + type: listRef + - title: Where will you keep the unicorns? + path: '/where-will-you-keep-the-unicorns' + section: susaYr + next: + - path: '/how-many-members-of-staff-will-look-after-the-unicorns' + components: + - name: bClCvo + options: {} + schema: {} + type: MultilineTextField + title: Where you keep the unicorn + hint: + This is a multi-line text box. We use it when you expect the response to + be more than 1 sentence long + - title: How many members of staff will look after the unicorns? + path: '/how-many-members-of-staff-will-look-after-the-unicorns' + section: susaYr + next: + - path: '/summary' + components: + - name: zhJMaM + options: + classes: govuk-!-width-one-quarter + type: NumberField + title: Number of staff + schema: {} + hint: + This is a number field. The answer must be a number. We can use custom validation + to set decimal places, minimum and maximum values + - title: Upload your insurance certificate + path: '/upload-your-insurance-certificate' + controller: FileUploadPageController + section: Regnsa + next: + - path: '/how-many-unicorns-do-you-expect-to-breed-each-year' + components: + - name: dLzALM + title: Documents + type: FileUploadField + hint: We can specify the format and number of uploaded files + options: + required: false + schema: + min: 1 + max: 3 +conditions: + - displayName: Address is different + name: IrVmYz + value: + name: Address is different + conditions: + - field: + name: dBfuID + type: YesNoField + display: 'Contact details: Send certificate to same address' + operator: is + value: + type: Value + value: 'false' + display: 'false' + - displayName: Address is not the same + name: oyGPwP + value: + name: Address is not the same + conditions: + - field: + name: dBfuID + type: YesNoField + display: 'Contact details: Send certificate to same address' + operator: is + value: + type: Value + value: 'false' + display: 'No' +sections: + - name: section + title: Contact details + hideTitle: false + - title: Unicorn details + name: susaYr + hideTitle: false + - title: Insurance details + name: Regnsa + hideTitle: false +lists: + - title: number of unicorns + name: IeFOkf + type: string + items: + - text: 1 to 5 + value: 1 to 5 + - text: 6 to 10 + value: 6 to 10 + - text: 11 or more + value: 11 or more + - title: Type of unicorn + name: fXiZrL + type: string + items: + - text: Flying + value: Flying + - text: Fire breathing + value: Fire breathing + - text: Aquatic + value: Aquatic + - text: Rainbow + value: Rainbow +outputEmail: defraforms@defra.gov.uk +startPage: '/whats-your-name' diff --git a/src/server/forms/report-a-terrorist.json b/src/server/forms/report-a-terrorist.json deleted file mode 100644 index fcd694831..000000000 --- a/src/server/forms/report-a-terrorist.json +++ /dev/null @@ -1,270 +0,0 @@ -{ - "startPage": "/do-you-have-a-link-to-the-evidence", - "pages": [ - { - "title": "Do you have a link to the evidence?", - "path": "/do-you-have-a-link-to-the-evidence", - "components": [ - { - "name": "UjidZI", - "title": "Html", - "options": {}, - "type": "Html", - "content": "

It’s helpful if you can send us links to the relevant pages, or posts if it was posted on social media.

", - "schema": {} - }, - { - "name": "rfUYC", - "options": {}, - "type": "Details", - "title": "Help me find the link", - "content": "If you’re on a website, the link appears in the bar at the top of the page. An example of a link is, www.gov.uk/page/1234/content#.", - "schema": {} - }, - { - "type": "RadiosField", - "title": "Do you have a link to the material?", - "options": {}, - "name": "doyouhavealink", - "schema": {}, - "list": "HTbt4V" - } - ], - "next": [ - { - "path": "/do-you-have-any-evidence" - }, - { - "path": "/yes-i-have-a-link-to-the-material", - "condition": "b-NGgWvGISkJJLuzsJIjv" - } - ], - "section": "PMXq1s" - }, - { - "path": "/do-you-have-any-evidence", - "title": "Do you have any evidence?", - "components": [ - { - "name": "OQrrkG", - "title": "Html", - "options": {}, - "type": "Html", - "content": "

This could be an image or video, for example. Evidence is helpful should the material be deleted before we can find it.It’s safe to save evidence to your device for the purpose of reporting it to us. We recommend deleting it afterwards.

", - "schema": {} - }, - { - "name": "ajdOpV", - "options": {}, - "type": "Details", - "title": "Help me take a screenshot", - "content": "Try this:Press the Shift key (⇧), command (or Cmd), and 3The screenshot will be saved to your DesktopYou can now upload it to the formTry this:Press the Ctrl key and the switch window keyThe screenshot will be saved to your DownloadsYou can now upload it to the formIf that doesn’t work, try pressing Ctrl and F5.When viewing the material:Press the Prt Scr key (or similar) to take a copy of your screenPaste the image into Microsoft Paint or a similar applicationSave the file to your computerUpload the file to the formIf that doesn’t work, you may need to search for how to take screenshots on your particular computer model.", - "schema": {} - }, - { - "name": "LUBRMD", - "options": {}, - "type": "RadiosField", - "title": "Do you have any evidence?", - "schema": {}, - "list": "mdmRq9" - } - ], - "next": [ - { - "path": "/is-there-anything-else-you-can-tell-us" - }, - { - "path": "/yes-i-have-evidence", - "condition": "On5IOaSRDSyLs1G7-Dmdy" - } - ], - "section": "PMXq1s" - }, - { - "title": "summary", - "path": "/summary", - "controller": "SummaryPageController", - "components": [] - }, - { - "path": "/is-there-anything-else-you-can-tell-us", - "title": "Is there anything else you can tell us?", - "components": [ - { - "name": "HETMBo", - "title": "Html", - "options": {}, - "type": "Html", - "content": "

Details may include:who shared the materialwhen it was shareda description, if you haven’t provided a link or evidence

", - "schema": {} - }, - { - "name": "evZxIJ", - "options": { - "required": false - }, - "type": "MultilineTextField", - "title": "Additional Info", - "schema": {} - } - ], - "next": [ - { - "path": "/summary" - } - ], - "section": "PMXq1s" - }, - { - "path": "/yes-i-have-a-link-to-the-material", - "title": "Yes I have a link to the material", - "components": [ - { - "type": "MultilineTextField", - "title": "Link to the material", - "hint": "Please put in the link to the material here", - "name": "blarGGH", - "options": {}, - "schema": {} - } - ], - "next": [ - { - "path": "/do-you-have-any-evidence" - } - ], - "section": "PMXq1s" - }, - { - "path": "/yes-i-have-evidence", - "title": "Yes I have evidence", - "components": [ - { - "name": "koExae", - "options": { - "required": false - }, - "type": "MultilineTextField", - "title": "Evidence", - "hint": "Please enter your evidence here", - "schema": {} - } - ], - "next": [ - { - "path": "/is-there-anything-else-you-can-tell-us" - } - ], - "section": "PMXq1s" - } - ], - "lists": [ - { - "title": "linktomateriallist", - "name": "HTbt4V", - "type": "string", - "items": [ - { - "text": "Yes, I do have a link", - "value": "yes" - }, - { - "text": "No, I don't have a link", - "value": "no" - } - ] - }, - { - "title": "evidencelist", - "name": "mdmRq9", - "type": "string", - "items": [ - { - "text": "Yes, I have evidence", - "value": "yes" - }, - { - "text": "No, I don't have evidence", - "value": "no" - } - ] - } - ], - "sections": [ - { - "name": "PMXq1s", - "title": "Evidence" - } - ], - "phaseBanner": {}, - "metadata": {}, - "conditions": [ - { - "name": "b-NGgWvGISkJJLuzsJIjv", - "displayName": "hasLink", - "value": { - "name": "hasLink", - "conditions": [ - { - "field": { - "name": "PMXq1s.doyouhavealink", - "type": "RadiosField", - "display": "Do you have a link to the material? in PMXq1s" - }, - "operator": "is", - "value": { - "type": "Value", - "value": "yes", - "display": "Yes, I do have a link" - } - } - ] - } - }, - { - "name": "xY51EDbc4lPr6kHZl1umG", - "displayName": "noEvidence", - "value": { - "name": "noEvidence", - "conditions": [ - { - "field": { - "name": "PMXq1s.LUBRMD", - "type": "RadiosField", - "display": "Do you have any evidence? in PMXq1s" - }, - "operator": "is", - "value": { - "type": "Value", - "value": "no", - "display": "No, I don't have evidence" - } - } - ] - } - }, - { - "name": "On5IOaSRDSyLs1G7-Dmdy", - "displayName": "hasEvidence", - "value": { - "name": "hasEvidence", - "conditions": [ - { - "field": { - "name": "PMXq1s.LUBRMD", - "type": "RadiosField", - "display": "Do you have any evidence? in PMXq1s" - }, - "operator": "is", - "value": { - "type": "Value", - "value": "yes", - "display": "Yes, I have evidence" - } - } - ] - } - } - ] -} diff --git a/src/server/forms/runner-components-test.json b/src/server/forms/runner-components-test.json deleted file mode 100644 index 763cd37a8..000000000 --- a/src/server/forms/runner-components-test.json +++ /dev/null @@ -1,365 +0,0 @@ -{ - "startPage": "/do-you-own-a-vehicle", - "pages": [ - { - "title": "Do you own a vehicle?", - "path": "/do-you-own-a-vehicle", - "components": [ - { - "name": "qqbRwx", - "options": {}, - "type": "YesNoField", - "title": "Do you own a vehicle?", - "schema": {} - }, - { - "name": "LdOljB", - "options": {}, - "type": "InsetText", - "title": "Inset text", - "content": "It does not matter what you pick as it will redirect you to the same page", - "schema": {} - } - ], - "next": [ - { - "path": "/what-address-is-the-vehicle-registered-to" - } - ] - }, - { - "path": "/what-address-is-the-vehicle-registered-to", - "title": "What address is the vehicle registered to?", - "components": [ - { - "name": "sFRxaX", - "options": {}, - "type": "UkAddressField", - "title": "What address is the vehicle registered to?", - "schema": {} - }, - { - "name": "tVlnZa", - "options": {}, - "type": "DatePartsField", - "title": "What date was the vehicle registered at this address?", - "schema": {} - }, - { - "name": "ZxGuyn", - "options": {}, - "type": "CheckboxesField", - "title": "Which Clean Air Zones are you claiming an exemption for?", - "list": "O2uEB1", - "hint": "Please check all that apply, however you are restricted to a maximum of two choices.", - "schema": {} - } - ], - "next": [ - { - "path": "/details-about-your-vehicle" - } - ] - }, - { - "path": "/clean-air-zone-caz-exemption", - "title": "Clean Air Zone (CAZ) Exemption", - "components": [ - { - "name": "MOBxxt", - "title": "Html", - "options": {}, - "type": "Html", - "content": "

How to check if you're exempt from paying a charge and how to create a business account, and what support or exemptions are available.

", - "schema": {} - } - ], - "next": [ - { - "path": "/do-you-own-a-vehicle" - } - ], - "controller": "StartPageController" - }, - { - "path": "/details-about-your-vehicle", - "title": "Details about your vehicle", - "components": [ - { - "name": "xZVmNx", - "options": {}, - "type": "AutocompleteField", - "title": "What is the make of you vehicle?", - "list": "-HMHHj", - "schema": {} - }, - { - "name": "gHSgox", - "options": {}, - "type": "TextField", - "title": "Vehicle Model", - "hint": "For example A1, 740, Elantra", - "schema": {} - }, - { - "name": "xLZxto", - "options": {}, - "type": "DatePartsField", - "title": "Date you purchased the vehicle?", - "schema": {} - }, - { - "type": "RadiosField", - "title": "What fuel type does your vehicle use?", - "list": "sm_ssM", - "name": "fsfsdfsdf", - "nameHasError": false, - "options": {}, - "schema": {} - }, - { - "name": "chYCuk", - "options": {}, - "type": "MultilineTextField", - "title": "Has the vehicle been modified in any way?", - "hint": "Failure to declare modifications will result in severe penalties if not disclosed at this point.", - "schema": {} - } - ], - "next": [ - { - "path": "/driver-details" - } - ] - }, - { - "path": "/driver-details", - "title": "Driver details", - "components": [ - { - "name": "wJzPKE", - "options": {}, - "type": "NumberField", - "title": "How many people in your household drive this vehicle?", - "schema": {} - }, - { - "name": "PNIThU", - "options": {}, - "type": "TextField", - "title": "Full name of the main driver", - "hint": "Please exclude your title", - "schema": {} - }, - { - "name": "xzLxbB", - "options": {}, - "type": "TelephoneNumberField", - "title": "Contact number", - "hint": "Landline or mobile", - "schema": {} - } - ], - "next": [ - { - "path": "/final-steps" - } - ] - }, - { - "path": "/final-steps", - "title": "final steps", - "components": [ - { - "name": "fkdxav", - "options": {}, - "type": "List", - "title": "Declaration", - "list": "mJHWaC", - "schema": {} - }, - { - "name": "LxxAYe", - "options": {}, - "type": "EmailAddressField", - "title": "Your email address", - "hint": "We will send confirmation of your application to the provided email address", - "schema": {} - } - ], - "next": [ - { - "path": "/summary" - } - ] - }, - { - "path": "/summary", - "title": "Summary", - "components": [], - "next": [], - "controller": "SummaryPageController" - } - ], - "lists": [ - { - "title": "Vehicle Type", - "name": "ckrDmV", - "type": "number", - "items": [ - { - "text": "Car", - "value": 1 - }, - { - "text": "4 x 4/SUV", - "value": 2 - }, - { - "text": "Van", - "value": 3 - }, - { - "text": "Motorbike/Moped", - "value": 4 - } - ] - }, - { - "title": "Vehicle Make", - "name": "-HMHHj", - "type": "string", - "items": [ - { - "text": "Alfa Romeo", - "value": "alfa-romeo" - }, - { - "text": "BMW", - "value": "bmw" - }, - { - "text": "Ford", - "value": "ford" - }, - { - "text": "Citroen", - "value": "citroen" - }, - { - "text": "Nissan", - "value": "nissan" - }, - { - "text": "Honda", - "value": "honda" - }, - { - "text": "Mercedes", - "value": "mercedes" - }, - { - "text": "Audi", - "value": "audi" - }, - { - "text": "Toyota", - "value": "toyota" - }, - { - "text": "Hyundai", - "value": "hyundai" - }, - { - "text": "Kia", - "value": "kia" - } - ] - }, - { - "title": "Fuel types", - "name": "sm_ssM", - "type": "string", - "items": [ - { - "text": "Diesel", - "value": "diesel" - }, - { - "text": "Electric", - "value": "electric" - }, - { - "text": "Hydrogen", - "value": "hyrogen" - }, - { - "text": "Petrol", - "value": "petrol" - }, - { - "text": "Hybrid", - "value": "hybrid" - } - ] - }, - { - "title": "CAZ Location", - "name": "O2uEB1", - "type": "string", - "items": [ - { - "text": "Bath", - "value": "bath" - }, - { - "text": "Bristol", - "value": "bristol" - }, - { - "text": "Birmingham", - "value": "birmingham" - }, - { - "text": "Cardiff", - "value": "cardiff" - }, - { - "text": "Liverpool", - "value": "liverpool" - }, - { - "text": "Leeds", - "value": "leeds" - }, - { - "text": "Manchester", - "value": "manchester" - } - ] - }, - { - "title": "Declaration", - "name": "mJHWaC", - "type": "string", - "items": [ - { - "text": "You are not a Robot", - "value": "not-robot" - }, - { - "text": "You have not previously claimed an exemption", - "value": "not-claimed-exemption" - }, - { - "text": "You have not omitted or purposely witheld information that might be detrimental to you claim", - "value": "not-a-liar" - } - ] - } - ], - "sections": [], - "phaseBanner": {}, - "metadata": {}, - "conditions": [] -} diff --git a/src/server/forms/test.json b/src/server/forms/test.json deleted file mode 100644 index 6eac7f283..000000000 --- a/src/server/forms/test.json +++ /dev/null @@ -1,581 +0,0 @@ -{ - "startPage": "/start", - "pages": [ - { - "title": "Start", - "path": "/start", - "components": [], - "next": [ - { - "path": "/uk-passport" - } - ], - "controller": "StartPageController" - }, - { - "path": "/uk-passport", - "components": [ - { - "type": "YesNoField", - "name": "ukPassport", - "title": "Do you have a UK passport?", - "options": { - "required": true - }, - "schema": {} - } - ], - "section": "checkBeforeYouStart", - "next": [ - { - "path": "/how-many-people" - }, - { - "path": "/no-uk-passport", - "condition": "doesntHaveUKPassport" - } - ], - "title": "Do you have a UK passport?" - }, - { - "path": "/no-uk-passport", - "title": "You're not eligible for this service", - "components": [ - { - "type": "Html", - "name": "html", - "title": "Html", - "content": "

If you still think you're eligible please contact the Foreign and Commonwealth Office.

", - "options": {}, - "schema": {} - } - ], - "next": [] - }, - { - "path": "/how-many-people", - "section": "applicantDetails", - "components": [ - { - "options": { - "classes": "govuk-input--width-10", - "required": true - }, - "type": "SelectField", - "name": "numberOfApplicants", - "title": "How many applicants are there?", - "list": "numberOfApplicants" - } - ], - "next": [ - { - "path": "/applicant-one" - } - ], - "title": "How many applicants are there?" - }, - { - "path": "/applicant-one", - "title": "Applicant 1", - "section": "applicantOneDetails", - "components": [ - { - "type": "Html", - "name": "html", - "title": "Html", - "content": "

Provide the details as they appear on your passport.

", - "options": {}, - "schema": {} - }, - { - "type": "TextField", - "name": "firstName", - "title": "First name", - "options": { - "required": true - }, - "schema": {} - }, - { - "options": { - "required": false, - "optionalText": false - }, - "type": "TextField", - "name": "middleName", - "title": "Middle name", - "hint": "If you have a middle name on your passport you must include it here", - "schema": {} - }, - { - "type": "TextField", - "name": "lastName", - "title": "Surname", - "options": { - "required": true - }, - "schema": {} - } - ], - "next": [ - { - "path": "/applicant-one-address" - } - ] - }, - { - "path": "/applicant-one-address", - "section": "applicantOneDetails", - "components": [ - { - "type": "UkAddressField", - "name": "address", - "title": "Address", - "options": { - "required": true - }, - "schema": {} - } - ], - "next": [ - { - "path": "/applicant-two", - "condition": "moreThanOneApplicant" - }, - { - "path": "/contact-details" - } - ], - "title": "Address" - }, - { - "path": "/applicant-two", - "title": "Applicant 2", - "section": "applicantTwoDetails", - "components": [ - { - "type": "Html", - "name": "html", - "title": "Html", - "content": "

Provide the details as they appear on your passport.

", - "options": {}, - "schema": {} - }, - { - "type": "TextField", - "name": "firstName", - "title": "First name", - "options": { - "required": true - }, - "schema": {} - }, - { - "options": { - "required": false, - "optionalText": false - }, - "type": "TextField", - "name": "middleName", - "title": "Middle name", - "hint": "If you have a middle name on your passport you must include it here", - "schema": {} - }, - { - "type": "TextField", - "name": "lastName", - "title": "Surname", - "options": { - "required": true - }, - "schema": {} - } - ], - "next": [ - { - "path": "/applicant-two-address" - } - ] - }, - { - "path": "/applicant-two-address", - "section": "applicantTwoDetails", - "components": [ - { - "type": "UkAddressField", - "name": "address", - "title": "Address", - "options": { - "required": true - }, - "schema": {} - } - ], - "next": [ - { - "path": "/applicant-three", - "condition": "moreThanTwoApplicants" - }, - { - "path": "/contact-details" - } - ], - "title": "Address" - }, - { - "path": "/applicant-three", - "title": "Applicant 3", - "section": "applicantThreeDetails", - "components": [ - { - "type": "Html", - "name": "html", - "title": "Html", - "content": "

Provide the details as they appear on your passport.

", - "options": {}, - "schema": {} - }, - { - "type": "TextField", - "name": "firstName", - "title": "First name", - "options": { - "required": true - }, - "schema": {} - }, - { - "options": { - "required": false, - "optionalText": false - }, - "type": "TextField", - "name": "middleName", - "title": "Middle name", - "hint": "If you have a middle name on your passport you must include it here", - "schema": {} - }, - { - "type": "TextField", - "name": "lastName", - "title": "Surname", - "options": { - "required": true - }, - "schema": {} - } - ], - "next": [ - { - "path": "/applicant-three-address" - } - ] - }, - { - "path": "/applicant-three-address", - "section": "applicantThreeDetails", - "components": [ - { - "type": "UkAddressField", - "name": "address", - "title": "Address", - "options": { - "required": true - }, - "schema": {} - } - ], - "next": [ - { - "path": "/applicant-four", - "condition": "moreThanThreeApplicants" - }, - { - "path": "/contact-details" - } - ], - "title": "Address" - }, - { - "path": "/applicant-four", - "title": "Applicant 4", - "section": "applicantFourDetails", - "components": [ - { - "type": "Html", - "name": "html", - "title": "Html", - "content": "

Provide the details as they appear on your passport.

", - "options": {}, - "schema": {} - }, - { - "type": "TextField", - "name": "firstName", - "title": "First name", - "options": { - "required": true - }, - "schema": {} - }, - { - "options": { - "required": false, - "optionalText": false - }, - "type": "TextField", - "name": "middleName", - "title": "Middle name", - "hint": "If you have a middle name on your passport you must include it here", - "schema": {} - }, - { - "type": "TextField", - "name": "lastName", - "title": "Surname", - "options": { - "required": true - }, - "schema": {} - } - ], - "next": [ - { - "path": "/applicant-four-address" - } - ] - }, - { - "path": "/applicant-four-address", - "section": "applicantFourDetails", - "components": [ - { - "type": "UkAddressField", - "name": "address", - "title": "Address", - "options": { - "required": true - }, - "schema": {} - } - ], - "next": [ - { - "path": "/contact-details" - } - ], - "title": "Address" - }, - { - "path": "/contact-details", - "section": "applicantDetails", - "components": [ - { - "type": "TelephoneNumberField", - "name": "phoneNumber", - "title": "Phone number", - "hint": "If you haven't got a UK phone number, include country code", - "options": { - "required": true - }, - "schema": {} - }, - { - "type": "EmailAddressField", - "name": "emailAddress", - "title": "Your email address", - "options": { - "required": true - }, - "schema": {} - } - ], - "next": [ - { - "path": "/summary" - } - ], - "title": "Applicant contact details" - }, - { - "path": "/summary", - "controller": "SummaryPageController", - "title": "Summary", - "components": [], - "next": [] - } - ], - "lists": [ - { - "name": "numberOfApplicants", - "title": "Number of people", - "type": "number", - "items": [ - { - "text": "1", - "value": 1, - "description": "", - "condition": "" - }, - { - "text": "2", - "value": 2, - "description": "", - "condition": "" - }, - { - "text": "3", - "value": 3, - "description": "", - "condition": "" - }, - { - "text": "4", - "value": 4, - "description": "", - "condition": "" - } - ] - } - ], - "sections": [ - { - "name": "checkBeforeYouStart", - "title": "Check before you start" - }, - { - "name": "applicantDetails", - "title": "Applicant details" - }, - { - "name": "applicantOneDetails", - "title": "Applicant 1" - }, - { - "name": "applicantTwoDetails", - "title": "Applicant 2" - }, - { - "name": "applicantThreeDetails", - "title": "Applicant 3" - }, - { - "name": "applicantFourDetails", - "title": "Applicant 4" - } - ], - "phaseBanner": {}, - "declaration": "

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

", - "conditions": [ - { - "name": "hasUKPassport", - "displayName": "hasUKPassport", - "value": { - "name": "hasUKPassport", - "conditions": [ - { - "field": { - "name": "checkBeforeYouStart.ukPassport", - "type": "YesNoField", - "display": "Do you have a UK passport?" - }, - "operator": "is", - "value": { - "type": "Value", - "value": "true", - "display": "true" - } - } - ] - } - }, - { - "name": "doesntHaveUKPassport", - "displayName": "doesntHaveUKPassport", - "value": { - "name": "doesntHaveUKPassport", - "conditions": [ - { - "field": { - "name": "checkBeforeYouStart.ukPassport", - "type": "YesNoField", - "display": "Do you have a UK passport?" - }, - "operator": "is", - "value": { - "type": "Value", - "value": "false", - "display": "false" - } - } - ] - } - }, - { - "name": "moreThanOneApplicant", - "displayName": "moreThanOneApplicant", - "value": { - "name": "moreThanOneApplicant", - "conditions": [ - { - "field": { - "name": "applicantDetails.numberOfApplicants", - "type": "SelectField", - "display": "How many applicants are there?" - }, - "operator": "is more than", - "value": { - "type": "Value", - "value": "1", - "display": "1" - } - } - ] - } - }, - { - "name": "moreThanTwoApplicants", - "displayName": "moreThanTwoApplicants", - "value": { - "name": "moreThanTwoApplicants", - "conditions": [ - { - "field": { - "name": "applicantDetails.numberOfApplicants", - "type": "SelectField", - "display": "How many applicants are there?" - }, - "operator": "is more than", - "value": { - "type": "Value", - "value": "2", - "display": "2" - } - } - ] - } - }, - { - "name": "moreThanThreeApplicants", - "displayName": "moreThanThreeApplicants", - "value": { - "name": "moreThanThreeApplicants", - "conditions": [ - { - "field": { - "name": "applicantDetails.numberOfApplicants", - "type": "SelectField", - "display": "How many applicants are there?" - }, - "operator": "is more than", - "value": { - "type": "Value", - "value": "3", - "display": "3" - } - } - ] - } - } - ] -} diff --git a/src/server/forms/test.yaml b/src/server/forms/test.yaml deleted file mode 100644 index 2f948b665..000000000 --- a/src/server/forms/test.yaml +++ /dev/null @@ -1,363 +0,0 @@ ---- -startPage: "/start" -pages: -- title: Start - path: "/start" - components: [] - next: - - path: "/uk-passport" - controller: StartPageController -- path: "/uk-passport" - components: - - type: YesNoField - name: ukPassport - title: Do you have a UK passport? - options: - required: true - schema: {} - section: checkBeforeYouStart - next: - - path: "/how-many-people" - - path: "/no-uk-passport" - condition: doesntHaveUKPassport - title: Do you have a UK passport? -- path: "/no-uk-passport" - title: You're not eligible for this service - components: - - type: Html - name: html - title: Html - content: >- -

- If you still think you're eligible please contact the Foreign and Commonwealth Office. -

- options: {} - schema: {} - next: [] -- path: "/how-many-people" - section: applicantDetails - components: - - options: - classes: govuk-input--width-10 - required: true - type: SelectField - name: numberOfApplicants - title: How many applicants are there? - list: numberOfApplicants - next: - - path: "/applicant-one" - title: How many applicants are there? -- path: "/applicant-one" - title: Applicant 1 - section: applicantOneDetails - components: - - type: Html - name: html - title: Html - content:

Provide the details as they appear on your passport.

- options: {} - schema: {} - - type: TextField - name: firstName - title: First name - options: - required: true - schema: {} - - options: - required: false - optionalText: false - type: TextField - name: middleName - title: Middle name - hint: If you have a middle name on your passport you must include it here - schema: {} - - type: TextField - name: lastName - title: Surname - options: - required: true - schema: {} - next: - - path: "/applicant-one-address" -- path: "/applicant-one-address" - section: applicantOneDetails - components: - - type: UkAddressField - name: address - title: Address - options: - required: true - schema: {} - next: - - path: "/applicant-two" - condition: moreThanOneApplicant - - path: "/contact-details" - title: Address -- path: "/applicant-two" - title: Applicant 2 - section: applicantTwoDetails - components: - - type: Html - name: html - title: Html - content:

Provide the details as they appear on your passport.

- options: {} - schema: {} - - type: TextField - name: firstName - title: First name - options: - required: true - schema: {} - - options: - required: false - optionalText: false - type: TextField - name: middleName - title: Middle name - hint: If you have a middle name on your passport you must include it here - schema: {} - - type: TextField - name: lastName - title: Surname - options: - required: true - schema: {} - next: - - path: "/applicant-two-address" -- path: "/applicant-two-address" - section: applicantTwoDetails - components: - - type: UkAddressField - name: address - title: Address - options: - required: true - schema: {} - next: - - path: "/applicant-three" - condition: moreThanTwoApplicants - - path: "/contact-details" - title: Address -- path: "/applicant-three" - title: Applicant 3 - section: applicantThreeDetails - components: - - type: Html - name: html - title: Html - content:

Provide the details as they appear on your passport.

- options: {} - schema: {} - - type: TextField - name: firstName - title: First name - options: - required: true - schema: {} - - options: - required: false - optionalText: false - type: TextField - name: middleName - title: Middle name - hint: If you have a middle name on your passport you must include it here - schema: {} - - type: TextField - name: lastName - title: Surname - options: - required: true - schema: {} - next: - - path: "/applicant-three-address" -- path: "/applicant-three-address" - section: applicantThreeDetails - components: - - type: UkAddressField - name: address - title: Address - options: - required: true - schema: {} - next: - - path: "/applicant-four" - condition: moreThanThreeApplicants - - path: "/contact-details" - title: Address -- path: "/applicant-four" - title: Applicant 4 - section: applicantFourDetails - components: - - type: Html - name: html - title: Html - content:

Provide the details as they appear on your passport.

- options: {} - schema: {} - - type: TextField - name: firstName - title: First name - options: - required: true - schema: {} - - options: - required: false - optionalText: false - type: TextField - name: middleName - title: Middle name - hint: If you have a middle name on your passport you must include it here - schema: {} - - type: TextField - name: lastName - title: Surname - options: - required: true - schema: {} - next: - - path: "/applicant-four-address" -- path: "/applicant-four-address" - section: applicantFourDetails - components: - - type: UkAddressField - name: address - title: Address - options: - required: true - schema: {} - next: - - path: "/contact-details" - title: Address -- path: "/contact-details" - section: applicantDetails - components: - - type: TelephoneNumberField - name: phoneNumber - title: Phone number - hint: If you haven't got a UK phone number, include country code - options: - required: true - schema: {} - - type: EmailAddressField - name: emailAddress - title: Your email address - options: - required: true - schema: {} - next: - - path: "/summary" - title: Applicant contact details -- path: "/summary" - controller: SummaryPageController - title: Summary - components: [] - next: [] -lists: -- name: numberOfApplicants - title: Number of people - type: number - items: - - text: '1' - value: 1 - description: '' - condition: '' - - text: '2' - value: 2 - description: '' - condition: '' - - text: '3' - value: 3 - description: '' - condition: '' - - text: '4' - value: 4 - description: '' - condition: '' -sections: -- name: checkBeforeYouStart - title: Check before you start -- name: applicantDetails - title: Applicant details -- name: applicantOneDetails - title: Applicant 1 -- name: applicantTwoDetails - title: Applicant 2 -- name: applicantThreeDetails - title: Applicant 3 -- name: applicantFourDetails - title: Applicant 4 -phaseBanner: {} -declaration:

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

-conditions: -- name: hasUKPassport - displayName: hasUKPassport - value: - name: hasUKPassport - conditions: - - field: - name: checkBeforeYouStart.ukPassport - type: YesNoField - display: Do you have a UK passport? - operator: is - value: - type: Value - value: 'true' - display: 'true' -- name: doesntHaveUKPassport - displayName: doesntHaveUKPassport - value: - name: doesntHaveUKPassport - conditions: - - field: - name: checkBeforeYouStart.ukPassport - type: YesNoField - display: Do you have a UK passport? - operator: is - value: - type: Value - value: 'false' - display: 'false' -- name: moreThanOneApplicant - displayName: moreThanOneApplicant - value: - name: moreThanOneApplicant - conditions: - - field: - name: applicantDetails.numberOfApplicants - type: SelectField - display: How many applicants are there? - operator: is more than - value: - type: Value - value: '1' - display: '1' -- name: moreThanTwoApplicants - displayName: moreThanTwoApplicants - value: - name: moreThanTwoApplicants - conditions: - - field: - name: applicantDetails.numberOfApplicants - type: SelectField - display: How many applicants are there? - operator: is more than - value: - type: Value - value: '2' - display: '2' -- name: moreThanThreeApplicants - displayName: moreThanThreeApplicants - value: - name: moreThanThreeApplicants - conditions: - - field: - name: applicantDetails.numberOfApplicants - type: SelectField - display: How many applicants are there? - operator: is more than - value: - type: Value - value: '3' - display: '3' diff --git a/src/server/index.test.ts b/src/server/index.test.ts index d4f108397..520a8b8c4 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -6,6 +6,7 @@ import { getFormDefinition, getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' +import * as defaultServices from '~/src/server/plugins/engine/services/index.js' import { getUploadStatus } from '~/src/server/plugins/engine/services/uploadService.js' import { FileStatus, @@ -30,7 +31,9 @@ describe('Model cache', () => { } beforeAll(async () => { - server = await createServer() + server = await createServer({ + services: defaultServices + }) await server.initialize() }) @@ -453,42 +456,6 @@ describe('Model cache', () => { expect(getCacheSize()).toBe(0) }) }) - - describe('Help pages', () => { - test('Contextual help page returns 200', async () => { - jest.mocked(getFormMetadata).mockResolvedValue({ - ...fixtures.form.metadata, - draft: fixtures.form.state, - live: fixtures.form.state - }) - - const options = { - method: 'GET', - url: '/help/get-support/slug' - } - - const res = await server.inject(options) - - expect(res.statusCode).toBe(StatusCodes.OK) - }) - - test('Privacy notice page returns 200', async () => { - jest.mocked(getFormMetadata).mockResolvedValue({ - ...fixtures.form.metadata, - draft: fixtures.form.state, - live: fixtures.form.state - }) - - const options = { - method: 'GET', - url: '/help/privacy/slug' - } - - const res = await server.inject(options) - - expect(res.statusCode).toBe(StatusCodes.OK) - }) - }) }) describe('Upload status route', () => { diff --git a/src/server/index.ts b/src/server/index.ts index 652fc2466..8c40298ce 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -15,14 +15,13 @@ import { config } from '~/src/config/index.js' import { requestLogger } from '~/src/server/common/helpers/logging/request-logger.js' import { requestTracing } from '~/src/server/common/helpers/logging/request-tracing.js' import { buildRedisClient } from '~/src/server/common/helpers/redis-client.js' -import { configureBlankiePlugin } from '~/src/server/plugins/blankie.js' import { configureCrumbPlugin } from '~/src/server/plugins/crumb.js' -import { configureEnginePlugin } from '~/src/server/plugins/engine/index.js' +import { configureEnginePlugin } from '~/src/server/plugins/engine/configureEnginePlugin.js' import pluginErrorPages from '~/src/server/plugins/errorPages.js' import { plugin as pluginViews } from '~/src/server/plugins/nunjucks/index.js' import pluginPulse from '~/src/server/plugins/pulse.js' -import pluginRouter from '~/src/server/plugins/router.js' import pluginSession from '~/src/server/plugins/session.js' +import { publicRoutes } from '~/src/server/routes/index.js' import { prepareSecureContext } from '~/src/server/secure-context.js' import { type RouteConfig } from '~/src/server/types.js' @@ -82,15 +81,13 @@ export async function createServer(routeConfig?: RouteConfig) { prepareSecureContext(server) } - const pluginEngine = await configureEnginePlugin(routeConfig) const pluginCrumb = configureCrumbPlugin(routeConfig) - const pluginBlankie = configureBlankiePlugin() + const pluginEngine = await configureEnginePlugin(routeConfig) await server.register(pluginSession) await server.register(pluginPulse) await server.register(inert) await server.register(Scooter) - await server.register(pluginBlankie) await server.register(pluginCrumb) server.ext('onPreResponse', (request: Request, h: ResponseToolkit) => { @@ -117,19 +114,19 @@ export async function createServer(routeConfig?: RouteConfig) { await server.register(pluginViews) await server.register(pluginEngine) - await server.register(pluginRouter) + + await server.register({ + plugin: { + name: 'router', + register: (server) => { + server.route(publicRoutes) + } + } + }) + await server.register(pluginErrorPages) await server.register(blipp) await server.register(requestTracing) - server.state('cookieConsent', { - ttl: 365 * 24 * 60 * 60 * 1000, // 1 year in ms - clearInvalid: true, - isHttpOnly: false, - isSecure: config.get('isProduction'), - path: '/', - encoding: 'none' // handle this inside the application so we can share frontend/backend cookie modification - }) - return server } diff --git a/src/server/plugins/blankie.test.ts b/src/server/plugins/blankie.test.ts deleted file mode 100644 index b62576d98..000000000 --- a/src/server/plugins/blankie.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { config } from '~/src/config/index.js' -import { configureBlankiePlugin } from '~/src/server/plugins/blankie.js' - -describe('Server Blankie Plugin', () => { - test('configuration default options are provided', () => { - config.set('googleAnalyticsTrackingId', '') - - const { options } = configureBlankiePlugin() - - expect(options).toEqual({ - defaultSrc: ['self'], - fontSrc: ['self', 'data:'], - frameSrc: ['self', 'data:'], - connectSrc: ['self', 'https://test-uploader.cdp-int.defra.cloud'], - scriptSrc: ['self', 'strict-dynamic', 'unsafe-inline'], - styleSrc: ['self', 'unsafe-inline'], - imgSrc: ['self'], - generateNonces: true - }) - }) - - test('configuration default and GA options are provided', () => { - config.set('googleAnalyticsTrackingId', '12345') - - const { options } = configureBlankiePlugin() - - expect(options).toEqual({ - defaultSrc: ['self'], - fontSrc: ['self', 'data:'], - frameSrc: ['self', 'data:'], - connectSrc: [ - 'self', - 'https://*.google-analytics.com', - 'https://*.analytics.google.com', - 'https://*.googletagmanager.com', - 'https://test-uploader.cdp-int.defra.cloud' - ], - scriptSrc: [ - 'self', - 'strict-dynamic', - 'unsafe-inline', - 'https://*.googletagmanager.com' - ], - styleSrc: ['self', 'unsafe-inline'], - imgSrc: [ - 'self', - 'https://*.google-analytics.com', - 'https://*.googletagmanager.com' - ], - generateNonces: true - }) - }) - - test('configuration includes uploaderUrl when provided', () => { - config.set('googleAnalyticsTrackingId', '') - config.set('uploaderUrl', 'https://some-random-uploader.example.com') - - const { options } = configureBlankiePlugin() - - expect(options?.connectSrc).toContain( - 'https://some-random-uploader.example.com' - ) - }) - - test('configuration does not include uploaderUrl when not provided', () => { - config.set('googleAnalyticsTrackingId', '') - config.set('uploaderUrl', '') - - const { options } = configureBlankiePlugin() - - expect(options?.connectSrc).toEqual(['self']) - }) -}) diff --git a/src/server/plugins/blankie.ts b/src/server/plugins/blankie.ts deleted file mode 100644 index b589ba534..000000000 --- a/src/server/plugins/blankie.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { type ServerRegisterPluginObject } from '@hapi/hapi' -import Blankie from 'blankie' - -import { config } from '~/src/config/index.js' - -const googleAnalyticsOptions = { - scriptSrc: ['https://*.googletagmanager.com'], - imgSrc: ['https://*.google-analytics.com', 'https://*.googletagmanager.com'], - connectSrc: [ - 'https://*.google-analytics.com', - 'https://*.analytics.google.com', - 'https://*.googletagmanager.com' - ] -} - -export const configureBlankiePlugin = (): ServerRegisterPluginObject< - Record -> => { - const gaTrackingId = config.get('googleAnalyticsTrackingId') - const uploaderUrl = config.get('uploaderUrl') - - /* - Note that unsafe-inline is a fallback for old browsers that don't support nonces. It will be ignored by modern browsers as the nonce is provided. - */ - return { - plugin: Blankie, - options: { - defaultSrc: ['self'], - fontSrc: ['self', 'data:'], - connectSrc: [ - ['self'], - gaTrackingId ? googleAnalyticsOptions.connectSrc : [], - uploaderUrl ? [uploaderUrl] : [] - ].flat(), - scriptSrc: [ - ['self', 'strict-dynamic', 'unsafe-inline'], - gaTrackingId ? googleAnalyticsOptions.scriptSrc : [] - ].flat(), - styleSrc: ['self', 'unsafe-inline'], - imgSrc: [ - ['self'], - gaTrackingId ? googleAnalyticsOptions.imgSrc : [] - ].flat(), - frameSrc: ['self', 'data:'], - generateNonces: true - } - } -} diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index c183f58ab..c4d08bbdc 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -8,6 +8,10 @@ import { plugin, type PluginOptions } from '~/src/server/plugins/engine/plugin.js' +import { findPackageRoot } from '~/src/server/plugins/engine/plugin.js' +import * as defaultServices from '~/src/server/plugins/engine/services/index.js' +import { formsService } from '~/src/server/plugins/engine/services/localFormsService.js' +import { devtoolContext } from '~/src/server/plugins/nunjucks/context.js' import { type RouteConfig } from '~/src/server/types.js' export const configureEnginePlugin = async ({ @@ -27,7 +31,21 @@ export const configureEnginePlugin = async ({ return { plugin, - options: { model, services, controllers } + options: { + model, + services: services ?? { + // services for testing, else use the disk loader option for running this service locally + ...defaultServices, + formsService: await formsService() + }, + controllers, + cacheName: 'session', + nunjucks: { + baseLayoutPath: 'dxt-devtool-baselayout.html', + paths: [join(findPackageRoot(), 'src/server/devserver')] // custom layout to make it really clear this is not the same as the runner + }, + viewContext: devtoolContext + } } } diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index ea634a19d..310dca477 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -1,3 +1,7 @@ +import { existsSync } from 'fs' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + import { hasFormComponents, slugSchema } from '@defra/forms-model' import Boom from '@hapi/boom' import { @@ -11,6 +15,7 @@ import vision from '@hapi/vision' import { isEqual } from 'date-fns' import Joi from 'joi' import nunjucks, { type Environment } from 'nunjucks' +import resolvePkg from 'resolve' import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' import { @@ -25,7 +30,6 @@ import { redirectPath } from '~/src/server/plugins/engine/helpers.js' import { - PLUGIN_PATH, VIEW_PATH, context, prepareNunjucksEnvironment @@ -65,6 +69,20 @@ import * as httpService from '~/src/server/services/httpService.js' import { CacheService } from '~/src/server/services/index.js' import { type Services } from '~/src/server/types.js' +export function findPackageRoot() { + const currentFileName = fileURLToPath(import.meta.url) + const currentDirectoryName = dirname(currentFileName) + + let dir = currentDirectoryName + while (dir !== '/') { + if (existsSync(join(dir, 'package.json'))) { + return dir + } + dir = dirname(dir) + } + + throw new Error('package.json not found in parent directories') +} export interface PluginOptions { model?: FormModel services?: Services @@ -73,6 +91,13 @@ export interface PluginOptions { viewPaths?: string[] filters?: Record pluginPath?: string + nunjucks: { + baseLayoutPath: string + paths: string[] + } + viewContext: ( + request: FormRequest | FormRequestPayload | null + ) => Record } export const plugin = { @@ -85,23 +110,25 @@ export const plugin = { services = defaultServices, controllers, cacheName, - viewPaths, filters, - pluginPath = PLUGIN_PATH + nunjucks: nunjucksOptions, + viewContext } = options const { formsService } = services const cacheService = new CacheService(server, cacheName) - // Paths array to tell `vision` and `nunjucks` where template files are stored. - // We need to include `VIEW_PATH` in addition the runtime path (node_modules) - // to keep the local tests working - const path = [`${pluginPath}/${VIEW_PATH}`, VIEW_PATH] + const packageRoot = findPackageRoot() + const govukFrontendPath = dirname( + resolvePkg.sync('govuk-frontend/package.json') + ) - // Include any additional user provided view paths so our internal views engine - // can find any files they provide from the consumer side if using custom `page.view`s - if (Array.isArray(viewPaths) && viewPaths.length) { - path.push(...viewPaths) - } + const viewPathResolved = join(packageRoot, VIEW_PATH) + + const paths = [ + ...nunjucksOptions.paths, + viewPathResolved, + join(govukFrontendPath, 'dist') + ] await server.register({ plugin: vision, @@ -127,10 +154,7 @@ export const plugin = { ) => { // Nunjucks also needs an additional path configuration // to use the templates and macros from `govuk-frontend` - const environment = nunjucks.configure([ - ...path, - 'node_modules/govuk-frontend/dist' - ]) + const environment = nunjucks.configure(paths) // Applies custom filters and globals for nunjucks // that are required by the `forms-engine-plugin` @@ -142,12 +166,14 @@ export const plugin = { } } }, - path, + path: paths, // Provides global context used with all templates context } }) + server.expose('baseLayoutPath', nunjucksOptions.baseLayoutPath) + server.expose('viewContext', viewContext) server.expose('cacheService', cacheService) server.app.model = model diff --git a/src/server/plugins/engine/services/formsService.js b/src/server/plugins/engine/services/formsService.js index 3e0ac0f1f..cb766daa7 100644 --- a/src/server/plugins/engine/services/formsService.js +++ b/src/server/plugins/engine/services/formsService.js @@ -1,46 +1,28 @@ -import { formMetadataSchema } from '@defra/forms-model' - -import { config } from '~/src/config/index.js' -import { FormStatus } from '~/src/server/routes/types.js' -import { getJson } from '~/src/server/services/httpService.js' +const error = Error( + 'Not implemented. Consider setting up a form loader or providing a service implementation.' +) +// eslint-disable-next-line jsdoc/require-returns-check /** - * Retrieves a form definition from the form manager for a given slug - * @param {string} slug - the slug of the form + * Dummy function to get form metadata. + * @param {string} _slug - the slug of the form + * @returns {Promise} */ -export async function getFormMetadata(slug) { - const getJsonByType = /** @type {typeof getJson} */ (getJson) - - const { payload: metadata } = await getJsonByType( - `${config.get('managerUrl')}/forms/slug/${slug}` - ) - - // Run it through the schema to coerce dates - const result = formMetadataSchema.validate(metadata) - - if (result.error) { - throw result.error - } - - return result.value +export function getFormMetadata(_slug) { + throw error } +// eslint-disable-next-line jsdoc/require-returns-check /** - * Retrieves a form definition from the form manager for a given id - * @param {string} id - the id of the form - * @param {FormStatus} state - the state of the form + * Dummy function to get form metadata. + * @param {string} _id - the id of the form + * @param {FormStatus} _state - the state of the form + * @returns {Promise} */ -export async function getFormDefinition(id, state) { - const getJsonByType = /** @type {typeof getJson} */ (getJson) - - const suffix = state === FormStatus.Draft ? `/${state}` : '' - const { payload: definition } = await getJsonByType( - `${config.get('managerUrl')}/forms/${id}/definition${suffix}` - ) - - return definition +export function getFormDefinition(_id, _state) { + throw error } /** - * @import { FormDefinition, FormMetadata } from '@defra/forms-model' + * @import { FormStatus, FormDefinition, FormMetadata } from '@defra/forms-model' */ diff --git a/src/server/plugins/engine/services/formsService.test.js b/src/server/plugins/engine/services/formsService.test.js deleted file mode 100644 index 2cc4b74e4..000000000 --- a/src/server/plugins/engine/services/formsService.test.js +++ /dev/null @@ -1,90 +0,0 @@ -import { StatusCodes } from 'http-status-codes' - -import { - getFormDefinition, - getFormMetadata -} from '~/src/server/plugins/engine/services/formsService.js' -import { FormStatus } from '~/src/server/routes/types.js' -import { getJson } from '~/src/server/services/httpService.js' -import * as fixtures from '~/test/fixtures/index.js' - -const { MANAGER_URL } = process.env - -jest.mock('~/src/server/services/httpService') - -describe('Forms service', () => { - const { definition, metadata } = fixtures.form - - describe('getFormMetadata', () => { - beforeEach(() => { - jest.mocked(getJson).mockResolvedValue({ - res: /** @type {IncomingMessage} */ ({ - statusCode: StatusCodes.OK - }), - payload: metadata - }) - }) - - it('requests JSON via form slug', async () => { - await getFormMetadata(metadata.slug) - - expect(getJson).toHaveBeenCalledWith( - `${MANAGER_URL}/forms/slug/${metadata.slug}` - ) - }) - - it('coerces timestamps from string to Date', async () => { - const payload = { - ...structuredClone(metadata), - - // JSON payload uses string dates in transit - createdAt: metadata.createdAt.toISOString(), - updatedAt: metadata.updatedAt.toISOString() - } - - jest.mocked(getJson).mockResolvedValue({ - res: /** @type {IncomingMessage} */ ({ - statusCode: StatusCodes.OK - }), - payload - }) - - await expect(getFormMetadata(metadata.slug)).resolves.toEqual({ - ...metadata, - createdAt: expect.any(Date), - updatedAt: expect.any(Date) - }) - }) - }) - - describe('getFormDefinition', () => { - beforeEach(() => { - jest.mocked(getJson).mockResolvedValue({ - res: /** @type {IncomingMessage} */ ({ - statusCode: StatusCodes.OK - }), - payload: definition - }) - }) - - it('requests JSON via form ID (draft)', async () => { - await getFormDefinition(metadata.id, FormStatus.Draft) - - expect(getJson).toHaveBeenCalledWith( - `${MANAGER_URL}/forms/${metadata.id}/definition/draft` - ) - }) - - it('requests JSON via form ID (live)', async () => { - await getFormDefinition(metadata.id, FormStatus.Live) - - expect(getJson).toHaveBeenCalledWith( - `${MANAGER_URL}/forms/${metadata.id}/definition` - ) - }) - }) -}) - -/** - * @import { IncomingMessage } from 'node:http' - */ diff --git a/src/server/plugins/engine/services/localFormsService.js b/src/server/plugins/engine/services/localFormsService.js new file mode 100644 index 000000000..bdbce67d1 --- /dev/null +++ b/src/server/plugins/engine/services/localFormsService.js @@ -0,0 +1,49 @@ +import { config } from '~/src/config/index.js' +import { FileFormService } from '~/src/server/utils/file-form-service.js' + +// Create shared form metadata +const now = new Date() +const user = { id: 'user', displayName: 'Username' } +const author = { + createdAt: now, + createdBy: user, + updatedAt: now, + updatedBy: user +} +const metadata = { + organisation: 'Defra', + teamName: 'Team name', + teamEmail: 'team@defra.gov.uk', + submissionGuidance: "Thanks for your submission, we'll be in touch", + notificationEmail: config.get('submissionEmailAddress'), + ...author, + live: author +} + +/** + * Return an function rather than the service directly. This is to prevent consumer applications + * blowing up as they won't have these files on disk. We can defer the execution until when it's + * needed, i.e. the createServer function of the devtool. + */ +export const formsService = async () => { + // Instantiate the file loader form service + const loader = new FileFormService() + + // Add a Json form + await loader.addForm('src/server/forms/register-as-a-unicorn-breeder.json', { + ...metadata, + id: '95e92559-968d-44ae-8666-2b1ad3dffd31', + title: 'Register as a unicorn breeder', + slug: 'register-as-a-unicorn-breeder' + }) + + // Add a Yaml form + await loader.addForm('src/server/forms/register-as-a-unicorn-breeder.yaml', { + ...metadata, + id: '641aeafd-13dd-40fa-9186-001703800efb', + title: 'Register as a unicorn breeder (yaml)', + slug: 'register-as-a-unicorn-breeder-yaml' // if we needed to validate any JSON logic, make it available for convenience + }) + + return loader.toFormsService() +} diff --git a/src/server/plugins/engine/views/confirmation.html b/src/server/plugins/engine/views/confirmation.html index e2975622e..7c9fbb1a7 100644 --- a/src/server/plugins/engine/views/confirmation.html +++ b/src/server/plugins/engine/views/confirmation.html @@ -1,4 +1,4 @@ -{% extends "layout.html" %} +{% extends baseLayoutPath %} {% from "govuk/components/panel/macro.njk" import govukPanel %} diff --git a/src/server/plugins/engine/views/file-upload.html b/src/server/plugins/engine/views/file-upload.html index 88dd43bdc..fd731f9e9 100644 --- a/src/server/plugins/engine/views/file-upload.html +++ b/src/server/plugins/engine/views/file-upload.html @@ -35,7 +35,7 @@ {% block bodyEnd %} {{ super() }} - - {% if config.googleAnalyticsTrackingId and cookieConsent.analytics === true %} - - -{% endif %} -{% endblock %} - -{% block footer %} - {% set meta = { - items: [ - { - href: '/help/get-support/' + slug, - text: 'Get help with this form' - }, - { - href: '/help/privacy/' + slug, - text: 'Privacy' - }, - { - href: '/help/cookies/' + slug, - text: 'Cookies' - }, - { - href: '/help/accessibility-statement/' + slug, - text: 'Accessibility Statement' - } - ] - } if slug %} - - {% if not context.isForceAccess %} - {{ govukFooter({ meta: meta }) }} - {% endif %} -{% endblock %} diff --git a/src/server/plugins/engine/views/repeat-list-summary.html b/src/server/plugins/engine/views/repeat-list-summary.html index bdbd57039..53eacbdbf 100644 --- a/src/server/plugins/engine/views/repeat-list-summary.html +++ b/src/server/plugins/engine/views/repeat-list-summary.html @@ -1,4 +1,4 @@ -{% extends "layout.html" %} +{% extends baseLayoutPath %} {% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} {% from "govuk/components/button/macro.njk" import govukButton %} diff --git a/src/server/plugins/engine/views/summary.html b/src/server/plugins/engine/views/summary.html index 014adfce9..30c4e96dc 100644 --- a/src/server/plugins/engine/views/summary.html +++ b/src/server/plugins/engine/views/summary.html @@ -1,4 +1,4 @@ -{% extends "layout.html" %} +{% extends baseLayoutPath %} {% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} {% from "govuk/components/summary-list/macro.njk" import govukSummaryList %} diff --git a/src/server/plugins/errorPages.ts b/src/server/plugins/errorPages.ts index 4718c62a9..ae41b80c0 100644 --- a/src/server/plugins/errorPages.ts +++ b/src/server/plugins/errorPages.ts @@ -3,7 +3,6 @@ import { type ResponseToolkit, type ServerRegisterPluginObject } from '@hapi/hapi' -import { StatusCodes } from 'http-status-codes' /* * Add an `onPreResponse` listener to return error pages @@ -20,36 +19,15 @@ export default { // processing the request const statusCode = response.output.statusCode - // Check for a form model on the request - // and use it to set the correct service name - // and start page path. In the event of a error - // happening inside a "form" level request, the header - // then displays the contextual form text and href - const model = request.app.model - const viewModel = model - ? { - name: model.name, - serviceUrl: `/${model.basePath}` - } - : undefined - - // In the event of 404 - // return the `404` view - if (statusCode === StatusCodes.NOT_FOUND.valueOf()) { - return h.view('404', viewModel).code(statusCode) - } - - request.log('error', { + const error = { statusCode, - data: response.data, message: response.message, stack: response.stack - }) + } - request.logger.error(response.stack) + request.log('error', error) - // The return the `500` view - return h.view('500', viewModel).code(statusCode) + return h.response(error).code(statusCode) } return h.continue }) diff --git a/src/server/plugins/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index dddf63255..bd8b4d29d 100644 --- a/src/server/plugins/nunjucks/context.js +++ b/src/server/plugins/nunjucks/context.js @@ -5,7 +5,6 @@ import Boom from '@hapi/boom' import { StatusCodes } from 'http-status-codes' import pkg from '~/package.json' with { type: 'json' } -import { parseCookieConsent } from '~/src/common/cookies.js' import { config } from '~/src/config/index.js' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' @@ -23,20 +22,8 @@ let webpackManifest * @param {FormRequest | FormRequestPayload | null} request */ export function context(request) { - const manifestPath = join(config.get('publicDir'), 'assets-manifest.json') - - if (!webpackManifest) { - try { - // eslint-disable-next-line -- Allow JSON type 'any' - webpackManifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) - } catch { - logger.error(`Webpack ${basename(manifestPath)} not found`) - } - } + const { params, path, response } = request ?? {} - const { params, path, query = {}, response, state } = request ?? {} - - const isForceAccess = 'force' in query const isPreviewMode = path?.startsWith(PREVIEW_PATH_PREFIX) // Only add the slug in to the context if the response is OK. @@ -44,45 +31,68 @@ export function context(request) { const isResponseOK = !Boom.isBoom(response) && response?.statusCode === StatusCodes.OK + const pluginStorage = request?.server.plugins['forms-engine-plugin'] + let consumerViewContext = {} + + if (!pluginStorage) { + throw Error('context called before plugin registered') + } + + if (!pluginStorage.baseLayoutPath) { + throw Error('Missing baseLayoutPath in plugin.options.nunjucks') + } + + if ('viewContext' in pluginStorage) { + consumerViewContext = pluginStorage.viewContext(request) + } + /** @type {ViewContext} */ const ctx = { + // take consumers props first so we can override it + ...consumerViewContext, + baseLayoutPath: pluginStorage.baseLayoutPath, appVersion: pkg.version, - assetPath: '/assets', config: { cdpEnvironment: config.get('cdpEnvironment'), designerUrl: config.get('designerUrl'), feedbackLink: encodeUrl(config.get('feedbackLink')), phaseTag: config.get('phaseTag'), - serviceBannerText: config.get('serviceBannerText'), serviceName: config.get('serviceName'), serviceVersion: config.get('serviceVersion') }, crumb: safeGenerateCrumb(request), - cspNonce: request?.plugins.blankie?.nonces?.script, - currentPath: request ? `${request.path}${request.url.search}` : undefined, + currentPath: `${request.path}${request.url.search}`, previewMode: isPreviewMode ? params?.state : undefined, - slug: isResponseOK ? params?.slug : undefined, - - getAssetPath: (asset = '') => { - return `/${webpackManifest?.[asset] ?? asset}` - } + slug: isResponseOK ? params?.slug : undefined } - if (!isForceAccess) { - ctx.config.googleAnalyticsTrackingId = config.get( - 'googleAnalyticsTrackingId' - ) + return ctx +} + +/** + * Returns the context for the devtool. Consumers won't have access to this. + */ +export function devtoolContext() { + const manifestPath = join(config.get('publicDir'), 'assets-manifest.json') - if (typeof state?.cookieConsent === 'string') { - ctx.cookieConsent = parseCookieConsent(state.cookieConsent) + if (!webpackManifest) { + try { + // eslint-disable-next-line -- Allow JSON type 'any' + webpackManifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) + } catch { + logger.error(`Webpack ${basename(manifestPath)} not found`) } } - return ctx + return { + assetPath: '/assets', + getDxtAssetPath: (asset = '') => { + return `/${webpackManifest?.[asset] ?? asset}` + } + } } /** - * @import { CookieConsent } from '~/src/common/types.js' * @import { ViewContext } from '~/src/server/plugins/nunjucks/types.js' * @import { FormRequest, FormRequestPayload } from '~/src/server/routes/types.js' */ diff --git a/src/server/plugins/nunjucks/context.test.js b/src/server/plugins/nunjucks/context.test.js index 8827ebe95..91865cf4d 100644 --- a/src/server/plugins/nunjucks/context.test.js +++ b/src/server/plugins/nunjucks/context.test.js @@ -1,28 +1,29 @@ import { tmpdir } from 'node:os' -import { config } from '~/src/config/index.js' -import { encodeUrl } from '~/src/server/plugins/engine/helpers.js' -import { context } from '~/src/server/plugins/nunjucks/context.js' +import { + context, + devtoolContext +} from '~/src/server/plugins/nunjucks/context.js' describe('Nunjucks context', () => { beforeEach(() => jest.resetModules()) describe('Asset path', () => { it("should include 'assetPath' for GOV.UK Frontend icons", () => { - const { assetPath } = context(null) + const { assetPath } = devtoolContext() expect(assetPath).toBe('/assets') }) }) describe('Asset helper', () => { it("should locate 'assets-manifest.json' assets", () => { - const { getAssetPath } = context(null) + const { getDxtAssetPath } = devtoolContext() - expect(getAssetPath('example.scss')).toBe( + expect(getDxtAssetPath('example.scss')).toBe( '/stylesheets/example.xxxxxxx.min.css' ) - expect(getAssetPath('example.mjs')).toBe( + expect(getDxtAssetPath('example.mjs')).toBe( '/javascripts/example.xxxxxxx.min.js' ) }) @@ -32,43 +33,33 @@ describe('Nunjucks context', () => { const { config } = await import('~/src/config/index.js') // Import when isolated to avoid cache - const { context } = await import( + const { devtoolContext } = await import( '~/src/server/plugins/nunjucks/context.js' ) // Update config for missing manifest config.set('publicDir', tmpdir()) - const { getAssetPath } = context(null) + const { getDxtAssetPath } = devtoolContext() // Uses original paths when missing - expect(getAssetPath('example.scss')).toBe('/example.scss') - expect(getAssetPath('example.mjs')).toBe('/example.mjs') + expect(getDxtAssetPath('example.scss')).toBe('/example.scss') + expect(getDxtAssetPath('example.mjs')).toBe('/example.mjs') }) }) it('should return path to unknown assets', () => { - const { getAssetPath } = context(null) + const { getDxtAssetPath } = devtoolContext() - expect(getAssetPath()).toBe('/') - expect(getAssetPath('example.jpg')).toBe('/example.jpg') - expect(getAssetPath('example.gif')).toBe('/example.gif') + expect(getDxtAssetPath()).toBe('/') + expect(getDxtAssetPath('example.jpg')).toBe('/example.jpg') + expect(getDxtAssetPath('example.gif')).toBe('/example.gif') }) }) describe('Config', () => { it('should include environment, phase tag and service info', () => { - const ctx = context(null) - - expect(ctx.config).toEqual( - expect.objectContaining({ - cdpEnvironment: config.get('cdpEnvironment'), - feedbackLink: encodeUrl(config.get('feedbackLink')), - googleAnalyticsTrackingId: config.get('googleAnalyticsTrackingId'), - phaseTag: config.get('phaseTag'), - serviceBannerText: config.get('serviceBannerText'), - serviceName: config.get('serviceName'), - serviceVersion: config.get('serviceVersion') - }) + expect(() => context(null)).toThrow( + 'context called before plugin registered' ) }) }) @@ -83,6 +74,9 @@ describe('Nunjucks context', () => { plugins: { crumb: { generate: jest.fn() + }, + 'forms-engine-plugin': { + baseLayoutPath: 'randomValue' } } }, @@ -113,6 +107,9 @@ describe('Nunjucks context', () => { plugins: { crumb: { generate: jest.fn().mockReturnValue(mockCrumb) + }, + 'forms-engine-plugin': { + baseLayoutPath: 'randomValue' } } }, diff --git a/src/server/plugins/nunjucks/types.js b/src/server/plugins/nunjucks/types.js index be3fe60bd..245820ad6 100644 --- a/src/server/plugins/nunjucks/types.js +++ b/src/server/plugins/nunjucks/types.js @@ -12,16 +12,15 @@ /** * @typedef {object} ViewContext - Nunjucks view context * @property {string} appVersion - Application version - * @property {string} assetPath - Asset path + * @property {string} [baseLayoutPath] - Base layout path * @property {Partial} config - Application config properties - * @property {CookieConsent} [cookieConsent] - Cookie consent preferences * @property {string} [crumb] - Cross-Site Request Forgery (CSRF) token * @property {string} [cspNonce] - Content Security Policy (CSP) nonce * @property {string} [currentPath] - Current path * @property {string} [previewMode] - Preview mode * @property {string} [slug] - Form slug - * @property {(asset?: string) => string} getAssetPath - Asset path resolver * @property {FormContext} [context] - the current form context + * @property {PluginOptions['viewContext']} [injectedViewContext] - the current form context */ /** @@ -34,7 +33,7 @@ */ /** - * @import { CookieConsent } from '~/src/common/types.js' * @import { config } from '~/src/config/index.js' * @import { FormContext } from '~/src/server/plugins/engine/types.js' + * @import { PluginOptions } from '~/src/server/plugins/engine/plugin.js' */ diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts deleted file mode 100644 index 95ab2bc7d..000000000 --- a/src/server/plugins/router.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { slugSchema } from '@defra/forms-model' -import Boom from '@hapi/boom' -import { type ServerRegisterPluginObject } from '@hapi/hapi' -import humanizeDuration from 'humanize-duration' -import Joi from 'joi' - -import { - defaultConsent, - parseCookieConsent, - serialiseCookieConsent -} from '~/src/common/cookies.js' -import { type CookieConsent } from '~/src/common/types.js' -import { config } from '~/src/config/index.js' -import { isPathRelative } from '~/src/server/plugins/engine/helpers.js' -import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' -import { healthRoute, publicRoutes } from '~/src/server/routes/index.js' -import { crumbSchema } from '~/src/server/schemas/index.js' - -const routes = [...publicRoutes, healthRoute] - -export default { - plugin: { - name: 'router', - register: (server) => { - server.route(routes) - - // Shared help routes params schema & options - const params = Joi.object() - .keys({ - slug: slugSchema - }) - .required() - - const options = { - validate: { - params - } - } - - server.route<{ Params: { slug: string } }>({ - method: 'get', - path: '/help/get-support/{slug}', - async handler(request, h) { - const { slug } = request.params - const form = await getFormMetadata(slug) - - return h.view('help/get-support', { form }) - }, - options - }) - - server.route<{ Params: { slug: string } }>({ - method: 'get', - path: '/help/privacy/{slug}', - async handler(request, h) { - const { slug } = request.params - const form = await getFormMetadata(slug) - - return h.view('help/privacy-notice', { form }) - }, - options - }) - - server.route<{ Params: { slug: string } }>({ - method: 'get', - path: '/help/cookies/{slug}', - handler(_request, h) { - const sessionTimeout = config.get('sessionTimeout') - - const sessionDurationPretty = humanizeDuration(sessionTimeout) - - return h.view('help/cookies', { - googleAnalyticsContainerId: config - .get('googleAnalyticsTrackingId') - .replace(/^G-/, ''), - sessionDurationPretty - }) - }, - options - }) - - server.route<{ - Params: { slug: string } - Payload: { - crumb?: string - 'cookies[analytics]'?: string - 'cookies[dismissed]'?: string - } - Query: { returnUrl?: string } - }>({ - method: 'post', - path: '/help/cookie-preferences/{slug}', - handler(request, h) { - const { params, payload, query } = request - const { slug } = params - let { returnUrl } = query - - if (returnUrl && !isPathRelative(returnUrl)) { - throw Boom.badRequest('Return URL must be relative') - } - - const analyticsDecision = ( - payload['cookies[analytics]'] ?? '' - ).toLowerCase() - - const dismissedDecision = ( - payload['cookies[dismissed]'] ?? '' - ).toLowerCase() - - // move the parser into our JS code so we can delegate to the frontend in a future iteration - let cookieConsent: CookieConsent - - if (typeof request.state.cookieConsent === 'string') { - cookieConsent = parseCookieConsent(request.state.cookieConsent) - } else { - cookieConsent = defaultConsent - } - - if (analyticsDecision) { - cookieConsent.analytics = analyticsDecision === 'yes' - cookieConsent.dismissed = false - } - - if (dismissedDecision) { - cookieConsent.dismissed = dismissedDecision === 'yes' - } - - if (!returnUrl) { - cookieConsent.dismissed = true // this page already has a confirmation message, don't show another - returnUrl = `/help/cookie-preferences/${slug}` - } - - const serialisedCookieConsent = serialiseCookieConsent(cookieConsent) - h.state('cookieConsent', serialisedCookieConsent) - - return h.redirect(returnUrl) - }, - options: { - validate: { - params, - payload: Joi.object({ - crumb: crumbSchema, - 'cookies[analytics]': Joi.string().valid('yes', 'no').optional(), - 'cookies[dismissed]': Joi.string().valid('yes', 'no').optional() - }), - query: Joi.object({ - returnUrl: Joi.string().optional() - }) - } - } - }) - - server.route({ - method: 'get', - path: '/', - handler() { - throw Boom.notFound() - } - }) - - server.route<{ Params: { slug: string } }>({ - method: 'get', - path: '/help/cookie-preferences/{slug}', - handler(request, h) { - const { params } = request - const { slug } = params - let cookieConsentDismissed = false - - if (typeof request.state.cookieConsent === 'string') { - const cookieConsent = parseCookieConsent( - request.state.cookieConsent - ) - - cookieConsentDismissed = cookieConsent.dismissed - } - - // if the user has come back to this page after updating their preferences - // override the 'dismissed' behaviour to show a success notification instead of - // the cookie banner - const showConsentSuccess = - cookieConsentDismissed && - request.info.referrer.endsWith(`/help/cookie-preferences/${slug}`) - - return h.view('help/cookie-preferences', { - cookieConsentUpdated: showConsentSuccess - }) - }, - options - }) - - server.route<{ Params: { slug: string } }>({ - method: 'get', - path: '/help/accessibility-statement/{slug}', - handler(_request, h) { - return h.view('help/accessibility-statement') - }, - options - }) - } - } -} satisfies ServerRegisterPluginObject diff --git a/src/server/routes/health.js b/src/server/routes/health.js deleted file mode 100644 index 2afe55498..000000000 --- a/src/server/routes/health.js +++ /dev/null @@ -1,13 +0,0 @@ -import { StatusCodes } from 'http-status-codes' - -export default /** @type {ServerRoute} */ ({ - method: 'GET', - path: '/health', - handler(_, h) { - return h.response({ message: 'success' }).code(StatusCodes.OK) - } -}) - -/** - * @import { ServerRoute } from '@hapi/hapi' - */ diff --git a/src/server/routes/health.test.js b/src/server/routes/health.test.js deleted file mode 100644 index 77b853387..000000000 --- a/src/server/routes/health.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import { createServer } from '~/src/server/index.js' - -describe('Health check route', () => { - const startServer = async () => { - const server = await createServer() - await server.initialize() - return server - } - - /** @type {Server} */ - let server - - afterEach(async () => { - await server.stop() - }) - - test('/health route response is correct', async () => { - server = await startServer() - - const options = { - method: 'GET', - url: '/health' - } - - const { result } = await server.inject(options) - - expect(result).toMatchObject({ - message: 'success' - }) - }) -}) - -/** - * @import { Server } from '@hapi/hapi' - */ diff --git a/src/server/routes/index.test.ts b/src/server/routes/index.test.ts deleted file mode 100644 index ae9c02b60..000000000 --- a/src/server/routes/index.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { type Server } from '@hapi/hapi' -import { StatusCodes } from 'http-status-codes' - -import { config } from '~/src/config/index.js' -import { createServer } from '~/src/server/index.js' -import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' -import * as fixtures from '~/test/fixtures/index.js' -import { renderResponse } from '~/test/helpers/component-helpers.js' - -jest.mock('~/src/server/plugins/engine/services/formsService.js') - -describe('Routes', () => { - let server: Server - - beforeAll(async () => { - server = await createServer() - await server.initialize() - }) - - afterAll(async () => { - await server.stop() - }) - - test('cookies page is served with 24 hour duration', async () => { - config.set('sessionTimeout', 86400000) - - const options = { - method: 'GET', - url: '/help/cookies/slug' - } - - const { container } = await renderResponse(server, options) - - const $heading = container.getByRole('heading', { - name: 'Cookies', - level: 1 - }) - - const $googleAnalyticsRowheader = container.getByRole('rowheader', { - name: '_ga_123456789' - }) - - const $sessionDurationRow = container.getByRole('row', { - name: 'session Remembers the information you enter When you close the browser, or after 1 day' - }) - - expect($heading).toBeInTheDocument() - expect($heading).toHaveClass('govuk-heading-l') - expect($googleAnalyticsRowheader).toBeInTheDocument() - expect($sessionDurationRow).toBeInTheDocument() - }) - - test('accessibility statement page is served', async () => { - const options = { - method: 'GET', - url: '/help/accessibility-statement/slug' - } - - const { container } = await renderResponse(server, options) - - const $heading = container.getByRole('heading', { - name: 'Accessibility statement for this form', - level: 1 - }) - - expect($heading).toBeInTheDocument() - expect($heading).toHaveClass('govuk-heading-l') - }) - - test('Help page is served', async () => { - jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) - - const options = { - method: 'GET', - url: '/help/get-support/slug' - } - - const res = await server.inject(options) - - expect(res.statusCode).toBe(StatusCodes.OK) - }) - - test('Service banner is not shown by default', async () => { - const { container } = await renderResponse(server, { - method: 'GET', - url: '/' - }) - - const $banner = container.queryByRole('complementary', { - name: 'Service status' - }) - - expect($banner).not.toBeInTheDocument() - }) - - test('Service banner is not shown when empty', async () => { - config.set('serviceBannerText', '') - - const { container } = await renderResponse(server, { - method: 'GET', - url: '/' - }) - - const $banner = container.queryByRole('complementary', { - name: 'Service status' - }) - - expect($banner).not.toBeInTheDocument() - }) - - test('Service banner is shown when configured', async () => { - config.set('serviceBannerText', 'Hello world') - - const { container } = await renderResponse(server, { - method: 'GET', - url: '/' - }) - - const $banner = container.getByRole('complementary', { - name: 'Service status' - }) - - expect($banner).toHaveTextContent('Hello world') - }) -}) diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 188dbaca0..be802355c 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1,2 +1 @@ export { default as publicRoutes } from '~/src/server/routes/public.js' -export { default as healthRoute } from '~/src/server/routes/health.js' diff --git a/src/server/utils/file-form-service.test.js b/src/server/utils/file-form-service.test.js deleted file mode 100644 index 68ca7a080..000000000 --- a/src/server/utils/file-form-service.test.js +++ /dev/null @@ -1,79 +0,0 @@ -import { FormStatus } from '~/src/server/routes/types.js' -import { FileFormService } from '~/src/server/utils/file-form-service.js' - -// Create the metadata which is shared for all forms -const now = new Date() -const user = { id: 'user', displayName: 'Username' } -const author = { - createdAt: now, - createdBy: user, - updatedAt: now, - updatedBy: user -} - -const metadata = { - id: '95e92559-968d-44ae-8666-2b1ad3dffd31', - slug: 'example-form', - title: 'Example form', - organisation: 'Defra', - teamName: 'Team name', - teamEmail: 'team@defra.gov.uk', - submissionGuidance: "Thanks for your submission, we'll be in touch", - notificationEmail: 'email@domain.com', - ...author, - live: author -} - -describe('File Form Service', () => { - it('should load JSON files from disk', async () => { - const loader = new FileFormService() - - const definition = await loader.addForm( - 'src/server/forms/test.json', - metadata - ) - - const formsService = loader.toFormsService() - expect(await formsService.getFormMetadata(metadata.slug)).toBe(metadata) - expect( - await formsService.getFormDefinition(metadata.id, FormStatus.Draft) - ).toBe(definition) - - expect(() => loader.getFormMetadata('invalid-slug')).toThrow( - "Form metadata 'invalid-slug' not found" - ) - expect(() => loader.getFormDefinition('invalid-id')).toThrow( - "Form definition 'invalid-id' not found" - ) - }) - - it('should load YAML files from disk', async () => { - const loader = new FileFormService() - - const definition = await loader.addForm( - 'src/server/forms/test.yaml', - metadata - ) - - const formsService = loader.toFormsService() - expect(await formsService.getFormMetadata(metadata.slug)).toBe(metadata) - expect( - await formsService.getFormDefinition(metadata.id, FormStatus.Draft) - ).toBe(definition) - - expect(() => loader.getFormMetadata('invalid-slug')).toThrow( - "Form metadata 'invalid-slug' not found" - ) - expect(() => loader.getFormDefinition('invalid-id')).toThrow( - "Form definition 'invalid-id' not found" - ) - }) - - it("should throw if the file isn't JSON or YAML", async () => { - const loader = new FileFormService() - - await expect( - loader.addForm('src/server/forms/test.txt', metadata) - ).rejects.toThrow("Invalid file extension '.txt'") - }) -}) diff --git a/src/server/views/404.html b/src/server/views/404.html deleted file mode 100755 index d46f69b4c..000000000 --- a/src/server/views/404.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends 'layout.html' %} - -{% block content %} -
-
-
-
-

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 button, contact the Defra Helpline if you need to speak to someone about your form.

-
-
-
-
-{% endblock %} diff --git a/src/server/views/500.html b/src/server/views/500.html deleted file mode 100755 index a9557ece2..000000000 --- a/src/server/views/500.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends 'layout.html' %} - -{% block content %} -
-
-
-
-

Sorry, there is a problem with the service

-

You can:

- -
-
-
-
-{% endblock %} diff --git a/src/server/views/help/accessibility-statement.html b/src/server/views/help/accessibility-statement.html deleted file mode 100644 index bdef99acf..000000000 --- a/src/server/views/help/accessibility-statement.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends 'layout.html' %} - -{% set pageTitle = "Accessibility statement" %} - -{% block content %} -
-
-

Accessibility statement for this form

-

This accessibility statement applies to online forms with a URL that starts with - https://submit-forms-to-defra.service.gov.uk.

- -

Technical information about this website’s accessibility

-

Defra is committed to making its forms accessible, in accordance with the Public Sector Bodies - (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018.

- -

Compliance status

-

Forms created by Defra are fully compliant with the Web Content Accessibility Guidelines - (WCAG) version 2.2 AA standard.

- -

How accessible this website is

-

This section lists accessibility issues with content found in Defra forms.

- -

Non-compliance with the accessibility regulations

-

Defra forms do not have accessibility issues that fail the WCAG 2.2 accessibility regulations.

- -

Content that’s not within the scope of the accessibility regulations

-

The accessibility issues listed in this section do not fail the accessibility regulations. We - plan to fix them as soon as possible.

-
    -
  • Forms do not default to a dark or light theme based on the ‘prefers-color-scheme’.
  • -
  • The email field allows for the entry of values that are not formatted as an email address.
  • -
  • The summary screen for each form does not display the question asked. Instead, it displays a summarised version of - the question.
  • -
  • Some optional questions are not marked as optional.
  • -
  • Section titles in some forms are formatted as H2 level headings. These headings are shown before page titles - which are formatted as H1 level headings (skipped heading hierarchy).
  • -
- -

Preparation of this accessibility statement

-

This statement was prepared on 24 June 2024. It was last reviewed on 26 June 2024.

-

The Defra Accessibility team tested 2 online forms against the WCAG 2.2 AA standard on 17 June - 2024. The tests were done using automated testing tools.

-

We will commission a full accessibility audit of forms created by Defra before April 2025.

- -

Feedback and contact information

-

If you find any problems not listed on this page or think we’re not meeting accessibility - requirements, email the Defra Forms team on defraforms@defra.gov.uk.

- -

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 (the ‘accessibility - regulations’). If you’re not happy with how we respond to your complaint, contact the Equality Advisory and Support Service (EASS). -

-
-
-{% endblock %} diff --git a/src/server/views/help/cookie-preferences.html b/src/server/views/help/cookie-preferences.html deleted file mode 100644 index c1d70788a..000000000 --- a/src/server/views/help/cookie-preferences.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends "layout.html" %} - -{% from "govuk/components/radios/macro.njk" import govukRadios %} -{% from "govuk/components/button/macro.njk" import govukButton %} -{% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner -%} - -{% set pageTitle = "Cookies" %} - -{% block content %} -
-
- {% if cookieConsentUpdated %} - {% set notificationHtml %} -

- You’ve set your cookie preferences. -

- {% endset %} - - {{ govukNotificationBanner({ - type: "success", - html: notificationHtml - }) }} - {% endif %} - -

Change your cookie settings

- -
- - - {{ govukRadios({ - name: "cookies[analytics]", - fieldset: { - legend: { - text: "Do you want to accept analytics cookies?", - classes: "govuk-fieldset__legend--s" - } - }, - items: [ - { - value: "yes", - text: "Yes" - }, - { - value: "no", - text: "No" - } - ], - value: "yes" if cookieConsent.analytics === true else "no" - }) }} - - {{ govukButton({ - text: "Save cookie settings" - }) }} -
-
-
-{% endblock %} diff --git a/src/server/views/help/cookies.html b/src/server/views/help/cookies.html deleted file mode 100644 index 090e76e94..000000000 --- a/src/server/views/help/cookies.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends 'layout.html' %} - -{% from "govuk/components/table/macro.njk" import govukTable %} - -{% set pageTitle = "Cookies" %} - -{% block content %} -
-
-

Cookies

-

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

-

We use essential cookies to make this form work.

- -

Essential cookies

-

Essential cookies keep your information secure. We do not need to ask your permission to use them.

- {{ govukTable({ - firstCellIsHeader: true, - caption: "Essential cookies we use", - head: [ - { text: "Name" }, - { text: "Purpose" }, - { text: "Expires" } - ], - rows: [ - [ - { text: "cookieConsent" }, - { text: "Remembers your cookie preferences" }, - { text: "1 year" } - ], - [ - { text: "session" }, - { text: "Remembers the information you enter" }, - { text: "When you close the browser, or after " + sessionDurationPretty } - ], - [ - { text: "crumb" }, - { text: "Ensures forms can only be submitted from this website" }, - { text: "When you close the browser" } - ] - ] - }) }} - -

Analytics cookies

-

We use Google Analytics software to understand how people use our forms. We do this to help make sure the site is meeting the needs of its users and to help us make improvements.

- {{ govukTable({ - firstCellIsHeader: true, - caption: "Analytics cookies we use", - head: [ - { text: "Name" }, - { text: "Purpose" }, - { text: "Expires" } - ], - rows: [ - [ - { text: "_ga" }, - { text: "Used by Google Analytics to help us count how many people visit our forms by tracking if you’ve visited before" }, - { text: "2 years" } - ], - [ - { text: "_ga_" + googleAnalyticsContainerId }, - { text: "Used by Google Analytics to find and track an individual session with your device" }, - { text: "2 years" } - ] - ] - }) }} - -

Change your settings

-

You can change which cookies you’re happy for us to use.

-
-
-{% endblock %} diff --git a/src/server/views/help/get-support.html b/src/server/views/help/get-support.html deleted file mode 100644 index ec70ec4b3..000000000 --- a/src/server/views/help/get-support.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "layout.html" %} - -{% set pageTitle = "Get help with your form" %} - -{% block content %} - {% if form.contact %} -
-
-
-

{{ pageTitle }}

- {% if form.contact.phone %} -

Telephone

-
- {{ form.contact.phone | markdown | safe }} -
-

Find out about call charges

- {% endif %} - - {% if form.contact.email %} -

Email

- - {% endif %} - - {% if form.contact.online %} -

Online contact form

- - {% endif %} -
-
-
- {% endif %} -{% endblock %} diff --git a/src/server/views/help/privacy-notice.html b/src/server/views/help/privacy-notice.html deleted file mode 100644 index 67a208d7b..000000000 --- a/src/server/views/help/privacy-notice.html +++ /dev/null @@ -1,68 +0,0 @@ -{% extends "layout.html" %} - -{% set pageTitle = config.serviceName + " privacy notice" %} - -{% block content %} -
-
-

{{ config.serviceName }} privacy notice

-

The {{ form.title }} form was created using ‘{{ config.serviceName }}’. This service is owned and operated by the Department for Environment, Food & Rural Affairs (Defra).

- -

Who collects your personal data

-

The organisation that created the {{ form.title }} form using ‘{{ config.serviceName }}’ is the data controller of personal data they collect. If the data controller is outside the Defra legal entity, then Defra is the data processor.

-

Read the specific privacy notice for the {{ form.title }} form.

-

Defra also collects some data as a data controller. This privacy notice explains what personal data Defra collects and processes as a data controller through forms made with ‘{{ config.serviceName }}’.

-

If you need further information about how Defra uses your personal data, email defraforms@defra.gov.uk.

-

If you want information and your associated rights you can email: data.protection@defra.gov.uk.

-

The data protection officer for Defra is responsible for checking that Defra complies with legislation. You can contact them at DefraGroupDataProtectionOfficer@defra.gov.uk.

- -

Data we collect from you and what we do with it

-

If you give your consent, we use Google Analytics cookies to collect information about how you use ‘{{ config.serviceName }}’. Read the data privacy and security policy for Google Analytics.

-

Google Analytics processes information about:

-
    -
  • your IP address
  • -
  • the pages you visit on ‘{{ config.serviceName }}’
  • -
  • how long you spend on each ‘{{ config.serviceName }}’ page
  • -
  • how you got to the site
  • -
  • what you select while you’re visiting the site
  • -
-

Defra will make sure you cannot be directly identified by Google Analytics data. We do this by using Google Analytics’ IP address anonymisation feature and by removing any other personal data from the titles or URLs of the pages you visit.

-

Defra will not combine analytics information with other data sets in a way that would directly identify who you are.

-

We use system logs to collect information about the usage of forms. The logs are stored in Amazon Web Services based in London.

-

We use the system logs and Google Analytics data to create anonymised reports about the performance of forms that use ‘{{ config.serviceName }}’. We use this data to improve forms, for example, if we discover a high number of drops offs at a certain point within a form. We may share this information with the data controller of the form.

-

If you email us feedback about a form that uses ‘{{ config.serviceName }}’, we’ll send your email address and any other personal information you choose to include in your email to the data controller for review. The data controller may use your personal information to reply to your query to update a form based on your feedback where it is appropriate.

- -

Lawful basis for processing your personal data

-

The lawful basis for processing your personal data is your consent.

- -

Cookies

-

We use cookies to make this form work. Read about the cookies we use to make this form work.

- -

How long we keep your data

-

Defra will keep tracking cookies on your device for up to 1 year.

- -

Transfer of your personal data outside of the UK

-

We will only transfer your personal data to another country that is deemed adequate for data protection purposes.

- -

What are your rights

-

Based on the lawful processing above, your individual rights are:

-
    -
  • the right to be informed
  • -
  • the right of access
  • -
  • the right to rectification
  • -
  • the right to erasure
  • -
  • the right to restrict processing
  • -
  • the right to data portability
  • -
  • rights in relation to automated decision making and profiling
  • -
-

Get more information about your individual rights under the UK General Data Protection Regulation (UK GDPR) and the Data Protection Act 2018 (DPA 2018).

- -

Complaints

-

You have the right to make a complaint to the Information Commissioner’s Office at any time.

- -

Personal information charter

-

Our personal information charter explains more about your rights over your personal data.

-

Last updated: 24 December 2024

-
-
-{% endblock %} diff --git a/src/server/views/help/terms-and-conditions.html b/src/server/views/help/terms-and-conditions.html deleted file mode 100644 index 60144ec7a..000000000 --- a/src/server/views/help/terms-and-conditions.html +++ /dev/null @@ -1,83 +0,0 @@ -{% extends 'layout.html' %} - -{% block content %} -
-
-

Terms and conditions

-

By using this digital service you agree to our - privacy policy and to these terms and conditions. Read them carefully. -

-

General

-

These terms and conditions affect your rights and liabilities under - the law. They govern your use of, and relationship with, the service. They don’t apply to other services - provided by the Foreign & Commonwealth Office, or to any other department or service which is linked - to in this service.

-

You agree to use this site only for lawful purposes, and in a - manner that does not infringe the rights of, or restrict or inhibit the use and enjoyment of, this site - by any third party.

-

We may occasionally update these terms and conditions. This might happen if there’s a change in the law or to the way this service works. Check these terms and conditions regularly, as continued use of the service after a change has been made is your acceptance of the change. If you don't agree to the terms and conditions and - privacy policy, you should not use this service.

-

Applicable law

-

Your use of this service and any dispute arising from its use will - be governed by and construed in accordance with the laws of England and Wales, including but not limited - to the:

-
    -
  • Computer Misuse Act 1990
  • -
  • Data Protection Act
  • -
  • Mental Capacity Act 2005
  • -
-

How to use this service responsibly

-

You must provide us with enough information for us to assess the - case fairly and if appropriate to provide the service. We also need these details to provide information - about the appointment, including rescheduling and cancellation when necessary, so you must provide - accurate details where we can reliably contact you.

-

When attending their appointment the person requiring an emergency - travel document will have to pass through Security checks. They should expect their belongings to be - checked, and will have to leave their mobile phone and other belongings with the Security Officer. Do - not bring laptops or other electronic devices. Arrive in advance of the appointment. If you need to - cancel or modify your booking, you will be able to do so by following the link in the confirmation - email.

-

There are risks in using a shared computer, such as in an internet - café, to use this digital service. It’s your responsibility to be aware of these risks and to avoid - using any computer which may leave your personal information accessible to others. You are responsible - if you choose to leave a computer unprotected while in the process of using the service.

-

We make every effort to check and test this service whenever we - amend or update it. However, you must take your own precautions to ensure that the way you access this - service does not expose you to the risk of viruses, malicious computer code or other forms of - interference which may damage your own computer system.

-

You must not misuse our service by knowingly introducing viruses, - trojans, worms, logic bombs or other material which is malicious or technologically harmful. You must - not attempt to gain unauthorised access to our service, the system on which our service is stored or any - server, computer or database connected to our service. You must not attack our site via a - denial-of-service attack or a distributed denial-of-service attack.

-

Disclaimer

-

While we make every effort to keep this service up to date, we - don’t provide any guarantees, conditions or warranties as to the accuracy of the information on the - site.

-

While Consular staff will give you as much advice as they can, it - is your responsibility to ensure that you are obtaining the correct service and that you bring all the - necessary documentation for the service. If you are unsure, you may wish to take legal advice before - booking the service

-

Our consular fees are not refundable, and are subject to change without notice. Check the relevant consular fees list for the latest information.

-

We don’t accept liability for loss or damage incurred by users of - this service, whether direct, indirect or consequential, whether caused by tort, breach of contract or - otherwise. This includes loss of income or revenue, business, profits or contracts, anticipated savings, - data, goodwill, tangible property or wasted time in connection with this service or any websites linked - to it and any materials posted on it. This condition shall not prevent claims for loss of or damage to - your tangible property or any other claims for direct financial loss that are not excluded by any of the - categories set out above.

-

This does not affect our liability for death or personal injury - arising from our negligence, nor our liability for fraudulent misrepresentation or misrepresentation as - to a fundamental matter, nor any other liability which cannot be excluded or limited under applicable - law.

-

Information provided by this service

-

We work hard to ensure that information within this service is - accurate. However, we can’t guarantee the accuracy and completeness of any information at all times. - While we make every effort to ensure this service is accessible at all times, we are not liable if it is - unavailable for any period of time.

-
-
-{% endblock %} diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index fc8406647..11f5c27f2 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -5,6 +5,7 @@ import { type ServerYar, type Yar } from '@hapi/yar' import { type Logger } from 'pino' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' +import { type context } from '~/src/server/plugins/engine/nunjucks.js' import { type FormRequest, type FormRequestPayload @@ -19,16 +20,9 @@ declare module '@hapi/hapi' { generate?: (request: Request | FormRequest | FormRequestPayload) => string } 'forms-engine-plugin': { + baseLayoutPath: string cacheService: CacheService - } - } - - interface PluginsStates { - blankie?: { - nonces?: { - script?: string - style?: string - } + viewContext: context } } diff --git a/test/form/cookies.test.js b/test/form/cookies.test.js deleted file mode 100644 index c32d95ebf..000000000 --- a/test/form/cookies.test.js +++ /dev/null @@ -1,338 +0,0 @@ -import { join } from 'node:path' - -import { within } from '@testing-library/dom' -import { StatusCodes } from 'http-status-codes' - -import { createServer } from '~/src/server/index.js' -import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' -import * as fixtures from '~/test/fixtures/index.js' -import { renderResponse } from '~/test/helpers/component-helpers.js' -import { getCookieHeader } from '~/test/utils/get-cookie.js' - -jest.mock('~/src/server/plugins/engine/services/formsService.js') - -describe(`Cookie banner and analytics`, () => { - /** @type {Server} */ - let server - - beforeEach(() => { - jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) - }) - - afterEach(async () => { - await server.stop() - }) - - test.each([ - '/basic/licence', // form pages - '/help/accessibility-statement/basic' // non-form pages - ])('shows the cookie banner by default', async (path) => { - server = await createServer({ - formFileName: 'basic.js', - formFilePath: join(import.meta.dirname, 'definitions') - }) - await server.initialize() - - const options = { - method: 'GET', - url: path - } - - const { container, document } = await renderResponse(server, options) - - const $cookieBanner = container.queryByRole('region', { - name: 'Cookies on Submit a form to Defra' - }) - - const $gaScriptMain = document.getElementById('ga-tag-js-main') - const $gaScriptInit = document.getElementById('ga-tag-js-init') - - expect($cookieBanner).toBeInTheDocument() - expect($gaScriptMain).not.toBeInTheDocument() - expect($gaScriptInit).not.toBeInTheDocument() - }) - - test.each([ - // form pages - '/basic/licence', - // non-form pages - '/help/accessibility-statement/basic' - ])('confirms when the user has accepted analytics cookies', async (path) => { - server = await createServer({ - formFileName: 'basic.js', - formFilePath: join(import.meta.dirname, 'definitions') - }) - - await server.initialize() - - // set the cookie preferences - const sessionInitialisationResponse = await server.inject({ - method: 'POST', - url: `/help/cookie-preferences/basic?returnUrl=${encodeURIComponent('/mypage')}`, - payload: { - 'cookies[analytics]': 'yes' - } - }) - - const headers = getCookieHeader(sessionInitialisationResponse, [ - 'crumb', - 'session', - 'cookieConsent' - ]) - - const { container, document } = await renderResponse(server, { - method: 'GET', - url: path, - headers - }) - - const $cookieBanner = container.getByRole('region', { - name: 'Cookies on Submit a form to Defra' - }) - - const $confirmationText = within($cookieBanner).getByText( - 'You’ve accepted analytics cookies.', - { exact: false } - ) - - const $gaScriptMain = document.getElementById('ga-tag-js-main') - const $gaScriptInit = document.getElementById('ga-tag-js-init') - - expect($cookieBanner).toBeInTheDocument() - expect($confirmationText).toBeInTheDocument() - expect($gaScriptMain).toBeInTheDocument() - expect($gaScriptInit).toBeInTheDocument() - }) - - test.each([ - // form pages - '/basic/licence', - // non-form pages - '/help/accessibility-statement/basic' - ])('confirms when the user has rejected analytics cookies', async (path) => { - server = await createServer({ - formFileName: 'basic.js', - formFilePath: join(import.meta.dirname, 'definitions') - }) - - await server.initialize() - - // set the cookie preferences - const sessionInitialisationResponse = await server.inject({ - method: 'POST', - url: `/help/cookie-preferences/basic?returnUrl=${encodeURIComponent('/mypage')}`, - payload: { - 'cookies[analytics]': 'no' - } - }) - - const headers = getCookieHeader(sessionInitialisationResponse, [ - 'crumb', - 'session', - 'cookieConsent' - ]) - - const { container, document } = await renderResponse(server, { - method: 'GET', - url: path, - headers - }) - - const $cookieBanner = container.getByRole('region', { - name: 'Cookies on Submit a form to Defra' - }) - - const $confirmationText = within($cookieBanner).getByText( - 'You’ve rejected analytics cookies.', - { exact: false } - ) - - const $gaScriptMain = document.getElementById('ga-tag-js-main') - const $gaScriptInit = document.getElementById('ga-tag-js-init') - - expect($cookieBanner).toBeInTheDocument() - expect($confirmationText).toBeInTheDocument() - - expect($gaScriptMain).not.toBeInTheDocument() - expect($gaScriptInit).not.toBeInTheDocument() - }) - - test.each([ - // form pages - '/basic/start', - // non-form pages - '/' - ])('hides the cookie banner once dismissed', async (path) => { - server = await createServer({ - formFileName: 'basic.js', - formFilePath: join(import.meta.dirname, 'definitions') - }) - - await server.initialize() - - // set the cookie preferences - const sessionInitialisationResponse = await server.inject({ - method: 'POST', - url: `/help/cookie-preferences/basic?returnUrl=${encodeURIComponent('/mypage')}`, - payload: { - 'cookies[analytics]': 'yes', - 'cookies[dismissed]': 'yes' - } - }) - - const headers = getCookieHeader(sessionInitialisationResponse, [ - 'crumb', - 'session', - 'cookieConsent' - ]) - - const { container } = await renderResponse(server, { - method: 'GET', - url: path, - headers - }) - - const $cookieBanner = container.queryByRole('region', { - name: 'Cookies on Submit a form to Defra' - }) - - expect($cookieBanner).not.toBeInTheDocument() - }) -}) - -describe(`Cookie preferences`, () => { - /** @type {Server} */ - let server - - afterEach(async () => { - await server.stop() - }) - - test.each([ - { - value: 'yes', - text: 'Yes' - }, - { - value: 'no', - text: 'No' - } - ])( - 'selects the cookie preference automatically based on the user selection', - async ({ text, value }) => { - server = await createServer() - await server.initialize() - - // set the cookie preferences - const sessionInitialisationResponse = await server.inject({ - method: 'POST', - url: `/help/cookie-preferences/basic`, - payload: { - 'cookies[analytics]': value - } - }) - - const headers = { - Referer: '/help/cookie-preferences/basic', - ...getCookieHeader(sessionInitialisationResponse, [ - 'crumb', - 'session', - 'cookieConsent' - ]) - } - - const { container } = await renderResponse(server, { - method: 'GET', - url: '/help/cookie-preferences/basic', - headers - }) - - const $input = container.getByRole('radio', { - name: text - }) - - const $successNotification = container.getByRole('alert', { - name: 'Success' - }) - - expect($input).toBeChecked() - expect($successNotification).toHaveTextContent( - 'You’ve set your cookie preferences.' - ) - } - ) - - test("doesn't show the success banner if the user hasn't been posted from the cookie preferences page", async () => { - server = await createServer() - await server.initialize() - - // set the cookie preferences - const sessionInitialisationResponse = await server.inject({ - method: 'POST', - url: `/help/cookie-preferences/basic?returnUrl=${encodeURIComponent('/another-page')}`, - payload: { - 'cookies[analytics]': 'yes' - } - }) - - const headers = { - ...getCookieHeader(sessionInitialisationResponse, [ - 'crumb', - 'session', - 'cookieConsent' - ]) - } - - const { container } = await renderResponse(server, { - method: 'GET', - url: '/help/cookie-preferences/basic', - headers - }) - - const $input = container.getByRole('radio', { - name: 'Yes' - }) - - const $successNotification = container.queryByRole('alert', { - name: 'Success' - }) - - expect($input).toBeChecked() - expect($successNotification).not.toBeInTheDocument() - }) - - test('defaults to no if one is not provided', async () => { - server = await createServer() - await server.initialize() - - const { container } = await renderResponse(server, { - method: 'GET', - url: '/help/cookie-preferences/basic' - }) - - const $input = container.getByRole('radio', { - name: 'No' - }) - - expect($input).toBeChecked() - }) - - test('returns bad request for invalid redirect urls', async () => { - server = await createServer() - await server.initialize() - - const { response } = await renderResponse(server, { - method: 'POST', - url: `/help/cookie-preferences/basic?returnUrl=${encodeURIComponent('https://my-malicious-url.com')}`, - payload: { - 'cookies[analytics]': 'yes' - } - }) - - expect(response.statusCode).toBe(StatusCodes.BAD_REQUEST) - }) -}) - -/** - * @import { Server } from '@hapi/hapi' - */ diff --git a/test/form/definitions/phase-alpha.json b/test/form/definitions/phase-alpha.json deleted file mode 100644 index 5cd1ee4c5..000000000 --- a/test/form/definitions/phase-alpha.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "metadata": {}, - "startPage": "/first-page", - "pages": [ - { - "title": "First page", - "path": "/first-page", - "components": [], - "next": [ - { - "path": "/summary" - } - ] - }, - { - "path": "/summary", - "controller": "SummaryPageController", - "title": "Summary", - "components": [], - "next": [] - } - ], - "lists": [], - "sections": [], - "conditions": [], - "name": "Alpha form", - "feedback": { - "feedbackForm": false - }, - "phaseBanner": { - "phase": "alpha" - } -} diff --git a/test/form/definitions/phase-default.json b/test/form/definitions/phase-default.json deleted file mode 100644 index 3db106a3c..000000000 --- a/test/form/definitions/phase-default.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "metadata": {}, - "startPage": "/first-page", - "pages": [ - { - "title": "First page", - "path": "/first-page", - "components": [], - "next": [ - { - "path": "/summary" - } - ] - }, - { - "path": "/summary", - "controller": "SummaryPageController", - "title": "Summary", - "components": [], - "next": [] - } - ], - "lists": [], - "sections": [], - "conditions": [] -} diff --git a/test/form/feedback.test.js b/test/form/feedback.test.js deleted file mode 100644 index 894484bb3..000000000 --- a/test/form/feedback.test.js +++ /dev/null @@ -1,68 +0,0 @@ -import { join } from 'node:path' - -import { createServer } from '~/src/server/index.js' -import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' -import * as fixtures from '~/test/fixtures/index.js' -import { renderResponse } from '~/test/helpers/component-helpers.js' - -const { FEEDBACK_LINK } = process.env -const basePath = '/feedback' - -jest.mock('~/src/server/plugins/engine/services/formsService.js') - -describe('Feedback link', () => { - /** @type {Server} */ - let server - - // Create server before each test - beforeAll(async () => { - server = await createServer({ - formFileName: 'feedback.json', - formFilePath: join(import.meta.dirname, 'definitions') - }) - - await server.initialize() - }) - - beforeEach(() => { - jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) - }) - - afterAll(async () => { - await server.stop() - }) - - it.each([ - { - // Default feedback link - url: '/help/cookies', - name: 'give your feedback (opens in new tab)', - href: FEEDBACK_LINK - }, - { - // Email address from feedback.json - url: `${basePath}/uk-passport`, - name: 'give your feedback by email', - href: 'mailto:test@feedback.cat' - } - ])("should match route '$url'", async ({ url, name, href }) => { - const { container } = await renderResponse(server, { - method: 'GET', - url - }) - - const $phaseBanner = document.querySelector('.govuk-phase-banner') - const $link = container.getByRole('link', { name }) - - expect($link).toBeInTheDocument() - expect($link).toHaveAttribute('href', href) - expect($link).toHaveClass('govuk-link') - - expect($phaseBanner).toHaveAttribute('class', 'govuk-phase-banner') - expect($phaseBanner).toContainElement($link) - }) -}) - -/** - * @import { Server } from '@hapi/hapi' - */ diff --git a/test/form/phase-banner.test.js b/test/form/phase-banner.test.js deleted file mode 100644 index 5b0cb2a6f..000000000 --- a/test/form/phase-banner.test.js +++ /dev/null @@ -1,71 +0,0 @@ -import { join } from 'node:path' - -import { within } from '@testing-library/dom' - -import { createServer } from '~/src/server/index.js' -import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' -import * as fixtures from '~/test/fixtures/index.js' -import { renderResponse } from '~/test/helpers/component-helpers.js' - -jest.mock('~/src/server/plugins/engine/services/formsService.js') - -describe(`Phase banner`, () => { - /** @type {Server} */ - let server - - afterEach(async () => { - await server.stop() - }) - - beforeEach(() => { - jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) - }) - - test('shows the server phase tag by default', async () => { - const basePath = '/phase-default' - - server = await createServer({ - formFileName: 'phase-default.json', - formFilePath: join(import.meta.dirname, 'definitions') - }) - - await server.initialize() - - await renderResponse(server, { - url: `${basePath}/first-page` - }) - - const $phaseBanner = /** @type {HTMLElement} */ ( - document.querySelector('.govuk-phase-banner') - ) - - const $phaseTag = within($phaseBanner).getByRole('strong') - expect($phaseTag).toHaveTextContent('Beta') - }) - - test('shows the form phase tag if provided', async () => { - const basePath = '/phase-alpha' - - server = await createServer({ - formFileName: 'phase-alpha.json', - formFilePath: join(import.meta.dirname, 'definitions') - }) - - await server.initialize() - - await renderResponse(server, { - url: `${basePath}/first-page` - }) - - const $phaseBanner = /** @type {HTMLElement} */ ( - document.querySelector('.govuk-phase-banner') - ) - - const $phaseTag = within($phaseBanner).getByRole('strong') - expect($phaseTag).toHaveTextContent('Alpha') - }) -}) - -/** - * @import { Server } from '@hapi/hapi' - */