@defra/forms-engine-plugin 4.0.28 → 4.0.29
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.
- package/.server/server/plugins/engine/beta/form-context.d.ts +25 -0
- package/.server/server/plugins/engine/beta/form-context.js +122 -0
- package/.server/server/plugins/engine/beta/form-context.js.map +1 -0
- package/.server/server/plugins/engine/index.d.ts +1 -0
- package/.server/server/plugins/engine/index.js +1 -0
- package/.server/server/plugins/engine/index.js.map +1 -1
- package/.server/server/plugins/engine/models/FormModel.d.ts +1 -1
- package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
- package/.server/server/plugins/engine/routes/index.js +11 -65
- package/.server/server/plugins/engine/routes/index.js.map +1 -1
- package/package.json +1 -1
- package/src/server/plugins/engine/beta/form-context.test.ts +359 -0
- package/src/server/plugins/engine/beta/form-context.ts +250 -0
- package/src/server/plugins/engine/index.ts +6 -0
- package/src/server/plugins/engine/models/FormModel.ts +2 -2
- package/src/server/plugins/engine/routes/index.ts +10 -71
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { type Request } from '@hapi/hapi'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getFirstJourneyPage,
|
|
5
|
+
getFormContext,
|
|
6
|
+
getFormModel,
|
|
7
|
+
resolveFormModel,
|
|
8
|
+
type FormModelOptions
|
|
9
|
+
} from '~/src/server/plugins/engine/beta/form-context.js'
|
|
10
|
+
import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js'
|
|
11
|
+
import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
|
|
12
|
+
import { type FormContext } from '~/src/server/plugins/engine/types.js'
|
|
13
|
+
import { FormStatus } from '~/src/server/routes/types.js'
|
|
14
|
+
import { type FormsService, type Services } from '~/src/server/types.js'
|
|
15
|
+
|
|
16
|
+
const mockGetCacheService = jest.fn()
|
|
17
|
+
const mockCacheService = { getState: jest.fn() }
|
|
18
|
+
const mockCheckEmailAddressForLiveFormSubmission = jest.fn()
|
|
19
|
+
|
|
20
|
+
jest.mock('../models/index.ts', () => ({
|
|
21
|
+
__esModule: true,
|
|
22
|
+
FormModel: jest.fn()
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
jest.mock('~/src/server/plugins/engine/services/index.js', () => ({
|
|
26
|
+
__esModule: true,
|
|
27
|
+
formsService: {
|
|
28
|
+
getFormMetadata: jest.fn(),
|
|
29
|
+
getFormDefinition: jest.fn()
|
|
30
|
+
},
|
|
31
|
+
formSubmissionService: {},
|
|
32
|
+
outputService: {}
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
jest.mock('../pageControllers/index.ts', () => {
|
|
36
|
+
class MockTerminalPageController {
|
|
37
|
+
path = ''
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
__esModule: true,
|
|
42
|
+
TerminalPageController: MockTerminalPageController
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
jest.mock('../helpers.ts', () => ({
|
|
47
|
+
__esModule: true,
|
|
48
|
+
getCacheService: (...args: unknown[]) => mockGetCacheService(...args),
|
|
49
|
+
checkEmailAddressForLiveFormSubmission: (...args: unknown[]) =>
|
|
50
|
+
mockCheckEmailAddressForLiveFormSubmission(...args)
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
const mockServices = jest.requireMock(
|
|
54
|
+
'~/src/server/plugins/engine/services/index.js'
|
|
55
|
+
)
|
|
56
|
+
const mockFormsService = mockServices.formsService
|
|
57
|
+
const { FormModel } = jest.requireMock('../models/index.ts')
|
|
58
|
+
const { TerminalPageController: MockTerminalPageController } = jest.requireMock(
|
|
59
|
+
'../pageControllers/index.ts'
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
describe('getFormContext helper', () => {
|
|
63
|
+
const request = {
|
|
64
|
+
yar: { set: jest.fn() } as unknown as Request['yar'],
|
|
65
|
+
server: {
|
|
66
|
+
app: {},
|
|
67
|
+
realm: { modifiers: { route: { prefix: '' } } }
|
|
68
|
+
} as unknown as Request['server']
|
|
69
|
+
} satisfies Pick<Request, 'yar' | 'server'>
|
|
70
|
+
const slug = 'tb-origin'
|
|
71
|
+
const cachedState = { answered: true }
|
|
72
|
+
const returnedContext = { errors: [] }
|
|
73
|
+
const metadata = {
|
|
74
|
+
id: 'metadata-123',
|
|
75
|
+
live: { updatedAt: new Date('2024-10-15T10:00:00Z') },
|
|
76
|
+
draft: { updatedAt: new Date('2024-10-10T10:00:00Z') },
|
|
77
|
+
versions: [{ versionNumber: 9 }],
|
|
78
|
+
notificationEmail: 'test@example.com'
|
|
79
|
+
}
|
|
80
|
+
const definition = { pages: [] }
|
|
81
|
+
let formModel: { getFormContext: jest.Mock }
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
jest.clearAllMocks()
|
|
85
|
+
formModel = { getFormContext: jest.fn().mockResolvedValue(returnedContext) }
|
|
86
|
+
FormModel.mockImplementation(
|
|
87
|
+
(_definition: unknown, modelOptions: FormModelOptions) =>
|
|
88
|
+
Object.assign(formModel, { basePath: modelOptions.basePath })
|
|
89
|
+
)
|
|
90
|
+
mockFormsService.getFormMetadata.mockResolvedValue(metadata)
|
|
91
|
+
mockFormsService.getFormDefinition.mockResolvedValue(definition)
|
|
92
|
+
mockGetCacheService.mockReturnValue(mockCacheService)
|
|
93
|
+
mockCacheService.getState.mockResolvedValue(cachedState)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('passes preview state into the summary request and uses cached reference numbers', async () => {
|
|
97
|
+
const errors = [
|
|
98
|
+
{ href: '#field', name: 'field', path: ['field'], text: 'is required' }
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
mockCacheService.getState.mockResolvedValue({
|
|
102
|
+
...cachedState,
|
|
103
|
+
$$__referenceNumber: 'CACHED-REF'
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const context = await getFormContext(request, slug, 'preview', {
|
|
107
|
+
errors
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const summaryRequest = mockCacheService.getState.mock.calls[0][0]
|
|
111
|
+
|
|
112
|
+
expect(summaryRequest.params).toEqual({
|
|
113
|
+
path: 'summary',
|
|
114
|
+
slug,
|
|
115
|
+
state: 'live'
|
|
116
|
+
})
|
|
117
|
+
expect(summaryRequest.path).toBe('/preview/live/tb-origin/summary')
|
|
118
|
+
expect(summaryRequest.url.toString()).toBe(
|
|
119
|
+
'https://form-context.local/preview/live/tb-origin/summary'
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
expect(formModel.getFormContext).toHaveBeenCalledWith(
|
|
123
|
+
summaryRequest,
|
|
124
|
+
expect.objectContaining({ $$__referenceNumber: 'CACHED-REF' }),
|
|
125
|
+
errors
|
|
126
|
+
)
|
|
127
|
+
expect(context).toBe(returnedContext)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('getFormModel helper', () => {
|
|
132
|
+
const slug = 'tb-origin'
|
|
133
|
+
const state = FormStatus.Draft
|
|
134
|
+
class CustomController extends PageController {}
|
|
135
|
+
const controllers = { CustomController }
|
|
136
|
+
const metadata = {
|
|
137
|
+
id: 'form-meta-123',
|
|
138
|
+
versions: [{ versionNumber: 17 }]
|
|
139
|
+
}
|
|
140
|
+
const definition = { pages: [{ path: '/start' }] }
|
|
141
|
+
let formsService: FormsService
|
|
142
|
+
let services: Services
|
|
143
|
+
let formModelInstance: { id: string }
|
|
144
|
+
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
jest.clearAllMocks()
|
|
147
|
+
formModelInstance = { id: 'form-model-instance' }
|
|
148
|
+
FormModel.mockImplementation(() => formModelInstance)
|
|
149
|
+
services = {
|
|
150
|
+
formsService: {
|
|
151
|
+
getFormMetadata: jest.fn().mockResolvedValue(metadata),
|
|
152
|
+
getFormMetadataById: jest.fn(),
|
|
153
|
+
getFormDefinition: jest.fn().mockResolvedValue(definition)
|
|
154
|
+
},
|
|
155
|
+
formSubmissionService: {
|
|
156
|
+
persistFiles: jest.fn(),
|
|
157
|
+
submit: jest.fn()
|
|
158
|
+
},
|
|
159
|
+
outputService: {
|
|
160
|
+
submit: jest.fn()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
formsService = services.formsService
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('constructs a FormModel using fetched metadata and definition', async () => {
|
|
167
|
+
const model = await getFormModel(slug, state, { services, controllers })
|
|
168
|
+
|
|
169
|
+
expect(formsService.getFormMetadata).toHaveBeenCalledWith(slug)
|
|
170
|
+
expect(formsService.getFormDefinition).toHaveBeenCalledWith(
|
|
171
|
+
metadata.id,
|
|
172
|
+
state
|
|
173
|
+
)
|
|
174
|
+
expect(FormModel).toHaveBeenCalledWith(
|
|
175
|
+
definition,
|
|
176
|
+
{
|
|
177
|
+
basePath: slug,
|
|
178
|
+
versionNumber: metadata.versions[0].versionNumber,
|
|
179
|
+
ordnanceSurveyApiKey: undefined,
|
|
180
|
+
formId: metadata.id
|
|
181
|
+
},
|
|
182
|
+
services,
|
|
183
|
+
controllers
|
|
184
|
+
)
|
|
185
|
+
expect(model).toBe(formModelInstance)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('maps preview state requests to the live form definition', async () => {
|
|
189
|
+
await getFormModel(slug, 'preview', { services, controllers })
|
|
190
|
+
|
|
191
|
+
expect(formsService.getFormDefinition).toHaveBeenCalledWith(
|
|
192
|
+
metadata.id,
|
|
193
|
+
'live'
|
|
194
|
+
)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('throws when no form definition is available', async () => {
|
|
198
|
+
jest.mocked(formsService.getFormDefinition).mockResolvedValue(undefined)
|
|
199
|
+
|
|
200
|
+
await expect(
|
|
201
|
+
getFormModel(slug, state, { services, controllers })
|
|
202
|
+
).rejects.toThrow(
|
|
203
|
+
`No definition found for form metadata ${metadata.id} (${slug}) ${state}`
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
expect(FormModel).not.toHaveBeenCalled()
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('resolveFormModel helper', () => {
|
|
211
|
+
const slug = 'tb-origin'
|
|
212
|
+
const definition = { pages: [], outputEmail: 'fallback@example.com' }
|
|
213
|
+
const metadata = {
|
|
214
|
+
id: 'metadata-123',
|
|
215
|
+
live: { updatedAt: new Date('2024-10-15T10:00:00Z') },
|
|
216
|
+
versions: [{ versionNumber: 9 }]
|
|
217
|
+
}
|
|
218
|
+
let server: Request['server']
|
|
219
|
+
let formModelInstance: { id: string }
|
|
220
|
+
|
|
221
|
+
beforeEach(() => {
|
|
222
|
+
jest.clearAllMocks()
|
|
223
|
+
server = {
|
|
224
|
+
app: {},
|
|
225
|
+
realm: { modifiers: { route: { prefix: '/forms/' } } }
|
|
226
|
+
} as unknown as Request['server']
|
|
227
|
+
formModelInstance = { id: 'form-model-instance' }
|
|
228
|
+
FormModel.mockImplementation(() => formModelInstance)
|
|
229
|
+
mockFormsService.getFormMetadata.mockResolvedValue(metadata)
|
|
230
|
+
mockFormsService.getFormDefinition.mockResolvedValue(definition)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('reuses cached models when metadata timestamps match', async () => {
|
|
234
|
+
const model = await resolveFormModel(server, slug, FormStatus.Live)
|
|
235
|
+
const cached = await resolveFormModel(server, slug, FormStatus.Live)
|
|
236
|
+
|
|
237
|
+
expect(model).toBe(formModelInstance)
|
|
238
|
+
expect(cached).toBe(model)
|
|
239
|
+
expect(server.app.models).toBeInstanceOf(Map)
|
|
240
|
+
expect(mockFormsService.getFormDefinition).toHaveBeenCalledTimes(1)
|
|
241
|
+
expect(FormModel).toHaveBeenCalledTimes(1)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('rebuilds the model when metadata changes and uses preview routing', async () => {
|
|
245
|
+
const refreshedModel = { id: 'refreshed-model' }
|
|
246
|
+
|
|
247
|
+
FormModel.mockImplementationOnce(
|
|
248
|
+
() => formModelInstance
|
|
249
|
+
).mockImplementationOnce(() => refreshedModel)
|
|
250
|
+
mockFormsService.getFormMetadata
|
|
251
|
+
.mockResolvedValueOnce({ ...metadata, notificationEmail: undefined })
|
|
252
|
+
.mockResolvedValueOnce({
|
|
253
|
+
...metadata,
|
|
254
|
+
notificationEmail: undefined,
|
|
255
|
+
live: { updatedAt: new Date('2024-12-01T09:00:00Z') }
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
const model = await resolveFormModel(server, slug, 'preview', {
|
|
259
|
+
ordnanceSurveyApiKey: 'os-api-key'
|
|
260
|
+
})
|
|
261
|
+
const rebuilt = await resolveFormModel(server, slug, 'preview')
|
|
262
|
+
|
|
263
|
+
expect(model).toBe(formModelInstance)
|
|
264
|
+
expect(rebuilt).toBe(refreshedModel)
|
|
265
|
+
expect(FormModel).toHaveBeenCalledTimes(2)
|
|
266
|
+
expect(mockFormsService.getFormDefinition).toHaveBeenCalledTimes(2)
|
|
267
|
+
expect(mockCheckEmailAddressForLiveFormSubmission).toHaveBeenCalledWith(
|
|
268
|
+
definition.outputEmail,
|
|
269
|
+
true
|
|
270
|
+
)
|
|
271
|
+
expect(FormModel).toHaveBeenCalledWith(
|
|
272
|
+
definition,
|
|
273
|
+
expect.objectContaining({
|
|
274
|
+
basePath: 'forms/preview/live/tb-origin',
|
|
275
|
+
versionNumber: metadata.versions[0].versionNumber,
|
|
276
|
+
ordnanceSurveyApiKey: 'os-api-key',
|
|
277
|
+
formId: metadata.id
|
|
278
|
+
}),
|
|
279
|
+
mockServices,
|
|
280
|
+
undefined
|
|
281
|
+
)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('throws when requested form state does not exist on metadata', async () => {
|
|
285
|
+
mockFormsService.getFormMetadata.mockResolvedValue({
|
|
286
|
+
id: 'metadata-123',
|
|
287
|
+
live: { updatedAt: new Date('2024-10-15T10:00:00Z') }
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
await expect(
|
|
291
|
+
resolveFormModel(server, slug, FormStatus.Draft)
|
|
292
|
+
).rejects.toThrow("No 'draft' state for form metadata metadata-123")
|
|
293
|
+
|
|
294
|
+
expect(FormModel).not.toHaveBeenCalled()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('throws when no form definition is available for the requested state', async () => {
|
|
298
|
+
mockFormsService.getFormDefinition.mockResolvedValue(undefined)
|
|
299
|
+
|
|
300
|
+
await expect(
|
|
301
|
+
resolveFormModel(server, slug, FormStatus.Live)
|
|
302
|
+
).rejects.toThrow(
|
|
303
|
+
`No definition found for form metadata ${metadata.id} (${slug}) ${FormStatus.Live}`
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
expect(FormModel).not.toHaveBeenCalled()
|
|
307
|
+
expect(mockCheckEmailAddressForLiveFormSubmission).not.toHaveBeenCalled()
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
describe('getFirstJourneyPage helper', () => {
|
|
312
|
+
const buildPage = (path: string, keys: string[] = []) =>
|
|
313
|
+
({ path, keys }) as unknown as PageControllerClass
|
|
314
|
+
|
|
315
|
+
test('returns undefined when no context or relevant target path is available', () => {
|
|
316
|
+
expect(getFirstJourneyPage()).toBeUndefined()
|
|
317
|
+
expect(getFirstJourneyPage({ relevantPages: [] })).toBeUndefined()
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test('returns the page matching the last recorded path', () => {
|
|
321
|
+
const startPage = buildPage('/start')
|
|
322
|
+
const nextPage = buildPage('/animals')
|
|
323
|
+
|
|
324
|
+
const context: Pick<FormContext, 'relevantPages'> = {
|
|
325
|
+
relevantPages: [startPage, nextPage]
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
expect(getFirstJourneyPage(context)).toBe(nextPage)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test('steps back from terminal pages to the previous relevant page', () => {
|
|
332
|
+
const startPage = buildPage('/start')
|
|
333
|
+
const exitPage = Object.assign(new MockTerminalPageController(), {
|
|
334
|
+
path: '/stop'
|
|
335
|
+
}) as unknown as PageControllerClass
|
|
336
|
+
|
|
337
|
+
const context: Pick<FormContext, 'relevantPages'> = {
|
|
338
|
+
relevantPages: [startPage, exitPage]
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
expect(getFirstJourneyPage(context)).toBe(startPage)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('returns the terminal page when it is the only relevant page available', () => {
|
|
345
|
+
const exitPage = Object.assign(new MockTerminalPageController(), {
|
|
346
|
+
path: '/stop'
|
|
347
|
+
}) as unknown as PageControllerClass
|
|
348
|
+
|
|
349
|
+
const context: Pick<FormContext, 'relevantPages'> = {
|
|
350
|
+
relevantPages: [exitPage]
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
expect(getFirstJourneyPage(context)).toBe(exitPage)
|
|
354
|
+
})
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* @import { FormContext } from '../types.js'
|
|
359
|
+
*/
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import Boom from '@hapi/boom'
|
|
2
|
+
import { type Request, type Server } from '@hapi/hapi'
|
|
3
|
+
import { isEqual } from 'date-fns'
|
|
4
|
+
|
|
5
|
+
import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js'
|
|
6
|
+
import {
|
|
7
|
+
checkEmailAddressForLiveFormSubmission,
|
|
8
|
+
getCacheService
|
|
9
|
+
} from '~/src/server/plugins/engine/helpers.js'
|
|
10
|
+
import { FormModel } from '~/src/server/plugins/engine/models/index.js'
|
|
11
|
+
import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js'
|
|
12
|
+
import { TerminalPageController } from '~/src/server/plugins/engine/pageControllers/index.js'
|
|
13
|
+
import * as defaultServices from '~/src/server/plugins/engine/services/index.js'
|
|
14
|
+
import {
|
|
15
|
+
type AnyRequest,
|
|
16
|
+
type FormContext,
|
|
17
|
+
type FormContextRequest,
|
|
18
|
+
type FormSubmissionError,
|
|
19
|
+
type FormSubmissionState
|
|
20
|
+
} from '~/src/server/plugins/engine/types.js'
|
|
21
|
+
import { FormStatus } from '~/src/server/routes/types.js'
|
|
22
|
+
import { type Services } from '~/src/server/types.js'
|
|
23
|
+
|
|
24
|
+
type JourneyState = FormStatus | 'preview'
|
|
25
|
+
|
|
26
|
+
export interface FormModelOptions {
|
|
27
|
+
services?: Services
|
|
28
|
+
controllers?: Record<string, typeof PageController>
|
|
29
|
+
basePath?: string
|
|
30
|
+
versionNumber?: number
|
|
31
|
+
ordnanceSurveyApiKey?: string
|
|
32
|
+
formId?: string
|
|
33
|
+
routePrefix?: string
|
|
34
|
+
isPreview?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FormContextOptions extends FormModelOptions {
|
|
38
|
+
errors?: FormSubmissionError[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type SummaryRequest = FormContextRequest & {
|
|
42
|
+
yar: Request['yar']
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function getFormModel(
|
|
46
|
+
slug: string,
|
|
47
|
+
state: JourneyState,
|
|
48
|
+
options: FormModelOptions = {}
|
|
49
|
+
) {
|
|
50
|
+
const services = options.services ?? defaultServices
|
|
51
|
+
const { formsService } = services
|
|
52
|
+
const isPreview = isPreviewState(state, options)
|
|
53
|
+
const formState = resolveState(state)
|
|
54
|
+
|
|
55
|
+
const metadata = await formsService.getFormMetadata(slug)
|
|
56
|
+
const versionNumber =
|
|
57
|
+
options.versionNumber ?? metadata.versions?.[0]?.versionNumber
|
|
58
|
+
|
|
59
|
+
const definition = await formsService.getFormDefinition(
|
|
60
|
+
metadata.id,
|
|
61
|
+
formState
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if (!definition) {
|
|
65
|
+
throw Boom.notFound(
|
|
66
|
+
`No definition found for form metadata ${metadata.id} (${slug}) ${state}`
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return new FormModel(
|
|
71
|
+
definition,
|
|
72
|
+
{
|
|
73
|
+
basePath:
|
|
74
|
+
options.basePath ??
|
|
75
|
+
buildBasePath(options.routePrefix ?? '', slug, formState, isPreview),
|
|
76
|
+
versionNumber,
|
|
77
|
+
ordnanceSurveyApiKey: options.ordnanceSurveyApiKey,
|
|
78
|
+
formId: options.formId ?? metadata.id
|
|
79
|
+
},
|
|
80
|
+
services,
|
|
81
|
+
options.controllers
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function getFormContext(
|
|
86
|
+
{ server, yar }: Pick<Request, 'server' | 'yar'>,
|
|
87
|
+
slug: string,
|
|
88
|
+
state: JourneyState = FormStatus.Live,
|
|
89
|
+
options: FormContextOptions = {}
|
|
90
|
+
): Promise<FormContext> {
|
|
91
|
+
const formModel = await resolveFormModel(server, slug, state, options)
|
|
92
|
+
|
|
93
|
+
const cacheService = getCacheService(server)
|
|
94
|
+
|
|
95
|
+
const summaryRequest: SummaryRequest = {
|
|
96
|
+
app: {},
|
|
97
|
+
method: 'get',
|
|
98
|
+
params: {
|
|
99
|
+
path: 'summary',
|
|
100
|
+
slug,
|
|
101
|
+
...(isPreviewState(state, options) && {
|
|
102
|
+
state: resolveState(state)
|
|
103
|
+
})
|
|
104
|
+
},
|
|
105
|
+
path: `/${formModel.basePath}/summary`,
|
|
106
|
+
query: {},
|
|
107
|
+
url: new URL(
|
|
108
|
+
`/${formModel.basePath}/summary`,
|
|
109
|
+
'https://form-context.local'
|
|
110
|
+
),
|
|
111
|
+
server,
|
|
112
|
+
yar
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const cachedState = await cacheService.getState(
|
|
116
|
+
summaryRequest as unknown as AnyRequest
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const formState = {
|
|
120
|
+
...cachedState,
|
|
121
|
+
$$__referenceNumber: cachedState.$$__referenceNumber
|
|
122
|
+
} as unknown as FormSubmissionState
|
|
123
|
+
|
|
124
|
+
return formModel.getFormContext(
|
|
125
|
+
summaryRequest,
|
|
126
|
+
formState,
|
|
127
|
+
options.errors ?? []
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function resolveFormModel(
|
|
132
|
+
server: Server,
|
|
133
|
+
slug: string,
|
|
134
|
+
state: JourneyState,
|
|
135
|
+
options: FormModelOptions = {}
|
|
136
|
+
) {
|
|
137
|
+
const services = options.services ?? defaultServices
|
|
138
|
+
const { formsService } = services
|
|
139
|
+
|
|
140
|
+
const metadata = await formsService.getFormMetadata(slug)
|
|
141
|
+
const formState = resolveState(state)
|
|
142
|
+
const isPreview = options.isPreview ?? isPreviewState(state, options)
|
|
143
|
+
const stateMetadata = metadata[formState]
|
|
144
|
+
|
|
145
|
+
if (!stateMetadata) {
|
|
146
|
+
throw Boom.notFound(
|
|
147
|
+
`No '${formState}' state for form metadata ${metadata.id}`
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The models cache is created lazily per server instance
|
|
152
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
153
|
+
if (!server.app.models) {
|
|
154
|
+
server.app.models = new Map<string, { model: FormModel; updatedAt: Date }>()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const cache = server.app.models as Map<
|
|
158
|
+
string,
|
|
159
|
+
{ model: FormModel; updatedAt: Date }
|
|
160
|
+
>
|
|
161
|
+
|
|
162
|
+
const cacheKey = `${metadata.id}_${formState}_${isPreview}`
|
|
163
|
+
let entry = cache.get(cacheKey)
|
|
164
|
+
|
|
165
|
+
if (!entry || !isEqual(entry.updatedAt, stateMetadata.updatedAt)) {
|
|
166
|
+
const definition = await formsService.getFormDefinition(
|
|
167
|
+
metadata.id,
|
|
168
|
+
formState
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if (!definition) {
|
|
172
|
+
throw Boom.notFound(
|
|
173
|
+
`No definition found for form metadata ${metadata.id} (${slug}) ${state}`
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const emailAddress = metadata.notificationEmail ?? definition.outputEmail
|
|
178
|
+
|
|
179
|
+
checkEmailAddressForLiveFormSubmission(emailAddress, isPreview)
|
|
180
|
+
|
|
181
|
+
const routePrefix =
|
|
182
|
+
options.routePrefix ?? server.realm.modifiers.route.prefix
|
|
183
|
+
|
|
184
|
+
const model = new FormModel(
|
|
185
|
+
definition,
|
|
186
|
+
{
|
|
187
|
+
basePath:
|
|
188
|
+
options.basePath ??
|
|
189
|
+
buildBasePath(routePrefix, slug, formState, isPreview),
|
|
190
|
+
versionNumber:
|
|
191
|
+
options.versionNumber ?? metadata.versions?.[0]?.versionNumber,
|
|
192
|
+
ordnanceSurveyApiKey: options.ordnanceSurveyApiKey,
|
|
193
|
+
formId: options.formId ?? metadata.id
|
|
194
|
+
},
|
|
195
|
+
services,
|
|
196
|
+
options.controllers
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
entry = { model, updatedAt: stateMetadata.updatedAt }
|
|
200
|
+
cache.set(cacheKey, entry)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return entry.model
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function buildBasePath(
|
|
207
|
+
routePrefix: string,
|
|
208
|
+
slug: string,
|
|
209
|
+
state: FormStatus,
|
|
210
|
+
isPreview: boolean
|
|
211
|
+
) {
|
|
212
|
+
const base = (
|
|
213
|
+
isPreview
|
|
214
|
+
? `${routePrefix}${PREVIEW_PATH_PREFIX}/${state}/${slug}`
|
|
215
|
+
: `${routePrefix}/${slug}`
|
|
216
|
+
).replace(/\/{2,}/g, '/')
|
|
217
|
+
|
|
218
|
+
return base.startsWith('/') ? base.slice(1) : base
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function getFirstJourneyPage(
|
|
222
|
+
context?: Pick<FormContext, 'relevantPages'>
|
|
223
|
+
) {
|
|
224
|
+
if (!context?.relevantPages) {
|
|
225
|
+
return undefined
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const lastPageReached = context.relevantPages.at(-1)
|
|
229
|
+
const penultimatePageReached = context.relevantPages.at(-2)
|
|
230
|
+
|
|
231
|
+
if (
|
|
232
|
+
lastPageReached instanceof TerminalPageController &&
|
|
233
|
+
penultimatePageReached
|
|
234
|
+
) {
|
|
235
|
+
return penultimatePageReached
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return lastPageReached
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function resolveState(state: JourneyState): FormStatus {
|
|
242
|
+
return state === 'preview' ? FormStatus.Live : state
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isPreviewState(
|
|
246
|
+
state: JourneyState,
|
|
247
|
+
options: FormModelOptions = {}
|
|
248
|
+
): boolean {
|
|
249
|
+
return options.isPreview ?? state === 'preview'
|
|
250
|
+
}
|
|
@@ -14,6 +14,12 @@ import * as filters from '~/src/server/plugins/nunjucks/filters/index.js'
|
|
|
14
14
|
|
|
15
15
|
export { getPageHref } from '~/src/server/plugins/engine/helpers.js'
|
|
16
16
|
export { context } from '~/src/server/plugins/nunjucks/context.js'
|
|
17
|
+
export {
|
|
18
|
+
getFirstJourneyPage,
|
|
19
|
+
getFormContext,
|
|
20
|
+
getFormModel,
|
|
21
|
+
resolveFormModel
|
|
22
|
+
} from '~/src/server/plugins/engine/beta/form-context.js'
|
|
17
23
|
|
|
18
24
|
const globals = {
|
|
19
25
|
checkComponentTemplates,
|
|
@@ -326,7 +326,7 @@ export class FormModel {
|
|
|
326
326
|
*/
|
|
327
327
|
getFormContext(
|
|
328
328
|
request: FormContextRequest,
|
|
329
|
-
state:
|
|
329
|
+
state: FormSubmissionState,
|
|
330
330
|
errors?: FormSubmissionError[]
|
|
331
331
|
): FormContext {
|
|
332
332
|
const { query } = request
|
|
@@ -625,7 +625,7 @@ function validateFormState(
|
|
|
625
625
|
return context
|
|
626
626
|
}
|
|
627
627
|
|
|
628
|
-
function getReferenceNumber(state:
|
|
628
|
+
function getReferenceNumber(state: FormSubmissionState): string {
|
|
629
629
|
if (
|
|
630
630
|
!state.$$__referenceNumber ||
|
|
631
631
|
typeof state.$$__referenceNumber !== 'string'
|