@defra/forms-engine-plugin 3.0.9 → 4.0.1

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 (127) hide show
  1. package/.public/stylesheets/application.min.css +3 -3
  2. package/.public/stylesheets/application.min.css.map +1 -1
  3. package/.server/client/stylesheets/application.scss +14 -0
  4. package/.server/config/index.d.ts +1 -0
  5. package/.server/config/index.js +7 -0
  6. package/.server/config/index.js.map +1 -1
  7. package/.server/index.js +6 -2
  8. package/.server/index.js.map +1 -1
  9. package/.server/server/constants.d.ts +2 -0
  10. package/.server/server/constants.js +2 -0
  11. package/.server/server/constants.js.map +1 -1
  12. package/.server/server/forms/components.json +7 -0
  13. package/.server/server/forms/register-as-a-unicorn-breeder.yaml +18 -2
  14. package/.server/server/plugins/engine/components/UkAddressField.d.ts +15 -9
  15. package/.server/server/plugins/engine/components/UkAddressField.js +67 -6
  16. package/.server/server/plugins/engine/components/UkAddressField.js.map +1 -1
  17. package/.server/server/plugins/engine/configureEnginePlugin.d.ts +1 -1
  18. package/.server/server/plugins/engine/configureEnginePlugin.js +6 -3
  19. package/.server/server/plugins/engine/configureEnginePlugin.js.map +1 -1
  20. package/.server/server/plugins/engine/models/FormModel.d.ts +2 -0
  21. package/.server/server/plugins/engine/models/FormModel.js +3 -1
  22. package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
  23. package/.server/server/plugins/engine/options.js +2 -1
  24. package/.server/server/plugins/engine/options.js.map +1 -1
  25. package/.server/server/plugins/engine/pageControllers/QuestionPageController.d.ts +1 -0
  26. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +46 -3
  27. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  28. package/.server/server/plugins/engine/plugin.js +15 -2
  29. package/.server/server/plugins/engine/plugin.js.map +1 -1
  30. package/.server/server/plugins/engine/routes/index.d.ts +2 -2
  31. package/.server/server/plugins/engine/routes/index.js +49 -9
  32. package/.server/server/plugins/engine/routes/index.js.map +1 -1
  33. package/.server/server/plugins/engine/routes/questions.d.ts +4 -4
  34. package/.server/server/plugins/engine/routes/questions.js +10 -10
  35. package/.server/server/plugins/engine/routes/questions.js.map +1 -1
  36. package/.server/server/plugins/engine/routes/repeaters/item-delete.d.ts +2 -1
  37. package/.server/server/plugins/engine/routes/repeaters/item-delete.js +31 -27
  38. package/.server/server/plugins/engine/routes/repeaters/item-delete.js.map +1 -1
  39. package/.server/server/plugins/engine/routes/repeaters/summary.d.ts +2 -1
  40. package/.server/server/plugins/engine/routes/repeaters/summary.js +31 -27
  41. package/.server/server/plugins/engine/routes/repeaters/summary.js.map +1 -1
  42. package/.server/server/plugins/engine/types.d.ts +21 -3
  43. package/.server/server/plugins/engine/types.js.map +1 -1
  44. package/.server/server/plugins/engine/validationHelpers.d.ts +15 -0
  45. package/.server/server/plugins/engine/validationHelpers.js +29 -0
  46. package/.server/server/plugins/engine/validationHelpers.js.map +1 -0
  47. package/.server/server/plugins/engine/views/components/ukaddressfield.html +50 -6
  48. package/.server/server/plugins/engine/vision.js +3 -1
  49. package/.server/server/plugins/engine/vision.js.map +1 -1
  50. package/.server/server/plugins/postcode-lookup/index.d.ts +8 -0
  51. package/.server/server/plugins/postcode-lookup/index.js +21 -0
  52. package/.server/server/plugins/postcode-lookup/index.js.map +1 -0
  53. package/.server/server/plugins/postcode-lookup/models/index.d.ts +255 -0
  54. package/.server/server/plugins/postcode-lookup/models/index.js +517 -0
  55. package/.server/server/plugins/postcode-lookup/models/index.js.map +1 -0
  56. package/.server/server/plugins/postcode-lookup/routes/index.d.ts +19 -0
  57. package/.server/server/plugins/postcode-lookup/routes/index.js +267 -0
  58. package/.server/server/plugins/postcode-lookup/routes/index.js.map +1 -0
  59. package/.server/server/plugins/postcode-lookup/service.d.ts +26 -0
  60. package/.server/server/plugins/postcode-lookup/service.js +148 -0
  61. package/.server/server/plugins/postcode-lookup/service.js.map +1 -0
  62. package/.server/server/plugins/postcode-lookup/service.test.js +144 -0
  63. package/.server/server/plugins/postcode-lookup/service.test.js.map +1 -0
  64. package/.server/server/plugins/postcode-lookup/test/__stubs__/postcode.d.ts +282 -0
  65. package/.server/server/plugins/postcode-lookup/test/__stubs__/postcode.js +370 -0
  66. package/.server/server/plugins/postcode-lookup/test/__stubs__/postcode.js.map +1 -0
  67. package/.server/server/plugins/postcode-lookup/test/__stubs__/query.d.ts +131 -0
  68. package/.server/server/plugins/postcode-lookup/test/__stubs__/query.js +195 -0
  69. package/.server/server/plugins/postcode-lookup/test/__stubs__/query.js.map +1 -0
  70. package/.server/server/plugins/postcode-lookup/test/__stubs__/uprn.d.ts +51 -0
  71. package/.server/server/plugins/postcode-lookup/test/__stubs__/uprn.js +52 -0
  72. package/.server/server/plugins/postcode-lookup/test/__stubs__/uprn.js.map +1 -0
  73. package/.server/server/plugins/postcode-lookup/types.d.ts +204 -0
  74. package/.server/server/plugins/postcode-lookup/types.js +144 -0
  75. package/.server/server/plugins/postcode-lookup/types.js.map +1 -0
  76. package/.server/server/plugins/postcode-lookup/views/postcode-lookup-details.html +83 -0
  77. package/.server/server/routes/types.d.ts +7 -2
  78. package/.server/server/routes/types.js +6 -0
  79. package/.server/server/routes/types.js.map +1 -1
  80. package/.server/server/schemas/index.js +1 -1
  81. package/.server/server/schemas/index.js.map +1 -1
  82. package/.server/server/types.d.ts +1 -0
  83. package/.server/server/types.js.map +1 -1
  84. package/package.json +2 -2
  85. package/src/client/stylesheets/application.scss +14 -0
  86. package/src/config/index.ts +9 -1
  87. package/src/index.ts +5 -4
  88. package/src/server/constants.js +2 -0
  89. package/src/server/forms/components.json +7 -0
  90. package/src/server/forms/register-as-a-unicorn-breeder.yaml +18 -2
  91. package/src/server/plugins/engine/components/UkAddressField.test.ts +50 -27
  92. package/src/server/plugins/engine/components/UkAddressField.ts +91 -8
  93. package/src/server/plugins/engine/configureEnginePlugin.ts +5 -3
  94. package/src/server/plugins/engine/helpers.test.ts +2 -1
  95. package/src/server/plugins/engine/models/FormModel.ts +10 -2
  96. package/src/server/plugins/engine/options.js +2 -1
  97. package/src/server/plugins/engine/pageControllers/PageController.test.ts +2 -1
  98. package/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +9 -4
  99. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +69 -1
  100. package/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts +2 -1
  101. package/src/server/plugins/engine/plugin.ts +22 -4
  102. package/src/server/plugins/engine/routes/index.test.ts +317 -0
  103. package/src/server/plugins/engine/routes/index.ts +81 -8
  104. package/src/server/plugins/engine/routes/questions.test.ts +126 -15
  105. package/src/server/plugins/engine/routes/questions.ts +71 -57
  106. package/src/server/plugins/engine/routes/repeaters/item-delete.test.ts +83 -0
  107. package/src/server/plugins/engine/routes/repeaters/item-delete.ts +39 -33
  108. package/src/server/plugins/engine/routes/repeaters/summary.test.ts +75 -0
  109. package/src/server/plugins/engine/routes/repeaters/summary.ts +28 -22
  110. package/src/server/plugins/engine/types.ts +27 -8
  111. package/src/server/plugins/engine/validationHelpers.ts +48 -0
  112. package/src/server/plugins/engine/views/components/ukaddressfield.html +50 -6
  113. package/src/server/plugins/engine/vision.ts +6 -0
  114. package/src/server/plugins/postcode-lookup/index.js +21 -0
  115. package/src/server/plugins/postcode-lookup/models/index.js +549 -0
  116. package/src/server/plugins/postcode-lookup/routes/index.js +258 -0
  117. package/src/server/plugins/postcode-lookup/service.js +188 -0
  118. package/src/server/plugins/postcode-lookup/service.test.js +177 -0
  119. package/src/server/plugins/postcode-lookup/test/__stubs__/postcode.js +382 -0
  120. package/src/server/plugins/postcode-lookup/test/__stubs__/query.js +200 -0
  121. package/src/server/plugins/postcode-lookup/test/__stubs__/uprn.js +53 -0
  122. package/src/server/plugins/postcode-lookup/types.js +143 -0
  123. package/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html +83 -0
  124. package/src/server/postcode-lookup.test.ts +64 -0
  125. package/src/server/routes/types.ts +11 -2
  126. package/src/server/schemas/index.ts +5 -7
  127. package/src/server/types.ts +1 -0
