@defra/forms-engine-plugin 4.0.33 → 4.0.35

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 (78) hide show
  1. package/.server/server/constants.d.ts +1 -0
  2. package/.server/server/constants.js +1 -0
  3. package/.server/server/constants.js.map +1 -1
  4. package/.server/server/forms/register-as-a-unicorn-breeder.yaml +0 -1
  5. package/.server/server/forms/simple-form.yaml +64 -0
  6. package/.server/server/plugins/engine/beta/form-context.js +1 -2
  7. package/.server/server/plugins/engine/beta/form-context.js.map +1 -1
  8. package/.server/server/plugins/engine/components/FileUploadField.d.ts +4 -3
  9. package/.server/server/plugins/engine/components/FileUploadField.js +38 -0
  10. package/.server/server/plugins/engine/components/FileUploadField.js.map +1 -1
  11. package/.server/server/plugins/engine/components/FormComponent.d.ts +9 -7
  12. package/.server/server/plugins/engine/components/FormComponent.js +3 -0
  13. package/.server/server/plugins/engine/components/FormComponent.js.map +1 -1
  14. package/.server/server/plugins/engine/helpers.d.ts +5 -0
  15. package/.server/server/plugins/engine/helpers.js +7 -0
  16. package/.server/server/plugins/engine/helpers.js.map +1 -1
  17. package/.server/server/plugins/engine/index.d.ts +2 -0
  18. package/.server/server/plugins/engine/index.js +2 -0
  19. package/.server/server/plugins/engine/index.js.map +1 -1
  20. package/.server/server/plugins/engine/models/FormModel.js +4 -0
  21. package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
  22. package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js +6 -2
  23. package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js.map +1 -1
  24. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +8 -3
  25. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  26. package/.server/server/plugins/engine/pageControllers/StartPageController.d.ts +4 -0
  27. package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +1 -1
  28. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +33 -35
  29. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  30. package/.server/server/plugins/engine/pageControllers/__stubs__/request.d.ts +2 -2
  31. package/.server/server/plugins/engine/pageControllers/__stubs__/request.js +9 -0
  32. package/.server/server/plugins/engine/pageControllers/__stubs__/request.js.map +1 -1
  33. package/.server/server/plugins/engine/pageControllers/errors.d.ts +15 -0
  34. package/.server/server/plugins/engine/pageControllers/errors.js +25 -0
  35. package/.server/server/plugins/engine/pageControllers/errors.js.map +1 -0
  36. package/.server/server/plugins/engine/pageControllers/helpers/state.d.ts +13 -1
  37. package/.server/server/plugins/engine/pageControllers/helpers/state.js +33 -0
  38. package/.server/server/plugins/engine/pageControllers/helpers/state.js.map +1 -1
  39. package/.server/server/plugins/engine/services/localFormsService.js +6 -0
  40. package/.server/server/plugins/engine/services/localFormsService.js.map +1 -1
  41. package/.server/server/plugins/engine/views/index.html +1 -1
  42. package/.server/server/plugins/nunjucks/context.test.js +9 -1
  43. package/.server/server/plugins/nunjucks/context.test.js.map +1 -1
  44. package/.server/server/plugins/nunjucks/types.d.ts +4 -0
  45. package/.server/server/plugins/nunjucks/types.js +1 -0
  46. package/.server/server/plugins/nunjucks/types.js.map +1 -1
  47. package/.server/server/services/cacheService.d.ts +1 -0
  48. package/.server/server/services/cacheService.js +10 -0
  49. package/.server/server/services/cacheService.js.map +1 -1
  50. package/.server/typings/hapi/index.d.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/server/constants.js +1 -0
  53. package/src/server/forms/register-as-a-unicorn-breeder.yaml +0 -1
  54. package/src/server/forms/simple-form.yaml +64 -0
  55. package/src/server/plugins/engine/beta/form-context.test.ts +4 -3
  56. package/src/server/plugins/engine/beta/form-context.ts +4 -3
  57. package/src/server/plugins/engine/components/FileUploadField.test.ts +203 -2
  58. package/src/server/plugins/engine/components/FileUploadField.ts +61 -2
  59. package/src/server/plugins/engine/components/FormComponent.ts +17 -1
  60. package/src/server/plugins/engine/helpers.ts +8 -0
  61. package/src/server/plugins/engine/index.ts +3 -0
  62. package/src/server/plugins/engine/models/FormModel.ts +4 -0
  63. package/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +9 -0
  64. package/src/server/plugins/engine/pageControllers/FileUploadPageController.ts +11 -4
  65. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +12 -2
  66. package/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts +3 -0
  67. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +55 -46
  68. package/src/server/plugins/engine/pageControllers/__stubs__/request.ts +14 -4
  69. package/src/server/plugins/engine/pageControllers/errors.test.ts +63 -0
  70. package/src/server/plugins/engine/pageControllers/errors.ts +30 -0
  71. package/src/server/plugins/engine/pageControllers/helpers/state.test.ts +75 -1
  72. package/src/server/plugins/engine/pageControllers/helpers/state.ts +50 -1
  73. package/src/server/plugins/engine/services/localFormsService.js +7 -0
  74. package/src/server/plugins/engine/views/index.html +1 -1
  75. package/src/server/plugins/nunjucks/context.test.js +10 -2
  76. package/src/server/plugins/nunjucks/types.js +1 -0
  77. package/src/server/services/cacheService.ts +16 -0
  78. package/src/typings/hapi/index.d.ts +2 -0
