From 6a872d3b13f9e804ce3e48305c45fb32e7f68caf Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 9 Apr 2025 15:16:41 +0100 Subject: [PATCH 01/27] Accept a base page layout path in plugin options --- src/server/plugins/engine/configureEnginePlugin.ts | 2 +- src/server/plugins/engine/plugin.ts | 5 ++++- src/server/plugins/engine/views/confirmation.html | 2 +- src/server/plugins/engine/views/index.html | 2 +- src/server/plugins/engine/views/item-delete.html | 2 +- src/server/plugins/engine/views/repeat-list-summary.html | 2 +- src/server/plugins/engine/views/summary.html | 2 +- src/server/plugins/nunjucks/context.js | 4 ++++ src/server/plugins/nunjucks/types.js | 6 ++++++ src/server/views/404.html | 2 +- src/server/views/500.html | 2 +- src/server/views/help/accessibility-statement.html | 2 +- src/server/views/help/cookie-preferences.html | 2 +- src/server/views/help/cookies.html | 2 +- src/server/views/help/get-support.html | 2 +- src/server/views/help/privacy-notice.html | 2 +- src/server/views/help/terms-and-conditions.html | 2 +- src/typings/hapi/index.d.ts | 1 + 18 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index c183f58ab..d8a42fda8 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -27,7 +27,7 @@ export const configureEnginePlugin = async ({ return { plugin, - options: { model, services, controllers } + options: { model, services, controllers, baseLayoutPath: 'layout.html' } } } diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index ea634a19d..7a3bbc3ad 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -73,6 +73,7 @@ export interface PluginOptions { viewPaths?: string[] filters?: Record pluginPath?: string + baseLayoutPath: string } export const plugin = { @@ -87,7 +88,8 @@ export const plugin = { cacheName, viewPaths, filters, - pluginPath = PLUGIN_PATH + pluginPath = PLUGIN_PATH, + baseLayoutPath } = options const { formsService } = services const cacheService = new CacheService(server, cacheName) @@ -148,6 +150,7 @@ export const plugin = { } }) + server.expose('baseLayoutPath', baseLayoutPath) server.expose('cacheService', cacheService) server.app.model = model diff --git a/src/server/plugins/engine/views/confirmation.html b/src/server/plugins/engine/views/confirmation.html index e2975622e..3feed9597 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 pluginOptions.baseLayoutPath %} {% from "govuk/components/panel/macro.njk" import govukPanel %} diff --git a/src/server/plugins/engine/views/index.html b/src/server/plugins/engine/views/index.html index 476547b41..9d3b2689d 100644 --- a/src/server/plugins/engine/views/index.html +++ b/src/server/plugins/engine/views/index.html @@ -1,4 +1,4 @@ -{% extends 'layout.html' %} +{% extends pluginOptions.baseLayoutPath %} {% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} {% from "partials/components.html" import componentList with context %} diff --git a/src/server/plugins/engine/views/item-delete.html b/src/server/plugins/engine/views/item-delete.html index c22f874e2..8ecd53e60 100644 --- a/src/server/plugins/engine/views/item-delete.html +++ b/src/server/plugins/engine/views/item-delete.html @@ -1,4 +1,4 @@ -{% extends "layout.html" %} +{% extends pluginOptions.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/repeat-list-summary.html b/src/server/plugins/engine/views/repeat-list-summary.html index bdbd57039..3ec8fe578 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 pluginOptions.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..b81137af5 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 pluginOptions.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/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index dddf63255..5a9a6828c 100644 --- a/src/server/plugins/nunjucks/context.js +++ b/src/server/plugins/nunjucks/context.js @@ -57,6 +57,10 @@ export function context(request) { serviceName: config.get('serviceName'), serviceVersion: config.get('serviceVersion') }, + pluginOptions: { + baseLayoutPath: + request?.server.plugins['forms-engine-plugin'].baseLayoutPath + }, crumb: safeGenerateCrumb(request), cspNonce: request?.plugins.blankie?.nonces?.script, currentPath: request ? `${request.path}${request.url.search}` : undefined, diff --git a/src/server/plugins/nunjucks/types.js b/src/server/plugins/nunjucks/types.js index be3fe60bd..7a1b7d800 100644 --- a/src/server/plugins/nunjucks/types.js +++ b/src/server/plugins/nunjucks/types.js @@ -9,6 +9,11 @@ * @property {object} [context] - Nunjucks render context */ +/** + * @typedef {object} PluginOptions + * @property {string} [baseLayoutPath] - Page layout to extend + */ + /** * @typedef {object} ViewContext - Nunjucks view context * @property {string} appVersion - Application version @@ -22,6 +27,7 @@ * @property {string} [slug] - Form slug * @property {(asset?: string) => string} getAssetPath - Asset path resolver * @property {FormContext} [context] - the current form context + * @property {PluginOptions} [pluginOptions] - the current form context */ /** diff --git a/src/server/views/404.html b/src/server/views/404.html index d46f69b4c..d5a6bc27b 100755 --- a/src/server/views/404.html +++ b/src/server/views/404.html @@ -1,4 +1,4 @@ -{% extends 'layout.html' %} +{% extends pluginOptions.baseLayoutPath %} {% block content %}
diff --git a/src/server/views/500.html b/src/server/views/500.html index a9557ece2..472f5af09 100755 --- a/src/server/views/500.html +++ b/src/server/views/500.html @@ -1,4 +1,4 @@ -{% extends 'layout.html' %} +{% extends pluginOptions.baseLayoutPath %} {% block content %}
diff --git a/src/server/views/help/accessibility-statement.html b/src/server/views/help/accessibility-statement.html index bdef99acf..ba20c85c2 100644 --- a/src/server/views/help/accessibility-statement.html +++ b/src/server/views/help/accessibility-statement.html @@ -1,4 +1,4 @@ -{% extends 'layout.html' %} +{% extends pluginOptions.baseLayoutPath %} {% set pageTitle = "Accessibility statement" %} diff --git a/src/server/views/help/cookie-preferences.html b/src/server/views/help/cookie-preferences.html index c1d70788a..1d2e72256 100644 --- a/src/server/views/help/cookie-preferences.html +++ b/src/server/views/help/cookie-preferences.html @@ -1,4 +1,4 @@ -{% extends "layout.html" %} +{% extends pluginOptions.baseLayoutPath %} {% from "govuk/components/radios/macro.njk" import govukRadios %} {% from "govuk/components/button/macro.njk" import govukButton %} diff --git a/src/server/views/help/cookies.html b/src/server/views/help/cookies.html index 090e76e94..369836248 100644 --- a/src/server/views/help/cookies.html +++ b/src/server/views/help/cookies.html @@ -1,4 +1,4 @@ -{% extends 'layout.html' %} +{% extends pluginOptions.baseLayoutPath %} {% from "govuk/components/table/macro.njk" import govukTable %} diff --git a/src/server/views/help/get-support.html b/src/server/views/help/get-support.html index ec70ec4b3..f04848067 100644 --- a/src/server/views/help/get-support.html +++ b/src/server/views/help/get-support.html @@ -1,4 +1,4 @@ -{% extends "layout.html" %} +{% extends pluginOptions.baseLayoutPath %} {% set pageTitle = "Get help with your form" %} diff --git a/src/server/views/help/privacy-notice.html b/src/server/views/help/privacy-notice.html index 67a208d7b..8e05081ce 100644 --- a/src/server/views/help/privacy-notice.html +++ b/src/server/views/help/privacy-notice.html @@ -1,4 +1,4 @@ -{% extends "layout.html" %} +{% extends pluginOptions.baseLayoutPath %} {% set pageTitle = config.serviceName + " privacy notice" %} diff --git a/src/server/views/help/terms-and-conditions.html b/src/server/views/help/terms-and-conditions.html index 60144ec7a..7278092d0 100644 --- a/src/server/views/help/terms-and-conditions.html +++ b/src/server/views/help/terms-and-conditions.html @@ -1,4 +1,4 @@ -{% extends 'layout.html' %} +{% extends pluginOptions.baseLayoutPath %} {% block content %}
diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index fc8406647..62e9d3e5e 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -20,6 +20,7 @@ declare module '@hapi/hapi' { } 'forms-engine-plugin': { cacheService: CacheService + baseLayoutPath: string } } From e8da583e87a82b3e3685d73200b717dd86af3d92 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 9 Apr 2025 15:33:04 +0100 Subject: [PATCH 02/27] remove non-engine pages, leaving just a development UI --- src/server/plugins/engine/views/layout.html | 76 +------ src/server/plugins/errorPages.ts | 29 +-- src/server/plugins/router.ts | 201 ------------------ src/server/views/404.html | 16 -- src/server/views/500.html | 19 -- .../views/help/accessibility-statement.html | 58 ----- src/server/views/help/cookie-preferences.html | 57 ----- src/server/views/help/cookies.html | 71 ------- src/server/views/help/get-support.html | 37 ---- src/server/views/help/privacy-notice.html | 68 ------ .../views/help/terms-and-conditions.html | 83 -------- 11 files changed, 5 insertions(+), 710 deletions(-) delete mode 100644 src/server/plugins/router.ts delete mode 100755 src/server/views/404.html delete mode 100755 src/server/views/500.html delete mode 100644 src/server/views/help/accessibility-statement.html delete mode 100644 src/server/views/help/cookie-preferences.html delete mode 100644 src/server/views/help/cookies.html delete mode 100644 src/server/views/help/get-support.html delete mode 100644 src/server/views/help/privacy-notice.html delete mode 100644 src/server/views/help/terms-and-conditions.html diff --git a/src/server/plugins/engine/views/layout.html b/src/server/plugins/engine/views/layout.html index f42a33661..0aba71dc2 100644 --- a/src/server/plugins/engine/views/layout.html +++ b/src/server/plugins/engine/views/layout.html @@ -45,85 +45,11 @@ {% endblock %} {% block header %} - {% if config.googleAnalyticsTrackingId and slug %} -
- - - {% set acceptHtml %} -

You’ve accepted analytics cookies. You can change your cookie settings at any time.

- {% endset %} - - {% set rejectedHtml %} -

You’ve rejected analytics cookies. You can change your cookie settings at any time.

- {% endset %} - - {% if cookieConsent.analytics !== true and cookieConsent.analytics !== false and cookieConsent.dismissed !== true %} - {% set html %} -

We use some essential cookies to make this service work.

-

We’d like to set analytics cookies so we can understand how people use the service and make improvements.

- {% endset %} - - {{ govukCookieBanner({ - ariaLabel: "Cookies on " + config.serviceName, - messages: [ - { - headingText: config.serviceName, - html: html, - actions: [ - { - text: "Accept analytics cookies", - type: "submit", - name: "cookies[analytics]", - value: "yes" - }, - { - text: "Reject analytics cookies", - type: "submit", - name: "cookies[analytics]", - value: "no" - }, - { - text: "View cookies", - href: "/help/cookies/" + slug - } - ] - } - ] - }) }} - {% elif cookieConsent.dismissed === false %} - {{ govukCookieBanner({ - ariaLabel: "Cookies on " + config.serviceName, - messages: [ - { - html: acceptHtml if cookieConsent.analytics === true else rejectedHtml, - actions: [ - { - text: "Hide cookie message", - type: "submit", - name: "cookies[dismissed]", - value: "yes" - } - ] - } - ] - }) }} - {% endif %} - -
- {% endif %} - - {% if config.serviceBannerText | length %} - {{ appServiceBanner({ - title: "Service status", - text: config.serviceBannerText - }) }} - {% endif %} - {{ govukHeader({ homepageUrl: currentPath if context.isForceAccess else "https://www.gov.uk", containerClasses: "govuk-width-container", productName: productName | safe | trim, - serviceName: name if name else config.serviceName, + serviceName: "DXT development server", serviceUrl: currentPath if context.isForceAccess else serviceUrl }) }} {% endblock %} diff --git a/src/server/plugins/errorPages.ts b/src/server/plugins/errorPages.ts index 4718c62a9..aa1805f2a 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,16 @@ 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/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/views/404.html b/src/server/views/404.html deleted file mode 100755 index d5a6bc27b..000000000 --- a/src/server/views/404.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends pluginOptions.baseLayoutPath %} - -{% 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 472f5af09..000000000 --- a/src/server/views/500.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends pluginOptions.baseLayoutPath %} - -{% 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 ba20c85c2..000000000 --- a/src/server/views/help/accessibility-statement.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends pluginOptions.baseLayoutPath %} - -{% 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 1d2e72256..000000000 --- a/src/server/views/help/cookie-preferences.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends pluginOptions.baseLayoutPath %} - -{% 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 369836248..000000000 --- a/src/server/views/help/cookies.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends pluginOptions.baseLayoutPath %} - -{% 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 f04848067..000000000 --- a/src/server/views/help/get-support.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends pluginOptions.baseLayoutPath %} - -{% 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 8e05081ce..000000000 --- a/src/server/views/help/privacy-notice.html +++ /dev/null @@ -1,68 +0,0 @@ -{% extends pluginOptions.baseLayoutPath %} - -{% 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 7278092d0..000000000 --- a/src/server/views/help/terms-and-conditions.html +++ /dev/null @@ -1,83 +0,0 @@ -{% extends pluginOptions.baseLayoutPath %} - -{% 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 %} From 36c380d5d0108dcd4e4c738dbf2728d692931aa9 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 9 Apr 2025 17:27:50 +0100 Subject: [PATCH 03/27] allow plugin consumer to provide viewContext props --- src/server/plugins/engine/configureEnginePlugin.ts | 9 ++++++++- src/server/plugins/engine/plugin.ts | 8 +++++--- src/server/plugins/engine/views/confirmation.html | 2 +- src/server/plugins/engine/views/index.html | 2 +- src/server/plugins/engine/views/item-delete.html | 2 +- src/server/plugins/engine/views/repeat-list-summary.html | 2 +- src/server/plugins/engine/views/summary.html | 2 +- src/server/plugins/nunjucks/context.js | 5 +---- src/server/plugins/nunjucks/types.js | 8 ++------ src/typings/hapi/index.d.ts | 3 ++- 10 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index d8a42fda8..2d617a671 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -27,7 +27,14 @@ export const configureEnginePlugin = async ({ return { plugin, - options: { model, services, controllers, baseLayoutPath: 'layout.html' } + options: { + model, + services, + controllers, + viewContext: { + baseLayoutPath: 'layout.html' + } + } } } diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 7a3bbc3ad..cf31c1e65 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -73,7 +73,9 @@ export interface PluginOptions { viewPaths?: string[] filters?: Record pluginPath?: string - baseLayoutPath: string + viewContext: { + baseLayoutPath: string + } } export const plugin = { @@ -89,7 +91,7 @@ export const plugin = { viewPaths, filters, pluginPath = PLUGIN_PATH, - baseLayoutPath + viewContext } = options const { formsService } = services const cacheService = new CacheService(server, cacheName) @@ -150,7 +152,7 @@ export const plugin = { } }) - server.expose('baseLayoutPath', baseLayoutPath) + server.expose('viewContext', viewContext) server.expose('cacheService', cacheService) server.app.model = model diff --git a/src/server/plugins/engine/views/confirmation.html b/src/server/plugins/engine/views/confirmation.html index 3feed9597..7c9fbb1a7 100644 --- a/src/server/plugins/engine/views/confirmation.html +++ b/src/server/plugins/engine/views/confirmation.html @@ -1,4 +1,4 @@ -{% extends pluginOptions.baseLayoutPath %} +{% extends baseLayoutPath %} {% from "govuk/components/panel/macro.njk" import govukPanel %} diff --git a/src/server/plugins/engine/views/index.html b/src/server/plugins/engine/views/index.html index 9d3b2689d..af3258c88 100644 --- a/src/server/plugins/engine/views/index.html +++ b/src/server/plugins/engine/views/index.html @@ -1,4 +1,4 @@ -{% extends pluginOptions.baseLayoutPath %} +{% extends baseLayoutPath %} {% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} {% from "partials/components.html" import componentList with context %} diff --git a/src/server/plugins/engine/views/item-delete.html b/src/server/plugins/engine/views/item-delete.html index 8ecd53e60..e34861f78 100644 --- a/src/server/plugins/engine/views/item-delete.html +++ b/src/server/plugins/engine/views/item-delete.html @@ -1,4 +1,4 @@ -{% extends pluginOptions.baseLayoutPath %} +{% 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/repeat-list-summary.html b/src/server/plugins/engine/views/repeat-list-summary.html index 3ec8fe578..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 pluginOptions.baseLayoutPath %} +{% 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 b81137af5..30c4e96dc 100644 --- a/src/server/plugins/engine/views/summary.html +++ b/src/server/plugins/engine/views/summary.html @@ -1,4 +1,4 @@ -{% extends pluginOptions.baseLayoutPath %} +{% 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/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index 5a9a6828c..b89ed6063 100644 --- a/src/server/plugins/nunjucks/context.js +++ b/src/server/plugins/nunjucks/context.js @@ -46,6 +46,7 @@ export function context(request) { /** @type {ViewContext} */ const ctx = { + ...request?.server.plugins['forms-engine-plugin'].viewContext, // take consumers props first so we can override it appVersion: pkg.version, assetPath: '/assets', config: { @@ -57,10 +58,6 @@ export function context(request) { serviceName: config.get('serviceName'), serviceVersion: config.get('serviceVersion') }, - pluginOptions: { - baseLayoutPath: - request?.server.plugins['forms-engine-plugin'].baseLayoutPath - }, crumb: safeGenerateCrumb(request), cspNonce: request?.plugins.blankie?.nonces?.script, currentPath: request ? `${request.path}${request.url.search}` : undefined, diff --git a/src/server/plugins/nunjucks/types.js b/src/server/plugins/nunjucks/types.js index 7a1b7d800..6c0c93b7d 100644 --- a/src/server/plugins/nunjucks/types.js +++ b/src/server/plugins/nunjucks/types.js @@ -9,11 +9,6 @@ * @property {object} [context] - Nunjucks render context */ -/** - * @typedef {object} PluginOptions - * @property {string} [baseLayoutPath] - Page layout to extend - */ - /** * @typedef {object} ViewContext - Nunjucks view context * @property {string} appVersion - Application version @@ -27,7 +22,7 @@ * @property {string} [slug] - Form slug * @property {(asset?: string) => string} getAssetPath - Asset path resolver * @property {FormContext} [context] - the current form context - * @property {PluginOptions} [pluginOptions] - the current form context + * @property {PluginOptions['viewContext']} [injectedViewContext] - the current form context */ /** @@ -43,4 +38,5 @@ * @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/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index 62e9d3e5e..f3b3ca056 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 ViewContext } from '~/src/server/plugins/nunjucks/types.js' import { type FormRequest, type FormRequestPayload @@ -20,7 +21,7 @@ declare module '@hapi/hapi' { } 'forms-engine-plugin': { cacheService: CacheService - baseLayoutPath: string + viewContext: ViewContext } } From 8149852eff722c393420a14eaa850eab43861a3d Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 17 Apr 2025 11:09:11 +0100 Subject: [PATCH 04/27] Export engine dir --- package.json | 1 + 1 file changed, 1 insertion(+) 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": { From 788b89a230109f631d37928abdb24079ba9cb6b0 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 17 Apr 2025 16:35:44 +0100 Subject: [PATCH 05/27] take nunjucks paths and view context from consumer --- .../plugins/engine/configureEnginePlugin.ts | 5 +- src/server/plugins/engine/plugin.ts | 54 +++++--- .../plugins/engine/views/file-upload.html | 2 +- src/server/plugins/engine/views/layout.html | 125 ------------------ src/server/plugins/nunjucks/context.js | 8 +- src/server/plugins/nunjucks/context.test.js | 20 +-- src/server/plugins/nunjucks/types.js | 2 +- src/typings/hapi/index.d.ts | 3 +- 8 files changed, 62 insertions(+), 157 deletions(-) delete mode 100644 src/server/plugins/engine/views/layout.html diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index 2d617a671..e3bbec7a9 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -31,8 +31,11 @@ export const configureEnginePlugin = async ({ model, services, controllers, + nunjucks: { + paths: [] // TODO + } viewContext: { - baseLayoutPath: 'layout.html' + baseLayoutPath: 'layout.html' // govuk-frontend } } } diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index cf31c1e65..8c6eec7b1 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,9 @@ 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 { paths } from '../nunjucks/environment.js' import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' import { @@ -65,6 +72,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' +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 +94,9 @@ export interface PluginOptions { viewPaths?: string[] filters?: Record pluginPath?: string + nunjucks: { + paths: string[] + } viewContext: { baseLayoutPath: string } @@ -88,24 +112,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, @@ -131,10 +156,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` @@ -146,7 +168,7 @@ export const plugin = { } } }, - path, + path: paths, // Provides global context used with all templates context } 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/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index b89ed6063..bb5050ec3 100644 --- a/src/server/plugins/nunjucks/context.js +++ b/src/server/plugins/nunjucks/context.js @@ -44,9 +44,13 @@ export function context(request) { const isResponseOK = !Boom.isBoom(response) && response?.statusCode === StatusCodes.OK + const consumerViewContext = + request?.server.plugins['forms-engine-plugin'].viewContext(request) + /** @type {ViewContext} */ const ctx = { - ...request?.server.plugins['forms-engine-plugin'].viewContext, // take consumers props first so we can override it + // take consumers props first so we can override it + ...consumerViewContext, appVersion: pkg.version, assetPath: '/assets', config: { @@ -64,7 +68,7 @@ export function context(request) { previewMode: isPreviewMode ? params?.state : undefined, slug: isResponseOK ? params?.slug : undefined, - getAssetPath: (asset = '') => { + getDxtAssetPath: (asset = '') => { return `/${webpackManifest?.[asset] ?? asset}` } } diff --git a/src/server/plugins/nunjucks/context.test.js b/src/server/plugins/nunjucks/context.test.js index 8827ebe95..40b84a52b 100644 --- a/src/server/plugins/nunjucks/context.test.js +++ b/src/server/plugins/nunjucks/context.test.js @@ -16,13 +16,13 @@ describe('Nunjucks context', () => { describe('Asset helper', () => { it("should locate 'assets-manifest.json' assets", () => { - const { getAssetPath } = context(null) + const { getDxtAssetPath } = context(null) - 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' ) }) @@ -38,20 +38,20 @@ describe('Nunjucks context', () => { // Update config for missing manifest config.set('publicDir', tmpdir()) - const { getAssetPath } = context(null) + const { getDxtAssetPath } = context(null) // 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 } = context(null) - 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') }) }) diff --git a/src/server/plugins/nunjucks/types.js b/src/server/plugins/nunjucks/types.js index 6c0c93b7d..73973df27 100644 --- a/src/server/plugins/nunjucks/types.js +++ b/src/server/plugins/nunjucks/types.js @@ -20,7 +20,7 @@ * @property {string} [currentPath] - Current path * @property {string} [previewMode] - Preview mode * @property {string} [slug] - Form slug - * @property {(asset?: string) => string} getAssetPath - Asset path resolver + * @property {(asset?: string) => string} getDxtAssetPath - Asset path resolver * @property {FormContext} [context] - the current form context * @property {PluginOptions['viewContext']} [injectedViewContext] - the current form context */ diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index f3b3ca056..d5c9d7205 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 ViewContext } from '~/src/server/plugins/nunjucks/types.js' import { type FormRequest, @@ -21,7 +22,7 @@ declare module '@hapi/hapi' { } 'forms-engine-plugin': { cacheService: CacheService - viewContext: ViewContext + viewContext: context } } From b461c45c664a7147994069a396577041ea21d3b8 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Tue, 22 Apr 2025 16:35:52 +0100 Subject: [PATCH 06/27] add local devtool UI --- .../devserver/dxt-devtool-baselayout.html | 81 +++++++++++++++++++ src/server/index.ts | 21 +++-- .../plugins/engine/configureEnginePlugin.ts | 6 +- src/server/plugins/engine/plugin.ts | 11 +-- 4 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 src/server/devserver/dxt-devtool-baselayout.html diff --git a/src/server/devserver/dxt-devtool-baselayout.html b/src/server/devserver/dxt-devtool-baselayout.html new file mode 100644 index 000000000..522b79486 --- /dev/null +++ b/src/server/devserver/dxt-devtool-baselayout.html @@ -0,0 +1,81 @@ +{% 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 %} + {% if config.serviceBannerText | length %} + {{ appServiceBanner({ + title: "Service status", + text: config.serviceBannerText + }) }} + {% endif %} + + {{ govukHeader({ + homepageUrl: currentPath if context.isForceAccess else "https://defra.github.io/forms-engine-plugin/", + containerClasses: "govuk-width-container", + productName: productName | safe | trim, + serviceName: name if name else config.serviceName, + serviceUrl: currentPath if context.isForceAccess else serviceUrl + }) }} +{% endblock %} + +{% block beforeContent %} + {% set feedbackLink = feedbackLink or config.feedbackLink -%} + {% set phaseTag = phaseTag or config.phaseTag -%} + + {% 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/index.ts b/src/server/index.ts index 652fc2466..4786626c8 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,3 +1,5 @@ +import { join } from 'path' + import { Engine as CatboxMemory } from '@hapi/catbox-memory' import { Engine as CatboxRedis } from '@hapi/catbox-redis' import hapi, { @@ -17,11 +19,11 @@ import { requestTracing } from '~/src/server/common/helpers/logging/request-trac 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 plugin from '~/src/server/plugins/engine/index.js' +import { findPackageRoot } from '~/src/server/plugins/engine/plugin.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 { prepareSecureContext } from '~/src/server/secure-context.js' import { type RouteConfig } from '~/src/server/types.js' @@ -82,7 +84,6 @@ export async function createServer(routeConfig?: RouteConfig) { prepareSecureContext(server) } - const pluginEngine = await configureEnginePlugin(routeConfig) const pluginCrumb = configureCrumbPlugin(routeConfig) const pluginBlankie = configureBlankiePlugin() @@ -116,8 +117,18 @@ export async function createServer(routeConfig?: RouteConfig) { }) await server.register(pluginViews) - await server.register(pluginEngine) - await server.register(pluginRouter) + + await server.register({ + plugin, + options: { + cacheName: 'session', + nunjucks: { + paths: [join(findPackageRoot(), 'src/server/devserver')] // this ia development server so we don't need any, but a consumer would provide their own paths + }, + viewContext: () => ({ baseLayoutPath: 'dxt-devtool-baselayout.html' }) // layout.html comes from govuk-frontend but could be defined anywhere in `paths` + } + }) + await server.register(pluginErrorPages) await server.register(blipp) await server.register(requestTracing) diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index e3bbec7a9..9fe799b30 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -33,10 +33,8 @@ export const configureEnginePlugin = async ({ controllers, nunjucks: { paths: [] // TODO - } - viewContext: { - baseLayoutPath: 'layout.html' // govuk-frontend - } + }, + viewContext: () => ({ baseLayoutPath: 'layout.html' }) // layout.html comes from govuk-frontend but could be defined anywhere in `paths` } } } diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 8c6eec7b1..a17fba973 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -17,8 +17,6 @@ import Joi from 'joi' import nunjucks, { type Environment } from 'nunjucks' import resolvePkg from 'resolve' -import { paths } from '../nunjucks/environment.js' - import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' import { checkEmailAddressForLiveFormSubmission, @@ -32,7 +30,6 @@ import { redirectPath } from '~/src/server/plugins/engine/helpers.js' import { - PLUGIN_PATH, VIEW_PATH, context, prepareNunjucksEnvironment @@ -72,7 +69,7 @@ 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' -function findPackageRoot() { +export function findPackageRoot() { const currentFileName = fileURLToPath(import.meta.url) const currentDirectoryName = dirname(currentFileName) @@ -97,9 +94,9 @@ export interface PluginOptions { nunjucks: { paths: string[] } - viewContext: { - baseLayoutPath: string - } + viewContext: ( + request: FormRequest | FormRequestPayload | null + ) => Record & { baseLayoutPath?: string } } export const plugin = { From 3855d550cbfbab1539a7d23c58d6938beb57b56e Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Tue, 22 Apr 2025 16:38:52 +0100 Subject: [PATCH 07/27] Drop redundant configureEnginePlugin --- .../plugins/engine/configureEnginePlugin.ts | 55 ------------------- src/server/plugins/engine/index.ts | 1 - test/form/definitions.test.js | 22 +++++++- 3 files changed, 20 insertions(+), 58 deletions(-) delete mode 100644 src/server/plugins/engine/configureEnginePlugin.ts diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts deleted file mode 100644 index 9fe799b30..000000000 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { join, parse } from 'node:path' - -import { type FormDefinition } from '@defra/forms-model' -import { type ServerRegisterPluginObject } from '@hapi/hapi' - -import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' -import { - plugin, - type PluginOptions -} from '~/src/server/plugins/engine/plugin.js' -import { type RouteConfig } from '~/src/server/types.js' - -export const configureEnginePlugin = async ({ - formFileName, - formFilePath, - services, - controllers -}: RouteConfig = {}): Promise> => { - let model: FormModel | undefined - - if (formFileName && formFilePath) { - const definition = await getForm(join(formFilePath, formFileName)) - const { name } = parse(formFileName) - - model = new FormModel(definition, { basePath: name }, services, controllers) - } - - return { - plugin, - options: { - model, - services, - controllers, - nunjucks: { - paths: [] // TODO - }, - viewContext: () => ({ baseLayoutPath: 'layout.html' }) // layout.html comes from govuk-frontend but could be defined anywhere in `paths` - } - } -} - -export async function getForm(importPath: string) { - const { ext } = parse(importPath) - - const attributes: ImportAttributes = { - type: ext === '.json' ? 'json' : 'module' - } - - const formImport = import(importPath, { with: attributes }) as Promise<{ - default: FormDefinition - }> - - const { default: definition } = await formImport - return definition -} diff --git a/src/server/plugins/engine/index.ts b/src/server/plugins/engine/index.ts index 25f15a43a..7dde3ceda 100644 --- a/src/server/plugins/engine/index.ts +++ b/src/server/plugins/engine/index.ts @@ -11,7 +11,6 @@ import { import * as filters from '~/src/server/plugins/nunjucks/filters/index.js' export { getPageHref } from '~/src/server/plugins/engine/helpers.js' -export { configureEnginePlugin } from '~/src/server/plugins/engine/configureEnginePlugin.js' export { context } from '~/src/server/plugins/nunjucks/context.js' const globals = { diff --git a/test/form/definitions.test.js b/test/form/definitions.test.js index 49a5377f9..9f5a2e577 100644 --- a/test/form/definitions.test.js +++ b/test/form/definitions.test.js @@ -1,10 +1,28 @@ -import { join } from 'node:path' +import { join, parse } from 'node:path' import { formDefinitionSchema } from '@defra/forms-model' -import { getForm } from '~/src/server/plugins/engine/configureEnginePlugin.js' import { getForms } from '~/test/utils/get-form-definitions.js' +/** + * @param {string} importPath + * @returns {Promise} + */ +async function getForm(importPath) { + const { ext } = parse(importPath) + + const attributes = { + type: ext === '.json' ? 'json' : 'module' + } + + const formImport = await import(importPath, { with: attributes }) + + const { default: definition } = formImport + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return definition +} + describe('Form definition JSON', () => { describe.each([ { From 0b43828911849241b9c5c83add3a40fc2851a8c5 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Tue, 22 Apr 2025 17:20:22 +0100 Subject: [PATCH 08/27] make DXT devtool layout more obvious --- src/server/devserver/dxt-devtool-baselayout.html | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/server/devserver/dxt-devtool-baselayout.html b/src/server/devserver/dxt-devtool-baselayout.html index 522b79486..b12f285ab 100644 --- a/src/server/devserver/dxt-devtool-baselayout.html +++ b/src/server/devserver/dxt-devtool-baselayout.html @@ -11,7 +11,7 @@ {% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner -%} {% set productName %} - {{ appTagEnv({ env: "devtool" }) }} + {{ appTagEnv({ env: "devtool" }) }} {% endset %} {% block head %} @@ -43,15 +43,12 @@ homepageUrl: currentPath if context.isForceAccess else "https://defra.github.io/forms-engine-plugin/", containerClasses: "govuk-width-container", productName: productName | safe | trim, - serviceName: name if name else config.serviceName, + serviceName: "Digital Express Toolkit", serviceUrl: currentPath if context.isForceAccess else serviceUrl }) }} {% endblock %} {% block beforeContent %} - {% set feedbackLink = feedbackLink or config.feedbackLink -%} - {% set phaseTag = phaseTag or config.phaseTag -%} - {% if backLink %} {{ govukBackLink(backLink) }} {% endif %} From a8dd77ec37d639c9b9b83ee0dc70edbd7dc424dc Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Tue, 22 Apr 2025 17:30:11 +0100 Subject: [PATCH 09/27] update docs --- src/server/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 4786626c8..693d79819 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -123,9 +123,11 @@ export async function createServer(routeConfig?: RouteConfig) { options: { cacheName: 'session', nunjucks: { - paths: [join(findPackageRoot(), 'src/server/devserver')] // this ia development server so we don't need any, but a consumer would provide their own paths + paths: [join(findPackageRoot(), 'src/server/devserver')] // custom layout to make it really clear this is not the same as the runner }, - viewContext: () => ({ baseLayoutPath: 'dxt-devtool-baselayout.html' }) // layout.html comes from govuk-frontend but could be defined anywhere in `paths` + viewContext: () => ({ + baseLayoutPath: 'dxt-devtool-baselayout.html' // from plugin.options.nunjucks.paths + }) } }) From 8cffdc82be3efa3c28d0523430f4284dea5a57a8 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 24 Apr 2025 09:48:05 +0100 Subject: [PATCH 10/27] fix circular reference error when stringifying error --- src/server/plugins/errorPages.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server/plugins/errorPages.ts b/src/server/plugins/errorPages.ts index aa1805f2a..ae41b80c0 100644 --- a/src/server/plugins/errorPages.ts +++ b/src/server/plugins/errorPages.ts @@ -21,7 +21,6 @@ export default { const error = { statusCode, - data: response.data, message: response.message, stack: response.stack } From 32e3be53813ccae31be7c9c0cd64998ae9549c77 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 24 Apr 2025 10:57:13 +0100 Subject: [PATCH 11/27] an example of custom styling --- src/client/stylesheets/application.scss | 14 ++++++++++++++ src/server/index.ts | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/client/stylesheets/application.scss b/src/client/stylesheets/application.scss index b406a0855..eeb37f835 100644 --- a/src/client/stylesheets/application.scss +++ b/src/client/stylesheets/application.scss @@ -12,3 +12,17 @@ .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 !important; +} + +.govuk-header__container { + border-bottom: 10px solid #003d16 !important; +} +/*****************/ diff --git a/src/server/index.ts b/src/server/index.ts index 693d79819..50a31fc9b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -25,6 +25,7 @@ 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 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' @@ -118,6 +119,15 @@ export async function createServer(routeConfig?: RouteConfig) { await server.register(pluginViews) + await server.register({ + plugin: { + name: 'router', + register: (server) => { + server.route(publicRoutes) + } + } + }) + await server.register({ plugin, options: { From 27b8234ab35e09b7f09be403a91a017adc0f85f8 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 24 Apr 2025 11:04:30 +0100 Subject: [PATCH 12/27] remove unused type --- src/typings/hapi/index.d.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index d5c9d7205..e3a1e448a 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -6,7 +6,6 @@ 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 ViewContext } from '~/src/server/plugins/nunjucks/types.js' import { type FormRequest, type FormRequestPayload From 06c425a438c5ca714eaba4eb44a0454addc7de1e Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 24 Apr 2025 11:08:20 +0100 Subject: [PATCH 13/27] Remove service banner text --- src/config/index.ts | 7 - .../devserver/dxt-devtool-baselayout.html | 7 - src/server/plugins/nunjucks/context.js | 1 - src/server/plugins/nunjucks/context.test.js | 1 - src/server/routes/index.test.ts | 125 ------------------ 5 files changed, 141 deletions(-) delete mode 100644 src/server/routes/index.test.ts diff --git a/src/config/index.ts b/src/config/index.ts index 7bd0e277f..f483ac550 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -253,13 +253,6 @@ 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', format: String, diff --git a/src/server/devserver/dxt-devtool-baselayout.html b/src/server/devserver/dxt-devtool-baselayout.html index b12f285ab..382a655bd 100644 --- a/src/server/devserver/dxt-devtool-baselayout.html +++ b/src/server/devserver/dxt-devtool-baselayout.html @@ -32,13 +32,6 @@ {% endblock %} {% block header %} - {% if config.serviceBannerText | length %} - {{ appServiceBanner({ - title: "Service status", - text: config.serviceBannerText - }) }} - {% endif %} - {{ govukHeader({ homepageUrl: currentPath if context.isForceAccess else "https://defra.github.io/forms-engine-plugin/", containerClasses: "govuk-width-container", diff --git a/src/server/plugins/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index bb5050ec3..5651923fa 100644 --- a/src/server/plugins/nunjucks/context.js +++ b/src/server/plugins/nunjucks/context.js @@ -58,7 +58,6 @@ export function context(request) { 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') }, diff --git a/src/server/plugins/nunjucks/context.test.js b/src/server/plugins/nunjucks/context.test.js index 40b84a52b..b0ac9a584 100644 --- a/src/server/plugins/nunjucks/context.test.js +++ b/src/server/plugins/nunjucks/context.test.js @@ -65,7 +65,6 @@ describe('Nunjucks context', () => { 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') }) 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') - }) -}) From de0a429d0d727266f9fda485af8f45e35a124e54 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 24 Apr 2025 11:13:01 +0100 Subject: [PATCH 14/27] Remove cookies + blankie --- src/common/cookies.js | 58 ---------------- src/common/cookies.test.js | 23 ------- src/common/types.js | 5 -- src/config/index.ts | 7 -- src/server/index.ts | 12 ---- src/server/plugins/blankie.test.ts | 73 --------------------- src/server/plugins/blankie.ts | 48 -------------- src/server/plugins/nunjucks/context.js | 24 ++----- src/server/plugins/nunjucks/context.test.js | 1 - src/server/plugins/nunjucks/types.js | 2 - src/typings/hapi/index.d.ts | 9 --- test/form/cookies.test.js | 21 ++---- 12 files changed, 12 insertions(+), 271 deletions(-) delete mode 100644 src/common/cookies.js delete mode 100644 src/common/cookies.test.js delete mode 100644 src/common/types.js delete mode 100644 src/server/plugins/blankie.test.ts delete mode 100644 src/server/plugins/blankie.ts 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 f483ac550..a578bdbdb 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -251,13 +251,6 @@ export const config = convict({ format: String, default: 'staging', env: 'STAGING_PREFIX' - }, - - googleAnalyticsTrackingId: { - doc: 'Google analytics tracking ID to be used when a user has opted in to additional cookies', - format: String, - default: '', - env: 'GOOGLE_ANALYTICS_TRACKING_ID' } }) diff --git a/src/server/index.ts b/src/server/index.ts index 50a31fc9b..f423e51c3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -17,7 +17,6 @@ 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 plugin from '~/src/server/plugins/engine/index.js' import { findPackageRoot } from '~/src/server/plugins/engine/plugin.js' @@ -86,13 +85,11 @@ export async function createServer(routeConfig?: RouteConfig) { } const pluginCrumb = configureCrumbPlugin(routeConfig) - const pluginBlankie = configureBlankiePlugin() 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) => { @@ -145,14 +142,5 @@ export async function createServer(routeConfig?: RouteConfig) { 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/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index 5651923fa..53e27325b 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' @@ -34,9 +33,8 @@ export function context(request) { } } - const { params, path, query = {}, response, state } = request ?? {} + const { params, path, response } = 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,8 +42,12 @@ export function context(request) { const isResponseOK = !Boom.isBoom(response) && response?.statusCode === StatusCodes.OK - const consumerViewContext = - request?.server.plugins['forms-engine-plugin'].viewContext(request) + const pluginStorage = request?.server.plugins['forms-engine-plugin'] + let consumerViewContext = {} + + if (pluginStorage && 'viewContext' in pluginStorage) { + consumerViewContext = pluginStorage.viewContext(request) + } /** @type {ViewContext} */ const ctx = { @@ -62,7 +64,6 @@ export function context(request) { serviceVersion: config.get('serviceVersion') }, crumb: safeGenerateCrumb(request), - cspNonce: request?.plugins.blankie?.nonces?.script, currentPath: request ? `${request.path}${request.url.search}` : undefined, previewMode: isPreviewMode ? params?.state : undefined, slug: isResponseOK ? params?.slug : undefined, @@ -72,21 +73,10 @@ export function context(request) { } } - if (!isForceAccess) { - ctx.config.googleAnalyticsTrackingId = config.get( - 'googleAnalyticsTrackingId' - ) - - if (typeof state?.cookieConsent === 'string') { - ctx.cookieConsent = parseCookieConsent(state.cookieConsent) - } - } - return ctx } /** - * @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 b0ac9a584..c908e4cb3 100644 --- a/src/server/plugins/nunjucks/context.test.js +++ b/src/server/plugins/nunjucks/context.test.js @@ -63,7 +63,6 @@ describe('Nunjucks context', () => { expect.objectContaining({ cdpEnvironment: config.get('cdpEnvironment'), feedbackLink: encodeUrl(config.get('feedbackLink')), - googleAnalyticsTrackingId: config.get('googleAnalyticsTrackingId'), phaseTag: config.get('phaseTag'), serviceName: config.get('serviceName'), serviceVersion: config.get('serviceVersion') diff --git a/src/server/plugins/nunjucks/types.js b/src/server/plugins/nunjucks/types.js index 73973df27..9a9e13c17 100644 --- a/src/server/plugins/nunjucks/types.js +++ b/src/server/plugins/nunjucks/types.js @@ -14,7 +14,6 @@ * @property {string} appVersion - Application version * @property {string} assetPath - Asset 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 @@ -35,7 +34,6 @@ */ /** - * @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/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index e3a1e448a..f88b0f4ef 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -25,15 +25,6 @@ declare module '@hapi/hapi' { } } - interface PluginsStates { - blankie?: { - nonces?: { - script?: string - style?: string - } - } - } - interface Request { logger: Logger yar: Yar diff --git a/test/form/cookies.test.js b/test/form/cookies.test.js index c32d95ebf..15aea5bb3 100644 --- a/test/form/cookies.test.js +++ b/test/form/cookies.test.js @@ -76,8 +76,7 @@ describe(`Cookie banner and analytics`, () => { const headers = getCookieHeader(sessionInitialisationResponse, [ 'crumb', - 'session', - 'cookieConsent' + 'session' ]) const { container, document } = await renderResponse(server, { @@ -128,8 +127,7 @@ describe(`Cookie banner and analytics`, () => { const headers = getCookieHeader(sessionInitialisationResponse, [ 'crumb', - 'session', - 'cookieConsent' + 'session' ]) const { container, document } = await renderResponse(server, { @@ -182,8 +180,7 @@ describe(`Cookie banner and analytics`, () => { const headers = getCookieHeader(sessionInitialisationResponse, [ 'crumb', - 'session', - 'cookieConsent' + 'session' ]) const { container } = await renderResponse(server, { @@ -234,11 +231,7 @@ describe(`Cookie preferences`, () => { const headers = { Referer: '/help/cookie-preferences/basic', - ...getCookieHeader(sessionInitialisationResponse, [ - 'crumb', - 'session', - 'cookieConsent' - ]) + ...getCookieHeader(sessionInitialisationResponse, ['crumb', 'session']) } const { container } = await renderResponse(server, { @@ -276,11 +269,7 @@ describe(`Cookie preferences`, () => { }) const headers = { - ...getCookieHeader(sessionInitialisationResponse, [ - 'crumb', - 'session', - 'cookieConsent' - ]) + ...getCookieHeader(sessionInitialisationResponse, ['crumb', 'session']) } const { container } = await renderResponse(server, { From 68f6ea34d2c0721fee31cdc9a81af02749d6e3e9 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 24 Apr 2025 11:41:13 +0100 Subject: [PATCH 15/27] Revert "Drop redundant configureEnginePlugin" This reverts commit a8a59a7547fd28fe37a383e5204d81781c5e2b6e. --- src/server/index.ts | 21 ++----- .../plugins/engine/configureEnginePlugin.ts | 59 +++++++++++++++++++ src/server/plugins/engine/index.ts | 1 + test/form/definitions.test.js | 22 +------ 4 files changed, 66 insertions(+), 37 deletions(-) create mode 100644 src/server/plugins/engine/configureEnginePlugin.ts diff --git a/src/server/index.ts b/src/server/index.ts index f423e51c3..c89ba551b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,3 @@ -import { join } from 'path' - import { Engine as CatboxMemory } from '@hapi/catbox-memory' import { Engine as CatboxRedis } from '@hapi/catbox-redis' import hapi, { @@ -13,13 +11,13 @@ import Wreck from '@hapi/wreck' import blipp from 'blipp' import { ProxyAgent } from 'proxy-agent' + 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 { configureCrumbPlugin } from '~/src/server/plugins/crumb.js' -import plugin from '~/src/server/plugins/engine/index.js' -import { findPackageRoot } from '~/src/server/plugins/engine/plugin.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' @@ -85,6 +83,7 @@ export async function createServer(routeConfig?: RouteConfig) { } const pluginCrumb = configureCrumbPlugin(routeConfig) + const pluginEngine = await configureEnginePlugin(routeConfig) await server.register(pluginSession) await server.register(pluginPulse) @@ -115,6 +114,7 @@ export async function createServer(routeConfig?: RouteConfig) { }) await server.register(pluginViews) + await server.register(pluginEngine) await server.register({ plugin: { @@ -125,19 +125,6 @@ export async function createServer(routeConfig?: RouteConfig) { } }) - await server.register({ - plugin, - options: { - cacheName: 'session', - nunjucks: { - paths: [join(findPackageRoot(), 'src/server/devserver')] // custom layout to make it really clear this is not the same as the runner - }, - viewContext: () => ({ - baseLayoutPath: 'dxt-devtool-baselayout.html' // from plugin.options.nunjucks.paths - }) - } - }) - await server.register(pluginErrorPages) await server.register(blipp) await server.register(requestTracing) diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts new file mode 100644 index 000000000..8d427223b --- /dev/null +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -0,0 +1,59 @@ +import { join, parse } from 'node:path' + +import { type FormDefinition } from '@defra/forms-model' +import { type ServerRegisterPluginObject } from '@hapi/hapi' + +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { + plugin, + type PluginOptions +} from '~/src/server/plugins/engine/plugin.js' +import { findPackageRoot } from '~/src/server/plugins/engine/plugin.js' +import { type RouteConfig } from '~/src/server/types.js' + +export const configureEnginePlugin = async ({ + formFileName, + formFilePath, + services, + controllers +}: RouteConfig = {}): Promise> => { + let model: FormModel | undefined + + if (formFileName && formFilePath) { + const definition = await getForm(join(formFilePath, formFileName)) + const { name } = parse(formFileName) + + model = new FormModel(definition, { basePath: name }, services, controllers) + } + + return { + plugin, + options: { + model, + services, + controllers, + cacheName: 'session', + nunjucks: { + paths: [join(findPackageRoot(), 'src/server/devserver')] // custom layout to make it really clear this is not the same as the runner + }, + viewContext: () => ({ + baseLayoutPath: 'dxt-devtool-baselayout.html' // from plugin.options.nunjucks.paths + }) + } + } +} + +export async function getForm(importPath: string) { + const { ext } = parse(importPath) + + const attributes: ImportAttributes = { + type: ext === '.json' ? 'json' : 'module' + } + + const formImport = import(importPath, { with: attributes }) as Promise<{ + default: FormDefinition + }> + + const { default: definition } = await formImport + return definition +} diff --git a/src/server/plugins/engine/index.ts b/src/server/plugins/engine/index.ts index 7dde3ceda..25f15a43a 100644 --- a/src/server/plugins/engine/index.ts +++ b/src/server/plugins/engine/index.ts @@ -11,6 +11,7 @@ import { import * as filters from '~/src/server/plugins/nunjucks/filters/index.js' export { getPageHref } from '~/src/server/plugins/engine/helpers.js' +export { configureEnginePlugin } from '~/src/server/plugins/engine/configureEnginePlugin.js' export { context } from '~/src/server/plugins/nunjucks/context.js' const globals = { diff --git a/test/form/definitions.test.js b/test/form/definitions.test.js index 9f5a2e577..49a5377f9 100644 --- a/test/form/definitions.test.js +++ b/test/form/definitions.test.js @@ -1,28 +1,10 @@ -import { join, parse } from 'node:path' +import { join } from 'node:path' import { formDefinitionSchema } from '@defra/forms-model' +import { getForm } from '~/src/server/plugins/engine/configureEnginePlugin.js' import { getForms } from '~/test/utils/get-form-definitions.js' -/** - * @param {string} importPath - * @returns {Promise} - */ -async function getForm(importPath) { - const { ext } = parse(importPath) - - const attributes = { - type: ext === '.json' ? 'json' : 'module' - } - - const formImport = await import(importPath, { with: attributes }) - - const { default: definition } = formImport - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return definition -} - describe('Form definition JSON', () => { describe.each([ { From 85970624570d915c1162b171762d2362126d2449 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 24 Apr 2025 11:47:50 +0100 Subject: [PATCH 16/27] remove runner responsibilities: health, cookies tests --- src/server/routes/health.js | 13 -- src/server/routes/health.test.js | 35 ---- src/server/routes/index.ts | 1 - test/form/cookies.test.js | 327 ------------------------------- 4 files changed, 376 deletions(-) delete mode 100644 src/server/routes/health.js delete mode 100644 src/server/routes/health.test.js delete mode 100644 test/form/cookies.test.js 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.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/test/form/cookies.test.js b/test/form/cookies.test.js deleted file mode 100644 index 15aea5bb3..000000000 --- a/test/form/cookies.test.js +++ /dev/null @@ -1,327 +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' - ]) - - 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' - ]) - - 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' - ]) - - 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']) - } - - 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']) - } - - 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' - */ From 70347d2aa3ce5e91085f9cc606bc1d4f143be665 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 24 Apr 2025 11:52:38 +0100 Subject: [PATCH 17/27] remove runner responsibility: feedback tests --- test/form/feedback.test.js | 68 -------------------------------------- 1 file changed, 68 deletions(-) delete mode 100644 test/form/feedback.test.js 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' - */ From 3d9527a0430f386d5a479665eab1088b78883e06 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 24 Apr 2025 11:56:11 +0100 Subject: [PATCH 18/27] Remove consumer responsibility: phase banner --- test/form/definitions/phase-alpha.json | 33 ----------- test/form/definitions/phase-default.json | 26 --------- test/form/phase-banner.test.js | 71 ------------------------ 3 files changed, 130 deletions(-) delete mode 100644 test/form/definitions/phase-alpha.json delete mode 100644 test/form/definitions/phase-default.json delete mode 100644 test/form/phase-banner.test.js 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/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' - */ From c2e40f87d024f279bb72bde043462b5b889bc6b9 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 24 Apr 2025 11:58:08 +0100 Subject: [PATCH 19/27] fix linting issues --- src/client/stylesheets/application.scss | 12 ++++-------- src/server/index.ts | 1 - 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/client/stylesheets/application.scss b/src/client/stylesheets/application.scss index eeb37f835..45e41c5b1 100644 --- a/src/client/stylesheets/application.scss +++ b/src/client/stylesheets/application.scss @@ -13,16 +13,12 @@ @include govuk-typography-common; } -/*****************/ -/* - An example of some user-supplied styling - Not great practice but it illustrates the point -*/ +// An example of some user-supplied styling +// Not great practice but it illustrates the point .govuk-header { - background: #008531 !important; + background: #008531; } .govuk-header__container { - border-bottom: 10px solid #003d16 !important; + border-bottom: 10px solid #003d16; } -/*****************/ diff --git a/src/server/index.ts b/src/server/index.ts index c89ba551b..8c40298ce 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -11,7 +11,6 @@ import Wreck from '@hapi/wreck' import blipp from 'blipp' import { ProxyAgent } from 'proxy-agent' - 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' From 1f80acf31e3ffce487886fe40cc7e56661c34696 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 24 Apr 2025 11:59:13 +0100 Subject: [PATCH 20/27] Remove runner responsibility: help page tests --- src/server/index.test.ts | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/src/server/index.test.ts b/src/server/index.test.ts index d4f108397..7268a2cf3 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -453,42 +453,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', () => { From 1efb66f68d8ab5b26e87a65e3ec9c9c47fc130cd Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 24 Apr 2025 12:32:57 +0100 Subject: [PATCH 21/27] Move devtool view context into separate function --- .../plugins/engine/configureEnginePlugin.ts | 5 +-- src/server/plugins/nunjucks/context.js | 38 +++++++++++-------- src/server/plugins/nunjucks/context.test.js | 15 +++++--- src/server/plugins/nunjucks/types.js | 2 - 4 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index 8d427223b..6dd25e8f3 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -9,6 +9,7 @@ import { type PluginOptions } from '~/src/server/plugins/engine/plugin.js' import { findPackageRoot } from '~/src/server/plugins/engine/plugin.js' +import { devtoolContext } from '~/src/server/plugins/nunjucks/context.js' import { type RouteConfig } from '~/src/server/types.js' export const configureEnginePlugin = async ({ @@ -36,9 +37,7 @@ export const configureEnginePlugin = async ({ nunjucks: { paths: [join(findPackageRoot(), 'src/server/devserver')] // custom layout to make it really clear this is not the same as the runner }, - viewContext: () => ({ - baseLayoutPath: 'dxt-devtool-baselayout.html' // from plugin.options.nunjucks.paths - }) + viewContext: devtoolContext } } } diff --git a/src/server/plugins/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index 53e27325b..f2d4060ea 100644 --- a/src/server/plugins/nunjucks/context.js +++ b/src/server/plugins/nunjucks/context.js @@ -22,17 +22,6 @@ 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 isPreviewMode = path?.startsWith(PREVIEW_PATH_PREFIX) @@ -54,7 +43,6 @@ export function context(request) { // take consumers props first so we can override it ...consumerViewContext, appVersion: pkg.version, - assetPath: '/assets', config: { cdpEnvironment: config.get('cdpEnvironment'), designerUrl: config.get('designerUrl'), @@ -66,14 +54,34 @@ export function context(request) { crumb: safeGenerateCrumb(request), currentPath: request ? `${request.path}${request.url.search}` : undefined, previewMode: isPreviewMode ? params?.state : undefined, - slug: isResponseOK ? params?.slug : undefined, + slug: isResponseOK ? params?.slug : undefined + } + + 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 (!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 { + baseLayoutPath: 'dxt-devtool-baselayout.html', // from plugin.options.nunjucks.paths + assetPath: '/assets', getDxtAssetPath: (asset = '') => { return `/${webpackManifest?.[asset] ?? asset}` } } - - return ctx } /** diff --git a/src/server/plugins/nunjucks/context.test.js b/src/server/plugins/nunjucks/context.test.js index c908e4cb3..823a9a2b7 100644 --- a/src/server/plugins/nunjucks/context.test.js +++ b/src/server/plugins/nunjucks/context.test.js @@ -2,21 +2,24 @@ 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 { getDxtAssetPath } = context(null) + const { getDxtAssetPath } = devtoolContext() expect(getDxtAssetPath('example.scss')).toBe( '/stylesheets/example.xxxxxxx.min.css' @@ -32,13 +35,13 @@ 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 { getDxtAssetPath } = context(null) + const { getDxtAssetPath } = devtoolContext() // Uses original paths when missing expect(getDxtAssetPath('example.scss')).toBe('/example.scss') @@ -47,7 +50,7 @@ describe('Nunjucks context', () => { }) it('should return path to unknown assets', () => { - const { getDxtAssetPath } = context(null) + const { getDxtAssetPath } = devtoolContext() expect(getDxtAssetPath()).toBe('/') expect(getDxtAssetPath('example.jpg')).toBe('/example.jpg') diff --git a/src/server/plugins/nunjucks/types.js b/src/server/plugins/nunjucks/types.js index 9a9e13c17..dd21d9a16 100644 --- a/src/server/plugins/nunjucks/types.js +++ b/src/server/plugins/nunjucks/types.js @@ -12,14 +12,12 @@ /** * @typedef {object} ViewContext - Nunjucks view context * @property {string} appVersion - Application version - * @property {string} assetPath - Asset path * @property {Partial} config - Application config properties * @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} getDxtAssetPath - Asset path resolver * @property {FormContext} [context] - the current form context * @property {PluginOptions['viewContext']} [injectedViewContext] - the current form context */ From 45ce42bf100a5cb10db72073b418944a22d60f07 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 24 Apr 2025 14:51:38 +0100 Subject: [PATCH 22/27] Load engine forms from disk --- docs/GETTING_STARTED.md | 3 +- jest.setup.cjs | 1 - src/config/index.ts | 15 +- src/server/forms/README.md | 10 - .../forms/register-as-a-unicorn-breeder.json | 393 ++++++++++++ .../forms/register-as-a-unicorn-breeder.yaml | 251 ++++++++ src/server/forms/report-a-terrorist.json | 270 -------- src/server/forms/runner-components-test.json | 365 ----------- src/server/forms/test.json | 581 ------------------ src/server/forms/test.yaml | 363 ----------- .../plugins/engine/services/formsService.js | 81 ++- .../engine/services/formsService.test.js | 90 --- src/server/plugins/engine/services/index.js | 2 +- src/server/utils/file-form-service.test.js | 79 --- 14 files changed, 693 insertions(+), 1811 deletions(-) delete mode 100644 src/server/forms/README.md create mode 100644 src/server/forms/register-as-a-unicorn-breeder.json create mode 100644 src/server/forms/register-as-a-unicorn-breeder.yaml delete mode 100644 src/server/forms/report-a-terrorist.json delete mode 100644 src/server/forms/runner-components-test.json delete mode 100644 src/server/forms/test.json delete mode 100644 src/server/forms/test.yaml delete mode 100644 src/server/plugins/engine/services/formsService.test.js delete mode 100644 src/server/utils/file-form-service.test.js diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index a96d754e0..15abd3262 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -116,8 +116,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/src/config/index.ts b/src/config/index.ts index a578bdbdb..8a2e53f20 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', @@ -251,7 +245,14 @@ export const config = convict({ format: String, default: 'staging', env: 'STAGING_PREFIX' - } + }, + + submissionEmailAddress: { + doc: 'Email address to send the form to (local devtool only)', + format: String, + default: null, + env: 'SUBMISSION_EMAIL_ADDRESS' + } as SchemaObj }) config.validate({ allowed: 'strict' }) 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/plugins/engine/services/formsService.js b/src/server/plugins/engine/services/formsService.js index 3e0ac0f1f..232fa7484 100644 --- a/src/server/plugins/engine/services/formsService.js +++ b/src/server/plugins/engine/services/formsService.js @@ -1,46 +1,43 @@ -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' - -/** - * Retrieves a form definition from the form manager for a given slug - * @param {string} slug - the slug of the form - */ -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 +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 } - -/** - * 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 - */ -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 +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 } -/** - * @import { FormDefinition, FormMetadata } from '@defra/forms-model' - */ +// 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 +}) + +// Get the forms service +export const formsService = loader.toFormsService() 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/index.js b/src/server/plugins/engine/services/index.js index b990525f2..3976b311a 100644 --- a/src/server/plugins/engine/services/index.js +++ b/src/server/plugins/engine/services/index.js @@ -1,3 +1,3 @@ -export * as formsService from '~/src/server/plugins/engine/services/formsService.js' +export { formsService } from '~/src/server/plugins/engine/services/formsService.js' export * as formSubmissionService from '~/src/server/plugins/engine/services/formSubmissionService.js' export * as outputService from '~/src/server/plugins/engine/services/notifyService.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'") - }) -}) From 94ab1fc6f79b6d218ab9f2b47b2836c369f75fd6 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 24 Apr 2025 16:01:05 +0100 Subject: [PATCH 23/27] Only load file-based forms when running the devserver --- src/config/index.ts | 2 +- .../plugins/engine/configureEnginePlugin.ts | 7 ++- .../plugins/engine/services/formsService.js | 48 ++++--------------- src/server/plugins/engine/services/index.js | 2 +- .../engine/services/localFormsService.js | 45 +++++++++++++++++ 5 files changed, 61 insertions(+), 43 deletions(-) create mode 100644 src/server/plugins/engine/services/localFormsService.js diff --git a/src/config/index.ts b/src/config/index.ts index 8a2e53f20..efdcb2c1b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -250,7 +250,7 @@ export const config = convict({ submissionEmailAddress: { doc: 'Email address to send the form to (local devtool only)', format: String, - default: null, + default: '', env: 'SUBMISSION_EMAIL_ADDRESS' } as SchemaObj }) diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index 6dd25e8f3..747d01205 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -9,6 +9,8 @@ import { 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' @@ -31,7 +33,10 @@ export const configureEnginePlugin = async ({ plugin, options: { model, - services, + services: { + ...defaultServices, + formsService: await formsService() + }, controllers, cacheName: 'session', nunjucks: { diff --git a/src/server/plugins/engine/services/formsService.js b/src/server/plugins/engine/services/formsService.js index 232fa7484..a97eb38cb 100644 --- a/src/server/plugins/engine/services/formsService.js +++ b/src/server/plugins/engine/services/formsService.js @@ -1,43 +1,11 @@ -import { config } from '~/src/config/index.js' -import { FileFormService } from '~/src/server/utils/file-form-service.js' +const error = Error( + 'Not implemented. Consider setting up a form loader or providing a service implementation.' +) -// Create shared form metadata -const now = new Date() -const user = { id: 'user', displayName: 'Username' } -const author = { - createdAt: now, - createdBy: user, - updatedAt: now, - updatedBy: user +export function getFormMetadata() { + throw error } -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 -} - -// 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 -}) - -// Get the forms service -export const formsService = loader.toFormsService() +export function getFormDefinition() { + throw error +} diff --git a/src/server/plugins/engine/services/index.js b/src/server/plugins/engine/services/index.js index 3976b311a..b990525f2 100644 --- a/src/server/plugins/engine/services/index.js +++ b/src/server/plugins/engine/services/index.js @@ -1,3 +1,3 @@ -export { formsService } from '~/src/server/plugins/engine/services/formsService.js' +export * as formsService from '~/src/server/plugins/engine/services/formsService.js' export * as formSubmissionService from '~/src/server/plugins/engine/services/formSubmissionService.js' export * as outputService from '~/src/server/plugins/engine/services/notifyService.js' diff --git a/src/server/plugins/engine/services/localFormsService.js b/src/server/plugins/engine/services/localFormsService.js new file mode 100644 index 000000000..d0d4013e2 --- /dev/null +++ b/src/server/plugins/engine/services/localFormsService.js @@ -0,0 +1,45 @@ +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 +} + +// Get the forms service +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() +} From 16136378acfc03bd5f4644e1c648c4b21191d8a5 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 24 Apr 2025 16:43:30 +0100 Subject: [PATCH 24/27] Resolve form mocking issue when running tests --- src/server/index.test.ts | 5 ++++- .../plugins/engine/configureEnginePlugin.ts | 3 ++- .../plugins/engine/services/formsService.js | 21 +++++++++++++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 7268a2cf3..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() }) diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index 747d01205..5f4132d51 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -33,7 +33,8 @@ export const configureEnginePlugin = async ({ plugin, options: { model, - services: { + services: services ?? { + // services for testing, else use the disk loader option for running this service locally ...defaultServices, formsService: await formsService() }, diff --git a/src/server/plugins/engine/services/formsService.js b/src/server/plugins/engine/services/formsService.js index a97eb38cb..cb766daa7 100644 --- a/src/server/plugins/engine/services/formsService.js +++ b/src/server/plugins/engine/services/formsService.js @@ -2,10 +2,27 @@ const error = Error( 'Not implemented. Consider setting up a form loader or providing a service implementation.' ) -export function getFormMetadata() { +// eslint-disable-next-line jsdoc/require-returns-check +/** + * Dummy function to get form metadata. + * @param {string} _slug - the slug of the form + * @returns {Promise} + */ +export function getFormMetadata(_slug) { throw error } -export function getFormDefinition() { +// eslint-disable-next-line jsdoc/require-returns-check +/** + * 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 function getFormDefinition(_id, _state) { throw error } + +/** + * @import { FormStatus, FormDefinition, FormMetadata } from '@defra/forms-model' + */ From 8c81a25c5ca3ab89688d72b4e93f030c7d506a08 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 25 Apr 2025 14:03:06 +0100 Subject: [PATCH 25/27] Move base layout into nunjucks options --- docs/GETTING_STARTED.md | 30 ++++++++++++++++++- .../plugins/engine/configureEnginePlugin.ts | 1 + src/server/plugins/engine/plugin.ts | 4 ++- src/server/plugins/nunjucks/context.js | 14 +++++++-- src/server/plugins/nunjucks/types.js | 1 + src/typings/hapi/index.d.ts | 1 + 6 files changed, 46 insertions(+), 5 deletions(-) diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 15abd3262..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) diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index 5f4132d51..c4d08bbdc 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -41,6 +41,7 @@ export const configureEnginePlugin = async ({ 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 a17fba973..310dca477 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -92,11 +92,12 @@ export interface PluginOptions { filters?: Record pluginPath?: string nunjucks: { + baseLayoutPath: string paths: string[] } viewContext: ( request: FormRequest | FormRequestPayload | null - ) => Record & { baseLayoutPath?: string } + ) => Record } export const plugin = { @@ -171,6 +172,7 @@ export const plugin = { } }) + server.expose('baseLayoutPath', nunjucksOptions.baseLayoutPath) server.expose('viewContext', viewContext) server.expose('cacheService', cacheService) diff --git a/src/server/plugins/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index f2d4060ea..bd8b4d29d 100644 --- a/src/server/plugins/nunjucks/context.js +++ b/src/server/plugins/nunjucks/context.js @@ -34,7 +34,15 @@ export function context(request) { const pluginStorage = request?.server.plugins['forms-engine-plugin'] let consumerViewContext = {} - if (pluginStorage && 'viewContext' in pluginStorage) { + 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) } @@ -42,6 +50,7 @@ export function context(request) { const ctx = { // take consumers props first so we can override it ...consumerViewContext, + baseLayoutPath: pluginStorage.baseLayoutPath, appVersion: pkg.version, config: { cdpEnvironment: config.get('cdpEnvironment'), @@ -52,7 +61,7 @@ export function context(request) { serviceVersion: config.get('serviceVersion') }, crumb: safeGenerateCrumb(request), - currentPath: request ? `${request.path}${request.url.search}` : undefined, + currentPath: `${request.path}${request.url.search}`, previewMode: isPreviewMode ? params?.state : undefined, slug: isResponseOK ? params?.slug : undefined } @@ -76,7 +85,6 @@ export function devtoolContext() { } return { - baseLayoutPath: 'dxt-devtool-baselayout.html', // from plugin.options.nunjucks.paths assetPath: '/assets', getDxtAssetPath: (asset = '') => { return `/${webpackManifest?.[asset] ?? asset}` diff --git a/src/server/plugins/nunjucks/types.js b/src/server/plugins/nunjucks/types.js index dd21d9a16..245820ad6 100644 --- a/src/server/plugins/nunjucks/types.js +++ b/src/server/plugins/nunjucks/types.js @@ -12,6 +12,7 @@ /** * @typedef {object} ViewContext - Nunjucks view context * @property {string} appVersion - Application version + * @property {string} [baseLayoutPath] - Base layout path * @property {Partial} config - Application config properties * @property {string} [crumb] - Cross-Site Request Forgery (CSRF) token * @property {string} [cspNonce] - Content Security Policy (CSP) nonce diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index f88b0f4ef..11f5c27f2 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -20,6 +20,7 @@ declare module '@hapi/hapi' { generate?: (request: Request | FormRequest | FormRequestPayload) => string } 'forms-engine-plugin': { + baseLayoutPath: string cacheService: CacheService viewContext: context } From 8ab723d12d4107aecc9b30b6069d595339b2e7a4 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 25 Apr 2025 14:41:49 +0100 Subject: [PATCH 26/27] clarify when localFormsService returns a function, not the serviec --- src/server/plugins/engine/services/localFormsService.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/services/localFormsService.js b/src/server/plugins/engine/services/localFormsService.js index d0d4013e2..bdbce67d1 100644 --- a/src/server/plugins/engine/services/localFormsService.js +++ b/src/server/plugins/engine/services/localFormsService.js @@ -20,7 +20,11 @@ const metadata = { live: author } -// Get the forms service +/** + * 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() From a2509b09d255a190cd475439df3c4afc44fa944a Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 25 Apr 2025 14:48:21 +0100 Subject: [PATCH 27/27] patch context test for new required plugin --- src/server/plugins/nunjucks/context.test.js | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/server/plugins/nunjucks/context.test.js b/src/server/plugins/nunjucks/context.test.js index 823a9a2b7..91865cf4d 100644 --- a/src/server/plugins/nunjucks/context.test.js +++ b/src/server/plugins/nunjucks/context.test.js @@ -1,7 +1,5 @@ import { tmpdir } from 'node:os' -import { config } from '~/src/config/index.js' -import { encodeUrl } from '~/src/server/plugins/engine/helpers.js' import { context, devtoolContext @@ -60,16 +58,8 @@ describe('Nunjucks context', () => { 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')), - phaseTag: config.get('phaseTag'), - serviceName: config.get('serviceName'), - serviceVersion: config.get('serviceVersion') - }) + expect(() => context(null)).toThrow( + 'context called before plugin registered' ) }) }) @@ -84,6 +74,9 @@ describe('Nunjucks context', () => { plugins: { crumb: { generate: jest.fn() + }, + 'forms-engine-plugin': { + baseLayoutPath: 'randomValue' } } }, @@ -114,6 +107,9 @@ describe('Nunjucks context', () => { plugins: { crumb: { generate: jest.fn().mockReturnValue(mockCrumb) + }, + 'forms-engine-plugin': { + baseLayoutPath: 'randomValue' } } },