@@ -12,6 +12,10 @@ import Boom from '@hapi/boom'
12
12
  import { type RouteOptions } from '@hapi/hapi'
13
13
  import { type ValidationErrorItem } from 'joi'
14
14
 
15
+ import {
16
+ EXTERNAL_STATE_APPENDAGE,
17
+ EXTERNAL_STATE_PAYLOAD
18
+ } from '~/src/server/constants.js'
15
19
  import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
16
20
  import { optionalText } from '~/src/server/plugins/engine/components/constants.js'
17
21
  import { type BackLink } from '~/src/server/plugins/engine/components/types.js'
@@ -35,6 +39,7 @@ import {
35
39
  type FormStateValue,
36
40
  type FormSubmissionState
37
41
  } from '~/src/server/plugins/engine/types.js'
42
+ import { getComponentsByType } from '~/src/server/plugins/engine/validationHelpers.js'
38
43
  import {
39
44
  FormAction,
40
45
  type FormRequest,
@@ -492,6 +497,11 @@ export class QuestionPageController extends PageController {
492
497
  ) => {
493
498
  const { collection, viewName, model } = this
494
499
  const { isForceAccess, state, evaluationState } = context
500
+ const action = request.payload.action
501
+
502
+ if (action?.startsWith(FormAction.External)) {
503
+ return this.dispatchExternal(request, h, context)
504
+ }
495
505
 
496
506
  /**
497
507
  * If there are any errors, render the page with the parsed errors
@@ -515,7 +525,6 @@ export class QuestionPageController extends PageController {
515
525
  await this.setState(request, state)
516
526
 
517
527
  // Check if this is a save-and-exit action
518
- const { action } = request.payload
519
528
  if (action === FormAction.SaveAndExit) {
520
529
  return this.handleSaveAndExit(request, context, h)
521
530
  }
@@ -525,6 +534,65 @@ export class QuestionPageController extends PageController {
525
534
  }
526
535
  }
527
536
 
537
+ private dispatchExternal(
538
+ request: FormRequestPayload,
539
+ h: FormResponseToolkit,
540
+ context: FormContext
541
+ ) {
542
+ const { externalComponents } = getComponentsByType()
543
+ const action = request.payload.action ?? ''
544
+
545
+ // Find the external action and arguments
546
+ // `external-{componentName}--{argname1}:{argvalue1}--{argname2}:{argvalue2}`
547
+ // E.g. external-abcdef--amount:10--step:manual
548
+ const externalActionsWithArgs = action
549
+ .slice(`${FormAction.External}-`.length)
550
+ .split('--')
551
+
552
+ const externalActionArgs = externalActionsWithArgs
553
+ .slice(1)
554
+ .map((arg) => arg.split(':'))
555
+
556
+ const args = Object.fromEntries(externalActionArgs) as Record<
557
+ string,
558
+ string
559
+ >
560
+
561
+ const componentName = externalActionsWithArgs[0]
562
+ const component = this.model.componentDefMap.get(componentName)
563
+ const componentType = component?.type
564
+
565
+ if (!componentType) {
566
+ throw Boom.internal(
567
+ `External component of type ${componentType} not found`
568
+ )
569
+ }
570
+
571
+ const selectedComponent = externalComponents.get(componentType)
572
+
573
+ if (!selectedComponent) {
574
+ throw Boom.internal(`External component ${componentName} not found`)
575
+ }
576
+
577
+ // Stash payload without crumb and action
578
+ const stashedPayload = {
579
+ ...context.payload,
580
+ crumb: undefined,
581
+ action: undefined
582
+ }
583
+ request.yar.flash(EXTERNAL_STATE_PAYLOAD, stashedPayload, true)
584
+
585
+ // Clear any previous state appendage
586
+ request.yar.clear(EXTERNAL_STATE_APPENDAGE)
587
+
588
+ return selectedComponent.dispatcher(request, h, {
589
+ component,
590
+ controller: this,
591
+ sourceUrl: request.url.toString(),
592
+ actionArgs: args
593
+ })
594
+ }
595
+
528
596
  proceed(
529
597
  request: FormContextRequest,
530
598
  h: FormResponseToolkit,
@@ -20,7 +20,8 @@ describe('SummaryPageController', () => {
20
20
  }
21
21
  const h: FormResponseToolkit = {
22
22
  redirect: jest.fn().mockReturnValue(response),
23
- view: jest.fn()
23
+ view: jest.fn(),
24
+ continue: Symbol('continue')
24
25
  }
25
26
 
26
27
  beforeEach(() => {
@@ -15,6 +15,7 @@ import { getRoutes as getRepeaterItemDeleteRoutes } from '~/src/server/plugins/e
15
15
  import { getRoutes as getRepeaterSummaryRoutes } from '~/src/server/plugins/engine/routes/repeaters/summary.js'
16
16
  import { type PluginOptions } from '~/src/server/plugins/engine/types.js'
17
17
  import { registerVision } from '~/src/server/plugins/engine/vision.js'
18
+ import { postcodeLookupPlugin } from '~/src/server/plugins/postcode-lookup/index.js'
18
19
  import {
19
20
  type FormRequestPayloadRefs,
20
21
  type FormRequestRefs
@@ -34,7 +35,9 @@ export const plugin = {
34
35
  saveAndExit,
35
36
  nunjucks: nunjucksOptions,
36
37
  viewContext,
37
- preparePageEventRequestOptions
38
+ preparePageEventRequestOptions,
39
+ onRequest,
40
+ ordnanceSurveyApiKey
38
41
  } = options
39
42
 
40
43
  const cacheService =
@@ -44,6 +47,16 @@ export const plugin = {
44
47
 
45
48
  await registerVision(server, options)
46
49
 
50
+ // Register the postcode lookup plugin only if we have an OS api key
51
+ if (ordnanceSurveyApiKey) {
52
+ await server.register({
53
+ plugin: postcodeLookupPlugin,
54
+ options: {
55
+ ordnanceSurveyApiKey
56
+ }
57
+ })
58
+ }
59
+
47
60
  server.expose('baseLayoutPath', nunjucksOptions.baseLayoutPath)
48
61
  server.expose('viewContext', viewContext)
49
62
  server.expose('cacheService', cacheService)
@@ -83,10 +96,15 @@ export const plugin = {
83
96
  ...getQuestionRoutes(
84
97
  getRouteOptions,
85
98
  postRouteOptions,
86
- preparePageEventRequestOptions
99
+ preparePageEventRequestOptions,
100
+ onRequest
101
+ ),
102
+ ...getRepeaterSummaryRoutes(getRouteOptions, postRouteOptions, onRequest),
103
+ ...getRepeaterItemDeleteRoutes(
104
+ getRouteOptions,
105
+ postRouteOptions,
106
+ onRequest
87
107
  ),
88
- ...getRepeaterSummaryRoutes(getRouteOptions, postRouteOptions),
89
- ...getRepeaterItemDeleteRoutes(getRouteOptions, postRouteOptions),
90
108
  ...getFileUploadStatusRoutes()
91
109
  ]
92
110
 
@@ -0,0 +1,317 @@
1
+ import Boom from '@hapi/boom'
2
+ import { type ResponseObject, type ResponseToolkit } from '@hapi/hapi'
3
+
4
+ import {
5
+ findPage,
6
+ getCacheService,
7
+ getPage,
8
+ proceed
9
+ } from '~/src/server/plugins/engine/helpers.js'
10
+ import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
11
+ import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
12
+ import { redirectOrMakeHandler } from '~/src/server/plugins/engine/routes/index.js'
13
+ import {
14
+ type AnyFormRequest,
15
+ type OnRequestCallback
16
+ } from '~/src/server/plugins/engine/types.js'
17
+ import { type FormResponseToolkit } from '~/src/server/routes/types.js'
18
+
19
+ jest.mock('~/src/server/plugins/engine/helpers')
20
+
21
+ describe('redirectOrMakeHandler', () => {
22
+ const mockServer = {} as unknown as Parameters<
23
+ typeof redirectOrMakeHandler
24
+ >[0]['server']
25
+ const mockRequest: AnyFormRequest = {
26
+ server: mockServer,
27
+ app: {},
28
+ yar: { flash: () => [] },
29
+ params: { path: 'test-path' },
30
+ query: {}
31
+ } as unknown as AnyFormRequest
32
+
33
+ const mockH: FormResponseToolkit = {
34
+ redirect: jest.fn(),
35
+ view: jest.fn(),
36
+ continue: Symbol('continue')
37
+ } as unknown as FormResponseToolkit
38
+
39
+ let mockPage: PageControllerClass
40
+
41
+ const mockModel: FormModel = {
42
+ def: {
43
+ metadata: {
44
+ submission: { code: 'TEST-CODE' }
45
+ } as { submission: { code: string } }
46
+ },
47
+ getFormContext: jest.fn().mockReturnValue({
48
+ isForceAccess: false,
49
+ data: {}
50
+ })
51
+ } as unknown as FormModel
52
+
53
+ const mockMakeHandler = jest
54
+ .fn()
55
+ .mockResolvedValue({ statusCode: 200 } as ResponseObject)
56
+
57
+ beforeEach(() => {
58
+ jest.clearAllMocks()
59
+ mockRequest.app = { model: mockModel }
60
+
61
+ // Reset mock page
62
+ mockPage = {
63
+ getState: jest.fn().mockResolvedValue({ $$__referenceNumber: 'REF-123' }),
64
+ mergeState: jest
65
+ .fn()
66
+ .mockResolvedValue({ $$__referenceNumber: 'REF-123' }),
67
+ getRelevantPath: jest.fn().mockReturnValue('/test-path'),
68
+ getSummaryPath: jest.fn().mockReturnValue('/summary'),
69
+ getHref: jest.fn().mockReturnValue('/test-href'),
70
+ path: '/test-path'
71
+ } as unknown as PageControllerClass
72
+
73
+ // Reset mock model
74
+ mockModel.getFormContext = jest.fn().mockReturnValue({
75
+ isForceAccess: false,
76
+ data: {}
77
+ })
78
+
79
+ // Setup mocks
80
+ ;(getCacheService as jest.Mock).mockReturnValue({
81
+ getFlash: jest.fn().mockReturnValue({ errors: [] })
82
+ })
83
+ ;(getPage as jest.Mock).mockReturnValue(mockPage)
84
+ ;(findPage as jest.Mock).mockReturnValue({ next: [] })
85
+ ;(proceed as jest.Mock).mockReturnValue({ statusCode: 302 })
86
+ })
87
+
88
+ describe('onRequest callback functionality', () => {
89
+ it('should call onRequest callback when provided', async () => {
90
+ const onRequestCallback: OnRequestCallback = jest
91
+ .fn()
92
+ .mockResolvedValue(undefined)
93
+
94
+ await redirectOrMakeHandler(
95
+ mockRequest,
96
+ mockH,
97
+ onRequestCallback,
98
+ mockMakeHandler
99
+ )
100
+
101
+ expect(onRequestCallback).toHaveBeenCalledWith(
102
+ mockRequest,
103
+ mockH as ResponseToolkit,
104
+ expect.objectContaining({
105
+ isForceAccess: false,
106
+ data: {}
107
+ })
108
+ )
109
+ })
110
+
111
+ it('should not call onRequest callback when not provided', async () => {
112
+ const onRequestCallback = jest.fn()
113
+
114
+ await redirectOrMakeHandler(
115
+ mockRequest,
116
+ mockH,
117
+ undefined,
118
+ mockMakeHandler
119
+ )
120
+
121
+ expect(onRequestCallback).not.toHaveBeenCalled()
122
+ })
123
+
124
+ it('should return takeover response when onRequest returns takeover response', async () => {
125
+ const takeoverResponse = {
126
+ statusCode: 302,
127
+ headers: { location: '/redirect-url' },
128
+ _takeover: true
129
+ } as unknown as ResponseObject
130
+
131
+ const onRequestCallback: OnRequestCallback = jest
132
+ .fn()
133
+ .mockResolvedValue(takeoverResponse)
134
+
135
+ const result = await redirectOrMakeHandler(
136
+ mockRequest,
137
+ mockH,
138
+ onRequestCallback,
139
+ mockMakeHandler
140
+ )
141
+
142
+ expect(result).toBe(takeoverResponse)
143
+ expect(mockMakeHandler).not.toHaveBeenCalled()
144
+ })
145
+
146
+ it('should continue processing when onRequest returns h.continue', async () => {
147
+ const onRequestCallback: OnRequestCallback = jest
148
+ .fn()
149
+ .mockResolvedValue(mockH.continue)
150
+
151
+ await redirectOrMakeHandler(
152
+ mockRequest,
153
+ mockH,
154
+ onRequestCallback,
155
+ mockMakeHandler
156
+ )
157
+
158
+ expect(mockMakeHandler).toHaveBeenCalledWith(mockPage, expect.any(Object))
159
+ })
160
+
161
+ it('should handle onRequest callback errors', async () => {
162
+ const error = new Error('onRequest callback error')
163
+ const onRequestCallback: OnRequestCallback = jest
164
+ .fn()
165
+ .mockRejectedValue(error)
166
+
167
+ await expect(
168
+ redirectOrMakeHandler(
169
+ mockRequest,
170
+ mockH,
171
+ onRequestCallback,
172
+ mockMakeHandler
173
+ )
174
+ ).rejects.toThrow('onRequest callback error')
175
+ })
176
+ })
177
+
178
+ describe('existing functionality', () => {
179
+ it('should throw error when model is missing', async () => {
180
+ mockRequest.app = {}
181
+
182
+ await expect(
183
+ redirectOrMakeHandler(mockRequest, mockH, undefined, mockMakeHandler)
184
+ ).rejects.toThrow(Boom.notFound('No model found for /test-path'))
185
+ })
186
+
187
+ it('should call makeHandler when page is relevant', async () => {
188
+ const testPage = {
189
+ getState: jest
190
+ .fn()
191
+ .mockResolvedValue({ $$__referenceNumber: 'REF-123' }),
192
+ mergeState: jest
193
+ .fn()
194
+ .mockResolvedValue({ $$__referenceNumber: 'REF-123' }),
195
+ getSummaryPath: jest.fn().mockReturnValue('/summary'),
196
+ getHref: jest.fn().mockReturnValue('/test-href'),
197
+ getRelevantPath: jest.fn().mockReturnValue('/test-path'),
198
+ path: '/test-path'
199
+ } as unknown as PageControllerClass
200
+ ;(getPage as jest.Mock).mockReturnValue(testPage)
201
+
202
+ await redirectOrMakeHandler(
203
+ mockRequest,
204
+ mockH,
205
+ undefined,
206
+ mockMakeHandler
207
+ )
208
+
209
+ expect(mockMakeHandler).toHaveBeenCalledWith(testPage, expect.any(Object))
210
+ })
211
+
212
+ it('should call makeHandler when context has force access', async () => {
213
+ mockModel.getFormContext = jest.fn().mockReturnValue({
214
+ isForceAccess: true,
215
+ data: {}
216
+ })
217
+
218
+ await redirectOrMakeHandler(
219
+ mockRequest,
220
+ mockH,
221
+ undefined,
222
+ mockMakeHandler
223
+ )
224
+
225
+ expect(mockMakeHandler).toHaveBeenCalledWith(mockPage, expect.any(Object))
226
+ })
227
+
228
+ it('should redirect when page is not relevant', async () => {
229
+ const testPage = {
230
+ getState: jest
231
+ .fn()
232
+ .mockResolvedValue({ $$__referenceNumber: 'REF-123' }),
233
+ mergeState: jest
234
+ .fn()
235
+ .mockResolvedValue({ $$__referenceNumber: 'REF-123' }),
236
+ getSummaryPath: jest.fn().mockReturnValue('/summary'),
237
+ getHref: jest.fn().mockReturnValue('/test-href'),
238
+ getRelevantPath: jest.fn().mockReturnValue('/other-path'),
239
+ path: '/test-path'
240
+ } as unknown as PageControllerClass
241
+ ;(getPage as jest.Mock).mockReturnValue(testPage)
242
+
243
+ await redirectOrMakeHandler(
244
+ mockRequest,
245
+ mockH,
246
+ undefined,
247
+ mockMakeHandler
248
+ )
249
+
250
+ expect(proceed).toHaveBeenCalledWith(mockRequest, mockH, '/test-href')
251
+ expect(mockMakeHandler).not.toHaveBeenCalled()
252
+ })
253
+
254
+ it('should set returnUrl when redirecting and next pages exist', async () => {
255
+ const testPage = {
256
+ getState: jest
257
+ .fn()
258
+ .mockResolvedValue({ $$__referenceNumber: 'REF-123' }),
259
+ mergeState: jest
260
+ .fn()
261
+ .mockResolvedValue({ $$__referenceNumber: 'REF-123' }),
262
+ getSummaryPath: jest.fn().mockReturnValue('/summary'),
263
+ getRelevantPath: jest.fn().mockReturnValue('/other-path'),
264
+ path: '/test-path',
265
+ getHref: jest
266
+ .fn()
267
+ .mockReturnValueOnce('/summary-href') // First call: for summaryPath (returnUrl)
268
+ .mockReturnValueOnce('/relevant-path-href') // Second call: for relevantPath (redirect)
269
+ } as unknown as PageControllerClass
270
+ ;(getPage as jest.Mock).mockReturnValue(testPage)
271
+ ;(findPage as jest.Mock).mockReturnValue({ next: ['next-page'] })
272
+
273
+ await redirectOrMakeHandler(
274
+ mockRequest,
275
+ mockH,
276
+ undefined,
277
+ mockMakeHandler
278
+ )
279
+
280
+ expect(mockRequest.query.returnUrl).toBe('/summary-href')
281
+ expect(proceed).toHaveBeenCalledWith(
282
+ mockRequest,
283
+ mockH,
284
+ '/relevant-path-href'
285
+ )
286
+ })
287
+
288
+ it('should not set returnUrl when redirecting and no next pages exist', async () => {
289
+ const testPage = {
290
+ getState: jest
291
+ .fn()
292
+ .mockResolvedValue({ $$__referenceNumber: 'REF-123' }),
293
+ mergeState: jest
294
+ .fn()
295
+ .mockResolvedValue({ $$__referenceNumber: 'REF-123' }),
296
+ getSummaryPath: jest.fn().mockReturnValue('/summary'),
297
+ getHref: jest.fn().mockReturnValue('/test-href'),
298
+ getRelevantPath: jest.fn().mockReturnValue('/other-path'),
299
+ path: '/test-path'
300
+ } as unknown as PageControllerClass
301
+ ;(getPage as jest.Mock).mockReturnValue(testPage)
302
+ const returnUrlBefore = mockRequest.query.returnUrl
303
+ ;(findPage as jest.Mock).mockReturnValue({ next: [] })
304
+
305
+ await redirectOrMakeHandler(
306
+ mockRequest,
307
+ mockH,
308
+ undefined,
309
+ mockMakeHandler
310
+ )
311
+
312
+ // returnUrl should not be set if next pages don't exist
313
+ expect(mockRequest.query.returnUrl).toBe(returnUrlBefore)
314
+ expect(proceed).toHaveBeenCalledWith(mockRequest, mockH, '/test-href')
315
+ })
316
+ })
317
+ })
@@ -6,7 +6,15 @@ import {
6
6
  } from '@hapi/hapi'
7
7
  import { isEqual } from 'date-fns'
8
8
 
9
- import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js'
9
+ import {
10
+ EXTERNAL_STATE_APPENDAGE,
11
+ EXTERNAL_STATE_PAYLOAD,
12
+ PREVIEW_PATH_PREFIX
13
+ } from '~/src/server/constants.js'
14
+ import {
15
+ FormComponent,
16
+ isFormState
17
+ } from '~/src/server/plugins/engine/components/FormComponent.js'
10
18
  import {
11
19
  checkEmailAddressForLiveFormSubmission,
12
20
  checkFormStatus,
@@ -22,7 +30,11 @@ import { generateUniqueReference } from '~/src/server/plugins/engine/referenceNu
22
30
  import * as defaultServices from '~/src/server/plugins/engine/services/index.js'
23
31
  import {
24
32
  type AnyFormRequest,
33
+ type ExternalStateAppendage,
25
34
  type FormContext,
35
+ type FormPayload,
36
+ type FormSubmissionState,
37
+ type OnRequestCallback,
26
38
  type PluginOptions
27
39
  } from '~/src/server/plugins/engine/types.js'
28
40
  import {
@@ -33,6 +45,7 @@ import {
33
45
  export async function redirectOrMakeHandler(
34
46
  request: AnyFormRequest,
35
47
  h: FormResponseToolkit,
48
+ onRequest: OnRequestCallback | undefined,
36
49
  makeHandler: (
37
50
  page: PageControllerClass,
38
51
  context: FormContext
@@ -64,11 +77,21 @@ export async function redirectOrMakeHandler(
64
77
  })
65
78
  }
66
79
 
80
+ state = await importExternalComponentState(request, page, state)
81
+
67
82
  const flash = cacheService.getFlash(request)
68
83
  const context = model.getFormContext(request, state, flash?.errors)
69
84
  const relevantPath = page.getRelevantPath(request, context)
70
85
  const summaryPath = page.getSummaryPath()
71
86
 
87
+ // Call the onRequest callback if it has been supplied
88
+ if (onRequest) {
89
+ const result = await onRequest(request, h, context)
90
+ if (result !== h.continue) {
91
+ return result
92
+ }
93
+ }
94
+
72
95
  // Return handler for relevant pages or preview URL direct access
73
96
  if (relevantPath.startsWith(page.path) || context.isForceAccess) {
74
97
  return makeHandler(page, context)
@@ -85,11 +108,66 @@ export async function redirectOrMakeHandler(
85
108
  return proceed(request, h, page.getHref(relevantPath))
86
109
  }
87
110
 
111
+ async function importExternalComponentState(
112
+ request: AnyFormRequest,
113
+ page: PageControllerClass,
114
+ state: FormSubmissionState
115
+ ): Promise<FormSubmissionState> {
116
+ const externalComponentData = request.yar.flash(EXTERNAL_STATE_APPENDAGE)
117
+
118
+ if (Array.isArray(externalComponentData)) {
119
+ return state
120
+ }
121
+
122
+ const typedStateAppendage = externalComponentData as ExternalStateAppendage
123
+ const componentName = typedStateAppendage.component
124
+ const stateAppendage = typedStateAppendage.data
125
+ const component = request.app.model?.componentMap.get(componentName)
126
+
127
+ if (!component) {
128
+ throw new Error(`Component ${componentName} not found in form`)
129
+ }
130
+
131
+ if (!(component instanceof FormComponent)) {
132
+ throw new TypeError(
133
+ `Component ${componentName} is not a FormComponent and does not support isState`
134
+ )
135
+ }
136
+
137
+ const isStateValid = component.isState(stateAppendage)
138
+
139
+ if (!isStateValid) {
140
+ throw new Error(`State for component ${componentName} is invalid`)
141
+ }
142
+
143
+ const componentState = isFormState(stateAppendage)
144
+ ? Object.fromEntries(
145
+ Object.entries(stateAppendage).map(([key, value]) => [
146
+ `${componentName}__${key}`,
147
+ value
148
+ ])
149
+ )
150
+ : { [componentName]: stateAppendage }
151
+
152
+ // Save the external component state immediately
153
+ const updatedState = await page.mergeState(request, state, componentState)
154
+
155
+ // Merge the stashed payload into the local state
156
+ const payload = request.yar.flash(EXTERNAL_STATE_PAYLOAD)
157
+ const stashedPayload = Array.isArray(payload) ? {} : (payload as FormPayload)
158
+
159
+ return { ...stashedPayload, ...updatedState }
160
+ }
161
+
88
162
  export function makeLoadFormPreHandler(server: Server, options: PluginOptions) {
89
163
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- hapi types are wrong
90
164
  const prefix = server.realm.modifiers.route.prefix ?? ''
91
165
 
92
- const { services = defaultServices, controllers, onRequest } = options
166
+ const {
167
+ services = defaultServices,
168
+ controllers,
169
+ ordnanceSurveyApiKey
170
+ } = options
93
171
 
94
172
  const { formsService } = services
95
173
 
@@ -156,7 +234,7 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) {
156
234
  // Construct the form model
157
235
  const model = new FormModel(
158
236
  definition,
159
- { basePath, versionNumber },
237
+ { basePath, versionNumber, ordnanceSurveyApiKey },
160
238
  services,
161
239
  controllers
162
240
  )
@@ -166,11 +244,6 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) {
166
244
  server.app.models.set(key, item)
167
245
  }
168
246
 
169
- // Call the onRequest callback if it has been supplied
170
- if (onRequest) {
171
- onRequest(request, params, item.model.def, metadata)
172
- }
173
-
174
247
  // Assign the model to the request data
175
248
  // for use in the downstream handler
176
249
  request.app.model = item.model