@@ -1 +1 @@
1
- {"version":3,"file":"localFormsService.js","names":["config","FileFormService","now","Date","user","id","displayName","author","createdAt","createdBy","updatedAt","updatedBy","metadata","organisation","teamName","teamEmail","submissionGuidance","notificationEmail","get","live","formsService","loader","addForm","title","slug","toFormsService"],"sources":["../../../../../src/server/plugins/engine/services/localFormsService.js"],"sourcesContent":["import { config } from '~/src/config/index.js'\nimport { FileFormService } from '~/src/server/utils/file-form-service.js'\n\n// Create shared form metadata\nconst now = new Date()\nconst user = { id: 'user', displayName: 'Username' }\nconst author = {\n createdAt: now,\n createdBy: user,\n updatedAt: now,\n updatedBy: user\n}\nconst metadata = {\n organisation: 'Defra',\n teamName: 'Team name',\n teamEmail: 'team@defra.gov.uk',\n submissionGuidance: \"Thanks for your submission, we'll be in touch\",\n notificationEmail: config.get('submissionEmailAddress'),\n ...author,\n live: author\n}\n\n/**\n * Return an function rather than the service directly. This is to prevent consumer applications\n * blowing up as they won't have these files on disk. We can defer the execution until when it's\n * needed, i.e. the createServer function of the devtool.\n */\nexport const formsService = async () => {\n // Instantiate the file loader form service\n const loader = new FileFormService()\n\n // Add a Yaml form\n await loader.addForm('src/server/forms/register-as-a-unicorn-breeder.yaml', {\n ...metadata,\n id: '641aeafd-13dd-40fa-9186-001703800efb',\n title: 'Register as a unicorn breeder',\n slug: 'register-as-a-unicorn-breeder'\n })\n\n await loader.addForm('src/server/forms/page-events.yaml', {\n ...metadata,\n id: '511db05e-ebbd-42e8-8270-5fe93f5c9762',\n title: 'Page events demo',\n slug: 'page-events-demo'\n })\n\n await loader.addForm('src/server/forms/components.json', {\n ...metadata,\n id: '6a872d3b-13f9e-804ce3e-4830-5c45fb32',\n title: 'Components',\n slug: 'components'\n })\n\n return loader.toFormsService()\n}\n"],"mappings":"AAAA,SAASA,MAAM;AACf,SAASC,eAAe;;AAExB;AACA,MAAMC,GAAG,GAAG,IAAIC,IAAI,CAAC,CAAC;AACtB,MAAMC,IAAI,GAAG;EAAEC,EAAE,EAAE,MAAM;EAAEC,WAAW,EAAE;AAAW,CAAC;AACpD,MAAMC,MAAM,GAAG;EACbC,SAAS,EAAEN,GAAG;EACdO,SAAS,EAAEL,IAAI;EACfM,SAAS,EAAER,GAAG;EACdS,SAAS,EAAEP;AACb,CAAC;AACD,MAAMQ,QAAQ,GAAG;EACfC,YAAY,EAAE,OAAO;EACrBC,QAAQ,EAAE,WAAW;EACrBC,SAAS,EAAE,mBAAmB;EAC9BC,kBAAkB,EAAE,+CAA+C;EACnEC,iBAAiB,EAAEjB,MAAM,CAACkB,GAAG,CAAC,wBAAwB,CAAC;EACvD,GAAGX,MAAM;EACTY,IAAI,EAAEZ;AACR,CAAC;;AAED;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMa,YAAY,GAAG,MAAAA,CAAA,KAAY;EACtC;EACA,MAAMC,MAAM,GAAG,IAAIpB,eAAe,CAAC,CAAC;;EAEpC;EACA,MAAMoB,MAAM,CAACC,OAAO,CAAC,qDAAqD,EAAE;IAC1E,GAAGV,QAAQ;IACXP,EAAE,EAAE,sCAAsC;IAC1CkB,KAAK,EAAE,+BAA+B;IACtCC,IAAI,EAAE;EACR,CAAC,CAAC;EAEF,MAAMH,MAAM,CAACC,OAAO,CAAC,mCAAmC,EAAE;IACxD,GAAGV,QAAQ;IACXP,EAAE,EAAE,sCAAsC;IAC1CkB,KAAK,EAAE,kBAAkB;IACzBC,IAAI,EAAE;EACR,CAAC,CAAC;EAEF,MAAMH,MAAM,CAACC,OAAO,CAAC,kCAAkC,EAAE;IACvD,GAAGV,QAAQ;IACXP,EAAE,EAAE,sCAAsC;IAC1CkB,KAAK,EAAE,YAAY;IACnBC,IAAI,EAAE;EACR,CAAC,CAAC;EAEF,OAAOH,MAAM,CAACI,cAAc,CAAC,CAAC;AAChC,CAAC","ignoreList":[]}
1
+ {"version":3,"file":"localFormsService.js","names":["config","FileFormService","now","Date","user","id","displayName","author","createdAt","createdBy","updatedAt","updatedBy","metadata","organisation","teamName","teamEmail","submissionGuidance","notificationEmail","get","live","formsService","loader","addForm","title","slug","toFormsService"],"sources":["../../../../../src/server/plugins/engine/services/localFormsService.js"],"sourcesContent":["import { config } from '~/src/config/index.js'\nimport { FileFormService } from '~/src/server/utils/file-form-service.js'\n\n// Create shared form metadata\nconst now = new Date()\nconst user = { id: 'user', displayName: 'Username' }\nconst author = {\n createdAt: now,\n createdBy: user,\n updatedAt: now,\n updatedBy: user\n}\nconst metadata = {\n organisation: 'Defra',\n teamName: 'Team name',\n teamEmail: 'team@defra.gov.uk',\n submissionGuidance: \"Thanks for your submission, we'll be in touch\",\n notificationEmail: config.get('submissionEmailAddress'),\n ...author,\n live: author\n}\n\n/**\n * Return an function rather than the service directly. This is to prevent consumer applications\n * blowing up as they won't have these files on disk. We can defer the execution until when it's\n * needed, i.e. the createServer function of the devtool.\n */\nexport const formsService = async () => {\n // Instantiate the file loader form service\n const loader = new FileFormService()\n\n // Add a Yaml form\n await loader.addForm('src/server/forms/register-as-a-unicorn-breeder.yaml', {\n ...metadata,\n id: '641aeafd-13dd-40fa-9186-001703800efb',\n title: 'Register as a unicorn breeder',\n slug: 'register-as-a-unicorn-breeder'\n })\n\n await loader.addForm('src/server/forms/page-events.yaml', {\n ...metadata,\n id: '511db05e-ebbd-42e8-8270-5fe93f5c9762',\n title: 'Page events demo',\n slug: 'page-events-demo'\n })\n\n await loader.addForm('src/server/forms/components.json', {\n ...metadata,\n id: '6a872d3b-13f9e-804ce3e-4830-5c45fb32',\n title: 'Components',\n slug: 'components'\n })\n\n await loader.addForm('src/server/forms/simple-form.yaml', {\n ...metadata,\n id: 'a1b2c3d4-e5f6-7890-abcd-ef0123456789',\n title: 'Simple Form',\n slug: 'simple-form'\n })\n\n return loader.toFormsService()\n}\n"],"mappings":"AAAA,SAASA,MAAM;AACf,SAASC,eAAe;;AAExB;AACA,MAAMC,GAAG,GAAG,IAAIC,IAAI,CAAC,CAAC;AACtB,MAAMC,IAAI,GAAG;EAAEC,EAAE,EAAE,MAAM;EAAEC,WAAW,EAAE;AAAW,CAAC;AACpD,MAAMC,MAAM,GAAG;EACbC,SAAS,EAAEN,GAAG;EACdO,SAAS,EAAEL,IAAI;EACfM,SAAS,EAAER,GAAG;EACdS,SAAS,EAAEP;AACb,CAAC;AACD,MAAMQ,QAAQ,GAAG;EACfC,YAAY,EAAE,OAAO;EACrBC,QAAQ,EAAE,WAAW;EACrBC,SAAS,EAAE,mBAAmB;EAC9BC,kBAAkB,EAAE,+CAA+C;EACnEC,iBAAiB,EAAEjB,MAAM,CAACkB,GAAG,CAAC,wBAAwB,CAAC;EACvD,GAAGX,MAAM;EACTY,IAAI,EAAEZ;AACR,CAAC;;AAED;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMa,YAAY,GAAG,MAAAA,CAAA,KAAY;EACtC;EACA,MAAMC,MAAM,GAAG,IAAIpB,eAAe,CAAC,CAAC;;EAEpC;EACA,MAAMoB,MAAM,CAACC,OAAO,CAAC,qDAAqD,EAAE;IAC1E,GAAGV,QAAQ;IACXP,EAAE,EAAE,sCAAsC;IAC1CkB,KAAK,EAAE,+BAA+B;IACtCC,IAAI,EAAE;EACR,CAAC,CAAC;EAEF,MAAMH,MAAM,CAACC,OAAO,CAAC,mCAAmC,EAAE;IACxD,GAAGV,QAAQ;IACXP,EAAE,EAAE,sCAAsC;IAC1CkB,KAAK,EAAE,kBAAkB;IACzBC,IAAI,EAAE;EACR,CAAC,CAAC;EAEF,MAAMH,MAAM,CAACC,OAAO,CAAC,kCAAkC,EAAE;IACvD,GAAGV,QAAQ;IACXP,EAAE,EAAE,sCAAsC;IAC1CkB,KAAK,EAAE,YAAY;IACnBC,IAAI,EAAE;EACR,CAAC,CAAC;EAEF,MAAMH,MAAM,CAACC,OAAO,CAAC,mCAAmC,EAAE;IACxD,GAAGV,QAAQ;IACXP,EAAE,EAAE,sCAAsC;IAC1CkB,KAAK,EAAE,aAAa;IACpBC,IAAI,EAAE;EACR,CAAC,CAAC;EAEF,OAAOH,MAAM,CAACI,cAAc,CAAC,CAAC;AAChC,CAAC","ignoreList":[]}
@@ -10,7 +10,7 @@
10
10
  {% include "partials/preview-banner.html" %}
