@defra/forms-engine-plugin 3.0.7 → 3.0.9

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 (29) hide show
  1. package/.public/javascripts/application.min.js +1 -1
  2. package/.public/javascripts/application.min.js.map +1 -1
  3. package/.public/javascripts/shared.min.js +1 -1
  4. package/.public/javascripts/shared.min.js.map +1 -1
  5. package/.server/client/javascripts/file-upload.d.ts +1 -0
  6. package/.server/client/javascripts/file-upload.js +8 -2
  7. package/.server/client/javascripts/file-upload.js.map +1 -1
  8. package/.server/server/forms/register-as-a-unicorn-breeder.yaml +1 -0
  9. package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +3 -1
  10. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +40 -37
  11. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  12. package/.server/server/plugins/engine/routes/file-upload.js +3 -1
  13. package/.server/server/plugins/engine/routes/file-upload.js.map +1 -1
  14. package/.server/server/plugins/engine/types/schema.js +2 -1
  15. package/.server/server/plugins/engine/types/schema.js.map +1 -1
  16. package/.server/server/plugins/engine/types.d.ts +1 -0
  17. package/.server/server/plugins/engine/types.js.map +1 -1
  18. package/.server/server/plugins/engine/views/summary.html +12 -2
  19. package/package.json +2 -2
  20. package/src/client/javascripts/file-upload.js +16 -2
  21. package/src/server/forms/register-as-a-unicorn-breeder.yaml +1 -0
  22. package/src/server/index.test.ts +3 -1
  23. package/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts +85 -0
  24. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +42 -33
  25. package/src/server/plugins/engine/routes/file-upload.ts +3 -1
  26. package/src/server/plugins/engine/types/schema.test.ts +34 -0
  27. package/src/server/plugins/engine/types/schema.ts +6 -1
  28. package/src/server/plugins/engine/types.ts +1 -0
  29. package/src/server/plugins/engine/views/summary.html +12 -2
