@defra/forms-engine-plugin 0.1.11 → 0.1.13
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/.public/javascripts/file-upload.min.js +1 -1
- package/.public/javascripts/file-upload.min.js.map +1 -1
- package/.server/client/javascripts/file-upload.js +45 -4
- package/.server/client/javascripts/file-upload.js.map +1 -1
- package/.server/server/constants.js +2 -0
- package/.server/server/constants.js.map +1 -1
- package/.server/server/index.js +1 -1
- package/.server/server/index.js.map +1 -1
- package/.server/server/plugins/engine/components/AutocompleteField.js +2 -0
- package/.server/server/plugins/engine/components/AutocompleteField.js.map +1 -1
- package/.server/server/plugins/engine/components/CheckboxesField.js +3 -4
- package/.server/server/plugins/engine/components/CheckboxesField.js.map +1 -1
- package/.server/server/plugins/engine/components/ComponentCollection.js +37 -16
- package/.server/server/plugins/engine/components/ComponentCollection.js.map +1 -1
- package/.server/server/plugins/engine/components/DatePartsField.js +36 -2
- package/.server/server/plugins/engine/components/DatePartsField.js.map +1 -1
- package/.server/server/plugins/engine/components/EmailAddressField.js +19 -3
- package/.server/server/plugins/engine/components/EmailAddressField.js.map +1 -1
- package/.server/server/plugins/engine/components/FileUploadField.js +44 -4
- package/.server/server/plugins/engine/components/FileUploadField.js.map +1 -1
- package/.server/server/plugins/engine/components/FormComponent.js +14 -2
- package/.server/server/plugins/engine/components/FormComponent.js.map +1 -1
- package/.server/server/plugins/engine/components/ListFormComponent.js +16 -3
- package/.server/server/plugins/engine/components/ListFormComponent.js.map +1 -1
- package/.server/server/plugins/engine/components/Markdown.js +24 -0
- package/.server/server/plugins/engine/components/Markdown.js.map +1 -0
- package/.server/server/plugins/engine/components/MonthYearField.js +30 -2
- package/.server/server/plugins/engine/components/MonthYearField.js.map +1 -1
- package/.server/server/plugins/engine/components/MultilineTextField.js +32 -3
- package/.server/server/plugins/engine/components/MultilineTextField.js.map +1 -1
- package/.server/server/plugins/engine/components/NumberField.js +28 -3
- package/.server/server/plugins/engine/components/NumberField.js.map +1 -1
- package/.server/server/plugins/engine/components/SelectionControlField.js +14 -0
- package/.server/server/plugins/engine/components/SelectionControlField.js.map +1 -1
- package/.server/server/plugins/engine/components/TelephoneNumberField.js +19 -3
- package/.server/server/plugins/engine/components/TelephoneNumberField.js.map +1 -1
- package/.server/server/plugins/engine/components/TextField.js +22 -3
- package/.server/server/plugins/engine/components/TextField.js.map +1 -1
- package/.server/server/plugins/engine/components/UkAddressField.js +29 -0
- package/.server/server/plugins/engine/components/UkAddressField.js.map +1 -1
- package/.server/server/plugins/engine/components/YesNoField.js +18 -0
- package/.server/server/plugins/engine/components/YesNoField.js.map +1 -1
- package/.server/server/plugins/engine/components/helpers.js +16 -0
- package/.server/server/plugins/engine/components/helpers.js.map +1 -1
- package/.server/server/plugins/engine/components/index.js +1 -0
- package/.server/server/plugins/engine/components/index.js.map +1 -1
- package/.server/server/plugins/engine/configureEnginePlugin.js +3 -1
- package/.server/server/plugins/engine/configureEnginePlugin.js.map +1 -1
- package/.server/server/plugins/engine/helpers.js +38 -18
- package/.server/server/plugins/engine/helpers.js.map +1 -1
- package/.server/server/plugins/engine/models/FormModel.js +60 -2
- package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
- package/.server/server/plugins/engine/models/SummaryViewModel.js +3 -2
- package/.server/server/plugins/engine/models/SummaryViewModel.js.map +1 -1
- package/.server/server/plugins/engine/outputFormatters/human/v1.js +1 -1
- package/.server/server/plugins/engine/outputFormatters/human/v1.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/PageController.js +13 -5
- package/.server/server/plugins/engine/pageControllers/PageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +2 -2
- package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +19 -5
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/validationOptions.js +6 -11
- package/.server/server/plugins/engine/pageControllers/validationOptions.js.map +1 -1
- package/.server/server/plugins/engine/plugin.js +5 -4
- package/.server/server/plugins/engine/plugin.js.map +1 -1
- package/.server/server/plugins/engine/services/notifyService.js +1 -4
- package/.server/server/plugins/engine/services/notifyService.js.map +1 -1
- package/.server/server/plugins/engine/services/uploadService.js +5 -3
- package/.server/server/plugins/engine/services/uploadService.js.map +1 -1
- package/.server/server/plugins/engine/types.js.map +1 -1
- package/.server/server/plugins/engine/views/components/html.html +1 -1
- package/.server/server/plugins/engine/views/components/markdown.html +5 -0
- package/.server/server/plugins/engine/views/summary.html +7 -1
- package/.server/server/plugins/nunjucks/context.js +7 -6
- package/.server/server/plugins/nunjucks/context.js.map +1 -1
- package/.server/server/plugins/nunjucks/enviroment.test.js +6 -3
- package/.server/server/plugins/nunjucks/enviroment.test.js.map +1 -1
- package/.server/server/utils/type-utils.js +8 -0
- package/.server/server/utils/type-utils.js.map +1 -0
- package/.server/typings/joi/index.d.js.map +1 -1
- package/package.json +3 -3
- package/src/client/javascripts/file-upload.js +60 -4
- package/src/server/constants.js +2 -0
- package/src/server/index.test.ts +34 -29
- package/src/server/index.ts +2 -1
- package/src/server/plugins/engine/components/AutocompleteField.test.ts +71 -3
- package/src/server/plugins/engine/components/AutocompleteField.ts +6 -2
- package/src/server/plugins/engine/components/CheckboxesField.test.ts +40 -8
- package/src/server/plugins/engine/components/CheckboxesField.ts +7 -3
- package/src/server/plugins/engine/components/ComponentCollection.ts +45 -18
- package/src/server/plugins/engine/components/DatePartsField.test.ts +13 -4
- package/src/server/plugins/engine/components/DatePartsField.ts +29 -8
- package/src/server/plugins/engine/components/EmailAddressField.test.ts +51 -1
- package/src/server/plugins/engine/components/EmailAddressField.ts +17 -2
- package/src/server/plugins/engine/components/FileUploadField.test.ts +53 -0
- package/src/server/plugins/engine/components/FileUploadField.ts +52 -3
- package/src/server/plugins/engine/components/FormComponent.ts +24 -2
- package/src/server/plugins/engine/components/ListFormComponent.ts +16 -2
- package/src/server/plugins/engine/components/Markdown.test.ts +48 -0
- package/src/server/plugins/engine/components/Markdown.ts +29 -0
- package/src/server/plugins/engine/components/MonthYearField.test.ts +35 -0
- package/src/server/plugins/engine/components/MonthYearField.ts +34 -9
- package/src/server/plugins/engine/components/MultilineTextField.test.ts +83 -5
- package/src/server/plugins/engine/components/MultilineTextField.ts +37 -2
- package/src/server/plugins/engine/components/NumberField.test.ts +24 -2
- package/src/server/plugins/engine/components/NumberField.ts +23 -3
- package/src/server/plugins/engine/components/RadiosField.test.ts +10 -1
- package/src/server/plugins/engine/components/SelectField.test.ts +2 -1
- package/src/server/plugins/engine/components/SelectionControlField.ts +14 -0
- package/src/server/plugins/engine/components/TelephoneNumberField.test.ts +30 -2
- package/src/server/plugins/engine/components/TelephoneNumberField.ts +17 -2
- package/src/server/plugins/engine/components/TextField.test.ts +33 -1
- package/src/server/plugins/engine/components/TextField.ts +17 -2
- package/src/server/plugins/engine/components/UkAddressField.test.ts +46 -3
- package/src/server/plugins/engine/components/UkAddressField.ts +28 -0
- package/src/server/plugins/engine/components/YesNoField.test.ts +9 -1
- package/src/server/plugins/engine/components/YesNoField.ts +24 -0
- package/src/server/plugins/engine/components/helpers.test.ts +24 -0
- package/src/server/plugins/engine/components/helpers.ts +39 -0
- package/src/server/plugins/engine/components/index.ts +1 -0
- package/src/server/plugins/engine/configureEnginePlugin.ts +13 -3
- package/src/server/plugins/engine/helpers.test.ts +71 -20
- package/src/server/plugins/engine/helpers.ts +46 -19
- package/src/server/plugins/engine/models/FormModel.test.ts +91 -1
- package/src/server/plugins/engine/models/FormModel.ts +86 -3
- package/src/server/plugins/engine/models/SummaryViewModel.test.ts +46 -7
- package/src/server/plugins/engine/models/SummaryViewModel.ts +7 -3
- package/src/server/plugins/engine/outputFormatters/human/v1.test.ts +1 -2
- package/src/server/plugins/engine/outputFormatters/human/v1.ts +1 -1
- package/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +1 -0
- package/src/server/plugins/engine/pageControllers/PageController.test.ts +9 -6
- package/src/server/plugins/engine/pageControllers/PageController.ts +15 -5
- package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +2 -2
- package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +21 -6
- package/src/server/plugins/engine/pageControllers/validationOptions.ts +31 -17
- package/src/server/plugins/engine/plugin.ts +9 -5
- package/src/server/plugins/engine/services/notifyService.ts +1 -2
- package/src/server/plugins/engine/services/uploadService.js +10 -6
- package/src/server/plugins/engine/types.ts +10 -1
- package/src/server/plugins/engine/views/components/html.html +1 -1
- package/src/server/plugins/engine/views/components/markdown.html +5 -0
- package/src/server/plugins/engine/views/summary.html +7 -1
- package/src/server/plugins/nunjucks/context.js +5 -5
- package/src/server/plugins/nunjucks/enviroment.test.js +9 -3
- package/src/server/utils/type-utils.ts +15 -0
- package/src/typings/joi/index.d.ts +8 -0
|
@@ -3,7 +3,6 @@ import { type ResponseObject, type ResponseToolkit } from '@hapi/hapi'
|
|
|
3
3
|
import { StatusCodes } from 'http-status-codes'
|
|
4
4
|
import { ValidationError } from 'joi'
|
|
5
5
|
|
|
6
|
-
import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js'
|
|
7
6
|
import {
|
|
8
7
|
checkEmailAddressForLiveFormSubmission,
|
|
9
8
|
checkFormStatus,
|
|
@@ -17,6 +16,7 @@ import {
|
|
|
17
16
|
safeGenerateCrumb,
|
|
18
17
|
type GlobalScope
|
|
19
18
|
} from '~/src/server/plugins/engine/helpers.js'
|
|
19
|
+
import { handleLegacyRedirect } from '~/src/server/plugins/engine/helpers.js'
|
|
20
20
|
import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
|
|
21
21
|
import {
|
|
22
22
|
createPage,
|
|
@@ -311,35 +311,42 @@ describe('Helpers', () => {
|
|
|
311
311
|
})
|
|
312
312
|
|
|
313
313
|
describe('checkFormStatus', () => {
|
|
314
|
-
it('should return true/live for
|
|
315
|
-
|
|
316
|
-
|
|
314
|
+
it('should return true/live for params that include live state segment', () => {
|
|
315
|
+
expect(
|
|
316
|
+
checkFormStatus({
|
|
317
|
+
state: FormStatus.Live,
|
|
318
|
+
slug: 'another',
|
|
319
|
+
path: 'segment'
|
|
320
|
+
})
|
|
321
|
+
).toStrictEqual({
|
|
317
322
|
state: FormStatus.Live,
|
|
318
323
|
isPreview: true
|
|
319
324
|
})
|
|
320
325
|
})
|
|
321
326
|
|
|
322
|
-
it('should return
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
it('should be case insensitive and return draft when form is draft', () => {
|
|
331
|
-
const path = `${PREVIEW_PATH_PREFIX.toUpperCase()}/draft/path`
|
|
332
|
-
expect(checkFormStatus(path)).toStrictEqual({
|
|
327
|
+
it('should return true/draft for params that include draft state segment', () => {
|
|
328
|
+
expect(
|
|
329
|
+
checkFormStatus({
|
|
330
|
+
state: FormStatus.Draft,
|
|
331
|
+
slug: 'another',
|
|
332
|
+
path: 'segment'
|
|
333
|
+
})
|
|
334
|
+
).toStrictEqual({
|
|
333
335
|
state: FormStatus.Draft,
|
|
334
336
|
isPreview: true
|
|
335
337
|
})
|
|
336
338
|
})
|
|
337
339
|
|
|
338
|
-
it('should
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
340
|
+
it('should return false/live for paths without a state segment', () => {
|
|
341
|
+
expect(
|
|
342
|
+
checkFormStatus({
|
|
343
|
+
slug: 'some',
|
|
344
|
+
path: 'other'
|
|
345
|
+
})
|
|
346
|
+
).toStrictEqual({
|
|
347
|
+
state: FormStatus.Live,
|
|
348
|
+
isPreview: false
|
|
349
|
+
})
|
|
343
350
|
})
|
|
344
351
|
})
|
|
345
352
|
|
|
@@ -789,4 +796,48 @@ describe('Helpers', () => {
|
|
|
789
796
|
})
|
|
790
797
|
})
|
|
791
798
|
})
|
|
799
|
+
|
|
800
|
+
describe('handleLegacyRedirect', () => {
|
|
801
|
+
let mockH: jest.Mocked<Pick<ResponseToolkit, 'redirect'>>
|
|
802
|
+
let mockRedirectResponse: jest.Mocked<
|
|
803
|
+
ReturnType<ResponseToolkit['redirect']>
|
|
804
|
+
>
|
|
805
|
+
|
|
806
|
+
beforeEach(() => {
|
|
807
|
+
mockRedirectResponse = {
|
|
808
|
+
permanent: jest.fn().mockReturnThis(),
|
|
809
|
+
takeover: jest.fn().mockReturnThis()
|
|
810
|
+
} as unknown as jest.Mocked<ReturnType<ResponseToolkit['redirect']>>
|
|
811
|
+
|
|
812
|
+
mockH = {
|
|
813
|
+
redirect: jest.fn().mockReturnValue(mockRedirectResponse)
|
|
814
|
+
}
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
it('should call h.redirect with the target URL', () => {
|
|
818
|
+
const targetUrl = '/another/target'
|
|
819
|
+
handleLegacyRedirect(mockH as unknown as ResponseToolkit, targetUrl)
|
|
820
|
+
|
|
821
|
+
expect(mockH.redirect).toHaveBeenCalledTimes(1)
|
|
822
|
+
expect(mockH.redirect).toHaveBeenCalledWith(targetUrl)
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
it('should call permanent() and takeover() on the redirect response', () => {
|
|
826
|
+
const targetUrl = '/final/destination'
|
|
827
|
+
handleLegacyRedirect(mockH as unknown as ResponseToolkit, targetUrl)
|
|
828
|
+
|
|
829
|
+
expect(mockRedirectResponse.permanent).toHaveBeenCalledTimes(1)
|
|
830
|
+
expect(mockRedirectResponse.takeover).toHaveBeenCalledTimes(1)
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
it('should return the final response object from takeover()', () => {
|
|
834
|
+
const targetUrl = '/the/end'
|
|
835
|
+
const response = handleLegacyRedirect(
|
|
836
|
+
mockH as unknown as ResponseToolkit,
|
|
837
|
+
targetUrl
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
expect(response).toBe(mockRedirectResponse)
|
|
841
|
+
})
|
|
842
|
+
})
|
|
792
843
|
})
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ControllerPath,
|
|
3
3
|
Engine,
|
|
4
|
+
hasComponents,
|
|
5
|
+
isFormType,
|
|
4
6
|
type ComponentDef,
|
|
7
|
+
type FormDefinition,
|
|
5
8
|
type Page
|
|
6
9
|
} from '@defra/forms-model'
|
|
7
10
|
import Boom from '@hapi/boom'
|
|
@@ -12,7 +15,6 @@ import { type Schema, type ValidationErrorItem } from 'joi'
|
|
|
12
15
|
import { Liquid } from 'liquidjs'
|
|
13
16
|
|
|
14
17
|
import { createLogger } from '~/src/server/common/helpers/logging/logger.js'
|
|
15
|
-
import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js'
|
|
16
18
|
import {
|
|
17
19
|
getAnswer,
|
|
18
20
|
type Field
|
|
@@ -27,6 +29,7 @@ import {
|
|
|
27
29
|
import {
|
|
28
30
|
FormAction,
|
|
29
31
|
FormStatus,
|
|
32
|
+
type FormParams,
|
|
30
33
|
type FormQuery,
|
|
31
34
|
type FormRequest,
|
|
32
35
|
type FormRequestPayload
|
|
@@ -253,29 +256,18 @@ export function getStartPath(model?: FormModel) {
|
|
|
253
256
|
return startPath ? `/${startPath}` : ControllerPath.Start
|
|
254
257
|
}
|
|
255
258
|
|
|
256
|
-
export function checkFormStatus(
|
|
257
|
-
const isPreview =
|
|
259
|
+
export function checkFormStatus(params?: FormParams) {
|
|
260
|
+
const isPreview = !!params?.state
|
|
258
261
|
|
|
259
|
-
let state
|
|
262
|
+
let state = FormStatus.Live
|
|
260
263
|
|
|
261
|
-
if (isPreview) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
for (const formState of Object.values(FormStatus)) {
|
|
265
|
-
if (previewState === formState.toString()) {
|
|
266
|
-
state = formState
|
|
267
|
-
break
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (!state) {
|
|
272
|
-
throw new Error(`Invalid form state: ${previewState}`)
|
|
273
|
-
}
|
|
264
|
+
if (isPreview && params.state === FormStatus.Draft) {
|
|
265
|
+
state = FormStatus.Draft
|
|
274
266
|
}
|
|
275
267
|
|
|
276
268
|
return {
|
|
277
269
|
isPreview,
|
|
278
|
-
state
|
|
270
|
+
state
|
|
279
271
|
}
|
|
280
272
|
}
|
|
281
273
|
|
|
@@ -337,7 +329,7 @@ export function safeGenerateCrumb(
|
|
|
337
329
|
return undefined
|
|
338
330
|
}
|
|
339
331
|
|
|
340
|
-
// crumb plugin or its generate method doesn
|
|
332
|
+
// crumb plugin or its generate method doesn't exist
|
|
341
333
|
if (!request.server.plugins.crumb.generate) {
|
|
342
334
|
return undefined
|
|
343
335
|
}
|
|
@@ -382,3 +374,38 @@ export function evaluateTemplate(
|
|
|
382
374
|
export function getCacheService(server: Server) {
|
|
383
375
|
return server.plugins['forms-engine-plugin'].cacheService
|
|
384
376
|
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Handles logging and issuing a permanent redirect for legacy routes.
|
|
380
|
+
* @param h - The Hapi response toolkit.
|
|
381
|
+
* @param targetUrl - The URL to redirect to.
|
|
382
|
+
* @returns The Hapi response object configured for permanent redirect.
|
|
383
|
+
*/
|
|
384
|
+
export function handleLegacyRedirect(h: ResponseToolkit, targetUrl: string) {
|
|
385
|
+
return h.redirect(targetUrl).permanent().takeover()
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* If the page doesn't have a title, set it from the title of the first form component
|
|
390
|
+
* @param def - the form definition
|
|
391
|
+
*/
|
|
392
|
+
export function setPageTitles(def: FormDefinition) {
|
|
393
|
+
def.pages.forEach((page) => {
|
|
394
|
+
if (!page.title) {
|
|
395
|
+
if (hasComponents(page)) {
|
|
396
|
+
// Set the page title from the first form component
|
|
397
|
+
const firstFormComponent = page.components.find((component) =>
|
|
398
|
+
isFormType(component.type)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
page.title = firstFormComponent?.title ?? ''
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!page.title) {
|
|
405
|
+
const formNameMsg = def.name ? ` in form '${def.name}'` : ''
|
|
406
|
+
|
|
407
|
+
logger.warn(`Page '${page.path}' has no title${formNameMsg}`)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
})
|
|
411
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
|
|
2
2
|
import { type FormContextRequest } from '~/src/server/plugins/engine/types.js'
|
|
3
|
+
import { V2 as definitionV2 } from '~/test/form/definitions/conditions-basic.js'
|
|
3
4
|
import definition from '~/test/form/definitions/conditions-escaping.js'
|
|
5
|
+
import conditionsListDefinition from '~/test/form/definitions/conditions-list.js'
|
|
4
6
|
import fieldsRequiredDefinition from '~/test/form/definitions/fields-required.js'
|
|
5
7
|
|
|
6
8
|
describe('FormModel', () => {
|
|
@@ -10,6 +12,20 @@ describe('FormModel', () => {
|
|
|
10
12
|
() => new FormModel(definition, { basePath: 'test' })
|
|
11
13
|
).not.toThrow()
|
|
12
14
|
})
|
|
15
|
+
|
|
16
|
+
it('Sets the page title from first form component when empty (V2 only)', () => {
|
|
17
|
+
const noTitlesDefinition = {
|
|
18
|
+
...definitionV2,
|
|
19
|
+
pages: definitionV2.pages.map((page) => ({ ...page, title: '' }))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const model = new FormModel(noTitlesDefinition, { basePath: 'test' })
|
|
23
|
+
|
|
24
|
+
expect(model.def.pages.at(0)?.title).toBe(
|
|
25
|
+
'Have you previously been married?'
|
|
26
|
+
)
|
|
27
|
+
expect(model.def.pages.at(1)?.title).toBe('Date of marriage')
|
|
28
|
+
})
|
|
13
29
|
})
|
|
14
30
|
|
|
15
31
|
describe('getFormContext', () => {
|
|
@@ -74,7 +90,7 @@ describe('FormModel', () => {
|
|
|
74
90
|
})
|
|
75
91
|
|
|
76
92
|
const state = {
|
|
77
|
-
$$__referenceNumber:
|
|
93
|
+
$$__referenceNumber: 123456789,
|
|
78
94
|
checkboxesSingle: ['Arabian', 'Shetland']
|
|
79
95
|
}
|
|
80
96
|
const pageUrl = new URL('http://example.com/components/fields-required')
|
|
@@ -93,5 +109,79 @@ describe('FormModel', () => {
|
|
|
93
109
|
'Reference number not found in form state'
|
|
94
110
|
)
|
|
95
111
|
})
|
|
112
|
+
|
|
113
|
+
it('redirects to the page if the list field (radio) is invalidated due to list item conditions', () => {
|
|
114
|
+
const formModel = new FormModel(conditionsListDefinition, {
|
|
115
|
+
basePath: '/conditional-list-items'
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const state = {
|
|
119
|
+
$$__referenceNumber: 'foobar',
|
|
120
|
+
gXsqLq: true,
|
|
121
|
+
QwcNsc: 'meat',
|
|
122
|
+
zeQDES: ['peppers', 'cheese', 'ham']
|
|
123
|
+
}
|
|
124
|
+
const pageUrl = new URL(
|
|
125
|
+
'http://example.com/conditional-list-items/summary'
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const request: FormContextRequest = {
|
|
129
|
+
method: 'get',
|
|
130
|
+
query: {},
|
|
131
|
+
path: pageUrl.pathname,
|
|
132
|
+
params: { path: 'summary', slug: 'conditional-list-items' },
|
|
133
|
+
url: pageUrl,
|
|
134
|
+
app: { model: formModel }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const context = formModel.getFormContext(request, state)
|
|
138
|
+
|
|
139
|
+
expect(context.errors).toHaveLength(1)
|
|
140
|
+
expect(context.errors?.at(0)?.text).toBe(
|
|
141
|
+
'Options are different because you changed a previous answer'
|
|
142
|
+
)
|
|
143
|
+
expect(context.relevantPages).toHaveLength(2)
|
|
144
|
+
expect(context.paths).toHaveLength(2)
|
|
145
|
+
expect(context.relevantState).toEqual({ gXsqLq: true, QwcNsc: 'meat' })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('redirects to the page if the list field (check) is invalidated due to list item conditions', () => {
|
|
149
|
+
const formModel = new FormModel(conditionsListDefinition, {
|
|
150
|
+
basePath: '/conditional-list-items'
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const state = {
|
|
154
|
+
$$__referenceNumber: 'foobar',
|
|
155
|
+
gXsqLq: true,
|
|
156
|
+
QwcNsc: 'vegan',
|
|
157
|
+
zeQDES: ['peppers', 'cheese', 'ham']
|
|
158
|
+
}
|
|
159
|
+
const pageUrl = new URL(
|
|
160
|
+
'http://example.com/conditional-list-items/summary'
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const request: FormContextRequest = {
|
|
164
|
+
method: 'get',
|
|
165
|
+
query: {},
|
|
166
|
+
path: pageUrl.pathname,
|
|
167
|
+
params: { path: 'summary', slug: 'conditional-list-items' },
|
|
168
|
+
url: pageUrl,
|
|
169
|
+
app: { model: formModel }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const context = formModel.getFormContext(request, state)
|
|
173
|
+
|
|
174
|
+
expect(context.errors).toHaveLength(1)
|
|
175
|
+
expect(context.errors?.at(0)?.text).toBe(
|
|
176
|
+
'Options are different because you changed a previous answer'
|
|
177
|
+
)
|
|
178
|
+
expect(context.relevantPages).toHaveLength(3)
|
|
179
|
+
expect(context.paths).toHaveLength(3)
|
|
180
|
+
expect(context.relevantState).toEqual({
|
|
181
|
+
gXsqLq: true,
|
|
182
|
+
QwcNsc: 'vegan',
|
|
183
|
+
zeQDES: ['peppers', 'cheese', 'ham']
|
|
184
|
+
})
|
|
185
|
+
})
|
|
96
186
|
})
|
|
97
187
|
})
|
|
@@ -19,11 +19,16 @@ import { add } from 'date-fns'
|
|
|
19
19
|
import { Parser, type Value } from 'expr-eval'
|
|
20
20
|
import joi from 'joi'
|
|
21
21
|
|
|
22
|
-
import { type
|
|
22
|
+
import { type ListFormComponent } from '~/src/server/plugins/engine/components/ListFormComponent.js'
|
|
23
|
+
import {
|
|
24
|
+
hasListFormField,
|
|
25
|
+
type Component
|
|
26
|
+
} from '~/src/server/plugins/engine/components/helpers.js'
|
|
23
27
|
import {
|
|
24
28
|
findPage,
|
|
25
29
|
getError,
|
|
26
|
-
getPage
|
|
30
|
+
getPage,
|
|
31
|
+
setPageTitles
|
|
27
32
|
} from '~/src/server/plugins/engine/helpers.js'
|
|
28
33
|
import { type ExecutableCondition } from '~/src/server/plugins/engine/models/types.js'
|
|
29
34
|
import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js'
|
|
@@ -100,6 +105,9 @@ export class FormModel {
|
|
|
100
105
|
]
|
|
101
106
|
})
|
|
102
107
|
|
|
108
|
+
// Fix up page titles
|
|
109
|
+
setPageTitles(def)
|
|
110
|
+
|
|
103
111
|
this.engine = def.engine
|
|
104
112
|
this.def = def
|
|
105
113
|
this.lists = def.lists
|
|
@@ -297,7 +305,10 @@ export class FormModel {
|
|
|
297
305
|
this.assignRelevantState(context, nextPage)
|
|
298
306
|
|
|
299
307
|
// Stop at current page
|
|
300
|
-
if (
|
|
308
|
+
if (
|
|
309
|
+
this.pageStateIsInvalid(context, nextPage) ||
|
|
310
|
+
nextPage.path === currentPath
|
|
311
|
+
) {
|
|
301
312
|
break
|
|
302
313
|
}
|
|
303
314
|
|
|
@@ -351,6 +362,78 @@ export class FormModel {
|
|
|
351
362
|
}
|
|
352
363
|
}
|
|
353
364
|
|
|
365
|
+
private pageStateIsInvalid(context: FormContext, page: PageControllerClass) {
|
|
366
|
+
// Get any list-bound fields on the page
|
|
367
|
+
const listFields = page.collection.fields.filter(hasListFormField)
|
|
368
|
+
|
|
369
|
+
// For each list field that is bound to a list that contains any conditional items,
|
|
370
|
+
// we need to check any answers are still valid. Do this by evaluating the conditions
|
|
371
|
+
// and ensuring any current answers are all included in the set of valid answers
|
|
372
|
+
for (const field of listFields) {
|
|
373
|
+
const list = field.list
|
|
374
|
+
|
|
375
|
+
// Filter out YesNo as they can't be conditional
|
|
376
|
+
if (list !== undefined && field.type !== ComponentType.YesNoField) {
|
|
377
|
+
const hasOptionalItems =
|
|
378
|
+
list.items.filter((item) => item.condition).length > 0
|
|
379
|
+
|
|
380
|
+
if (hasOptionalItems) {
|
|
381
|
+
return this.fieldStateIsInvalid(context, field, list)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private fieldStateIsInvalid(
|
|
388
|
+
context: FormContext,
|
|
389
|
+
field: ListFormComponent,
|
|
390
|
+
list: List
|
|
391
|
+
) {
|
|
392
|
+
const { evaluationState, state } = context
|
|
393
|
+
|
|
394
|
+
const validValues = list.items
|
|
395
|
+
.filter((item) =>
|
|
396
|
+
item.condition
|
|
397
|
+
? this.conditions[item.condition]?.fn(evaluationState)
|
|
398
|
+
: true
|
|
399
|
+
)
|
|
400
|
+
.map((item) => item.value)
|
|
401
|
+
|
|
402
|
+
// Get the field state
|
|
403
|
+
const fieldState = field.getFormValueFromState(state)
|
|
404
|
+
|
|
405
|
+
if (fieldState !== undefined) {
|
|
406
|
+
let isInvalid = false
|
|
407
|
+
const isArray = Array.isArray(fieldState)
|
|
408
|
+
|
|
409
|
+
// Check if any saved state value(s) are still valid
|
|
410
|
+
// and return true if any are invalid
|
|
411
|
+
if (isArray) {
|
|
412
|
+
isInvalid = !fieldState.every((item) => validValues.includes(item))
|
|
413
|
+
} else {
|
|
414
|
+
isInvalid = !validValues.includes(fieldState)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (isInvalid) {
|
|
418
|
+
if (!context.errors) {
|
|
419
|
+
context.errors = []
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const text =
|
|
423
|
+
'Options are different because you changed a previous answer'
|
|
424
|
+
|
|
425
|
+
context.errors.push({
|
|
426
|
+
text,
|
|
427
|
+
name: field.name,
|
|
428
|
+
href: `#${field.name}`,
|
|
429
|
+
path: [`#${field.name}`]
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return isInvalid
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
354
437
|
private assignPaths(context: FormContext) {
|
|
355
438
|
for (const { keys, path } of context.relevantPages) {
|
|
356
439
|
context.paths.push(path)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FORM_PREFIX } from '~/src/server/constants.js'
|
|
1
2
|
import {
|
|
2
3
|
FormModel,
|
|
3
4
|
SummaryViewModel
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
} from '~/src/server/plugins/engine/types.js'
|
|
14
15
|
import definition from '~/test/form/definitions/repeat-mixed.js'
|
|
15
16
|
|
|
16
|
-
const basePath =
|
|
17
|
+
const basePath = `${FORM_PREFIX}/test`
|
|
17
18
|
|
|
18
19
|
describe('SummaryViewModel', () => {
|
|
19
20
|
const itemId1 = 'abc-123'
|
|
@@ -28,7 +29,7 @@ describe('SummaryViewModel', () => {
|
|
|
28
29
|
|
|
29
30
|
beforeEach(() => {
|
|
30
31
|
model = new FormModel(definition, {
|
|
31
|
-
basePath:
|
|
32
|
+
basePath: `${FORM_PREFIX}/test`
|
|
32
33
|
})
|
|
33
34
|
|
|
34
35
|
page = createPage(model, definition.pages[2])
|
|
@@ -55,7 +56,13 @@ describe('SummaryViewModel', () => {
|
|
|
55
56
|
orderType: 'collection',
|
|
56
57
|
pizza: []
|
|
57
58
|
} satisfies FormState,
|
|
58
|
-
keys: [
|
|
59
|
+
keys: [
|
|
60
|
+
'How would you like to receive your pizza?',
|
|
61
|
+
'Pizzas',
|
|
62
|
+
'How you would like to receive your pizza',
|
|
63
|
+
'Pizzas',
|
|
64
|
+
'Pizza'
|
|
65
|
+
],
|
|
59
66
|
values: ['Collection', 'Not supplied']
|
|
60
67
|
},
|
|
61
68
|
{
|
|
@@ -71,7 +78,13 @@ describe('SummaryViewModel', () => {
|
|
|
71
78
|
}
|
|
72
79
|
]
|
|
73
80
|
} satisfies FormState,
|
|
74
|
-
keys: [
|
|
81
|
+
keys: [
|
|
82
|
+
'How would you like to receive your pizza?',
|
|
83
|
+
'Pizza added',
|
|
84
|
+
'How you would like to receive your pizza',
|
|
85
|
+
'Pizzas',
|
|
86
|
+
'Pizza'
|
|
87
|
+
],
|
|
75
88
|
values: ['Delivery', 'You added 1 Pizza']
|
|
76
89
|
},
|
|
77
90
|
{
|
|
@@ -92,7 +105,13 @@ describe('SummaryViewModel', () => {
|
|
|
92
105
|
}
|
|
93
106
|
]
|
|
94
107
|
} satisfies FormState,
|
|
95
|
-
keys: [
|
|
108
|
+
keys: [
|
|
109
|
+
'How would you like to receive your pizza?',
|
|
110
|
+
'Pizzas added',
|
|
111
|
+
'How you would like to receive your pizza',
|
|
112
|
+
'Pizzas',
|
|
113
|
+
'Pizza'
|
|
114
|
+
],
|
|
96
115
|
values: ['Delivery', 'You added 2 Pizzas']
|
|
97
116
|
}
|
|
98
117
|
])('Check answers ($description)', ({ state, keys, values }) => {
|
|
@@ -124,7 +143,7 @@ describe('SummaryViewModel', () => {
|
|
|
124
143
|
expect(summaryList1).toHaveProperty('rows', [
|
|
125
144
|
{
|
|
126
145
|
key: {
|
|
127
|
-
text: keys[
|
|
146
|
+
text: keys[2]
|
|
128
147
|
},
|
|
129
148
|
value: {
|
|
130
149
|
classes: 'app-prose-scope',
|
|
@@ -181,7 +200,7 @@ describe('SummaryViewModel', () => {
|
|
|
181
200
|
expect(summaryList1).toHaveProperty('rows', [
|
|
182
201
|
{
|
|
183
202
|
key: {
|
|
184
|
-
text: keys[
|
|
203
|
+
text: keys[2]
|
|
185
204
|
},
|
|
186
205
|
value: {
|
|
187
206
|
classes: 'app-prose-scope',
|
|
@@ -208,5 +227,25 @@ describe('SummaryViewModel', () => {
|
|
|
208
227
|
}
|
|
209
228
|
])
|
|
210
229
|
})
|
|
230
|
+
|
|
231
|
+
it('should use correct summary labels', () => {
|
|
232
|
+
request.query.force = '' // Preview URL '?force'
|
|
233
|
+
context = model.getFormContext(request, state)
|
|
234
|
+
summaryViewModel = new SummaryViewModel(request, page, context)
|
|
235
|
+
|
|
236
|
+
expect(summaryViewModel.details).toHaveLength(2)
|
|
237
|
+
|
|
238
|
+
const [details1, details2] = summaryViewModel.details
|
|
239
|
+
|
|
240
|
+
expect(details1.items[0]).toMatchObject({
|
|
241
|
+
title: keys[2],
|
|
242
|
+
label: keys[0]
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
expect(details2.items[0]).toMatchObject({
|
|
246
|
+
title: keys[1],
|
|
247
|
+
label: keys[4]
|
|
248
|
+
})
|
|
249
|
+
})
|
|
211
250
|
})
|
|
212
251
|
})
|
|
@@ -4,7 +4,10 @@ import {
|
|
|
4
4
|
getAnswer,
|
|
5
5
|
type Field
|
|
6
6
|
} from '~/src/server/plugins/engine/components/helpers.js'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type BackLink,
|
|
9
|
+
type ComponentViewModel
|
|
10
|
+
} from '~/src/server/plugins/engine/components/types.js'
|
|
8
11
|
import {
|
|
9
12
|
evaluateTemplate,
|
|
10
13
|
getError,
|
|
@@ -47,6 +50,7 @@ export class SummaryViewModel {
|
|
|
47
50
|
errors?: FormSubmissionError[]
|
|
48
51
|
serviceUrl: string
|
|
49
52
|
hasMissingNotificationEmail?: boolean
|
|
53
|
+
components?: ComponentViewModel[]
|
|
50
54
|
|
|
51
55
|
constructor(
|
|
52
56
|
request: FormContextRequest,
|
|
@@ -207,8 +211,8 @@ function ItemField(
|
|
|
207
211
|
return {
|
|
208
212
|
name: field.name,
|
|
209
213
|
label: field.title,
|
|
210
|
-
title: field.
|
|
211
|
-
error: field.
|
|
214
|
+
title: field.label,
|
|
215
|
+
error: field.getFirstError(options.errors),
|
|
212
216
|
value: getAnswer(field, state),
|
|
213
217
|
href: getPageHref(page, options.path, {
|
|
214
218
|
returnUrl: getPageHref(page, page.getSummaryPath())
|
|
@@ -93,10 +93,9 @@ describe('getPersonalisation', () => {
|
|
|
93
93
|
`^ For security reasons, the links in this email expire at ${dateFormat(dateExpiry, 'h:mmaaa')} on ${dateFormat(dateExpiry, 'eeee d MMMM yyyy')}`
|
|
94
94
|
)
|
|
95
95
|
|
|
96
|
-
// Check for form answers
|
|
97
96
|
expect(body).toContain(
|
|
98
97
|
outdent`
|
|
99
|
-
|
|
98
|
+
${definition.name} form received at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}.
|
|
100
99
|
|
|
101
100
|
---
|
|
102
101
|
|
|
@@ -42,7 +42,7 @@ export function format(
|
|
|
42
42
|
lines.push(`This is a test of the ${formName} ${formStatus.state} form.\n`)
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
lines.push(
|
|
45
|
+
lines.push(`${formName} form received at ${escapeMarkdown(formattedNow)}.\n`)
|
|
46
46
|
lines.push('---\n')
|
|
47
47
|
|
|
48
48
|
items.forEach((item) => {
|
|
@@ -210,6 +210,7 @@ describe('FileUploadPageController', () => {
|
|
|
210
210
|
expect(getUploadStatusSpy).toHaveBeenCalledTimes(2)
|
|
211
211
|
expect(request.logger.info).toHaveBeenCalled()
|
|
212
212
|
|
|
213
|
+
/* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access */
|
|
213
214
|
const logMsg = (request.logger.info as jest.Mock).mock.calls[0][0]
|
|
214
215
|
expect(logMsg).toEqual(expect.stringContaining('Waiting'))
|
|
215
216
|
expect(logMsg).toEqual(expect.stringContaining('some-id'))
|