diff --git a/src/server/devserver/dxt-devtool-baselayout.html b/src/server/devserver/dxt-devtool-baselayout.html index ca39a0be8..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,16 +45,6 @@ }) }} {% endblock %} -{% block beforeContent %} - {% if backLink %} - {{ govukBackLink(backLink) }} - {% endif %} -{% endblock %} - -{% 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 c003d48c2..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, @@ -61,6 +64,40 @@ export const configureEnginePlugin = async ({ baseUrl: 'http://localhost:3009', // always runs locally saveAndReturn } + + /* + To enable custom buttons, use this config: + + ``` + actionHandlers: { + 'withdraw-submission': async (request, _) => { + await getCacheService(request.server).clearState(request) + return '/summary' + } + } + ``` + + /* + 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/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 aec627846..03432b177 100644 --- a/src/server/plugins/engine/options.js +++ b/src/server/plugins/engine/options.js @@ -24,7 +24,8 @@ const pluginRegistrationOptionsSchema = Joi.object({ keyGenerator: Joi.function(), sessionHydrator: Joi.function(), sessionPersister: Joi.function() - }).optional() + }).optional(), + actionHandlers: Joi.object().pattern(Joi.string(), Joi.function()) }) /** 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/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index c43afb82f..c17de0ad0 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -8,9 +8,8 @@ import { type Link, type Page } from '@defra/forms-model' -import Boom from '@hapi/boom' 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,7 +17,7 @@ import { type BackLink } from '~/src/server/plugins/engine/components/types.js' import { getCacheService, getErrors, - getSaveAndReturnHelpers, + getSchemas, normalisePath, proceed } from '~/src/server/plugins/engine/helpers.js' @@ -35,17 +34,12 @@ import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js' import { - FormAction, type FormRequest, type FormRequestPayload, 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 { @@ -64,7 +58,7 @@ export class QuestionPageController extends PageController { this.collection.formSchema = this.collection.formSchema.keys({ crumb: crumbSchema, - action: actionSchema + action: Joi.string() }) } @@ -276,7 +270,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 }) @@ -515,10 +509,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 +536,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 f915614c4..728e6c2a7 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,9 +18,14 @@ 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' +import { + buildActionSchema, + buildParamsSchema +} from '~/src/server/schemas/index.js' import { CacheService } from '~/src/server/services/index.js' export const plugin = { @@ -49,10 +55,18 @@ export const plugin = { await registerVision(server, 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('actionHandlers', actionHandlers) + server.expose('schemas', { + actionSchema: buildActionSchema(customActions), + paramsSchema: buildParamsSchema(customActions) + }) server.app.model = model @@ -86,12 +100,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() ] @@ -99,3 +114,15 @@ export const plugin = { server.route(routes as unknown as ServerRoute[]) // TODO } } satisfies Plugin + +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/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/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 3a566d6ff..106d055cf 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -383,4 +383,6 @@ 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'] + actionHandlers?: PluginProperties['forms-engine-plugin']['actionHandlers'] } 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 fb3476725..36eedf704 100644 --- a/src/server/plugins/engine/views/partials/form.html +++ b/src/server/plugins/engine/views/partials/form.html @@ -4,23 +4,27 @@
- {{ componentList(components) }} + {% block components %} + {{ componentList(components) }} + {% endblock %} -
- {{ govukButton({ - text: "Start now" if isStartPage else "Continue", - isStartButton: isStartPage, - preventDoubleClick: true - }) }} - - {% if allowSaveAndReturn %} + {% block buttons %} +
{{ govukButton({ - text: "Save and return", - classes: "govuk-button--secondary", - name: "action", - value: "save-and-return", + text: "Start now" if isStartPage else "Continue", + isStartButton: isStartPage, preventDoubleClick: true }) }} - {% endif %} -
+ + {% if allowSaveAndReturn %} + {{ govukButton({ + text: "Save and return", + classes: "govuk-button--secondary", + name: "action", + value: "save-and-return", + preventDoubleClick: true + }) }} + {% endif %} +
+ {% endblock %}
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/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 8067b4e43..0852e8acb 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -2,11 +2,13 @@ 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' import { type PluginOptions } from '~/src/server/plugins/engine/types.ts' import { + type FormAction, type FormRequest, type FormRequestPayload } from '~/src/server/routes/types.js' @@ -26,6 +28,19 @@ declare module '@hapi/hapi' { request: FormRequest | FormRequestPayload | null ) => Record | Promise> saveAndReturn?: PluginOptions['saveAndReturn'] + buttons?: { + text: string + name?: string + action?: string + }[] + actionHandlers: Record< + FormAction | string, + (request: FormRequestPayload, context: FormContext) => Promise + > + schemas: { + actionSchema: JoiSchema + paramsSchema: JoiSchema + } } }