@@ -230,13 +230,27 @@ function reloadPage() {
230
230
 
231
231
  /**
232
232
  * Build the upload status URL given the current pathname and the upload ID.
233
+ * This only works when called on a file upload page that has a maximum depth of 1 URL segments following the slug.
233
234
  * @param {string} pathname – e.g. window.location.pathname
234
235
  * @param {string} uploadId
235
236
  * @returns {string} e.g. "/form/upload-status/abc123"
236
237
  */
237
238
  export function buildUploadStatusUrl(pathname, uploadId) {
238
- const pathSegments = pathname.split('/').filter((segment) => segment)
239
- const prefix = pathSegments.length > 0 ? `/${pathSegments[0]}` : ''
239
+ // Remove preview markers and duplicate slashes
240
+ const normalisedPath = pathname
241
+ .replace(/\/preview\/(draft|live)/g, '')
242
+ .replace(/\/{2,}/g, '/')
243
+ .replace(/\/$/, '')
244
+
245
+ const segments = normalisedPath.split('/').filter(Boolean)
246
+
247
+ // The slug is always the second to last segment
248
+ // The prefix is everything before the slug
249
+ const prefix =
250
+ segments.length > 2
251
+ ? `/${segments.slice(0, segments.length - 2).join('/')}`
252
+ : ''
253
+
240
254
  return `${prefix}/upload-status/${uploadId}`
241
255
  }
242
256
 
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: Register as a unicorn breeder
3
+ declaration: "<p class=\"govuk-body\">All the answers you have provided are true to the best of your knowledge.</p>"
3
4
  pages:
4
5
  - path: '/whats-your-name'
5
6
  title: What's your name?
@@ -508,7 +508,9 @@ describe('Upload status route', () => {
508
508
  const res = await server.inject(options)
509
509
 
510
510
  expect(res.statusCode).toBe(StatusCodes.OK)
511
- expect(res.result).toEqual(mockStatus)
511
+ expect(res.result).toEqual({
512
+ uploadStatus: UploadStatus.ready
513
+ })
512
514
  expect(getUploadStatus).toHaveBeenCalledWith(
513
515
  '123e4567-e89b-12d3-a456-426614174000'
514
516
  )
@@ -0,0 +1,85 @@
1
+ import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
2
+ import { SummaryPageController } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js'
3
+ import { buildFormRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js'
4
+ import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js'
5
+ import {
6
+ type FormRequest,
7
+ type FormRequestPayload,
8
+ type FormResponseToolkit
9
+ } from '~/src/server/routes/types.js'
10
+ import { type CacheService } from '~/src/server/services/cacheService.js'
11
+ import definition from '~/test/form/definitions/basic.js'
12
+
13
+ describe('SummaryPageController', () => {
14
+ let model: FormModel
15
+ let controller: SummaryPageController
16
+ let requestPage: FormRequest
17
+
18
+ const response = {
19
+ code: jest.fn().mockImplementation(() => response)
20
+ }
21
+ const h: FormResponseToolkit = {
22
+ redirect: jest.fn().mockReturnValue(response),
23
+ view: jest.fn()
24
+ }
25
+
26
+ beforeEach(() => {
27
+ model = new FormModel(definition, {
28
+ basePath: 'test'
29
+ })
30
+
31
+ // Create a mock page for SummaryPageController
32
+ const mockPage = {
33
+ ...definition.pages[0],
34
+ controller: 'summary'
35
+ }
36
+
37
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
38
+ controller = new SummaryPageController(model, mockPage as any)
39
+
40
+ requestPage = buildFormRequest({
41
+ method: 'get',
42
+ url: new URL('http://example.com/test/summary'),
43
+ path: '/test/summary',
44
+ params: {
45
+ path: 'summary',
46
+ slug: 'test'
47
+ },
48
+ query: {},
49
+ app: { model }
50
+ } as FormRequest)
51
+ })
52
+
53
+ describe('handleSaveAndExit', () => {
54
+ it('should invoke saveAndExit plugin option', async () => {
55
+ const saveAndExitMock = jest.fn(() => ({}))
56
+ const state: FormSubmissionState = {
57
+ $$__referenceNumber: 'foobar',
58
+ licenceLength: 365,
59
+ fullName: 'John Smith'
60
+ }
61
+ const request = {
62
+ ...requestPage,
63
+ server: {
64
+ plugins: {
65
+ 'forms-engine-plugin': {
66
+ saveAndExit: saveAndExitMock,
67
+ cacheService: {
68
+ clearState: jest.fn()
69
+ } as unknown as CacheService
70
+ }
71
+ }
72
+ },
73
+ method: 'post',
74
+ payload: { fullName: 'John Smith', action: 'save-and-exit' }
75
+ } as unknown as FormRequestPayload
76
+
77
+ const context = model.getFormContext(request, state)
78
+
79
+ const postHandler = controller.makePostRouteHandler()
80
+ await postHandler(request, context, h)
81
+
82
+ expect(saveAndExitMock).toHaveBeenCalledWith(request, h, context)
83
+ })
84
+ })
85
+ })
@@ -73,6 +73,7 @@ export class SummaryPageController extends QuestionPageController {
73
73
  viewModel.phaseTag = this.phaseTag
74
74
  viewModel.components = components
75
75
  viewModel.allowSaveAndExit = this.shouldShowSaveAndExit(request.server)
76
+ viewModel.errors = errors
76
77
 
77
78
  return viewModel
78
79
  }
@@ -107,48 +108,56 @@ export class SummaryPageController extends QuestionPageController {
107
108
  context: FormContext,
108
109
  h: FormResponseToolkit
109
110
  ) => {
110
- const { model } = this
111
- const { params } = request
112
-
113
111
  // Check if this is a save-and-exit action
114
112
  const { action } = request.payload
115
113
  if (action === FormAction.SaveAndExit) {
116
114
  return this.handleSaveAndExit(request, context, h)
117
115
  }
118
116
 
119
- const cacheService = getCacheService(request.server)
120
-
121
- const { formsService } = this.model.services
122
- const { getFormMetadata } = formsService
123
-
124
- // Get the form metadata using the `slug` param
125
- const formMetadata = await getFormMetadata(params.slug)
126
- const { notificationEmail } = formMetadata
127
- const { isPreview } = checkFormStatus(request.params)
128
- const emailAddress = notificationEmail ?? this.model.def.outputEmail
129
-
130
- checkEmailAddressForLiveFormSubmission(emailAddress, isPreview)
131
-
132
- // Send submission email
133
- if (emailAddress) {
134
- const viewModel = this.getSummaryViewModel(request, context)
135
- await submitForm(
136
- context,
137
- request,
138
- viewModel,
139
- model,
140
- emailAddress,
141
- formMetadata
142
- )
143
- }
117
+ return this.handleFormSubmit(request, context, h)
118
+ }
119
+ }
120
+
121
+ async handleFormSubmit(
122
+ request: FormRequestPayload,
123
+ context: FormContext,
124
+ h: FormResponseToolkit
125
+ ) {
126
+ const { model } = this
127
+ const { params } = request
128
+
129
+ const cacheService = getCacheService(request.server)
144
130
 
145
- await cacheService.setConfirmationState(request, { confirmed: true })
131
+ const { formsService } = this.model.services
132
+ const { getFormMetadata } = formsService
146
133
 
147
- // Clear all form data
148
- await cacheService.clearState(request)
134
+ // Get the form metadata using the `slug` param
135
+ const formMetadata = await getFormMetadata(params.slug)
136
+ const { notificationEmail } = formMetadata
137
+ const { isPreview } = checkFormStatus(request.params)
138
+ const emailAddress = notificationEmail ?? this.model.def.outputEmail
149
139
 
150
- return this.proceed(request, h, this.getStatusPath())
140
+ checkEmailAddressForLiveFormSubmission(emailAddress, isPreview)
141
+
142
+ // Send submission email
143
+ if (emailAddress) {
144
+ const viewModel = this.getSummaryViewModel(request, context)
145
+ await submitForm(
146
+ context,
147
+ request,
148
+ viewModel,
149
+ model,
150
+ emailAddress,
151
+ formMetadata
152
+ )
151
153
  }
154
+
155
+ await cacheService.setConfirmationState(request, { confirmed: true })
156
+
157
+ // Clear all form data
158
+ await cacheService.clearState(request)
159
+
160
+ return this.proceed(request, h, this.getStatusPath())
152
161
  }
153
162
 
154
163
  get postRouteOptions(): RouteOptions<FormRequestPayloadRefs> {
@@ -164,7 +173,7 @@ export class SummaryPageController extends QuestionPageController {
164
173
  }
165
174
  }
166
175
 
167
- async function submitForm(
176
+ export async function submitForm(
168
177
  context: FormContext,
169
178
  request: FormRequestPayload,
170
179
  summaryViewModel: SummaryViewModel,
@@ -22,7 +22,9 @@ export async function getHandler(
22
22
  return h.response({ error: 'Status check failed' }).code(400)
23
23
  }
24
24
 
25
- return h.response(status)
25
+ return h.response({
26
+ uploadStatus: status.uploadStatus
27
+ })
26
28
  } catch (err) {
27
29
  request.logger.error(
28
30
  err,
@@ -78,6 +78,29 @@ describe('Schema validation', () => {
78
78
  expect(error).toBeUndefined()
79
79
  })
80
80
 
81
+ it('should validate valid meta object with valid custom properties', () => {
82
+ const validMetaWithCustom = {
83
+ ...validMeta,
84
+ custom: {
85
+ property1: 'value 1',
86
+ property2: 'value2'
87
+ }
88
+ }
89
+ const { error } =
90
+ formAdapterSubmissionMessageMetaSchema.validate(validMetaWithCustom)
91
+ expect(error).toBeUndefined()
92
+ })
93
+
94
+ it('should validate valid meta object with empty custom properties', () => {
95
+ const validMetaWithCustom = {
96
+ ...validMeta,
97
+ custom: {}
98
+ }
99
+ const { error } =
100
+ formAdapterSubmissionMessageMetaSchema.validate(validMetaWithCustom)
101
+ expect(error).toBeUndefined()
102
+ })
103
+
81
104
  it('should reject invalid schema version', () => {
82
105
  const invalidMeta = { ...validMeta, schemaVersion: 'invalid' }
83
106
  const { error } =
@@ -92,6 +115,17 @@ describe('Schema validation', () => {
92
115
  formAdapterSubmissionMessageMetaSchema.validate(metaWithoutTimestamp)
93
116
  expect(error).toBeDefined()
94
117
  })
118
+
119
+ it('should reject invalid custom structure', () => {
120
+ const validMetaWithInvalidCustom = {
121
+ ...validMeta,
122
+ custom: 'invalid'
123
+ }
124
+ const { error } = formAdapterSubmissionMessageMetaSchema.validate(
125
+ validMetaWithInvalidCustom
126
+ )
127
+ expect(error).toBeDefined()
128
+ })
95
129
  })
96
130
 
97
131
  describe('formAdapterSubmissionMessageDataSchema', () => {
@@ -31,7 +31,12 @@ export const formAdapterSubmissionMessageMetaSchema =
31
31
  .required(),
32
32
  isPreview: Joi.boolean().required(),
33
33
  notificationEmail: notificationEmailAddressSchema.required(),
34
- versionMetadata: formVersionMetadataSchema.optional()
34
+ versionMetadata: formVersionMetadataSchema.optional(),
35
+ custom: Joi.object()
36
+ .pattern(/^/, Joi.any())
37
+ .unknown()
38
+ .optional()
39
+ .description('Custom properties for the message')
35
40
  })
36
41
 
37
42
  export const formAdapterSubmissionMessageDataSchema =
@@ -409,6 +409,7 @@ export interface FormAdapterSubmissionMessageMeta {
409
409
  isPreview: boolean
410
410
  notificationEmail: string
411
411
  versionMetadata?: FormVersionMetadata
412
+ custom?: Record<string, unknown>
412
413
  }
413
414
 
414
415
  export type FormAdapterSubmissionMessageMetaSerialised = Omit<
@@ -4,6 +4,7 @@
4
4
  {% from "govuk/components/summary-list/macro.njk" import govukSummaryList %}
5
5
  {% from "govuk/components/button/macro.njk" import govukButton %}
6
6
  {% from "partials/components.html" import componentList with context %}
7
+ {% from "govuk/components/input/macro.njk" import govukInput %}
7
8
 
8
9
  {% block content %}
9
10
  <div class="govuk-grid-row">
@@ -12,6 +13,13 @@
12
13
  {% include "partials/preview-banner.html" %}
13
14
  {% endif %}
14
15
 
16
+ {% if errors %}
17
+ {{ govukErrorSummary({
18
+ titleText: "There is a problem",
19
+ errorList: checkErrorTemplates(errors)
20
+ }) }}
21
+ {% endif %}
22
+
15
23
  {% if hasMissingNotificationEmail %}
16
24
  {% include "partials/warn-missing-notification-email.html" %}
17
25
  {% endif %}
@@ -33,6 +41,10 @@
33
41
  <form method="post" novalidate>
34
42
  <input type="hidden" name="crumb" value="{{ crumb }}">
35
43
 
44
+ {{ componentList(components) }}
45
+
46
+ {% block customPageContent %}{% endblock %}
47
+
36
48
  {% if declaration %}
37
49
  <h2 class="govuk-heading-m" id="declaration">Declaration</h2>
38
50
  <div class="govuk-body">
@@ -40,8 +52,6 @@
40
52
  </div>
41
53
  {% endif %}
42
54
 
43
- {{ componentList(components) }}
44
-
45
55
  <div class="govuk-button-group">
46
56
  {% set isDeclaration = declaration or components | length %}
47
57