From ecac5116ee604b8dabe01385b0edbee635e4c57f Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 22 Aug 2025 12:55:43 +0100 Subject: [PATCH 1/6] Custom buttons --- src/server/plugins/engine/options.js | 10 ++++- src/server/plugins/engine/plugin.ts | 22 ++++++++++ src/server/plugins/engine/types.ts | 1 + .../plugins/engine/views/partials/form.html | 44 ++++++++++++++----- src/server/plugins/nunjucks/context.js | 3 +- src/server/plugins/nunjucks/types.js | 1 + src/typings/hapi/index.d.ts | 5 +++ 7 files changed, 72 insertions(+), 14 deletions(-) diff --git a/src/server/plugins/engine/options.js b/src/server/plugins/engine/options.js index aec627846..c1f1038f9 100644 --- a/src/server/plugins/engine/options.js +++ b/src/server/plugins/engine/options.js @@ -24,7 +24,15 @@ const pluginRegistrationOptionsSchema = Joi.object({ keyGenerator: Joi.function(), sessionHydrator: Joi.function(), sessionPersister: Joi.function() - }).optional() + }).optional(), + buttons: Joi.array() + .items( + Joi.object({ + text: Joi.string().required(), + action: Joi.string().optional() + }) + ) + .optional() }) /** diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index f915614c4..33a6ebd2a 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -53,6 +53,7 @@ export const plugin = { server.expose('viewContext', viewContext) server.expose('cacheService', cacheService) server.expose('saveAndReturn', saveAndReturn) + server.expose('buttons', getButtons(options)) server.app.model = model @@ -99,3 +100,24 @@ export const plugin = { server.route(routes as unknown as ServerRoute[]) // TODO } } satisfies Plugin + +function getButtons(pluginOptions: PluginOptions) { + let buttons: PluginOptions['buttons'] = [ + { + text: 'Continue' + } + ] + + if (pluginOptions.saveAndReturn) { + buttons.push({ + text: 'Save and return', + action: 'action' + }) + } + + if (pluginOptions.buttons) { + buttons = pluginOptions.buttons + } + + return buttons +} diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 3a566d6ff..a7f2b9012 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -383,4 +383,5 @@ export interface PluginOptions { preparePageEventRequestOptions?: PreparePageEventRequestOptions onRequest?: OnRequestCallback baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com" + buttons?: PluginProperties['forms-engine-plugin']['buttons'] } diff --git a/src/server/plugins/engine/views/partials/form.html b/src/server/plugins/engine/views/partials/form.html index fb3476725..4714f887b 100644 --- a/src/server/plugins/engine/views/partials/form.html +++ b/src/server/plugins/engine/views/partials/form.html @@ -7,20 +7,40 @@ {{ componentList(components) }}
- {{ govukButton({ - text: "Start now" if isStartPage else "Continue", - isStartButton: isStartPage, - preventDoubleClick: true - }) }} + {% for button in buttons %} + + {% set isFirst = loop.index0 == 0 %} + + {% if isFirst and isStartPage %} + {% set btnText = "Start now" %} + {% set btnIsStart = true %} + {% else %} + {% set btnText = button.text %} + {% set btnIsStart = false %} + {% endif %} + + {% if button.action %} + {% set btnName = 'action' %} + {% else %} + {% set btnName = undefined %} + {% endif %} + + {% set btnValue = button.action %} + + {% if isFirst %} + {% set btnClasses = "" %} + {% else %} + {% set btnClasses = "govuk-button--secondary" %} + {% endif %} - {% if allowSaveAndReturn %} {{ govukButton({ - text: "Save and return", - classes: "govuk-button--secondary", - name: "action", - value: "save-and-return", - preventDoubleClick: true + text: btnText, + isStartButton: btnIsStart, + name: btnName, + value: btnValue, + preventDoubleClick: true, + classes: btnClasses }) }} - {% endif %} + {% endfor %}
diff --git a/src/server/plugins/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index 6d39a3e50..8ca6adb36 100644 --- a/src/server/plugins/nunjucks/context.js +++ b/src/server/plugins/nunjucks/context.js @@ -54,7 +54,8 @@ export async function context(request) { crumb: safeGenerateCrumb(request), currentPath: `${request.path}${request.url.search}`, previewMode: isPreviewMode ? formState : undefined, - slug: isResponseOK ? params?.slug : undefined + slug: isResponseOK ? params?.slug : undefined, + buttons: pluginStorage.buttons } return ctx diff --git a/src/server/plugins/nunjucks/types.js b/src/server/plugins/nunjucks/types.js index 2ad7ad3d7..530ea3b70 100644 --- a/src/server/plugins/nunjucks/types.js +++ b/src/server/plugins/nunjucks/types.js @@ -17,6 +17,7 @@ * @property {string} [currentPath] - Current path * @property {string} [previewMode] - Preview mode * @property {string} [slug] - Form slug + * @property {object[]} [buttons] - Button override * @property {FormContext} [context] - the current form context */ diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index 8067b4e43..eff3c68c6 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -26,6 +26,11 @@ declare module '@hapi/hapi' { request: FormRequest | FormRequestPayload | null ) => Record | Promise> saveAndReturn?: PluginOptions['saveAndReturn'] + buttons?: { + text: string + name?: string + action?: string + }[] } } From 5f5df92f1698a68c1ad99673fd44c4a2ab190bbd Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 22 Aug 2025 14:23:49 +0100 Subject: [PATCH 2/6] Custom action handlers --- .../plugins/engine/configureEnginePlugin.ts | 22 +++++++++++ .../pageControllers/QuestionPageController.ts | 38 ++++--------------- .../engine/pageControllers/buttonHandlers.ts | 32 ++++++++++++++++ src/server/plugins/engine/plugin.ts | 17 ++++++++- src/server/plugins/engine/types.ts | 1 + src/typings/hapi/index.d.ts | 5 +++ 6 files changed, 83 insertions(+), 32 deletions(-) create mode 100644 src/server/plugins/engine/pageControllers/buttonHandlers.ts diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index c003d48c2..871101785 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -61,6 +61,28 @@ export const configureEnginePlugin = async ({ baseUrl: 'http://localhost:3009', // always runs locally saveAndReturn } + + /* + To enable save and return for testing purposes, use this config: + + ``` + saveAndReturn: { + keyGenerator: (_) => { + return `save-and-return` + }, + sessionHydrator: (_) => { + return Promise.resolve({ + applicantFirstName: 'Joe' + }) + }, + sessionPersister: () => { + console.log('no-op') + } + } + ``` + + Then load http://localhost:3009/page-events-demo and the applicantFirstName should be pre-filled as 'Joe' + */ } } diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index c43afb82f..a333eadf8 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -8,7 +8,7 @@ import { type Link, type Page } from '@defra/forms-model' -import Boom from '@hapi/boom' +// import Boom from '@hapi/boom' // No longer needed import { type ResponseToolkit, type RouteOptions } from '@hapi/hapi' import { type ValidationErrorItem } from 'joi' @@ -18,7 +18,6 @@ import { type BackLink } from '~/src/server/plugins/engine/components/types.js' import { getCacheService, getErrors, - getSaveAndReturnHelpers, normalisePath, proceed } from '~/src/server/plugins/engine/helpers.js' @@ -35,7 +34,6 @@ import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js' import { - FormAction, type FormRequest, type FormRequestPayload, type FormRequestPayloadRefs, @@ -515,10 +513,13 @@ export class QuestionPageController extends PageController { return h.view(viewName, viewModel) } - // Check if this is a save-and-return action + // Check if this is a custom action that needs handling const { action } = request.payload - if (action === FormAction.SaveAndReturn) { - return this.handleSaveAndReturn(request, context, h) + const { actionHandlers } = request.server.plugins['forms-engine-plugin'] + + if (action && action in actionHandlers) { + const exitPath = await actionHandlers[action](request, context) + return h.redirect(this.getHref(exitPath)) } // Save and proceed @@ -539,31 +540,6 @@ export class QuestionPageController extends PageController { return proceed(request, h, nextUrl) } - /** - * Handle save-and-return action by processing form data and redirecting to exit page - */ - async handleSaveAndReturn( - request: FormRequestPayload, - context: FormContext, - h: Pick - ) { - const { state } = context - - // Save the current state and redirect to exit page - const saveAndReturn = getSaveAndReturnHelpers(request.server) - - if (!saveAndReturn?.sessionPersister) { - throw Boom.internal('Server misconfigured for save and return') - } - - await saveAndReturn.sessionPersister(state, request) - - const cacheService = getCacheService(request.server) - await cacheService.clearState(request) - - return h.redirect(this.getHref('/exit')) - } - /** * {@link https://hapi.dev/api/?v=20.1.2#route-options} */ diff --git a/src/server/plugins/engine/pageControllers/buttonHandlers.ts b/src/server/plugins/engine/pageControllers/buttonHandlers.ts new file mode 100644 index 000000000..ab2e1e069 --- /dev/null +++ b/src/server/plugins/engine/pageControllers/buttonHandlers.ts @@ -0,0 +1,32 @@ +import Boom from '@hapi/boom' + +import { + getCacheService, + getSaveAndReturnHelpers +} from '~/src/server/plugins/engine/helpers.js' +import { type FormContext } from '~/src/server/plugins/engine/types.js' +import { type FormRequestPayload } from '~/src/server/routes/types.js' + +/** + * Handle save-and-return action by processing form data and return exit path + */ +export async function handleSaveAndReturn( + request: FormRequestPayload, + context: FormContext +): Promise { + const { state } = context + + // Save the current state and return the exit path + const saveAndReturn = getSaveAndReturnHelpers(request.server) + + if (!saveAndReturn?.sessionPersister) { + throw Boom.internal('Server misconfigured for save and return') + } + + await saveAndReturn.sessionPersister(state, request) + + const cacheService = getCacheService(request.server) + await cacheService.clearState(request) + + return '/exit' +} diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 33a6ebd2a..5da242740 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -8,6 +8,7 @@ import { import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { validatePluginOptions } from '~/src/server/plugins/engine/options.js' +import { handleSaveAndReturn } from '~/src/server/plugins/engine/pageControllers/buttonHandlers.js' import { getRoutes as getSaveAndReturnExitRoutes } from '~/src/server/plugins/engine/routes/exit.js' import { getRoutes as getFileUploadStatusRoutes } from '~/src/server/plugins/engine/routes/file-upload.js' import { makeLoadFormPreHandler } from '~/src/server/plugins/engine/routes/index.js' @@ -17,6 +18,7 @@ import { getRoutes as getRepeaterSummaryRoutes } from '~/src/server/plugins/engi import { type PluginOptions } from '~/src/server/plugins/engine/types.js' import { registerVision } from '~/src/server/plugins/engine/vision.js' import { + FormAction, type FormRequestPayloadRefs, type FormRequestRefs } from '~/src/server/routes/types.js' @@ -54,6 +56,7 @@ export const plugin = { server.expose('cacheService', cacheService) server.expose('saveAndReturn', saveAndReturn) server.expose('buttons', getButtons(options)) + server.expose('actionHandlers', getActionHandlers(options)) server.app.model = model @@ -111,7 +114,7 @@ function getButtons(pluginOptions: PluginOptions) { if (pluginOptions.saveAndReturn) { buttons.push({ text: 'Save and return', - action: 'action' + action: FormAction.SaveAndReturn }) } @@ -121,3 +124,15 @@ function getButtons(pluginOptions: PluginOptions) { return buttons } + +function getActionHandlers(pluginOptions: PluginOptions) { + let actionHandlers: PluginOptions['actionHandlers'] = { + [FormAction.SaveAndReturn]: handleSaveAndReturn + } + + if (pluginOptions.actionHandlers) { + actionHandlers = pluginOptions.actionHandlers + } + + return actionHandlers +} diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index a7f2b9012..106d055cf 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -384,4 +384,5 @@ export interface PluginOptions { onRequest?: OnRequestCallback baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com" buttons?: PluginProperties['forms-engine-plugin']['buttons'] + actionHandlers?: PluginProperties['forms-engine-plugin']['actionHandlers'] } diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index eff3c68c6..bb1419ac5 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -7,6 +7,7 @@ import { type Logger } from 'pino' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type PluginOptions } from '~/src/server/plugins/engine/types.ts' import { + type FormAction, type FormRequest, type FormRequestPayload } from '~/src/server/routes/types.js' @@ -31,6 +32,10 @@ declare module '@hapi/hapi' { name?: string action?: string }[] + actionHandlers: Record< + FormAction | string, + (request: FormRequestPayload, context: FormContext) => Promise + > } } From 1af167c59a47c6634d78a25732433bed967211a1 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 22 Aug 2025 15:25:44 +0100 Subject: [PATCH 3/6] Build schemas on server creation to support custom actions --- .../plugins/engine/configureEnginePlugin.ts | 21 +++++++++++++++++++ src/server/plugins/engine/helpers.ts | 14 ++++++++++++- src/server/plugins/engine/options.js | 3 ++- .../pageControllers/QuestionPageController.ts | 13 +++++------- src/server/plugins/engine/plugin.ts | 21 +++++++++++++++---- src/server/plugins/engine/routes/questions.ts | 6 +++++- .../engine/routes/repeaters/item-delete.ts | 6 +++++- .../engine/routes/repeaters/summary.ts | 6 +++++- src/server/schemas/index.ts | 14 +++++++++++-- src/typings/hapi/index.d.ts | 5 +++++ 10 files changed, 90 insertions(+), 19 deletions(-) diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index 871101785..e1b7a2266 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -62,6 +62,27 @@ export const configureEnginePlugin = async ({ saveAndReturn } + /* + To enable custom buttons, use this config: + + ``` + buttons: [ + { + text: 'My custom submit button' + }, + { + text: 'Withdraw button', + action: 'withdraw-submission' + } + ], + actionHandlers: { + 'withdraw-submission': async (request, _) => { + await getCacheService(request.server).clearState(request) + return '/summary' + } + } + ``` + /* To enable save and return for testing purposes, use this config: diff --git a/src/server/plugins/engine/helpers.ts b/src/server/plugins/engine/helpers.ts index 5feae0c8c..727ff2b86 100644 --- a/src/server/plugins/engine/helpers.ts +++ b/src/server/plugins/engine/helpers.ts @@ -12,7 +12,7 @@ import Boom from '@hapi/boom' import { type ResponseToolkit, type Server } from '@hapi/hapi' import { format, parseISO } from 'date-fns' import { StatusCodes } from 'http-status-codes' -import { type Schema, type ValidationErrorItem } from 'joi' +import Joi, { type Schema, type ValidationErrorItem } from 'joi' import { Liquid } from 'liquidjs' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' @@ -376,6 +376,18 @@ export function evaluateTemplate( }) } +export function getSchemas(server?: Server) { + if (!server) { + // only used in debug UI helper where validation isn't really important + return { + actionSchema: Joi.any().required(), + paramsSchema: Joi.any().required() + } + } + + return getPluginOptions(server).schemas +} + export function getCacheService(server: Server) { return getPluginOptions(server).cacheService } diff --git a/src/server/plugins/engine/options.js b/src/server/plugins/engine/options.js index c1f1038f9..1666f9af9 100644 --- a/src/server/plugins/engine/options.js +++ b/src/server/plugins/engine/options.js @@ -32,7 +32,8 @@ const pluginRegistrationOptionsSchema = Joi.object({ action: Joi.string().optional() }) ) - .optional() + .optional(), + actionHandlers: Joi.object().pattern(Joi.string(), Joi.function()) }) /** diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index a333eadf8..02a975fe9 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -10,7 +10,7 @@ import { } from '@defra/forms-model' // import Boom from '@hapi/boom' // No longer needed import { type ResponseToolkit, type RouteOptions } from '@hapi/hapi' -import { type ValidationErrorItem } from 'joi' +import Joi, { type ValidationErrorItem } from 'joi' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { optionalText } from '~/src/server/plugins/engine/components/constants.js' @@ -18,6 +18,7 @@ import { type BackLink } from '~/src/server/plugins/engine/components/types.js' import { getCacheService, getErrors, + getSchemas, normalisePath, proceed } from '~/src/server/plugins/engine/helpers.js' @@ -39,11 +40,7 @@ import { type FormRequestPayloadRefs, type FormRequestRefs } from '~/src/server/routes/types.js' -import { - actionSchema, - crumbSchema, - paramsSchema -} from '~/src/server/schemas/index.js' +import { crumbSchema } from '~/src/server/schemas/index.js' import { merge } from '~/src/server/services/cacheService.js' export class QuestionPageController extends PageController { @@ -62,7 +59,7 @@ export class QuestionPageController extends PageController { this.collection.formSchema = this.collection.formSchema.keys({ crumb: crumbSchema, - action: actionSchema + action: Joi.string() }) } @@ -274,7 +271,7 @@ export class QuestionPageController extends PageController { getFormParams(request?: FormContextRequest): FormPayloadParams { const { payload } = request ?? {} - const result = paramsSchema.validate(payload, { + const result = getSchemas(request?.server).paramsSchema.validate(payload, { abortEarly: false, stripUnknown: true }) diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 5da242740..5bb573322 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -22,6 +22,10 @@ import { type FormRequestPayloadRefs, type FormRequestRefs } from '~/src/server/routes/types.js' +import { + buildActionSchema, + buildParamsSchema +} from '~/src/server/schemas/index.js' import { CacheService } from '~/src/server/services/index.js' export const plugin = { @@ -51,12 +55,20 @@ export const plugin = { await registerVision(server, options) + const buttons = getButtons(options) + const actionHandlers = getActionHandlers(options) + const customActions = Object.keys(actionHandlers) + server.expose('baseLayoutPath', nunjucksOptions.baseLayoutPath) server.expose('viewContext', viewContext) server.expose('cacheService', cacheService) server.expose('saveAndReturn', saveAndReturn) - server.expose('buttons', getButtons(options)) - server.expose('actionHandlers', getActionHandlers(options)) + server.expose('buttons', buttons) + server.expose('actionHandlers', actionHandlers) + server.expose('schemas', { + actionSchema: buildActionSchema(customActions), + paramsSchema: buildParamsSchema(customActions) + }) server.app.model = model @@ -90,12 +102,13 @@ export const plugin = { const routes = [ ...getQuestionRoutes( + server, getRouteOptions, postRouteOptions, preparePageEventRequestOptions ), - ...getRepeaterSummaryRoutes(getRouteOptions, postRouteOptions), - ...getRepeaterItemDeleteRoutes(getRouteOptions, postRouteOptions), + ...getRepeaterSummaryRoutes(server, getRouteOptions, postRouteOptions), + ...getRepeaterItemDeleteRoutes(server, getRouteOptions, postRouteOptions), ...getSaveAndReturnExitRoutes(getRouteOptions), ...getFileUploadStatusRoutes() ] diff --git a/src/server/plugins/engine/routes/questions.ts b/src/server/plugins/engine/routes/questions.ts index 049074f8a..4ebad8fe3 100644 --- a/src/server/plugins/engine/routes/questions.ts +++ b/src/server/plugins/engine/routes/questions.ts @@ -4,11 +4,13 @@ import { type ResponseObject, type ResponseToolkit, type RouteOptions, + type Server, type ServerRoute } from '@hapi/hapi' import Joi from 'joi' import { + getSchemas, normalisePath, proceed, redirectPath @@ -35,7 +37,6 @@ import { type FormRequestRefs } from '~/src/server/routes/types.js' import { - actionSchema, crumbSchema, itemIdSchema, pathSchema, @@ -164,10 +165,13 @@ function isSuccessful(response: ResponseObject): boolean { } export function getRoutes( + server: Server, getRouteOptions: RouteOptions, postRouteOptions: RouteOptions, preparePageEventRequestOptions?: PreparePageEventRequestOptions ): (ServerRoute | ServerRoute)[] { + const { actionSchema } = getSchemas(server) + return [ { method: 'get', diff --git a/src/server/plugins/engine/routes/repeaters/item-delete.ts b/src/server/plugins/engine/routes/repeaters/item-delete.ts index ad18c92ae..c8e962df4 100644 --- a/src/server/plugins/engine/routes/repeaters/item-delete.ts +++ b/src/server/plugins/engine/routes/repeaters/item-delete.ts @@ -3,10 +3,12 @@ import Boom from '@hapi/boom' import { type ResponseToolkit, type RouteOptions, + type Server, type ServerRoute } from '@hapi/hapi' import Joi from 'joi' +import { getSchemas } from '~/src/server/plugins/engine/helpers.js' import { FileUploadPageController } from '~/src/server/plugins/engine/pageControllers/FileUploadPageController.js' import { RepeatPageController } from '~/src/server/plugins/engine/pageControllers/RepeatPageController.js' import { redirectOrMakeHandler } from '~/src/server/plugins/engine/routes/index.js' @@ -17,7 +19,6 @@ import { type FormRequestRefs } from '~/src/server/routes/types.js' import { - actionSchema, confirmSchema, crumbSchema, itemIdSchema, @@ -70,9 +71,12 @@ function postHandler( } export function getRoutes( + server: Server, getRouteOptions: RouteOptions, postRouteOptions: RouteOptions ): (ServerRoute | ServerRoute)[] { + const { actionSchema } = getSchemas(server) + return [ { method: 'get', diff --git a/src/server/plugins/engine/routes/repeaters/summary.ts b/src/server/plugins/engine/routes/repeaters/summary.ts index 557980dca..214208b6a 100644 --- a/src/server/plugins/engine/routes/repeaters/summary.ts +++ b/src/server/plugins/engine/routes/repeaters/summary.ts @@ -4,10 +4,12 @@ import Boom from '@hapi/boom' import { type ResponseToolkit, type RouteOptions, + type Server, type ServerRoute } from '@hapi/hapi' import Joi from 'joi' +import { getSchemas } from '~/src/server/plugins/engine/helpers.js' import { RepeatPageController } from '~/src/server/plugins/engine/pageControllers/RepeatPageController.js' import { redirectOrMakeHandler } from '~/src/server/plugins/engine/routes/index.js' import { @@ -17,7 +19,6 @@ import { type FormRequestRefs } from '~/src/server/routes/types.js' import { - actionSchema, crumbSchema, pathSchema, stateSchema @@ -56,9 +57,12 @@ function postHandler( } export function getRoutes( + server: Server, getRouteOptions: RouteOptions, postRouteOptions: RouteOptions ): (ServerRoute | ServerRoute)[] { + const { actionSchema } = getSchemas(server) + return [ { method: 'get', diff --git a/src/server/schemas/index.ts b/src/server/schemas/index.ts index 38006860c..4ff6824f3 100644 --- a/src/server/schemas/index.ts +++ b/src/server/schemas/index.ts @@ -7,7 +7,7 @@ export const stateSchema = Joi.string() .valid(FormStatus.Draft, FormStatus.Live) .required() -export const actionSchema = Joi.string() +const actionSchema = Joi.string() .valid( FormAction.Continue, FormAction.Validate, @@ -24,7 +24,7 @@ export const itemIdSchema = Joi.string().uuid().required() export const crumbSchema = Joi.string().optional().allow('') export const confirmSchema = Joi.boolean().empty(false) -export const paramsSchema = Joi.object() +const paramsSchema = Joi.object() .keys({ action: actionSchema, confirm: confirmSchema, @@ -33,3 +33,13 @@ export const paramsSchema = Joi.object() }) .default({}) .optional() + +export function buildActionSchema(customActions: string[]) { + return actionSchema.valid(...customActions) +} + +export function buildParamsSchema(customActions: string[]) { + return paramsSchema.alter({ + action: (schema) => schema.valid(...customActions) + }) +} diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index bb1419ac5..0852e8acb 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -2,6 +2,7 @@ import { type Plugin } from '@hapi/hapi' import { type ServerYar, type Yar } from '@hapi/yar' +import { type Schema as JoiSchema } from 'joi' import { type Logger } from 'pino' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' @@ -36,6 +37,10 @@ declare module '@hapi/hapi' { FormAction | string, (request: FormRequestPayload, context: FormContext) => Promise > + schemas: { + actionSchema: JoiSchema + paramsSchema: JoiSchema + } } } From e469915ccd09196a24a82d0eec420307f000c9a3 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 22 Aug 2025 15:49:09 +0100 Subject: [PATCH 4/6] remove unused import --- .../plugins/engine/pageControllers/QuestionPageController.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 02a975fe9..c17de0ad0 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -8,7 +8,6 @@ import { type Link, type Page } from '@defra/forms-model' -// import Boom from '@hapi/boom' // No longer needed import { type ResponseToolkit, type RouteOptions } from '@hapi/hapi' import Joi, { type ValidationErrorItem } from 'joi' From 69665966e553dbe9502bb8419442a4f05a8b7820 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Tue, 26 Aug 2025 15:53:58 +0100 Subject: [PATCH 5/6] Idea: custom buttons with 'set' --- .../devserver/dxt-devtool-baselayout.html | 4 ++ .../plugins/engine/configureEnginePlugin.ts | 9 ---- src/server/plugins/engine/options.js | 8 --- src/server/plugins/engine/plugin.ts | 23 -------- .../plugins/engine/views/partials/form.html | 54 +++++++------------ src/server/plugins/nunjucks/context.js | 3 +- 6 files changed, 24 insertions(+), 77 deletions(-) diff --git a/src/server/devserver/dxt-devtool-baselayout.html b/src/server/devserver/dxt-devtool-baselayout.html index ca39a0be8..b00e6dcc1 100644 --- a/src/server/devserver/dxt-devtool-baselayout.html +++ b/src/server/devserver/dxt-devtool-baselayout.html @@ -47,6 +47,10 @@ {% endif %} {% endblock %} +{# {% set engineButtons %} + I'VE DROPPED THE BUTTONS! +{% endset %} #} + {% block content %}

Default page template

{% endblock %} diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index e1b7a2266..dc7438ec3 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -66,15 +66,6 @@ export const configureEnginePlugin = async ({ To enable custom buttons, use this config: ``` - buttons: [ - { - text: 'My custom submit button' - }, - { - text: 'Withdraw button', - action: 'withdraw-submission' - } - ], actionHandlers: { 'withdraw-submission': async (request, _) => { await getCacheService(request.server).clearState(request) diff --git a/src/server/plugins/engine/options.js b/src/server/plugins/engine/options.js index 1666f9af9..03432b177 100644 --- a/src/server/plugins/engine/options.js +++ b/src/server/plugins/engine/options.js @@ -25,14 +25,6 @@ const pluginRegistrationOptionsSchema = Joi.object({ sessionHydrator: Joi.function(), sessionPersister: Joi.function() }).optional(), - buttons: Joi.array() - .items( - Joi.object({ - text: Joi.string().required(), - action: Joi.string().optional() - }) - ) - .optional(), actionHandlers: Joi.object().pattern(Joi.string(), Joi.function()) }) diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 5bb573322..728e6c2a7 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -55,7 +55,6 @@ export const plugin = { await registerVision(server, options) - const buttons = getButtons(options) const actionHandlers = getActionHandlers(options) const customActions = Object.keys(actionHandlers) @@ -63,7 +62,6 @@ export const plugin = { server.expose('viewContext', viewContext) server.expose('cacheService', cacheService) server.expose('saveAndReturn', saveAndReturn) - server.expose('buttons', buttons) server.expose('actionHandlers', actionHandlers) server.expose('schemas', { actionSchema: buildActionSchema(customActions), @@ -117,27 +115,6 @@ export const plugin = { } } satisfies Plugin -function getButtons(pluginOptions: PluginOptions) { - let buttons: PluginOptions['buttons'] = [ - { - text: 'Continue' - } - ] - - if (pluginOptions.saveAndReturn) { - buttons.push({ - text: 'Save and return', - action: FormAction.SaveAndReturn - }) - } - - if (pluginOptions.buttons) { - buttons = pluginOptions.buttons - } - - return buttons -} - function getActionHandlers(pluginOptions: PluginOptions) { let actionHandlers: PluginOptions['actionHandlers'] = { [FormAction.SaveAndReturn]: handleSaveAndReturn diff --git a/src/server/plugins/engine/views/partials/form.html b/src/server/plugins/engine/views/partials/form.html index 4714f887b..ad7081a87 100644 --- a/src/server/plugins/engine/views/partials/form.html +++ b/src/server/plugins/engine/views/partials/form.html @@ -6,41 +6,25 @@ {{ componentList(components) }} -
- {% for button in buttons %} - - {% set isFirst = loop.index0 == 0 %} - - {% if isFirst and isStartPage %} - {% set btnText = "Start now" %} - {% set btnIsStart = true %} - {% else %} - {% set btnText = button.text %} - {% set btnIsStart = false %} - {% endif %} - - {% if button.action %} - {% set btnName = 'action' %} - {% else %} - {% set btnName = undefined %} - {% endif %} - - {% set btnValue = button.action %} - - {% if isFirst %} - {% set btnClasses = "" %} - {% else %} - {% set btnClasses = "govuk-button--secondary" %} - {% endif %} - + {% if not engineButtons %} +
{{ govukButton({ - text: btnText, - isStartButton: btnIsStart, - name: btnName, - value: btnValue, - preventDoubleClick: true, - classes: btnClasses + text: "Start now" if isStartPage else "Continue", + isStartButton: isStartPage, + preventDoubleClick: true }) }} - {% endfor %} -
+ + {% if allowSaveAndReturn %} + {{ govukButton({ + text: "Save and return", + classes: "govuk-button--secondary", + name: "action", + value: "save-and-return", + preventDoubleClick: true + }) }} + {% endif %} +
+ {% else %} + {{ engineButtons | safe }} + {% endif %} diff --git a/src/server/plugins/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index 8ca6adb36..6d39a3e50 100644 --- a/src/server/plugins/nunjucks/context.js +++ b/src/server/plugins/nunjucks/context.js @@ -54,8 +54,7 @@ export async function context(request) { crumb: safeGenerateCrumb(request), currentPath: `${request.path}${request.url.search}`, previewMode: isPreviewMode ? formState : undefined, - slug: isResponseOK ? params?.slug : undefined, - buttons: pluginStorage.buttons + slug: isResponseOK ? params?.slug : undefined } return ctx From 02d946c765d596b86e2dd5bb802a4e18f0b798a6 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Tue, 26 Aug 2025 16:42:09 +0100 Subject: [PATCH 6/6] Idea: inherit with content blocks --- .../devserver/dxt-devtool-baselayout.html | 20 +++------ .../plugins/engine/configureEnginePlugin.ts | 5 ++- .../engine/pageControllers/PageController.ts | 2 +- src/server/plugins/engine/views/index.html | 41 +++++++++++++++++-- .../plugins/engine/views/partials/form.html | 10 ++--- 5 files changed, 53 insertions(+), 25 deletions(-) diff --git a/src/server/devserver/dxt-devtool-baselayout.html b/src/server/devserver/dxt-devtool-baselayout.html index b00e6dcc1..9a814e1c7 100644 --- a/src/server/devserver/dxt-devtool-baselayout.html +++ b/src/server/devserver/dxt-devtool-baselayout.html @@ -1,4 +1,4 @@ -{% extends "govuk/template.njk" %} +{% extends "index.html" %} {% from "govuk/components/back-link/macro.njk" import govukBackLink -%} {% from "govuk/components/footer/macro.njk" import govukFooter -%} @@ -31,6 +31,10 @@ }) }} {% endblock %} +{% block buttons %} +I've overridden the buttons +{% endblock %} + {% block header %} {{ govukHeader({ homepageUrl: currentPath if context.isForceAccess else "https://defra.github.io/forms-engine-plugin/", @@ -41,20 +45,6 @@ }) }} {% endblock %} -{% block beforeContent %} - {% if backLink %} - {{ govukBackLink(backLink) }} - {% endif %} -{% endblock %} - -{# {% set engineButtons %} - I'VE DROPPED THE BUTTONS! -{% endset %} #} - -{% block content %} -

Default page template

-{% endblock %} - {% block bodyEnd %} {% endblock %} diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index dc7438ec3..acee81509 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -53,7 +53,10 @@ export const configureEnginePlugin = async ({ 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 + paths: [ + join(findPackageRoot(), 'src/server/devserver'), // custom layout to make it really clear this is not the same as the runner + join(findPackageRoot(), 'src/server/plugins/engine/views') // add engine views path so dxt-devtool-baselayout.html can find index.html + ] }, viewContext: devtoolContext, preparePageEventRequestOptions, diff --git a/src/server/plugins/engine/pageControllers/PageController.ts b/src/server/plugins/engine/pageControllers/PageController.ts index d3c475998..b7d570c63 100644 --- a/src/server/plugins/engine/pageControllers/PageController.ts +++ b/src/server/plugins/engine/pageControllers/PageController.ts @@ -46,7 +46,7 @@ export class PageController { condition?: ExecutableCondition events?: Events collection?: ComponentCollection - viewName = 'index' + viewName = 'dxt-devtool-baselayout.html' allowSaveAndReturn = false constructor(model: FormModel, pageDef: Page) { diff --git a/src/server/plugins/engine/views/index.html b/src/server/plugins/engine/views/index.html index af3258c88..3fa2cee0d 100644 --- a/src/server/plugins/engine/views/index.html +++ b/src/server/plugins/engine/views/index.html @@ -1,8 +1,15 @@ -{% extends baseLayoutPath %} +{% extends "govuk/template.njk" %} {% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} +{% from "govuk/components/button/macro.njk" import govukButton %} {% from "partials/components.html" import componentList with context %} +{% block beforeContent %} + {% if backLink %} + {{ govukBackLink(backLink) }} + {% endif %} +{% endblock %} + {% block content %}
@@ -25,9 +32,37 @@ {% block form %} {% if page.allowContinue %} - {% include "partials/form.html" %} +
+ + + {% block formComponents %} + {{ componentList(components) }} + {% endblock %} + + {% block formButtons %} +
+ {{ govukButton({ + text: "Start now" if isStartPage else "Continue", + isStartButton: isStartPage, + preventDoubleClick: true + }) }} + + {% if allowSaveAndReturn %} + {{ govukButton({ + text: "Save and return", + classes: "govuk-button--secondary", + name: "action", + value: "save-and-return", + preventDoubleClick: true + }) }} + {% endif %} +
+ {% endblock %} +
{% else %} - {{ componentList(components) }} + {% block nonFormComponents %} + {{ componentList(components) }} + {% endblock %} {% endif %} {% endblock %}
diff --git a/src/server/plugins/engine/views/partials/form.html b/src/server/plugins/engine/views/partials/form.html index ad7081a87..36eedf704 100644 --- a/src/server/plugins/engine/views/partials/form.html +++ b/src/server/plugins/engine/views/partials/form.html @@ -4,9 +4,11 @@
- {{ componentList(components) }} + {% block components %} + {{ componentList(components) }} + {% endblock %} - {% if not engineButtons %} + {% block buttons %}
{{ govukButton({ text: "Start now" if isStartPage else "Continue", @@ -24,7 +26,5 @@ }) }} {% endif %}
- {% else %} - {{ engineButtons | safe }} - {% endif %} + {% endblock %}