@defra/forms-engine-plugin 1.4.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.server/server/plugins/engine/README.md +2 -46
  2. package/.server/server/plugins/engine/configureEnginePlugin.d.ts +1 -1
  3. package/.server/server/plugins/engine/configureEnginePlugin.js +5 -2
  4. package/.server/server/plugins/engine/configureEnginePlugin.js.map +1 -1
  5. package/.server/server/plugins/engine/helpers.d.ts +11 -0
  6. package/.server/server/plugins/engine/helpers.js +7 -1
  7. package/.server/server/plugins/engine/helpers.js.map +1 -1
  8. package/.server/server/plugins/engine/models/SummaryViewModel.d.ts +1 -1
  9. package/.server/server/plugins/engine/models/SummaryViewModel.js +1 -1
  10. package/.server/server/plugins/engine/models/SummaryViewModel.js.map +1 -1
  11. package/.server/server/plugins/engine/options.js +5 -3
  12. package/.server/server/plugins/engine/options.js.map +1 -1
  13. package/.server/server/plugins/engine/options.test.js +18 -9
  14. package/.server/server/plugins/engine/options.test.js.map +1 -1
  15. package/.server/server/plugins/engine/pageControllers/PageController.d.ts +3 -1
  16. package/.server/server/plugins/engine/pageControllers/PageController.js +5 -1
  17. package/.server/server/plugins/engine/pageControllers/PageController.js.map +1 -1
  18. package/.server/server/plugins/engine/pageControllers/QuestionPageController.d.ts +1 -1
  19. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +2 -4
  20. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  21. package/.server/server/plugins/engine/pageControllers/StartPageController.d.ts +2 -2
  22. package/.server/server/plugins/engine/pageControllers/StartPageController.js +1 -3
  23. package/.server/server/plugins/engine/pageControllers/StartPageController.js.map +1 -1
  24. package/.server/server/plugins/engine/pageControllers/StatusPageController.d.ts +1 -0
  25. package/.server/server/plugins/engine/pageControllers/StatusPageController.js +1 -0
  26. package/.server/server/plugins/engine/pageControllers/StatusPageController.js.map +1 -1
  27. package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +0 -1
  28. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +1 -4
  29. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  30. package/.server/server/plugins/engine/pageControllers/TerminalPageController.d.ts +1 -0
  31. package/.server/server/plugins/engine/pageControllers/TerminalPageController.js +1 -0
  32. package/.server/server/plugins/engine/pageControllers/TerminalPageController.js.map +1 -1
  33. package/.server/server/plugins/engine/pageControllers/__stubs__/request.d.ts +4 -0
  34. package/.server/server/plugins/engine/pageControllers/__stubs__/request.js +14 -0
  35. package/.server/server/plugins/engine/pageControllers/__stubs__/request.js.map +1 -0
  36. package/.server/server/plugins/engine/pageControllers/__stubs__/server.d.ts +3 -0
  37. package/.server/server/plugins/engine/pageControllers/__stubs__/server.js +23 -0
  38. package/.server/server/plugins/engine/pageControllers/__stubs__/server.js.map +1 -0
  39. package/.server/server/plugins/engine/plugin.js +5 -6
  40. package/.server/server/plugins/engine/plugin.js.map +1 -1
  41. package/.server/server/plugins/engine/types.d.ts +7 -5
  42. package/.server/server/plugins/engine/types.js.map +1 -1
  43. package/.server/server/types.d.ts +2 -1
  44. package/.server/server/types.js.map +1 -1
  45. package/.server/typings/hapi/index.d.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/server/plugins/engine/README.md +2 -46
  48. package/src/server/plugins/engine/configureEnginePlugin.ts +4 -2
  49. package/src/server/plugins/engine/helpers.test.ts +3 -2
  50. package/src/server/plugins/engine/helpers.ts +9 -1
  51. package/src/server/plugins/engine/models/FormModel.test.ts +11 -10
  52. package/src/server/plugins/engine/models/SummaryViewModel.test.ts +7 -5
  53. package/src/server/plugins/engine/models/SummaryViewModel.ts +1 -1
  54. package/src/server/plugins/engine/options.js +5 -3
  55. package/src/server/plugins/engine/options.test.js +22 -11
  56. package/src/server/plugins/engine/outputFormatters/human/v1.test.ts +3 -3
  57. package/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +12 -1
  58. package/src/server/plugins/engine/pageControllers/PageController.test.ts +9 -0
  59. package/src/server/plugins/engine/pageControllers/PageController.ts +10 -1
  60. package/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +34 -28
  61. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +2 -5
  62. package/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts +19 -4
  63. package/src/server/plugins/engine/pageControllers/StartPageController.test.ts +32 -0
  64. package/src/server/plugins/engine/pageControllers/StartPageController.ts +2 -4
  65. package/src/server/plugins/engine/pageControllers/StatusPageController.test.ts +32 -0
  66. package/src/server/plugins/engine/pageControllers/StatusPageController.ts +1 -0
  67. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +1 -5
  68. package/src/server/plugins/engine/pageControllers/TerminalController.test.ts +9 -0
  69. package/src/server/plugins/engine/pageControllers/TerminalPageController.ts +1 -0
  70. package/src/server/plugins/engine/pageControllers/__stubs__/request.ts +21 -0
  71. package/src/server/plugins/engine/pageControllers/__stubs__/server.ts +27 -0
  72. package/src/server/plugins/engine/plugin.ts +6 -6
  73. package/src/server/plugins/engine/types.ts +14 -9
  74. package/src/server/types.ts +2 -0
  75. package/src/typings/hapi/index.d.ts +2 -0
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","names":["UploadStatus","FileStatus"],"sources":["../../../../src/server/plugins/engine/types.ts"],"sourcesContent":["import {\n type ComponentDef,\n type Event,\n type FormDefinition,\n type FormMetadata,\n type Item,\n type List,\n type Page\n} from '@defra/forms-model'\nimport { type PluginProperties, type Request } from '@hapi/hapi'\nimport { type JoiExpression, type ValidationErrorItem } from 'joi'\n\nimport { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'\nimport { type Component } from '~/src/server/plugins/engine/components/helpers.js'\nimport {\n type BackLink,\n type ComponentText,\n type ComponentViewModel\n} from '~/src/server/plugins/engine/components/types.js'\nimport { type FormModel } from '~/src/server/plugins/engine/models/index.js'\nimport { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js'\nimport { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers.js'\nimport { type ViewContext } from '~/src/server/plugins/nunjucks/types.js'\nimport {\n type FormAction,\n type FormParams,\n type FormRequest,\n type FormRequestPayload\n} from '~/src/server/routes/types.js'\nimport { type RequestOptions } from '~/src/server/services/httpService.js'\nimport { type Services } from '~/src/server/types.js'\n\ntype RequestType = Request | FormRequest | FormRequestPayload\n\n/**\n * Form submission state stores the following in Redis:\n * Props containing user's submitted values as `{ [inputId]: value }` or as `{ [sectionName]: { [inputName]: value } }`\n * a) . e.g:\n * ```ts\n * {\n * _C9PRHmsgt: 'Ben',\n * WfLk9McjzX: 'Music',\n * IK7jkUFCBL: 'Royal Academy of Music'\n * }\n * ```\n *\n * b)\n * ```ts\n * {\n * checkBeforeYouStart: { ukPassport: true },\n * applicantDetails: {\n * numberOfApplicants: 1,\n * phoneNumber: '77777777',\n * emailAddress: 'aaa@aaa.com'\n * },\n * applicantOneDetails: {\n * firstName: 'a',\n * middleName: 'a',\n * lastName: 'a',\n * address: { addressLine1: 'a', addressLine2: 'a', town: 'a', postcode: 'a' }\n * }\n * }\n * ```\n */\n\n/**\n * Form submission state\n */\nexport type FormSubmissionState = {\n upload?: Record<string, TempFileState>\n} & FormState\n\nexport interface FormSubmissionError\n extends Pick<ValidationErrorItem, 'context' | 'path'> {\n href: string // e.g: '#dateField__day'\n name: string // e.g: 'dateField__day'\n text: string // e.g: 'Date field must be a real date'\n}\n\nexport interface FormPayloadParams {\n action?: FormAction\n confirm?: true\n crumb?: string\n itemId?: string\n}\n\n/**\n * Form POST for question pages\n * (after Joi has converted value types)\n */\nexport type FormPayload = FormPayloadParams & Partial<Record<string, FormValue>>\n\nexport type FormValue =\n | Item['value']\n | Item['value'][]\n | UploadState\n | RepeatListState\n | undefined\n\nexport type FormState = Partial<Record<string, FormStateValue>>\nexport type FormStateValue = Exclude<FormValue, undefined> | null\n\nexport interface FormValidationResult<\n ValueType extends FormPayload | FormSubmissionState\n> {\n value: ValueType\n errors: FormSubmissionError[] | undefined\n}\n\nexport interface FormContext {\n /**\n * Evaluation form state only (filtered by visited paths),\n * with values formatted for condition evaluation using\n * {@link FormComponent.getContextValueFromState}\n */\n evaluationState: FormState\n\n /**\n * Relevant form state only (filtered by visited paths)\n */\n relevantState: FormState\n\n /**\n * Relevant pages only (filtered by visited paths)\n */\n relevantPages: PageControllerClass[]\n\n /**\n * Form submission payload (single page)\n */\n payload: FormPayload\n\n /**\n * Form submission state (entire form)\n */\n state: FormSubmissionState\n\n /**\n * Validation errors (entire form)\n */\n errors?: FormSubmissionError[]\n\n /**\n * Visited paths evaluated from form state\n */\n paths: string[]\n\n /**\n * Preview URL direct access is allowed\n */\n isForceAccess: boolean\n\n /**\n * Miscellaneous extra data from event responses\n */\n data: object\n\n pageDefMap: Map<string, Page>\n listDefMap: Map<string, List>\n componentDefMap: Map<string, ComponentDef>\n pageMap: Map<string, PageControllerClass>\n componentMap: Map<string, Component>\n referenceNumber: string\n}\n\nexport type FormContextRequest = (\n | {\n method: 'get'\n payload?: undefined\n }\n | {\n method: 'post'\n payload: FormPayload\n }\n | {\n method: FormRequest['method']\n payload?: object | undefined\n }\n) &\n Pick<FormRequest, 'app' | 'method' | 'params' | 'path' | 'query' | 'url'>\n\nexport interface UploadInitiateResponse {\n uploadId: string\n uploadUrl: string\n statusUrl: string\n}\n\nexport enum UploadStatus {\n initiated = 'initiated',\n pending = 'pending',\n ready = 'ready'\n}\n\nexport enum FileStatus {\n complete = 'complete',\n rejected = 'rejected',\n pending = 'pending'\n}\n\nexport type UploadState = FileState[]\n\nexport type FileUpload = {\n fileId: string\n filename: string\n contentLength: number\n} & (\n | {\n fileStatus: FileStatus.complete | FileStatus.rejected | FileStatus.pending\n errorMessage?: string\n }\n | {\n fileStatus: FileStatus.complete\n errorMessage?: undefined\n }\n)\n\nexport interface FileUploadMetadata {\n retrievalKey: string\n}\n\nexport type UploadStatusResponse =\n | {\n uploadStatus: UploadStatus.initiated\n metadata: FileUploadMetadata\n form: { file?: undefined }\n }\n | {\n uploadStatus: UploadStatus.pending | UploadStatus.ready\n metadata: FileUploadMetadata\n form: { file: FileUpload }\n numberOfRejectedFiles?: number\n }\n | {\n uploadStatus: UploadStatus.ready\n metadata: FileUploadMetadata\n form: { file: FileUpload }\n numberOfRejectedFiles: 0\n }\n\nexport type UploadStatusFileResponse = Exclude<\n UploadStatusResponse,\n { uploadStatus: UploadStatus.initiated }\n>\n\nexport interface FileState {\n uploadId: string\n status: UploadStatusFileResponse\n}\n\nexport interface TempFileState {\n upload?: UploadInitiateResponse\n files: UploadState\n}\n\nexport interface RepeatItemState extends FormPayload {\n itemId: string\n}\n\nexport type RepeatListState = RepeatItemState[]\n\nexport interface CheckAnswers {\n title?: ComponentText\n summaryList: SummaryList\n}\n\nexport interface SummaryList {\n classes?: string\n rows: SummaryListRow[]\n}\n\nexport interface SummaryListRow {\n key: ComponentText\n value: ComponentText\n actions?: { items: SummaryListAction[] }\n}\n\nexport type SummaryListAction = ComponentText & {\n href: string\n visuallyHiddenText: string\n}\n\nexport interface PageViewModelBase extends Partial<ViewContext> {\n page: PageController\n name?: string\n pageTitle: string\n sectionTitle?: string\n showTitle: boolean\n isStartPage: boolean\n backLink?: BackLink\n feedbackLink?: string\n serviceUrl: string\n phaseTag?: string\n}\n\nexport interface ItemDeletePageViewModel extends PageViewModelBase {\n context: FormContext\n itemTitle: string\n confirmation?: ComponentText\n buttonConfirm: ComponentText\n buttonCancel: ComponentText\n}\n\nexport interface FormPageViewModel extends PageViewModelBase {\n components: ComponentViewModel[]\n context: FormContext\n errors?: FormSubmissionError[]\n hasMissingNotificationEmail?: boolean\n allowSaveAndReturn?: boolean\n}\n\nexport interface RepeaterSummaryPageViewModel extends PageViewModelBase {\n context: FormContext\n errors?: FormSubmissionError[]\n checkAnswers: CheckAnswers[]\n repeatTitle: string\n}\n\nexport interface FeaturedFormPageViewModel extends FormPageViewModel {\n formAction?: string\n formComponent: ComponentViewModel\n componentsBefore: ComponentViewModel[]\n uploadId: string | undefined\n proxyUrl: string | null\n}\n\nexport type PageViewModel =\n | PageViewModelBase\n | ItemDeletePageViewModel\n | FormPageViewModel\n | RepeaterSummaryPageViewModel\n | FeaturedFormPageViewModel\n\nexport type GlobalFunction = (value: unknown) => unknown\nexport type FilterFunction = (value: unknown) => unknown\nexport interface ErrorMessageTemplate {\n type: string\n template: JoiExpression\n}\n\nexport interface ErrorMessageTemplateList {\n baseErrors: ErrorMessageTemplate[]\n advancedSettingsErrors: ErrorMessageTemplate[]\n}\n\nexport type PreparePageEventRequestOptions = (\n options: RequestOptions,\n event: Event,\n page: PageControllerClass,\n context: FormContext\n) => void\n\nexport type OnRequestCallback = (\n request: FormRequest | FormRequestPayload,\n params: FormParams,\n definition: FormDefinition,\n metadata: FormMetadata\n) => void\n\nexport interface PluginOptions {\n model?: FormModel\n services?: Services\n controllers?: Record<string, typeof PageController>\n cacheName?: string\n globals?: Record<string, GlobalFunction>\n filters?: Record<string, FilterFunction>\n keyGenerator?: (request: RequestType) => string\n sessionHydrator?: (request: RequestType) => Promise<FormSubmissionState>\n sessionPersister?: (\n key: string,\n state: FormSubmissionState,\n request: RequestType\n ) => Promise<void>\n pluginPath?: string\n nunjucks: {\n baseLayoutPath: string\n paths: string[]\n }\n viewContext: PluginProperties['forms-engine-plugin']['viewContext']\n preparePageEventRequestOptions?: PreparePageEventRequestOptions\n onRequest?: OnRequestCallback\n baseUrl: string // base URL of the application, protocol and hostname e.g. \"https://myapp.com\"\n}\n"],"mappings":"AAkCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAmBA;AACA;AACA;AACA;;AAkGA,WAAYA,YAAY,0BAAZA,YAAY;EAAZA,YAAY;EAAZA,YAAY;EAAZA,YAAY;EAAA,OAAZA,YAAY;AAAA;AAMxB,WAAYC,UAAU,0BAAVA,UAAU;EAAVA,UAAU;EAAVA,UAAU;EAAVA,UAAU;EAAA,OAAVA,UAAU;AAAA","ignoreList":[]}
1
+ {"version":3,"file":"types.js","names":["UploadStatus","FileStatus"],"sources":["../../../../src/server/plugins/engine/types.ts"],"sourcesContent":["import {\n type ComponentDef,\n type Event,\n type FormDefinition,\n type FormMetadata,\n type Item,\n type List,\n type Page\n} from '@defra/forms-model'\nimport { type PluginProperties, type Request } from '@hapi/hapi'\nimport { type JoiExpression, type ValidationErrorItem } from 'joi'\n\nimport { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'\nimport { type Component } from '~/src/server/plugins/engine/components/helpers.js'\nimport {\n type BackLink,\n type ComponentText,\n type ComponentViewModel\n} from '~/src/server/plugins/engine/components/types.js'\nimport { type FormModel } from '~/src/server/plugins/engine/models/index.js'\nimport { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js'\nimport { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers.js'\nimport { type ViewContext } from '~/src/server/plugins/nunjucks/types.js'\nimport {\n type FormAction,\n type FormParams,\n type FormRequest,\n type FormRequestPayload\n} from '~/src/server/routes/types.js'\nimport { type RequestOptions } from '~/src/server/services/httpService.js'\nimport { type Services } from '~/src/server/types.js'\n\ntype RequestType = Request | FormRequest | FormRequestPayload\n\n/**\n * Form submission state stores the following in Redis:\n * Props containing user's submitted values as `{ [inputId]: value }` or as `{ [sectionName]: { [inputName]: value } }`\n * a) . e.g:\n * ```ts\n * {\n * _C9PRHmsgt: 'Ben',\n * WfLk9McjzX: 'Music',\n * IK7jkUFCBL: 'Royal Academy of Music'\n * }\n * ```\n *\n * b)\n * ```ts\n * {\n * checkBeforeYouStart: { ukPassport: true },\n * applicantDetails: {\n * numberOfApplicants: 1,\n * phoneNumber: '77777777',\n * emailAddress: 'aaa@aaa.com'\n * },\n * applicantOneDetails: {\n * firstName: 'a',\n * middleName: 'a',\n * lastName: 'a',\n * address: { addressLine1: 'a', addressLine2: 'a', town: 'a', postcode: 'a' }\n * }\n * }\n * ```\n */\n\n/**\n * Form submission state\n */\nexport type FormSubmissionState = {\n upload?: Record<string, TempFileState>\n} & FormState\n\nexport interface FormSubmissionError\n extends Pick<ValidationErrorItem, 'context' | 'path'> {\n href: string // e.g: '#dateField__day'\n name: string // e.g: 'dateField__day'\n text: string // e.g: 'Date field must be a real date'\n}\n\nexport interface FormPayloadParams {\n action?: FormAction\n confirm?: true\n crumb?: string\n itemId?: string\n}\n\n/**\n * Form POST for question pages\n * (after Joi has converted value types)\n */\nexport type FormPayload = FormPayloadParams & Partial<Record<string, FormValue>>\n\nexport type FormValue =\n | Item['value']\n | Item['value'][]\n | UploadState\n | RepeatListState\n | undefined\n\nexport type FormState = Partial<Record<string, FormStateValue>>\nexport type FormStateValue = Exclude<FormValue, undefined> | null\n\nexport interface FormValidationResult<\n ValueType extends FormPayload | FormSubmissionState\n> {\n value: ValueType\n errors: FormSubmissionError[] | undefined\n}\n\nexport interface FormContext {\n /**\n * Evaluation form state only (filtered by visited paths),\n * with values formatted for condition evaluation using\n * {@link FormComponent.getContextValueFromState}\n */\n evaluationState: FormState\n\n /**\n * Relevant form state only (filtered by visited paths)\n */\n relevantState: FormState\n\n /**\n * Relevant pages only (filtered by visited paths)\n */\n relevantPages: PageControllerClass[]\n\n /**\n * Form submission payload (single page)\n */\n payload: FormPayload\n\n /**\n * Form submission state (entire form)\n */\n state: FormSubmissionState\n\n /**\n * Validation errors (entire form)\n */\n errors?: FormSubmissionError[]\n\n /**\n * Visited paths evaluated from form state\n */\n paths: string[]\n\n /**\n * Preview URL direct access is allowed\n */\n isForceAccess: boolean\n\n /**\n * Miscellaneous extra data from event responses\n */\n data: object\n\n pageDefMap: Map<string, Page>\n listDefMap: Map<string, List>\n componentDefMap: Map<string, ComponentDef>\n pageMap: Map<string, PageControllerClass>\n componentMap: Map<string, Component>\n referenceNumber: string\n}\n\nexport type FormContextRequest = (\n | {\n method: 'get'\n payload?: undefined\n }\n | {\n method: 'post'\n payload: FormPayload\n }\n | {\n method: FormRequest['method']\n payload?: object | undefined\n }\n) &\n Pick<\n FormRequest,\n 'app' | 'method' | 'params' | 'path' | 'query' | 'url' | 'server'\n >\n\nexport interface UploadInitiateResponse {\n uploadId: string\n uploadUrl: string\n statusUrl: string\n}\n\nexport enum UploadStatus {\n initiated = 'initiated',\n pending = 'pending',\n ready = 'ready'\n}\n\nexport enum FileStatus {\n complete = 'complete',\n rejected = 'rejected',\n pending = 'pending'\n}\n\nexport type UploadState = FileState[]\n\nexport type FileUpload = {\n fileId: string\n filename: string\n contentLength: number\n} & (\n | {\n fileStatus: FileStatus.complete | FileStatus.rejected | FileStatus.pending\n errorMessage?: string\n }\n | {\n fileStatus: FileStatus.complete\n errorMessage?: undefined\n }\n)\n\nexport interface FileUploadMetadata {\n retrievalKey: string\n}\n\nexport type UploadStatusResponse =\n | {\n uploadStatus: UploadStatus.initiated\n metadata: FileUploadMetadata\n form: { file?: undefined }\n }\n | {\n uploadStatus: UploadStatus.pending | UploadStatus.ready\n metadata: FileUploadMetadata\n form: { file: FileUpload }\n numberOfRejectedFiles?: number\n }\n | {\n uploadStatus: UploadStatus.ready\n metadata: FileUploadMetadata\n form: { file: FileUpload }\n numberOfRejectedFiles: 0\n }\n\nexport type UploadStatusFileResponse = Exclude<\n UploadStatusResponse,\n { uploadStatus: UploadStatus.initiated }\n>\n\nexport interface FileState {\n uploadId: string\n status: UploadStatusFileResponse\n}\n\nexport interface TempFileState {\n upload?: UploadInitiateResponse\n files: UploadState\n}\n\nexport interface RepeatItemState extends FormPayload {\n itemId: string\n}\n\nexport type RepeatListState = RepeatItemState[]\n\nexport interface CheckAnswers {\n title?: ComponentText\n summaryList: SummaryList\n}\n\nexport interface SummaryList {\n classes?: string\n rows: SummaryListRow[]\n}\n\nexport interface SummaryListRow {\n key: ComponentText\n value: ComponentText\n actions?: { items: SummaryListAction[] }\n}\n\nexport type SummaryListAction = ComponentText & {\n href: string\n visuallyHiddenText: string\n}\n\nexport interface PageViewModelBase extends Partial<ViewContext> {\n page: PageController\n name?: string\n pageTitle: string\n sectionTitle?: string\n showTitle: boolean\n isStartPage: boolean\n backLink?: BackLink\n feedbackLink?: string\n serviceUrl: string\n phaseTag?: string\n}\n\nexport interface ItemDeletePageViewModel extends PageViewModelBase {\n context: FormContext\n itemTitle: string\n confirmation?: ComponentText\n buttonConfirm: ComponentText\n buttonCancel: ComponentText\n}\n\nexport interface FormPageViewModel extends PageViewModelBase {\n components: ComponentViewModel[]\n context: FormContext\n errors?: FormSubmissionError[]\n hasMissingNotificationEmail?: boolean\n allowSaveAndReturn: boolean\n}\n\nexport interface RepeaterSummaryPageViewModel extends PageViewModelBase {\n context: FormContext\n errors?: FormSubmissionError[]\n checkAnswers: CheckAnswers[]\n repeatTitle: string\n}\n\nexport interface FeaturedFormPageViewModel extends FormPageViewModel {\n formAction?: string\n formComponent: ComponentViewModel\n componentsBefore: ComponentViewModel[]\n uploadId: string | undefined\n proxyUrl: string | null\n}\n\nexport type PageViewModel =\n | PageViewModelBase\n | ItemDeletePageViewModel\n | FormPageViewModel\n | RepeaterSummaryPageViewModel\n | FeaturedFormPageViewModel\n\nexport type GlobalFunction = (value: unknown) => unknown\nexport type FilterFunction = (value: unknown) => unknown\nexport interface ErrorMessageTemplate {\n type: string\n template: JoiExpression\n}\n\nexport interface ErrorMessageTemplateList {\n baseErrors: ErrorMessageTemplate[]\n advancedSettingsErrors: ErrorMessageTemplate[]\n}\n\nexport type PreparePageEventRequestOptions = (\n options: RequestOptions,\n event: Event,\n page: PageControllerClass,\n context: FormContext\n) => void\n\nexport type OnRequestCallback = (\n request: FormRequest | FormRequestPayload,\n params: FormParams,\n definition: FormDefinition,\n metadata: FormMetadata\n) => void\n\nexport interface PluginOptions {\n model?: FormModel\n services?: Services\n controllers?: Record<string, typeof PageController>\n cacheName?: string\n globals?: Record<string, GlobalFunction>\n filters?: Record<string, FilterFunction>\n saveAndReturn?: {\n keyGenerator: (request: RequestType) => string\n sessionHydrator: (request: RequestType) => Promise<FormSubmissionState>\n sessionPersister: (\n key: string,\n state: FormSubmissionState,\n request: RequestType\n ) => Promise<void>\n }\n pluginPath?: string\n nunjucks: {\n baseLayoutPath: string\n paths: string[]\n }\n viewContext: PluginProperties['forms-engine-plugin']['viewContext']\n preparePageEventRequestOptions?: PreparePageEventRequestOptions\n onRequest?: OnRequestCallback\n baseUrl: string // base URL of the application, protocol and hostname e.g. \"https://myapp.com\"\n}\n"],"mappings":"AAkCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAmBA;AACA;AACA;AACA;;AAqGA,WAAYA,YAAY,0BAAZA,YAAY;EAAZA,YAAY;EAAZA,YAAY;EAAZA,YAAY;EAAA,OAAZA,YAAY;AAAA;AAMxB,WAAYC,UAAU,0BAAVA,UAAU;EAAVA,UAAU;EAAVA,UAAU;EAAVA,UAAU;EAAA,OAAVA,UAAU;AAAA","ignoreList":[]}
@@ -2,7 +2,7 @@ import { type FormDefinition, type FormMetadata, type SubmitPayload, type Submit
2
2
  import { type FormModel } from '~/src/server/plugins/engine/models/index.js';
3
3
  import { type DetailItem } from '~/src/server/plugins/engine/models/types.js';
4
4
  import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js';
5
- import { type OnRequestCallback, type PreparePageEventRequestOptions } from '~/src/server/plugins/engine/types.js';
5
+ import { type OnRequestCallback, type PluginOptions, type PreparePageEventRequestOptions } from '~/src/server/plugins/engine/types.js';
6
6
  import { type FormRequestPayload, type FormStatus } from '~/src/server/routes/types.js';
7
7
  export interface FormsService {
8
8
  getFormMetadata: (slug: string) => Promise<FormMetadata>;
@@ -28,6 +28,7 @@ export interface RouteConfig {
28
28
  controllers?: Record<string, typeof PageController>;
29
29
  preparePageEventRequestOptions?: PreparePageEventRequestOptions;
30
30
  onRequest?: OnRequestCallback;
31
+ saveAndReturn?: PluginOptions['saveAndReturn'];
31
32
  }
32
33
  export interface OutputService {
33
34
  submit: (request: FormRequestPayload, model: FormModel, emailAddress: string, items: DetailItem[], submitResponse: SubmitResponsePayload) => Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","names":[],"sources":["../../src/server/types.ts"],"sourcesContent":["import {\n type FormDefinition,\n type FormMetadata,\n type SubmitPayload,\n type SubmitResponsePayload\n} from '@defra/forms-model'\n\nimport { type FormModel } from '~/src/server/plugins/engine/models/index.js'\nimport { type DetailItem } from '~/src/server/plugins/engine/models/types.js'\nimport { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js'\nimport {\n type OnRequestCallback,\n type PreparePageEventRequestOptions\n} from '~/src/server/plugins/engine/types.js'\nimport {\n type FormRequestPayload,\n type FormStatus\n} from '~/src/server/routes/types.js'\n\nexport interface FormsService {\n getFormMetadata: (slug: string) => Promise<FormMetadata>\n getFormDefinition: (\n id: string,\n state: FormStatus\n ) => Promise<FormDefinition | undefined>\n}\n\nexport interface FormSubmissionService {\n persistFiles: (\n files: { fileId: string; initiatedRetrievalKey: string }[],\n persistedRetrievalKey: string\n ) => Promise<object>\n submit: (data: SubmitPayload) => Promise<SubmitResponsePayload | undefined>\n}\n\nexport interface Services {\n formsService: FormsService\n formSubmissionService: FormSubmissionService\n outputService: OutputService\n}\n\nexport interface RouteConfig {\n formFileName?: string\n formFilePath?: string\n enforceCsrf?: boolean\n services?: Services\n controllers?: Record<string, typeof PageController>\n preparePageEventRequestOptions?: PreparePageEventRequestOptions\n onRequest?: OnRequestCallback\n}\n\nexport interface OutputService {\n submit: (\n request: FormRequestPayload,\n model: FormModel,\n emailAddress: string,\n items: DetailItem[],\n submitResponse: SubmitResponsePayload\n ) => Promise<void>\n}\n"],"mappings":"","ignoreList":[]}
1
+ {"version":3,"file":"types.js","names":[],"sources":["../../src/server/types.ts"],"sourcesContent":["import {\n type FormDefinition,\n type FormMetadata,\n type SubmitPayload,\n type SubmitResponsePayload\n} from '@defra/forms-model'\n\nimport { type FormModel } from '~/src/server/plugins/engine/models/index.js'\nimport { type DetailItem } from '~/src/server/plugins/engine/models/types.js'\nimport { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js'\nimport {\n type OnRequestCallback,\n type PluginOptions,\n type PreparePageEventRequestOptions\n} from '~/src/server/plugins/engine/types.js'\nimport {\n type FormRequestPayload,\n type FormStatus\n} from '~/src/server/routes/types.js'\n\nexport interface FormsService {\n getFormMetadata: (slug: string) => Promise<FormMetadata>\n getFormDefinition: (\n id: string,\n state: FormStatus\n ) => Promise<FormDefinition | undefined>\n}\n\nexport interface FormSubmissionService {\n persistFiles: (\n files: { fileId: string; initiatedRetrievalKey: string }[],\n persistedRetrievalKey: string\n ) => Promise<object>\n submit: (data: SubmitPayload) => Promise<SubmitResponsePayload | undefined>\n}\n\nexport interface Services {\n formsService: FormsService\n formSubmissionService: FormSubmissionService\n outputService: OutputService\n}\n\nexport interface RouteConfig {\n formFileName?: string\n formFilePath?: string\n enforceCsrf?: boolean\n services?: Services\n controllers?: Record<string, typeof PageController>\n preparePageEventRequestOptions?: PreparePageEventRequestOptions\n onRequest?: OnRequestCallback\n saveAndReturn?: PluginOptions['saveAndReturn']\n}\n\nexport interface OutputService {\n submit: (\n request: FormRequestPayload,\n model: FormModel,\n emailAddress: string,\n items: DetailItem[],\n submitResponse: SubmitResponsePayload\n ) => Promise<void>\n}\n"],"mappings":"","ignoreList":[]}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.js","names":[],"sources":["../../../src/typings/hapi/index.d.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/unified-signatures */\n\nimport { type Plugin } from '@hapi/hapi'\nimport { type ServerYar, type Yar } from '@hapi/yar'\nimport { type Logger } from 'pino'\n\nimport { type FormModel } from '~/src/server/plugins/engine/models/index.js'\nimport {\n type FormRequest,\n type FormRequestPayload\n} from '~/src/server/routes/types.js'\nimport { type CacheService } from '~/src/server/services/index.js'\n\ndeclare module '@hapi/hapi' {\n // Here we are decorating Hapi interface types with\n // props from plugins which doesn't export @types\n interface PluginProperties {\n crumb: {\n generate?: (request: Request | FormRequest | FormRequestPayload) => string\n }\n 'forms-engine-plugin': {\n baseLayoutPath: string\n cacheService: CacheService\n viewContext?: (\n request: FormRequest | FormRequestPayload | null\n ) => Record<string, unknown> | Promise<Record<string, unknown>>\n }\n }\n\n interface Request {\n logger: Logger\n yar: Yar\n }\n\n interface RequestApplicationState {\n model?: FormModel\n }\n\n interface Server {\n logger: Logger\n yar: ServerYar\n }\n\n interface ServerApplicationState {\n model?: FormModel\n models: Map<string, { model: FormModel; updatedAt: Date }>\n }\n}\n\ndeclare module '@hapi/scooter' {\n declare const hapiScooter: {\n plugin: Plugin\n }\n\n export = hapiScooter\n}\n\ndeclare module 'blankie' {\n declare const blankie: {\n plugin: Plugin<Record<string, boolean | string | string[]>>\n }\n\n export = blankie\n}\n\ndeclare module 'blipp' {\n declare const blipp: {\n plugin: Plugin\n }\n\n export = blipp\n}\n\ndeclare module 'hapi-pulse' {\n declare const hapiPulse: {\n plugin: Plugin<{\n timeout: number\n }>\n }\n\n export = hapiPulse\n}\n"],"mappings":"","ignoreList":[]}
1
+ {"version":3,"file":"index.d.js","names":[],"sources":["../../../src/typings/hapi/index.d.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/unified-signatures */\n\nimport { type Plugin } from '@hapi/hapi'\nimport { type ServerYar, type Yar } from '@hapi/yar'\nimport { type Logger } from 'pino'\n\nimport { type FormModel } from '~/src/server/plugins/engine/models/index.js'\nimport { type PluginOptions } from '~/src/server/plugins/engine/types.ts'\nimport {\n type FormRequest,\n type FormRequestPayload\n} from '~/src/server/routes/types.js'\nimport { type CacheService } from '~/src/server/services/index.js'\n\ndeclare module '@hapi/hapi' {\n // Here we are decorating Hapi interface types with\n // props from plugins which doesn't export @types\n interface PluginProperties {\n crumb: {\n generate?: (request: Request | FormRequest | FormRequestPayload) => string\n }\n 'forms-engine-plugin': {\n baseLayoutPath: string\n cacheService: CacheService\n viewContext?: (\n request: FormRequest | FormRequestPayload | null\n ) => Record<string, unknown> | Promise<Record<string, unknown>>\n saveAndReturn?: PluginOptions['saveAndReturn']\n }\n }\n\n interface Request {\n logger: Logger\n yar: Yar\n }\n\n interface RequestApplicationState {\n model?: FormModel\n }\n\n interface Server {\n logger: Logger\n yar: ServerYar\n }\n\n interface ServerApplicationState {\n model?: FormModel\n models: Map<string, { model: FormModel; updatedAt: Date }>\n }\n}\n\ndeclare module '@hapi/scooter' {\n declare const hapiScooter: {\n plugin: Plugin\n }\n\n export = hapiScooter\n}\n\ndeclare module 'blankie' {\n declare const blankie: {\n plugin: Plugin<Record<string, boolean | string | string[]>>\n }\n\n export = blankie\n}\n\ndeclare module 'blipp' {\n declare const blipp: {\n plugin: Plugin\n }\n\n export = blipp\n}\n\ndeclare module 'hapi-pulse' {\n declare const hapiPulse: {\n plugin: Plugin<{\n timeout: number\n }>\n }\n\n export = hapiPulse\n}\n"],"mappings":"","ignoreList":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/forms-engine-plugin",
3
- "version": "1.4.1",
3
+ "version": "2.0.0",
4
4
  "description": "Defra forms engine",
5
5
  "type": "module",
6
6
  "files": [
@@ -86,53 +86,9 @@ There are a number of `LiquidJS` filters available to you from within the templa
86
86
  ]
87
87
  ```
88
88
 
89
- ## Session Rehydration
89
+ ### Save and return
90
90
 
91
- To support Save and Return functionality, this application now supports session rehydration. This allows user session state to be recovered across browser sessions or devices — even after the in-memory Redis session has expired.
92
-
93
- ### How it works
94
-
95
- To support session rehydration from a backend (e.g. for Save & Return), the consuming application must provide two functions when registering the DXT engine plugin:
96
-
97
- ```
98
- export interface PluginOptions {
99
- ...
100
- keyGenerator?: (request) => string
101
- sessionHydrator?: (request) => Promise<FormSubmissionState | null>
102
- ...
103
- }
104
-
105
- ```
106
-
107
- 1. `keyGenerator(request)`
108
-
109
- This generates a stable and consistent cache key used to store and retrieve user state. It should return a string based on persistent identifiers such as userId, businessId, and grantId — i.e., something like:
110
-
111
- ```
112
- const keyGenerator = request => {
113
- const { userId, businessId, grantId } = request.app.userContext
114
- return `${userId}:${businessId}:${grantId}`
115
- }
116
- ```
117
-
118
- 2. `sessionHydrator(request, key)`
119
-
120
- This function is called when no session state is found in Redis. It should fetch saved state (e.g., from an API) using the provided key and return it in the same structure expected by the form engine:
121
-
122
- ```
123
- const sessionHydrator = async (request, key) => {
124
- const response = await fetch(`https://backend.api/state/${key}`)
125
- if (!response.ok) return null
126
- return await response.json() // Must match form engine state shape
127
- }
128
- ```
129
-
130
- #### Session flow
131
-
132
- - When user resumes a journey and Redis session data is missing or expired, DXT will use `keyGenerator` and `sessionHydrator` to fetch the saved state from an external API (e.g. `/state` endpoint).
133
- - The fetched state is written back into Redis and used to continue the user journey.
134
- - The rehydrated state must include enough information to satisfy schema validation on the current or next page.
135
- - To properly resume a session, users should be redirected to the `/summary` page. This ensures the UI has all required answers preloaded and avoids invalid transitions from deep links.
91
+ See [our save and return feature page](/docs/features/code-based/SAVE_AND_RETURN.md).
136
92
 
137
93
  ### Additional notes
138
94
 
@@ -18,7 +18,8 @@ export const configureEnginePlugin = async ({
18
18
  services,
19
19
  controllers,
20
20
  preparePageEventRequestOptions,
21
- onRequest
21
+ onRequest,
22
+ saveAndReturn
22
23
  }: RouteConfig = {}): Promise<{
23
24
  plugin: typeof plugin
24
25
  options: PluginOptions
@@ -57,7 +58,8 @@ export const configureEnginePlugin = async ({
57
58
  viewContext: devtoolContext,
58
59
  preparePageEventRequestOptions,
59
60
  onRequest,
60
- baseUrl: 'http://localhost:3009' // always runs locally
61
+ baseUrl: 'http://localhost:3009', // always runs locally
62
+ saveAndReturn
61
63
  }
62
64
  }
63
65
  }
@@ -18,6 +18,7 @@ import {
18
18
  } from '~/src/server/plugins/engine/helpers.js'
19
19
  import { handleLegacyRedirect } from '~/src/server/plugins/engine/helpers.js'
20
20
  import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
21
+ import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js'
21
22
  import {
22
23
  createPage,
23
24
  type PageControllerClass
@@ -56,7 +57,7 @@ describe('Helpers', () => {
56
57
  page = createPage(model, definition.pages[0])
57
58
  const pageUrl = new URL(page.href, 'http://example.com')
58
59
 
59
- request = {
60
+ request = buildFormContextRequest({
60
61
  method: 'get',
61
62
  url: pageUrl,
62
63
  path: pageUrl.pathname,
@@ -66,7 +67,7 @@ describe('Helpers', () => {
66
67
  },
67
68
  query: {},
68
69
  app: { model }
69
- }
70
+ })
70
71
 
71
72
  const response = {
72
73
  code: jest.fn().mockImplementation(() => response)
@@ -377,7 +377,15 @@ export function evaluateTemplate(
377
377
  }
378
378
 
379
379
  export function getCacheService(server: Server) {
380
- return server.plugins['forms-engine-plugin'].cacheService
380
+ return getPluginOptions(server).cacheService
381
+ }
382
+
383
+ export function getSaveAndReturnHelpers(server: Server) {
384
+ return getPluginOptions(server).saveAndReturn
385
+ }
386
+
387
+ export function getPluginOptions(server: Server) {
388
+ return server.plugins['forms-engine-plugin']
381
389
  }
382
390
 
383
391
  /**
@@ -6,6 +6,7 @@ import {
6
6
 
7
7
  import { todayAsDateOnly } from '~/src/server/plugins/engine/date-helper.js'
8
8
  import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
9
+ import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js'
9
10
  import { type FormContextRequest } from '~/src/server/plugins/engine/types.js'
10
11
  import { V2 as definitionV2 } from '~/test/form/definitions/conditions-basic.js'
11
12
  import definition from '~/test/form/definitions/conditions-escaping.js'
@@ -151,7 +152,7 @@ describe('FormModel', () => {
151
152
  }
152
153
  const pageUrl = new URL('http://example.com/components/fields-required')
153
154
 
154
- const request: FormContextRequest = {
155
+ const request: FormContextRequest = buildFormContextRequest({
155
156
  method: 'post',
156
157
  payload: { crumb: 'dummyCrumb', action: 'validate' },
157
158
  query: {},
@@ -159,7 +160,7 @@ describe('FormModel', () => {
159
160
  params: { path: 'components', slug: 'fields-required' },
160
161
  url: pageUrl,
161
162
  app: { model: formModel }
162
- }
163
+ })
163
164
 
164
165
  const context = formModel.getFormContext(request, state)
165
166
 
@@ -180,7 +181,7 @@ describe('FormModel', () => {
180
181
  }
181
182
  const pageUrl = new URL('http://example.com/components/fields-required')
182
183
 
183
- const request: FormContextRequest = {
184
+ const request: FormContextRequest = buildFormContextRequest({
184
185
  method: 'post',
185
186
  payload: { crumb: 'dummyCrumb', action: 'validate' },
186
187
  query: {},
@@ -188,7 +189,7 @@ describe('FormModel', () => {
188
189
  params: { path: 'components', slug: 'fields-required' },
189
190
  url: pageUrl,
190
191
  app: { model: formModel }
191
- }
192
+ })
192
193
 
193
194
  expect(() => formModel.getFormContext(request, state)).toThrow(
194
195
  'Reference number not found in form state'
@@ -206,7 +207,7 @@ describe('FormModel', () => {
206
207
  }
207
208
  const pageUrl = new URL('http://example.com/components/fields-required')
208
209
 
209
- const request: FormContextRequest = {
210
+ const request: FormContextRequest = buildFormContextRequest({
210
211
  method: 'post',
211
212
  payload: { crumb: 'dummyCrumb', action: 'validate' },
212
213
  query: {},
@@ -214,7 +215,7 @@ describe('FormModel', () => {
214
215
  params: { path: 'components', slug: 'fields-required' },
215
216
  url: pageUrl,
216
217
  app: { model: formModel }
217
- }
218
+ })
218
219
 
219
220
  expect(() => formModel.getFormContext(request, state)).toThrow(
220
221
  'Reference number not found in form state'
@@ -236,14 +237,14 @@ describe('FormModel', () => {
236
237
  'http://example.com/conditional-list-items/summary'
237
238
  )
238
239
 
239
- const request: FormContextRequest = {
240
+ const request: FormContextRequest = buildFormContextRequest({
240
241
  method: 'get',
241
242
  query: {},
242
243
  path: pageUrl.pathname,
243
244
  params: { path: 'summary', slug: 'conditional-list-items' },
244
245
  url: pageUrl,
245
246
  app: { model: formModel }
246
- }
247
+ })
247
248
 
248
249
  const context = formModel.getFormContext(request, state)
249
250
 
@@ -271,14 +272,14 @@ describe('FormModel', () => {
271
272
  'http://example.com/conditional-list-items/summary'
272
273
  )
273
274
 
274
- const request: FormContextRequest = {
275
+ const request: FormContextRequest = buildFormContextRequest({
275
276
  method: 'get',
276
277
  query: {},
277
278
  path: pageUrl.pathname,
278
279
  params: { path: 'summary', slug: 'conditional-list-items' },
279
280
  url: pageUrl,
280
281
  app: { model: formModel }
281
- }
282
+ })
282
283
 
283
284
  const context = formModel.getFormContext(request, state)
284
285
 
@@ -4,6 +4,8 @@ import {
4
4
  SummaryViewModel
5
5
  } from '~/src/server/plugins/engine/models/index.js'
6
6
  import { SummaryPageController } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js'
7
+ import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js'
8
+ import { serverWithSaveAndReturn } from '~/src/server/plugins/engine/pageControllers/__stubs__/server.js'
7
9
  import {
8
10
  createPage,
9
11
  type PageControllerClass
@@ -14,7 +16,6 @@ import {
14
16
  type FormState
15
17
  } from '~/src/server/plugins/engine/types.js'
16
18
  import definition from '~/test/form/definitions/repeat-mixed.js'
17
-
18
19
  const basePath = `${FORM_PREFIX}/test`
19
20
 
20
21
  describe('SummaryViewModel', () => {
@@ -36,7 +37,7 @@ describe('SummaryViewModel', () => {
36
37
  page = createPage(model, definition.pages[2])
37
38
  pageUrl = new URL('http://example.com/repeat/pizza-order/summary')
38
39
 
39
- request = {
40
+ request = buildFormContextRequest({
40
41
  method: 'get',
41
42
  url: pageUrl,
42
43
  path: pageUrl.pathname,
@@ -46,7 +47,7 @@ describe('SummaryViewModel', () => {
46
47
  },
47
48
  query: {},
48
49
  app: { model }
49
- }
50
+ })
50
51
  })
51
52
 
52
53
  describe.each([
@@ -272,13 +273,14 @@ describe('SummaryPageController', () => {
272
273
  slug: 'repeat'
273
274
  },
274
275
  query: {},
275
- app: { model }
276
+ app: { model },
277
+ server: serverWithSaveAndReturn
276
278
  }
277
279
  })
278
280
 
279
281
  describe('Save and Return functionality', () => {
280
282
  it('should show save and return button on summary page', () => {
281
- expect(controller.shouldShowSaveAndReturn()).toBe(true)
283
+ expect(controller.shouldShowSaveAndReturn(request.server)).toBe(true)
282
284
  })
283
285
 
284
286
  it('should handle save and return from summary page', () => {
@@ -51,7 +51,7 @@ export class SummaryViewModel {
51
51
  serviceUrl: string
52
52
  hasMissingNotificationEmail?: boolean
53
53
  components?: ComponentViewModel[]
54
- allowSaveAndReturn?: boolean
54
+ allowSaveAndReturn = false
55
55
 
56
56
  constructor(
57
57
  request: FormContextRequest,
@@ -20,9 +20,11 @@ const pluginRegistrationOptionsSchema = Joi.object({
20
20
  preparePageEventRequestOptions: Joi.function().optional(),
21
21
  onRequest: Joi.function().optional(),
22
22
  baseUrl: Joi.string().uri().required(),
23
- keyGenerator: Joi.function().optional(),
24
- sessionHydrator: Joi.function().optional(),
25
- sessionPersister: Joi.function().optional()
23
+ saveAndReturn: Joi.object({
24
+ keyGenerator: Joi.function(),
25
+ sessionHydrator: Joi.function(),
26
+ sessionPersister: Joi.function()
27
+ }).optional()
26
28
  })
27
29
 
28
30
  /**
@@ -2,6 +2,9 @@ import { validatePluginOptions } from '~/src/server/plugins/engine/options.js'
2
2
 
3
3
  describe('validatePluginOptions', () => {
4
4
  it('returns the validated value for valid options', () => {
5
+ /**
6
+ * @type {PluginOptions}
7
+ */
5
8
  const validOptions = {
6
9
  nunjucks: {
7
10
  baseLayoutPath: 'dxt-devtool-baselayout.html',
@@ -17,6 +20,9 @@ describe('validatePluginOptions', () => {
17
20
  })
18
21
 
19
22
  it('accepts optional properties keyGenerator, sessionHydrator, and sessionPersister', () => {
23
+ /**
24
+ * @type {PluginOptions}
25
+ */
20
26
  const validOptionsWithOptionals = {
21
27
  nunjucks: {
22
28
  baseLayoutPath: 'dxt-devtool-baselayout.html',
@@ -26,9 +32,11 @@ describe('validatePluginOptions', () => {
26
32
  return { hello: 'world' }
27
33
  },
28
34
  baseUrl: 'http://localhost:3009',
29
- keyGenerator: () => 'test-key',
30
- sessionHydrator: () => Promise.resolve({ someState: 'value' }),
31
- sessionPersister: () => Promise.resolve(undefined)
35
+ saveAndReturn: {
36
+ keyGenerator: () => 'test-key',
37
+ sessionHydrator: () => Promise.resolve({ someState: 'value' }),
38
+ sessionPersister: () => Promise.resolve(undefined)
39
+ }
32
40
  }
33
41
 
34
42
  expect(validatePluginOptions(validOptionsWithOptionals)).toEqual(
@@ -40,17 +48,20 @@ describe('validatePluginOptions', () => {
40
48
  * tsc would usually check compliance with the type, but given a user might be using plain JS we still want a test
41
49
  */
42
50
  it('fails if a required attribute is missing', () => {
43
- // viewContext is missing
44
- const invalidOptions = {
45
- nunjucks: {
46
- baseLayoutPath: 'dxt-devtool-baselayout.html',
47
- paths: ['src/server/devserver'] // custom layout to make it really clear this is not the same as the runner
48
- }
49
- }
51
+ const invalidOptions =
52
+ /** @type {PluginOptions} testing without viewContext */ ({
53
+ nunjucks: {
54
+ baseLayoutPath: 'dxt-devtool-baselayout.html',
55
+ paths: ['src/server/devserver'] // custom layout to make it really clear this is not the same as the runner
56
+ }
57
+ })
50
58
 
51
- // @ts-expect-error -- add a test for JS users
52
59
  expect(() => validatePluginOptions(invalidOptions)).toThrow(
53
60
  'Invalid plugin options'
54
61
  )
55
62
  })
56
63
  })
64
+
65
+ /**
66
+ * @import { PluginOptions } from '~/src/server/plugins/engine/types.js'
67
+ */
@@ -7,7 +7,7 @@ import {
7
7
  SummaryPageController,
8
8
  getFormSubmissionData
9
9
  } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js'
10
- import { type FormContextRequest } from '~/src/server/plugins/engine/types.js'
10
+ import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js'
11
11
  import { FormStatus } from '~/src/server/routes/types.js'
12
12
  import definition from '~/test/form/definitions/repeat-mixed.js'
13
13
 
@@ -52,7 +52,7 @@ const pageUrl = new URL('http://example.com/repeat/pizza-order/summary')
52
52
 
53
53
  const controller = new SummaryPageController(model, pageDef)
54
54
 
55
- const request = {
55
+ const request = buildFormContextRequest({
56
56
  method: 'get',
57
57
  url: pageUrl,
58
58
  path: pageUrl.pathname,
@@ -62,7 +62,7 @@ const request = {
62
62
  },
63
63
  query: {},
64
64
  app: { model }
65
- } satisfies FormContextRequest
65
+ })
66
66
 
67
67
  const context = model.getFormContext(request, state)
68
68
  const summaryViewModel = controller.getSummaryViewModel(request, context)
@@ -14,6 +14,7 @@ import {
14
14
  prepareStatus
15
15
  } from '~/src/server/plugins/engine/pageControllers/FileUploadPageController.js'
16
16
  import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js'
17
+ import { serverWithSaveAndReturn } from '~/src/server/plugins/engine/pageControllers/__stubs__/server.js'
17
18
  import * as pageHelpers from '~/src/server/plugins/engine/pageControllers/helpers.js'
18
19
  import * as uploadService from '~/src/server/plugins/engine/services/uploadService.js'
19
20
  import {
@@ -31,6 +32,7 @@ import {
31
32
  type FormRequest,
32
33
  type FormRequestPayload
33
34
  } from '~/src/server/routes/types.js'
35
+ import { type CacheService } from '~/src/server/services/index.js'
34
36
  import definition from '~/test/form/definitions/file-upload-basic.js'
35
37
 
36
38
  type TestableFileUploadPageController = FileUploadPageController & {
@@ -77,12 +79,13 @@ describe('FileUploadPageController', () => {
77
79
  server: {
78
80
  plugins: {
79
81
  'forms-engine-plugin': {
82
+ baseLayoutPath: '',
80
83
  cacheService: {
81
84
  setFlash: jest.fn(),
82
85
  setState: jest
83
86
  .fn()
84
87
  .mockImplementation((req, updated) => Promise.resolve(updated))
85
- }
88
+ } as unknown as CacheService
86
89
  }
87
90
  }
88
91
  },
@@ -1115,4 +1118,12 @@ describe('FileUploadPageController', () => {
1115
1118
  )
1116
1119
  })
1117
1120
  })
1121
+
1122
+ describe('shouldShowSaveAndReturn', () => {
1123
+ it('should return true when save and return is enabled', () => {
1124
+ expect(controller.shouldShowSaveAndReturn(serverWithSaveAndReturn)).toBe(
1125
+ true
1126
+ )
1127
+ })
1128
+ })
1118
1129
  })
@@ -3,6 +3,7 @@ import { type ResponseToolkit } from '@hapi/hapi'
3
3
  import { FORM_PREFIX } from '~/src/server/constants.js'
4
4
  import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
5
5
  import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js'
6
+ import { serverWithSaveAndReturn } from '~/src/server/plugins/engine/pageControllers/__stubs__/server.js'
6
7
  import { type FormRequest } from '~/src/server/routes/types.js'
7
8
  import definition from '~/test/form/definitions/basic.js'
8
9
 
@@ -230,4 +231,12 @@ describe('PageController', () => {
230
231
  )
231
232
  })
232
233
  })
234
+
235
+ describe('shouldShowSaveAndReturn', () => {
236
+ it('should return false (PageController does not allow save and return)', () => {
237
+ expect(controller1.shouldShowSaveAndReturn(serverWithSaveAndReturn)).toBe(
238
+ false
239
+ )
240
+ })
241
+ })
233
242
  })
@@ -9,12 +9,14 @@ import Boom from '@hapi/boom'
9
9
  import {
10
10
  type Lifecycle,
11
11
  type ResponseToolkit,
12
- type RouteOptions
12
+ type RouteOptions,
13
+ type Server
13
14
  } from '@hapi/hapi'
14
15
 
15
16
  import { type ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
16
17
  import {
17
18
  encodeUrl,
19
+ getSaveAndReturnHelpers,
18
20
  getStartPath,
19
21
  normalisePath
20
22
  } from '~/src/server/plugins/engine/helpers.js'
@@ -45,6 +47,7 @@ export class PageController {
45
47
  events?: Events
46
48
  collection?: ComponentCollection
47
49
  viewName = 'index'
50
+ allowSaveAndReturn = false
48
51
 
49
52
  constructor(model: FormModel, pageDef: Page) {
50
53
  const { def } = model
@@ -183,4 +186,10 @@ export class PageController {
183
186
  ) => ReturnType<Lifecycle.Method<FormRequestPayloadRefs>> {
184
187
  throw Boom.badRequest('Unsupported POST route handler for this page')
185
188
  }
189
+
190
+ shouldShowSaveAndReturn(server: Server): boolean {
191
+ return (
192
+ getSaveAndReturnHelpers(server) !== undefined && this.allowSaveAndReturn
193
+ )
194
+ }
186
195
  }