@defra/forms-engine-plugin 4.0.28 → 4.0.30

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.
@@ -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,
@@ -716,4 +716,74 @@ describe('FormModel - Joined Conditions', () => {
716
716
  expect(Object.keys(model.conditions)).toHaveLength(3)
717
717
  })
718
718
  })
719
+
720
+ describe('getSection', () => {
721
+ it('should look up section by name for V1 schema', () => {
722
+ const v1Definition = {
723
+ ...definition,
724
+ sections: [
725
+ { name: 'personal', title: 'Personal details' },
726
+ { name: 'contact', title: 'Contact details' }
727
+ ]
728
+ }
729
+
730
+ const model = new FormModel(v1Definition, { basePath: 'test' })
731
+
732
+ expect(model.getSection('personal')).toEqual(
733
+ expect.objectContaining({
734
+ name: 'personal',
735
+ title: 'Personal details'
736
+ })
737
+ )
738
+ expect(model.getSection('contact')).toEqual(
739
+ expect.objectContaining({
740
+ name: 'contact',
741
+ title: 'Contact details'
742
+ })
743
+ )
744
+ expect(model.getSection('nonexistent')).toBeUndefined()
745
+ })
746
+
747
+ it('should look up section by ID for V2 schema', () => {
748
+ const v2Definition = {
749
+ ...definitionV2,
750
+ sections: [
751
+ {
752
+ id: 'a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d',
753
+ name: 'personal',
754
+ title: 'Personal details'
755
+ },
756
+ {
757
+ id: 'b2c3d4e5-6f7a-8b9c-0d1e-2f3a4b5c6d7e',
758
+ name: 'contact',
759
+ title: 'Contact details'
760
+ }
761
+ ]
762
+ }
763
+
764
+ formDefinitionV2Schema.validate = jest
765
+ .fn()
766
+ .mockReturnValue({ value: v2Definition })
767
+
768
+ const model = new FormModel(v2Definition, { basePath: 'test' })
769
+
770
+ expect(model.getSection('a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d')).toEqual(
771
+ expect.objectContaining({
772
+ id: 'a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d',
773
+ name: 'personal',
774
+ title: 'Personal details'
775
+ })
776
+ )
777
+ expect(model.getSection('b2c3d4e5-6f7a-8b9c-0d1e-2f3a4b5c6d7e')).toEqual(
778
+ expect.objectContaining({
779
+ id: 'b2c3d4e5-6f7a-8b9c-0d1e-2f3a4b5c6d7e',
780
+ name: 'contact',
781
+ title: 'Contact details'
782
+ })
783
+ )
784
+ // V2 should not find by name
785
+ expect(model.getSection('personal')).toBeUndefined()
786
+ expect(model.getSection('nonexistent')).toBeUndefined()
787
+ })
788
+ })
719
789
  })
@@ -21,7 +21,8 @@ import {
21
21
  type Engine,
22
22
  type FormDefinition,
23
23
  type List,
24
- type Page
24
+ type Page,
25
+ type Section
25
26
  } from '@defra/forms-model'
26
27
  import { add, format } from 'date-fns'
27
28
  import { Parser, type Value } from 'expr-eval-fork'
@@ -321,12 +322,18 @@ export class FormModel {
321
322
  : this.lists.find((list) => list.id === nameOrId)
322
323
  }
323
324
 
325
+ getSection(nameOrId: string): Section | undefined {
326
+ return this.schemaVersion === SchemaVersion.V1
327
+ ? this.sections.find((section) => section.name === nameOrId)
328
+ : this.sections.find((section) => section.id === nameOrId)
329
+ }
330
+
324
331
  /**
325
332
  * Form context for the current page
326
333
  */