11
11
  {% endif %}
12
12
 
13
- {% if errors %}
13
+ {% if errors | length %}
14
14
  {{ govukErrorSummary({
15
15
  titleText: "There is a problem",
16
16
  errorList: checkErrorTemplates(errors)
@@ -79,6 +79,10 @@ describe('Nunjucks context', () => {
79
79
  path: '/test',
80
80
  url: {
81
81
  search: ''
82
+ },
83
+ yar: {
84
+ flash: jest.fn().mockReturnValue([]),
85
+ commit: jest.fn()
82
86
  }
83
87
  // state intentionally omitted to test real malformed requests
84
88
  };
@@ -112,7 +116,11 @@ describe('Nunjucks context', () => {
112
116
  url: {
113
117
  search: ''
114
118
  },
115
- state: {}
119
+ state: {},
120
+ yar: {
121
+ flash: jest.fn().mockReturnValue([]),
122
+ commit: jest.fn()
123
+ }
116
124
  };
117
125
  const {
118
126
  crumb
@@ -1 +1 @@
1
- {"version":3,"file":"context.test.js","names":["tmpdir","context","devtoolContext","describe","beforeEach","jest","resetModules","it","assetPath","expect","toBe","getDxtAssetPath","isolateModulesAsync","config","set","rejects","toThrow","malformedRequest","server","plugins","crumb","generate","fn","baseLayoutPath","route","settings","path","url","search","toBeUndefined","not","toHaveBeenCalled","mockCrumb","validRequest","mockReturnValue","state","toHaveBeenCalledWith"],"sources":["../../../../src/server/plugins/nunjucks/context.test.js"],"sourcesContent":["import { tmpdir } from 'node:os'\n\nimport {\n context,\n devtoolContext\n} from '~/src/server/plugins/nunjucks/context.js'\n\ndescribe('Nunjucks context', () => {\n beforeEach(() => jest.resetModules())\n\n describe('Asset path', () => {\n it(\"should include 'assetPath' for GOV.UK Frontend icons\", () => {\n const { assetPath } = devtoolContext(null)\n expect(assetPath).toBe('/assets')\n })\n })\n\n describe('Asset helper', () => {\n it(\"should locate 'assets-manifest.json' assets\", () => {\n const { getDxtAssetPath } = devtoolContext(null)\n\n expect(getDxtAssetPath('example.scss')).toBe(\n '/stylesheets/example.xxxxxxx.min.css'\n )\n\n expect(getDxtAssetPath('example.mjs')).toBe(\n '/javascripts/example.xxxxxxx.min.js'\n )\n })\n\n it(\"should return path when 'assets-manifest.json' is missing\", async () => {\n await jest.isolateModulesAsync(async () => {\n const { config } = await import('~/src/config/index.js')\n\n // Import when isolated to avoid cache\n const { devtoolContext } = await import(\n '~/src/server/plugins/nunjucks/context.js'\n )\n\n // Update config for missing manifest\n config.set('publicDir', tmpdir())\n const { getDxtAssetPath } = devtoolContext(null)\n\n // Uses original paths when missing\n expect(getDxtAssetPath('example.scss')).toBe('/example.scss')\n expect(getDxtAssetPath('example.mjs')).toBe('/example.mjs')\n })\n })\n\n it('should return path to unknown assets', () => {\n const { getDxtAssetPath } = devtoolContext(null)\n\n expect(getDxtAssetPath('')).toBe('/')\n expect(getDxtAssetPath('example.jpg')).toBe('/example.jpg')\n expect(getDxtAssetPath('example.gif')).toBe('/example.gif')\n })\n })\n\n describe('Config', () => {\n it('should include environment, phase tag and service info', async () => {\n await expect(context(null)).rejects.toThrow(\n 'context called before plugin registered'\n )\n })\n })\n\n describe('Crumb', () => {\n it('should handle malformed requests with missing state', async () => {\n // While state should always exist in a valid Hapi request (it holds cookies),\n // we've seen malformed requests in production where it's missing\n const malformedRequest = /** @type {FormRequest} */ (\n /** @type {unknown} */ ({\n server: {\n plugins: {\n crumb: {\n generate: jest.fn()\n },\n 'forms-engine-plugin': {\n baseLayoutPath: 'randomValue'\n }\n }\n },\n plugins: {},\n route: {\n settings: {\n plugins: {}\n }\n },\n path: '/test',\n url: { search: '' }\n // state intentionally omitted to test real malformed requests\n })\n )\n\n const { crumb } = await context(malformedRequest)\n expect(crumb).toBeUndefined()\n expect(\n malformedRequest.server.plugins.crumb.generate\n ).not.toHaveBeenCalled()\n })\n\n it('should generate crumb when state exists', async () => {\n const mockCrumb = 'generated-crumb-value'\n const validRequest = /** @type {FormRequest} */ (\n /** @type {unknown} */ ({\n server: {\n plugins: {\n crumb: {\n generate: jest.fn().mockReturnValue(mockCrumb)\n },\n 'forms-engine-plugin': {\n baseLayoutPath: 'randomValue'\n }\n }\n },\n plugins: {},\n route: {\n settings: {\n plugins: {}\n }\n },\n path: '/test',\n url: { search: '' },\n state: {}\n })\n )\n\n const { crumb } = await context(validRequest)\n expect(crumb).toBe(mockCrumb)\n expect(validRequest.server.plugins.crumb.generate).toHaveBeenCalledWith(\n validRequest\n )\n })\n })\n})\n\n/**\n * @import { FormRequest } from '~/src/server/routes/types.js'\n */\n"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"file":"context.test.js","names":["tmpdir","context","devtoolContext","describe","beforeEach","jest","resetModules","it","assetPath","expect","toBe","getDxtAssetPath","isolateModulesAsync","config","set","rejects","toThrow","malformedRequest","server","plugins","crumb","generate","fn","baseLayoutPath","route","settings","path","url","search","yar","flash","mockReturnValue","commit","toBeUndefined","not","toHaveBeenCalled","mockCrumb","validRequest","state","toHaveBeenCalledWith"],"sources":["../../../../src/server/plugins/nunjucks/context.test.js"],"sourcesContent":["import { tmpdir } from 'node:os'\n\nimport {\n context,\n devtoolContext\n} from '~/src/server/plugins/nunjucks/context.js'\n\ndescribe('Nunjucks context', () => {\n beforeEach(() => jest.resetModules())\n\n describe('Asset path', () => {\n it(\"should include 'assetPath' for GOV.UK Frontend icons\", () => {\n const { assetPath } = devtoolContext(null)\n expect(assetPath).toBe('/assets')\n })\n })\n\n describe('Asset helper', () => {\n it(\"should locate 'assets-manifest.json' assets\", () => {\n const { getDxtAssetPath } = devtoolContext(null)\n\n expect(getDxtAssetPath('example.scss')).toBe(\n '/stylesheets/example.xxxxxxx.min.css'\n )\n\n expect(getDxtAssetPath('example.mjs')).toBe(\n '/javascripts/example.xxxxxxx.min.js'\n )\n })\n\n it(\"should return path when 'assets-manifest.json' is missing\", async () => {\n await jest.isolateModulesAsync(async () => {\n const { config } = await import('~/src/config/index.js')\n\n // Import when isolated to avoid cache\n const { devtoolContext } = await import(\n '~/src/server/plugins/nunjucks/context.js'\n )\n\n // Update config for missing manifest\n config.set('publicDir', tmpdir())\n const { getDxtAssetPath } = devtoolContext(null)\n\n // Uses original paths when missing\n expect(getDxtAssetPath('example.scss')).toBe('/example.scss')\n expect(getDxtAssetPath('example.mjs')).toBe('/example.mjs')\n })\n })\n\n it('should return path to unknown assets', () => {\n const { getDxtAssetPath } = devtoolContext(null)\n\n expect(getDxtAssetPath('')).toBe('/')\n expect(getDxtAssetPath('example.jpg')).toBe('/example.jpg')\n expect(getDxtAssetPath('example.gif')).toBe('/example.gif')\n })\n })\n\n describe('Config', () => {\n it('should include environment, phase tag and service info', async () => {\n await expect(context(null)).rejects.toThrow(\n 'context called before plugin registered'\n )\n })\n })\n\n describe('Crumb', () => {\n it('should handle malformed requests with missing state', async () => {\n // While state should always exist in a valid Hapi request (it holds cookies),\n // we've seen malformed requests in production where it's missing\n const malformedRequest = /** @type {FormRequest} */ (\n /** @type {unknown} */ ({\n server: {\n plugins: {\n crumb: {\n generate: jest.fn()\n },\n 'forms-engine-plugin': {\n baseLayoutPath: 'randomValue'\n }\n }\n },\n plugins: {},\n route: {\n settings: {\n plugins: {}\n }\n },\n path: '/test',\n url: { search: '' },\n yar: {\n flash: jest.fn().mockReturnValue([]),\n commit: jest.fn()\n }\n // state intentionally omitted to test real malformed requests\n })\n )\n\n const { crumb } = await context(malformedRequest)\n expect(crumb).toBeUndefined()\n expect(\n malformedRequest.server.plugins.crumb.generate\n ).not.toHaveBeenCalled()\n })\n\n it('should generate crumb when state exists', async () => {\n const mockCrumb = 'generated-crumb-value'\n const validRequest = /** @type {FormRequest} */ (\n /** @type {unknown} */ ({\n server: {\n plugins: {\n crumb: {\n generate: jest.fn().mockReturnValue(mockCrumb)\n },\n 'forms-engine-plugin': {\n baseLayoutPath: 'randomValue'\n }\n }\n },\n plugins: {},\n route: {\n settings: {\n plugins: {}\n }\n },\n path: '/test',\n url: { search: '' },\n state: {},\n yar: {\n flash: jest.fn().mockReturnValue([]),\n commit: jest.fn()\n }\n })\n )\n\n const { crumb } = await context(validRequest)\n expect(crumb).toBe(mockCrumb)\n expect(validRequest.server.plugins.crumb.generate).toHaveBeenCalledWith(\n validRequest\n )\n })\n })\n})\n\n/**\n * @import { FormRequest } from '~/src/server/routes/types.js'\n */\n"],"mappings":"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","ignoreList":[]}
@@ -42,6 +42,10 @@ export type ViewContext = {
42
42
  * - Form slug
43
43
  */
44
44
  slug?: string | undefined;
45
+ /**
46
+ * - Error message for temporary error messages (not related to form state)
47
+ */
48
+ error?: string | undefined;
45
49
  /**
46
50
  * - the current form context
47
51
  */
@@ -17,6 +17,7 @@
17
17
  * @property {string} [currentPath] - Current path
18
18
  * @property {string} [previewMode] - Preview mode
19
19
  * @property {string} [slug] - Form slug
20
+ * @property {string} [error] - Error message for temporary error messages (not related to form state)
20
21
  * @property {FormContext} [context] - the current form context
21
22
  */
22
23
 
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","names":[],"sources":["../../../../src/server/plugins/nunjucks/types.js"],"sourcesContent":["/**\n * @typedef {object} MacroOptions\n * @property {string} [callBlock] - Nunjucks call block content\n * @property {object} [params] - Nunjucks macro params\n */\n\n/**\n * @typedef {object} RenderOptions\n * @property {object} [context] - Nunjucks render context\n */\n\n/**\n * @typedef {object} ViewContext - Nunjucks view context\n * @property {string} [baseLayoutPath] - Base layout path\n * @property {string} [crumb] - Cross-Site Request Forgery (CSRF) token\n * @property {string} [cspNonce] - Content Security Policy (CSP) nonce\n * @property {string} [currentPath] - Current path\n * @property {string} [previewMode] - Preview mode\n * @property {string} [slug] - Form slug\n * @property {FormContext} [context] - the current form context\n */\n\n/**\n * @typedef NunjucksContext\n * @property {ViewContext} ctx - the current nunjucks view context\n */\n\n/**\n * @import { FormContext } from '~/src/server/plugins/engine/types.js'\n */\n"],"mappings":"AAAA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA","ignoreList":[]}
1
+ {"version":3,"file":"types.js","names":[],"sources":["../../../../src/server/plugins/nunjucks/types.js"],"sourcesContent":["/**\n * @typedef {object} MacroOptions\n * @property {string} [callBlock] - Nunjucks call block content\n * @property {object} [params] - Nunjucks macro params\n */\n\n/**\n * @typedef {object} RenderOptions\n * @property {object} [context] - Nunjucks render context\n */\n\n/**\n * @typedef {object} ViewContext - Nunjucks view context\n * @property {string} [baseLayoutPath] - Base layout path\n * @property {string} [crumb] - Cross-Site Request Forgery (CSRF) token\n * @property {string} [cspNonce] - Content Security Policy (CSP) nonce\n * @property {string} [currentPath] - Current path\n * @property {string} [previewMode] - Preview mode\n * @property {string} [slug] - Form slug\n * @property {string} [error] - Error message for temporary error messages (not related to form state)\n * @property {FormContext} [context] - the current form context\n */\n\n/**\n * @typedef NunjucksContext\n * @property {ViewContext} ctx - the current nunjucks view context\n */\n\n/**\n * @import { FormContext } from '~/src/server/plugins/engine/types.js'\n */\n"],"mappings":"AAAA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA","ignoreList":[]}
@@ -27,6 +27,7 @@ export declare class CacheService {
27
27
  setFlash(request: AnyFormRequest, message: {
28
28
  errors: FormSubmissionError[];
29
29
  }): void;
30
+ resetComponentStates(request: AnyFormRequest, componentNames: string[]): Promise<FormSubmissionState>;
30
31
  /**
31
32
  * The key used to store user session data against.
32
33
  * If there are multiple forms on the same runner instance, for example `form-a` and `form-a-feedback` this will prevent CacheService from clearing data from `form-a` if a user gave feedback before they finished `form-a`
@@ -61,6 +61,16 @@ export class CacheService {
61
61
  const key = this.Key(request);
62
62
  request.yar.flash(key.id, message);
63
63
  }
64
+ async resetComponentStates(request, componentNames) {
65
+ const state = await this.getState(request);
66
+ for (const componentName of componentNames) {
67
+ if (componentName in state) {
68
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
69
+ delete state[componentName];
70
+ }
71
+ }
72
+ return this.setState(request, state);
73
+ }
64
74
 
65
75
  /**
66
76
  * The key used to store user session data against.
@@ -1 +1 @@
1
- {"version":3,"file":"cacheService.js","names":["Hoek","config","partition","ADDITIONAL_IDENTIFIER","CacheService","cache","logger","constructor","server","cacheName","log","segment","getState","request","key","Key","cached","get","setState","state","ttl","set","getConfirmationState","Confirmation","value","setConfirmationState","confirmationState","clearState","yar","id","drop","getFlash","messages","flash","Array","isArray","length","at","setFlash","message","additionalIdentifier","Error","params","slug","merge","update","mergeArrays"],"sources":["../../../src/server/services/cacheService.ts"],"sourcesContent":["import { type Server } from '@hapi/hapi'\nimport * as Hoek from '@hapi/hoek'\n\nimport { config } from '~/src/config/index.js'\nimport { type createServer } from '~/src/server/index.js'\nimport {\n type AnyFormRequest,\n type AnyRequest,\n type FormConfirmationState,\n type FormPayload,\n type FormState,\n type FormSubmissionError,\n type FormSubmissionState\n} from '~/src/server/plugins/engine/types.js'\n\nconst partition = 'cache'\n\nexport enum ADDITIONAL_IDENTIFIER {\n Confirmation = ':confirmation'\n}\n\nexport class CacheService {\n /**\n * This service is responsible for getting, storing or deleting a user's session data in the cache. This service has been registered by {@link createServer}\n */\n cache\n logger: Server['logger']\n\n constructor({ server, cacheName }: { server: Server; cacheName?: string }) {\n if (!cacheName) {\n server.log(\n 'warn',\n 'You are using the default hapi cache. Please provide a cache name in plugin registration options.'\n )\n }\n\n this.cache = server.cache({ cache: cacheName, segment: 'formSubmission' })\n this.logger = server.logger\n }\n\n async getState(request: AnyRequest): Promise<FormSubmissionState> {\n const key = this.Key(request)\n const cached = await this.cache.get(key)\n\n return cached ?? {}\n }\n\n async setState(request: AnyFormRequest, state: FormSubmissionState) {\n const key = this.Key(request)\n const ttl = config.get('sessionTimeout')\n\n await this.cache.set(key, state, ttl)\n\n return this.getState(request)\n }\n\n async getConfirmationState(\n request: AnyFormRequest\n ): Promise<FormConfirmationState> {\n const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation)\n const value = await this.cache.get(key)\n\n return value ?? {}\n }\n\n async setConfirmationState(\n request: AnyFormRequest,\n confirmationState: FormConfirmationState\n ) {\n const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation)\n const ttl = config.get('confirmationSessionTimeout')\n\n return this.cache.set(key, confirmationState, ttl)\n }\n\n async clearState(request: AnyFormRequest) {\n if (request.yar.id) {\n await this.cache.drop(this.Key(request))\n }\n }\n\n getFlash(\n request: AnyFormRequest\n ): { errors: FormSubmissionError[] } | undefined {\n const key = this.Key(request)\n const messages = request.yar.flash(key.id)\n\n if (Array.isArray(messages) && messages.length) {\n return messages.at(0)\n }\n }\n\n setFlash(\n request: AnyFormRequest,\n message: { errors: FormSubmissionError[] }\n ) {\n const key = this.Key(request)\n\n request.yar.flash(key.id, message)\n }\n\n /**\n * The key used to store user session data against.\n * If there are multiple forms on the same runner instance, for example `form-a` and `form-a-feedback` this will prevent CacheService from clearing data from `form-a` if a user gave feedback before they finished `form-a`\n * @param request - hapi request object\n * @param additionalIdentifier - appended to the id\n */\n Key(request: AnyRequest, additionalIdentifier?: ADDITIONAL_IDENTIFIER) {\n if (!request.yar.id) {\n throw new Error('No session ID found')\n }\n\n const state = (request.params.state as string) || ''\n const slug = (request.params.slug as string) || ''\n const key = `${request.yar.id}:${state}:${slug}:`\n\n return {\n segment: partition,\n id: `${key}${additionalIdentifier ?? ''}`\n }\n }\n}\n\n/**\n * State merge helper\n * 1. Merges objects (form fields)\n * 2. Overwrites arrays\n */\nexport function merge<StateType extends FormState | FormPayload>(\n state: StateType,\n update: object\n): StateType {\n return Hoek.merge(state, update, {\n mergeArrays: false\n })\n}\n"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"file":"cacheService.js","names":["Hoek","config","partition","ADDITIONAL_IDENTIFIER","CacheService","cache","logger","constructor","server","cacheName","log","segment","getState","request","key","Key","cached","get","setState","state","ttl","set","getConfirmationState","Confirmation","value","setConfirmationState","confirmationState","clearState","yar","id","drop","getFlash","messages","flash","Array","isArray","length","at","setFlash","message","resetComponentStates","componentNames","componentName","additionalIdentifier","Error","params","slug","merge","update","mergeArrays"],"sources":["../../../src/server/services/cacheService.ts"],"sourcesContent":["import { type Server } from '@hapi/hapi'\nimport * as Hoek from '@hapi/hoek'\n\nimport { config } from '~/src/config/index.js'\nimport { type createServer } from '~/src/server/index.js'\nimport {\n type AnyFormRequest,\n type AnyRequest,\n type FormConfirmationState,\n type FormPayload,\n type FormState,\n type FormSubmissionError,\n type FormSubmissionState\n} from '~/src/server/plugins/engine/types.js'\n\nconst partition = 'cache'\n\nexport enum ADDITIONAL_IDENTIFIER {\n Confirmation = ':confirmation'\n}\n\nexport class CacheService {\n /**\n * This service is responsible for getting, storing or deleting a user's session data in the cache. This service has been registered by {@link createServer}\n */\n cache\n logger: Server['logger']\n\n constructor({ server, cacheName }: { server: Server; cacheName?: string }) {\n if (!cacheName) {\n server.log(\n 'warn',\n 'You are using the default hapi cache. Please provide a cache name in plugin registration options.'\n )\n }\n\n this.cache = server.cache({ cache: cacheName, segment: 'formSubmission' })\n this.logger = server.logger\n }\n\n async getState(request: AnyRequest): Promise<FormSubmissionState> {\n const key = this.Key(request)\n const cached = await this.cache.get(key)\n\n return cached ?? {}\n }\n\n async setState(request: AnyFormRequest, state: FormSubmissionState) {\n const key = this.Key(request)\n const ttl = config.get('sessionTimeout')\n\n await this.cache.set(key, state, ttl)\n\n return this.getState(request)\n }\n\n async getConfirmationState(\n request: AnyFormRequest\n ): Promise<FormConfirmationState> {\n const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation)\n const value = await this.cache.get(key)\n\n return value ?? {}\n }\n\n async setConfirmationState(\n request: AnyFormRequest,\n confirmationState: FormConfirmationState\n ) {\n const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation)\n const ttl = config.get('confirmationSessionTimeout')\n\n return this.cache.set(key, confirmationState, ttl)\n }\n\n async clearState(request: AnyFormRequest) {\n if (request.yar.id) {\n await this.cache.drop(this.Key(request))\n }\n }\n\n getFlash(\n request: AnyFormRequest\n ): { errors: FormSubmissionError[] } | undefined {\n const key = this.Key(request)\n const messages = request.yar.flash(key.id)\n\n if (Array.isArray(messages) && messages.length) {\n return messages.at(0)\n }\n }\n\n setFlash(\n request: AnyFormRequest,\n message: { errors: FormSubmissionError[] }\n ) {\n const key = this.Key(request)\n\n request.yar.flash(key.id, message)\n }\n\n async resetComponentStates(\n request: AnyFormRequest,\n componentNames: string[]\n ) {\n const state = await this.getState(request)\n\n for (const componentName of componentNames) {\n if (componentName in state) {\n // eslint-disable-next-line @typescript-eslint/no-dynamic-delete\n delete state[componentName]\n }\n }\n\n return this.setState(request, state)\n }\n\n /**\n * The key used to store user session data against.\n * If there are multiple forms on the same runner instance, for example `form-a` and `form-a-feedback` this will prevent CacheService from clearing data from `form-a` if a user gave feedback before they finished `form-a`\n * @param request - hapi request object\n * @param additionalIdentifier - appended to the id\n */\n Key(request: AnyRequest, additionalIdentifier?: ADDITIONAL_IDENTIFIER) {\n if (!request.yar.id) {\n throw new Error('No session ID found')\n }\n\n const state = (request.params.state as string) || ''\n const slug = (request.params.slug as string) || ''\n const key = `${request.yar.id}:${state}:${slug}:`\n\n return {\n segment: partition,\n id: `${key}${additionalIdentifier ?? ''}`\n }\n }\n}\n\n/**\n * State merge helper\n * 1. Merges objects (form fields)\n * 2. Overwrites arrays\n */\nexport function merge<StateType extends FormState | FormPayload>(\n state: StateType,\n update: object\n): StateType {\n return Hoek.merge(state, update, {\n mergeArrays: false\n })\n}\n"],"mappings":"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","ignoreList":[]}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.js","names":[],"sources":["../../../src/typings/hapi/index.d.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/unified-signatures */\n\nimport { type Plugin } from '@hapi/hapi'\nimport { type ServerYar, type Yar } from '@hapi/yar'\nimport { type Logger } from 'pino'\n\nimport {\n type EXTERNAL_STATE_APPENDAGE,\n type EXTERNAL_STATE_PAYLOAD\n} from '~/src/server/constants.js'\nimport { type FormModel } from '~/src/server/plugins/engine/models/index.js'\nimport {\n type AnyFormRequest,\n type FormSubmissionError,\n type PluginOptions\n} from '~/src/server/plugins/engine/types.ts'\nimport { type CacheService } from '~/src/server/services/index.js'\n\ndeclare module '@hapi/yar' {\n interface YarFlashes {\n [EXTERNAL_STATE_APPENDAGE]: object\n [EXTERNAL_STATE_PAYLOAD]: object\n [key: string]: { errors: FormSubmissionError[] }\n }\n}\n\ndeclare module '@hapi/hapi' {\n // Here we are decorating Hapi interface types with\n // props from plugins which doesn't export @types\n interface PluginProperties {\n crumb: {\n generate?: (request: AnyRequest) => string\n }\n 'forms-engine-plugin': {\n baseLayoutPath: string\n cacheService: CacheService\n viewContext?: (\n request: AnyFormRequest | null\n ) => Record<string, unknown> | Promise<Record<string, unknown>>\n saveAndExit?: PluginOptions['saveAndExit']\n }\n }\n\n interface Request {\n logger: Logger\n yar: Yar\n }\n\n interface RequestApplicationState {\n model?: FormModel\n }\n\n interface Server {\n logger: Logger\n yar: ServerYar\n }\n\n interface ServerApplicationState {\n model?: FormModel\n models: Map<string, { model: FormModel; updatedAt: Date }>\n }\n}\n\ndeclare module 'blankie' {\n declare const blankie: {\n plugin: Plugin<Record<string, boolean | string | string[]>>\n }\n\n export = blankie\n}\n\ndeclare module 'blipp' {\n declare const blipp: {\n plugin: Plugin\n }\n\n export = blipp\n}\n\ndeclare module 'hapi-pulse' {\n declare const hapiPulse: {\n plugin: Plugin<{\n timeout: number\n }>\n }\n\n export = hapiPulse\n}\n"],"mappings":"","ignoreList":[]}
1
+ {"version":3,"file":"index.d.js","names":[],"sources":["../../../src/typings/hapi/index.d.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/unified-signatures */\n\nimport { type Plugin } from '@hapi/hapi'\nimport { type ServerYar, type Yar } from '@hapi/yar'\nimport { type Logger } from 'pino'\n\nimport {\n type COMPONENT_STATE_ERROR,\n type EXTERNAL_STATE_APPENDAGE,\n type EXTERNAL_STATE_PAYLOAD\n} from '~/src/server/constants.js'\nimport { type FormModel } from '~/src/server/plugins/engine/models/index.js'\nimport {\n type AnyFormRequest,\n type FormSubmissionError,\n type PluginOptions\n} from '~/src/server/plugins/engine/types.ts'\nimport { type CacheService } from '~/src/server/services/index.js'\n\ndeclare module '@hapi/yar' {\n interface YarFlashes {\n [EXTERNAL_STATE_APPENDAGE]: object\n [EXTERNAL_STATE_PAYLOAD]: object\n [COMPONENT_STATE_ERROR]: string\n [key: string]: { errors: FormSubmissionError[] }\n }\n}\n\ndeclare module '@hapi/hapi' {\n // Here we are decorating Hapi interface types with\n // props from plugins which doesn't export @types\n interface PluginProperties {\n crumb: {\n generate?: (request: AnyRequest) => string\n }\n 'forms-engine-plugin': {\n baseLayoutPath: string\n cacheService: CacheService\n viewContext?: (\n request: AnyFormRequest | null\n ) => Record<string, unknown> | Promise<Record<string, unknown>>\n saveAndExit?: PluginOptions['saveAndExit']\n }\n }\n\n interface Request {\n logger: Logger\n yar: Yar\n }\n\n interface RequestApplicationState {\n model?: FormModel\n }\n\n interface Server {\n logger: Logger\n yar: ServerYar\n }\n\n interface ServerApplicationState {\n model?: FormModel\n models: Map<string, { model: FormModel; updatedAt: Date }>\n }\n}\n\ndeclare module 'blankie' {\n declare const blankie: {\n plugin: Plugin<Record<string, boolean | string | string[]>>\n }\n\n export = blankie\n}\n\ndeclare module 'blipp' {\n declare const blipp: {\n plugin: Plugin\n }\n\n export = blipp\n}\n\ndeclare module 'hapi-pulse' {\n declare const hapiPulse: {\n plugin: Plugin<{\n timeout: number\n }>\n }\n\n export = hapiPulse\n}\n"],"mappings":"","ignoreList":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/forms-engine-plugin",
3
- "version": "4.0.33",
3
+ "version": "4.0.35",
4
4
  "description": "Defra forms engine",
5
5
  "type": "module",
6
6
  "files": [
@@ -2,3 +2,4 @@ export const PREVIEW_PATH_PREFIX = '/preview'
2
2
  export const FORM_PREFIX = ''
3
3
  export const EXTERNAL_STATE_PAYLOAD = 'EXTERNAL_STATE_PAYLOAD'
4
4
  export const EXTERNAL_STATE_APPENDAGE = 'EXTERNAL_STATE_APPENDAGE'
5
+ export const COMPONENT_STATE_ERROR = 'COMPONENT_STATE_ERROR'
@@ -306,5 +306,4 @@ lists:
306
306
  value: Aquatic
307
307
  - text: Rainbow
308
308
  value: Rainbow
309
- outputEmail: defraforms@defra.gov.uk
310
309
  startPage: '/whats-your-name'
@@ -0,0 +1,64 @@
1
+ ---
2
+ name: Page events
3
+ engine: V2
4
+ schema: 2
5
+ startPage: '/summary'
6
+ pages:
7
+ - title: Your name
8
+ path: '/your-name'
9
+ components:
10
+ - type: TextField
11
+ title: What is your first name?
12
+ name: applicantFirstName
13
+ shortDescription: Your first name
14
+ hint: ''
15
+ options:
16
+ required: true
17
+ schema: {}
18
+ id: 1fb8e182-c709-4792-8f83-e01d8b1fee1a
19
+ - type: TextField
20
+ title: What is your last name?
21
+ name: applicantLastName
22
+ shortDescription: Your last name
23
+ hint: ''
24
+ options:
25
+ required: true
26
+ schema: {}
27
+ id: b68df7f1-d4f4-4c17-83c8-402f584906c9
28
+ next: []
29
+ id: 622a35ec-3795-418a-81f3-a45746959045
30
+ - title: Upload a copy of your passport
31
+ controller: FileUploadPageController
32
+ path: '/upload-passport'
33
+ components:
34
+ - type: FileUploadField
35
+ title: Please upload a copy of your passport
36
+ name: passportUpload
37
+ shortDescription: Upload passport
38
+ hint: ''
39
+ options:
40
+ required: true
41
+ schema: {}
42
+ id: 987c1234-56d7-89e0-1234-56789abcdef0
43
+ id: 23456789-0abc-def1-2345-67890abcdef1
44
+ - title: ''
45
+ path: '/date-of-birth'
46
+ components:
47
+ - type: DatePartsField
48
+ title: When is {{ applicantFirstName }} {{ applicantLastName }}'s birthday?
49
+ name: dateOfBirth
50
+ shortDescription: Your birthday
51
+ hint: ''
52
+ options:
53
+ required: true
54
+ schema: {}
55
+ id: '00738799-3489-4ab2-a57b-542eecb31bfa'
56
+ next: []
57
+ id: da0fbdb4-a2de-4650-be16-9ba552af135f
58
+ - id: 449a45f6-4541-4a46-91bd-8b8931b07b50
59
+ title: ''
60
+ path: '/summary'
61
+ controller: SummaryPageController
62
+ conditions: []
63
+ sections: []
64
+ lists: []
@@ -209,11 +209,12 @@ describe('getFormModel helper', () => {
209
209
 
210
210
  describe('resolveFormModel helper', () => {
211
211
  const slug = 'tb-origin'
212
- const definition = { pages: [], outputEmail: 'fallback@example.com' }
212
+ const definition = { pages: [] }
213
213
  const metadata = {
214
214
  id: 'metadata-123',
215
215
  live: { updatedAt: new Date('2024-10-15T10:00:00Z') },
216
- versions: [{ versionNumber: 9 }]
216
+ versions: [{ versionNumber: 9 }],
217
+ notificationEmail: 'enrique.chase@defra.gov.uk'
217
218
  }
218
219
  let server: Request['server']
219
220
  let formModelInstance: { id: string }
@@ -265,7 +266,7 @@ describe('resolveFormModel helper', () => {
265
266
  expect(FormModel).toHaveBeenCalledTimes(2)
266
267
  expect(mockFormsService.getFormDefinition).toHaveBeenCalledTimes(2)
267
268
  expect(mockCheckEmailAddressForLiveFormSubmission).toHaveBeenCalledWith(
268
- definition.outputEmail,
269
+ undefined,
269
270
  true
270
271
  )
271
272
  expect(FormModel).toHaveBeenCalledWith(
@@ -174,9 +174,10 @@ export async function resolveFormModel(
174
174
  )
175
175
  }
176
176
 
177
- const emailAddress = metadata.notificationEmail ?? definition.outputEmail
178
-
179
- checkEmailAddressForLiveFormSubmission(emailAddress, isPreview)
177
+ checkEmailAddressForLiveFormSubmission(
178
+ metadata.notificationEmail,
179
+ isPreview
180
+ )
180
181
 
181
182
  const routePrefix =
182
183
  options.routePrefix ?? server.realm.modifiers.route.prefix
@@ -1,25 +1,34 @@
1
1
  import {
2
2
  ComponentType,
3
- type FileUploadFieldComponent
3
+ type FileUploadFieldComponent,
4
+ type FormMetadata
4
5
  } from '@defra/forms-model'
6
+ import Boom from '@hapi/boom'
5
7
 
6
8
  import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
7
- import { tempItemSchema } from '~/src/server/plugins/engine/components/FileUploadField.js'
9
+ import {
10
+ FileUploadField,
11
+ tempItemSchema
12
+ } from '~/src/server/plugins/engine/components/FileUploadField.js'
8
13
  import {
9
14
  getAnswer,
10
15
  type Field
11
16
  } from '~/src/server/plugins/engine/components/helpers/components.js'
12
17
  import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
18
+ import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js'
13
19
  import {
14
20
  createPage,
15
21
  type PageControllerClass
16
22
  } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
17
23
  import { validationOptions as opts } from '~/src/server/plugins/engine/pageControllers/validationOptions.js'
24
+ import { type Services } from '~/src/server/plugins/engine/types/index.js'
18
25
  import {
19
26
  FileStatus,
20
27
  UploadStatus,
28
+ type FormContext,
21
29
  type UploadState
22
30
  } from '~/src/server/plugins/engine/types.js'
31
+ import { type FormRequestPayload } from '~/src/server/routes/types.js'
23
32
  import definition from '~/test/form/definitions/file-upload-basic.js'
24
33
  import { getFormData, getFormState } from '~/test/helpers/component-helpers.js'
25
34
 
@@ -828,4 +837,196 @@ describe('FileUploadField', () => {
828
837
  )
829
838
  })
830
839
  })
840
+
841
+ describe('onSubmit', () => {
842
+ let fileUploadField: FileUploadField
843
+ let mockRequest: FormRequestPayload
844
+ let mockMetadata: FormMetadata
845
+ let mockContext: FormContext
846
+ let mockPersistFiles: jest.Mock
847
+
848
+ beforeEach(() => {
849
+ // Create a FileUploadField instance
850
+ const componentDef: FileUploadFieldComponent = {
851
+ name: 'fileUpload',
852
+ title: 'Upload something',
853
+ type: ComponentType.FileUploadField,
854
+ options: {},
855
+ schema: {}
856
+ }
857
+
858
+ const page = model.pages.find((p) => p.path === '/file-upload-component')
859
+ fileUploadField = new FileUploadField(componentDef, {
860
+ model,
861
+ page
862
+ })
863
+
864
+ // Mock persistFiles
865
+ mockPersistFiles = jest.fn().mockResolvedValue(undefined)
866
+
867
+ // Mock request
868
+ mockRequest = {
869
+ app: {
870
+ model: {
871
+ services: {
872
+ formSubmissionService: {
873
+ persistFiles: mockPersistFiles
874
+ }
875
+ }
876
+ }
877
+ }
878
+ } as unknown as FormRequestPayload
879
+
880
+ // Mock metadata
881
+ mockMetadata = {
882
+ notificationEmail: 'test@example.com'
883
+ } as FormMetadata
884
+
885
+ // Mock context with state
886
+ mockContext = {
887
+ state: {
888
+ fileUpload: validState
889
+ }
890
+ } as unknown as FormContext
891
+ })
892
+
893
+ afterEach(() => {
894
+ jest.clearAllMocks()
895
+ })
896
+
897
+ it('should successfully persist files', async () => {
898
+ await fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
899
+
900
+ expect(mockPersistFiles).toHaveBeenCalledTimes(1)
901
+ expect(mockPersistFiles).toHaveBeenCalledWith(
902
+ [
903
+ {
904
+ fileId: 'fcb4f0f8-6862-4836-86dc-f56ff900b0ff',
905
+ initiatedRetrievalKey: 'enrique.chase@defra.gov.uk'
906
+ },
907
+ {
908
+ fileId: 'e1d6cf98-35a7-4f97-8a28-cdd2b115d8fa',
909
+ initiatedRetrievalKey: 'enrique.chase@defra.gov.uk'
910
+ },
911
+ {
912
+ fileId: '71fb359c-dee7-4c2e-8701-239eb892765a',
913
+ initiatedRetrievalKey: 'enrique.chase@defra.gov.uk'
914
+ }
915
+ ],
916
+ 'test@example.com'
917
+ )
918
+ })
919
+
920
+ it('should fail when notificationEmail is not set', async () => {
921
+ mockMetadata.notificationEmail = undefined
922
+
923
+ await expect(
924
+ fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
925
+ ).rejects.toThrow('Unexpected missing notificationEmail in metadata')
926
+ })
927
+
928
+ it('should fail when notificationEmail is empty string', async () => {
929
+ mockMetadata.notificationEmail = ''
930
+
931
+ await expect(
932
+ fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
933
+ ).rejects.toThrow('Unexpected missing notificationEmail in metadata')
934
+ })
935
+
936
+ it('should not call persistFiles when no files in state', async () => {
937
+ mockContext.state = {}
938
+
939
+ await fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
940
+
941
+ expect(mockPersistFiles).not.toHaveBeenCalled()
942
+ })
943
+
944
+ it('should not call persistFiles when empty array in state', async () => {
945
+ mockContext.state = { fileUpload: [] }
946
+
947
+ await fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
948
+
949
+ expect(mockPersistFiles).not.toHaveBeenCalled()
950
+ })
951
+
952
+ it('should throw Error when formSubmissionService is not available', async () => {
953
+ if (!mockRequest.app.model) {
954
+ throw new Error('Invalid test setup')
955
+ }
956
+
957
+ mockRequest.app.model.services = {} as unknown as Services
958
+
959
+ await expect(
960
+ fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
961
+ ).rejects.toThrow('No form submission service available in app model')
962
+ })
963
+
964
+ it('should throw InvalidComponentStateError when persistFiles throws 403 Forbidden', async () => {
965
+ const forbiddenError = Boom.forbidden('Invalid retrieval key')
966
+ mockPersistFiles.mockRejectedValue(forbiddenError)
967
+
968
+ await expect(
969
+ fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
970
+ ).rejects.toThrow(InvalidComponentStateError)
971
+
972
+ const error = await fileUploadField
973
+ .onSubmit(mockRequest, mockMetadata, mockContext)
974
+ .catch((e: unknown) => e)
975
+
976
+ expect(error).toBeInstanceOf(InvalidComponentStateError)
977
+ expect((error as InvalidComponentStateError).component).toBe(
978
+ fileUploadField
979
+ )
980
+ expect((error as InvalidComponentStateError).userMessage).toBe(
981
+ 'There was a problem with your uploaded files. Re-upload them before submitting the form again.'
982
+ )
983
+ })
984
+
985
+ it('should throw InvalidComponentStateError when persistFiles throws 410 Gone', async () => {
986
+ const goneError = Boom.resourceGone('File has expired')
987
+ mockPersistFiles.mockRejectedValue(goneError)
988
+
989
+ await expect(
990
+ fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
991
+ ).rejects.toThrow(InvalidComponentStateError)
992
+
993
+ const error = await fileUploadField
994
+ .onSubmit(mockRequest, mockMetadata, mockContext)
995
+ .catch((e: unknown) => e)
996
+
997
+ expect(error).toBeInstanceOf(InvalidComponentStateError)
998
+ expect((error as InvalidComponentStateError).component).toBe(
999
+ fileUploadField
1000
+ )
1001
+ expect((error as InvalidComponentStateError).userMessage).toBe(
1002
+ 'There was a problem with your uploaded files. Re-upload them before submitting the form again.'
1003
+ )
1004
+ })
1005
+
1006
+ it('should re-throw other Boom errors without wrapping', async () => {
1007
+ const serverError = Boom.internal('Internal server error')
1008
+ mockPersistFiles.mockRejectedValue(serverError)
1009
+
1010
+ await expect(
1011
+ fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
1012
+ ).rejects.toThrow(serverError)
1013
+
1014
+ await expect(
1015
+ fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
1016
+ ).rejects.not.toThrow(InvalidComponentStateError)
1017
+ })
1018
+
1019
+ it('should re-throw non-Boom errors without wrapping', async () => {
1020
+ const genericError = new Error('Something went wrong')
1021
+ mockPersistFiles.mockRejectedValue(genericError)
1022
+
1023
+ await expect(
1024
+ fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
1025
+ ).rejects.toThrow(genericError)
1026
+
1027
+ await expect(
1028
+ fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
1029
+ ).rejects.not.toThrow(InvalidComponentStateError)
1030
+ })
1031
+ })
831
1032
  })