327
334
  getFormContext(
328
335
  request: FormContextRequest,
329
- state: FormState,
336
+ state: FormSubmissionState,
330
337
  errors?: FormSubmissionError[]
331
338
  ): FormContext {
332
339
  const { query } = request
@@ -625,7 +632,7 @@ function validateFormState(
625
632
  return context
626
633
  }
627
634
 
628
- function getReferenceNumber(state: FormState): string {
635
+ function getReferenceNumber(state: FormSubmissionState): string {
629
636
  if (
630
637
  !state.$$__referenceNumber ||
631
638
  typeof state.$$__referenceNumber !== 'string'
@@ -55,9 +55,9 @@ export class PageController {
55
55
  this.events = pageDef.events
56
56
 
57
57
  // Resolve section
58
- this.section = model.sections.find(
59
- (section) => section.name === pageDef.section
60
- )
58
+ if (pageDef.section) {
59
+ this.section = model.getSection(pageDef.section)
60
+ }
61
61
 
62
62
  // Resolve condition
63
63
  if (pageDef.condition) {
@@ -1007,11 +1007,13 @@ describe('QuestionPageController V2', () => {
1007
1007
 
1008
1008
  it('returns the page section', () => {
1009
1009
  expect(controller1).toHaveProperty('section', undefined)
1010
- expect(controller2).toHaveProperty('section', {
1011
- name: 'marriage',
1012
- title: 'Your marriage',
1013
- hideTitle: false
1014
- })
1010
+ expect(controller2.section).toEqual(
1011
+ expect.objectContaining({
1012
+ name: 'marriage',
1013
+ title: 'Your marriage',
1014
+ hideTitle: false
1015
+ })
1016
+ )
1015
1017
  })
1016
1018
  })
1017
1019
 
@@ -4,19 +4,17 @@ import {
4
4
  type ResponseToolkit,
5
5
  type Server
6
6
  } from '@hapi/hapi'
7
- import { isEqual } from 'date-fns'
8
7
 
9
8
  import {
10
9
  EXTERNAL_STATE_APPENDAGE,
11
- EXTERNAL_STATE_PAYLOAD,
12
- PREVIEW_PATH_PREFIX
10
+ EXTERNAL_STATE_PAYLOAD
13
11
  } from '~/src/server/constants.js'
12
+ import { resolveFormModel } from '~/src/server/plugins/engine/beta/form-context.js'
14
13
  import {
15
14
  FormComponent,
16
15
  isFormState
17
16
  } from '~/src/server/plugins/engine/components/FormComponent.js'
18
17
  import {
19
- checkEmailAddressForLiveFormSubmission,
20
18
  checkFormStatus,
21
19
  findPage,
22
20
  getCacheService,
@@ -24,7 +22,6 @@ import {
24
22
  getStartPath,
25
23
  proceed
26
24
  } from '~/src/server/plugins/engine/helpers.js'
27
- import { FormModel } from '~/src/server/plugins/engine/models/index.js'
28
25
  import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
29
26
  import { generateUniqueReference } from '~/src/server/plugins/engine/referenceNumbers.js'
30
27
  import * as defaultServices from '~/src/server/plugins/engine/services/index.js'
@@ -179,8 +176,6 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) {
179
176
  ordnanceSurveyApiKey
180
177
  } = options
181
178
 
182
- const { formsService } = services
183
-
184
179
  async function handler(request: AnyFormRequest, h: ResponseToolkit) {
185
180
  if (server.app.model) {
186
181
  request.app.model = server.app.model
@@ -192,71 +187,15 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) {
192
187
  const { slug } = params
193
188
  const { isPreview, state: formState } = checkFormStatus(params)
194
189
 
195
- // Get the form metadata using the `slug` param
196
- const metadata = await formsService.getFormMetadata(slug)
197
-
198
- const { id, [formState]: state } = metadata
199
-
200
- // Check the metadata supports the requested state
201
- if (!state) {
202
- throw Boom.notFound(`No '${formState}' state for form metadata ${id}`)
203
- }
204
-
205
- // Cache the models based on id, state and whether
206
- // it's a preview or not. There could be up to 3 models
207
- // cached for a single form:
208
- // "{id}_live_false" (live/live)
209
- // "{id}_live_true" (live/preview)
210
- // "{id}_draft_true" (draft/preview)
211
- const key = `${id}_${formState}_${isPreview}`
212
- let item = server.app.models.get(key)
213
-
214
- if (!item || !isEqual(item.updatedAt, state.updatedAt)) {
215
- server.logger.info(`Getting form definition ${id} (${slug}) ${formState}`)
216
-
217
- // Get the form definition using the `id` from the metadata
218
- const definition = await formsService.getFormDefinition(id, formState)
219
-
220
- if (!definition) {
221
- throw Boom.notFound(
222
- `No definition found for form metadata ${id} (${slug}) ${formState}`
223
- )
224
- }
225
-
226
- const emailAddress = metadata.notificationEmail ?? definition.outputEmail
227
-
228
- checkEmailAddressForLiveFormSubmission(emailAddress, isPreview)
229
-
230
- // Build the form model
231
- server.logger.info(
232
- `Building model for form definition ${id} (${slug}) ${formState}`
233
- )
234
-
235
- // Set up the basePath for the model
236
- const basePath = (
237
- isPreview
238
- ? `${prefix}${PREVIEW_PATH_PREFIX}/${formState}/${slug}`
239
- : `${prefix}/${slug}`
240
- ).substring(1)
241
-
242
- const versionNumber = metadata.versions?.[0]?.versionNumber
243
-
244
- // Construct the form model
245
- const model = new FormModel(
246
- definition,
247
- { basePath, versionNumber, ordnanceSurveyApiKey, formId: id },
248
- services,
249
- controllers
250
- )
251
-
252
- // Create new item and add it to the item cache
253
- item = { model, updatedAt: state.updatedAt }
254
- server.app.models.set(key, item)
255
- }
190
+ const model = await resolveFormModel(server, slug, formState, {
191
+ services,
192
+ controllers,
193
+ ordnanceSurveyApiKey,
194
+ routePrefix: prefix,
195
+ isPreview
196
+ })
256
197
 
257
- // Assign the model to the request data
258
- // for use in the downstream handler
259
- request.app.model = item.model
198
+ request.app.model = model
260
199
 
261
200
  return h.continue
262
201
  }