@defra/forms-engine-plugin 5.0.0-alpha.7 → 5.0.0-alpha.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 (178) hide show
  1. package/.public/javascripts/application.min.js.map +1 -1
  2. package/.public/javascripts/shared.min.js +1 -1
  3. package/.public/javascripts/shared.min.js.map +1 -1
  4. package/.public/stylesheets/application.min.css.map +1 -1
  5. package/.server/client/javascripts/maps.d.ts +5 -0
  6. package/.server/client/javascripts/maps.js +6 -0
  7. package/.server/client/javascripts/maps.js.map +1 -0
  8. package/.server/client/javascripts/shared.d.ts +1 -4
  9. package/.server/client/javascripts/shared.js +1 -4
  10. package/.server/client/javascripts/shared.js.map +1 -1
  11. package/.server/client/stylesheets/maps.scss +4 -0
  12. package/.server/client/stylesheets/shared.scss +1 -5
  13. package/.server/server/constants.d.ts +1 -0
  14. package/.server/server/constants.js +2 -1
  15. package/.server/server/constants.js.map +1 -1
  16. package/.server/server/plugins/engine/components/ComponentCollection.js +10 -6
  17. package/.server/server/plugins/engine/components/ComponentCollection.js.map +1 -1
  18. package/.server/server/plugins/engine/components/DatePartsField.d.ts +4 -2
  19. package/.server/server/plugins/engine/components/DatePartsField.js +14 -11
  20. package/.server/server/plugins/engine/components/DatePartsField.js.map +1 -1
  21. package/.server/server/plugins/engine/components/DeclarationField.d.ts +4 -2
  22. package/.server/server/plugins/engine/components/DeclarationField.js +5 -3
  23. package/.server/server/plugins/engine/components/DeclarationField.js.map +1 -1
  24. package/.server/server/plugins/engine/components/EastingNorthingField.d.ts +24 -2
  25. package/.server/server/plugins/engine/components/EastingNorthingField.js +93 -52
  26. package/.server/server/plugins/engine/components/EastingNorthingField.js.map +1 -1
  27. package/.server/server/plugins/engine/components/FormComponent.d.ts +4 -1
  28. package/.server/server/plugins/engine/components/FormComponent.js +3 -0
  29. package/.server/server/plugins/engine/components/FormComponent.js.map +1 -1
  30. package/.server/server/plugins/engine/components/LatLongField.d.ts +24 -2
  31. package/.server/server/plugins/engine/components/LatLongField.js +85 -46
  32. package/.server/server/plugins/engine/components/LatLongField.js.map +1 -1
  33. package/.server/server/plugins/engine/components/LocationFieldHelpers.d.ts +3 -2
  34. package/.server/server/plugins/engine/components/LocationFieldHelpers.js +10 -11
  35. package/.server/server/plugins/engine/components/LocationFieldHelpers.js.map +1 -1
  36. package/.server/server/plugins/engine/components/MonthYearField.d.ts +4 -2
  37. package/.server/server/plugins/engine/components/MonthYearField.js +16 -13
  38. package/.server/server/plugins/engine/components/MonthYearField.js.map +1 -1
  39. package/.server/server/plugins/engine/components/PaymentField.d.ts +1 -0
  40. package/.server/server/plugins/engine/components/PaymentField.js +9 -5
  41. package/.server/server/plugins/engine/components/PaymentField.js.map +1 -1
  42. package/.server/server/plugins/engine/components/UkAddressField.js +22 -7
  43. package/.server/server/plugins/engine/components/UkAddressField.js.map +1 -1
  44. package/.server/server/plugins/engine/components/YesNoField.d.ts +3 -2
  45. package/.server/server/plugins/engine/components/YesNoField.js +5 -3
  46. package/.server/server/plugins/engine/components/YesNoField.js.map +1 -1
  47. package/.server/server/plugins/engine/components/helpers/components.js +1 -2
  48. package/.server/server/plugins/engine/components/helpers/components.js.map +1 -1
  49. package/.server/server/plugins/engine/components/helpers/index.d.ts +7 -0
  50. package/.server/server/plugins/engine/components/helpers/index.js +13 -1
  51. package/.server/server/plugins/engine/components/helpers/index.js.map +1 -1
  52. package/.server/server/plugins/engine/helpers.d.ts +21 -2
  53. package/.server/server/plugins/engine/helpers.js +43 -5
  54. package/.server/server/plugins/engine/helpers.js.map +1 -1
  55. package/.server/server/plugins/engine/i18n/createFormTranslator.d.ts +4 -4
  56. package/.server/server/plugins/engine/i18n/createFormTranslator.js +27 -5
  57. package/.server/server/plugins/engine/i18n/createFormTranslator.js.map +1 -1
  58. package/.server/server/plugins/engine/i18n/extractBaseTranslations.d.ts +1 -1
  59. package/.server/server/plugins/engine/i18n/extractBaseTranslations.js +9 -0
  60. package/.server/server/plugins/engine/i18n/extractBaseTranslations.js.map +1 -1
  61. package/.server/server/plugins/engine/i18n/form.d.ts +13 -0
  62. package/.server/server/plugins/engine/i18n/form.js +35 -0
  63. package/.server/server/plugins/engine/i18n/form.js.map +1 -0
  64. package/.server/server/plugins/engine/i18n/languages.d.ts +10 -0
  65. package/.server/server/plugins/engine/i18n/languages.js +19 -0
  66. package/.server/server/plugins/engine/i18n/languages.js.map +1 -0
  67. package/.server/server/plugins/engine/i18n/translations/cy.json +3 -1
  68. package/.server/server/plugins/engine/i18n/translations/en-GB.json +3 -1
  69. package/.server/server/plugins/engine/index.d.ts +3 -0
  70. package/.server/server/plugins/engine/index.js +3 -0
  71. package/.server/server/plugins/engine/index.js.map +1 -1
  72. package/.server/server/plugins/engine/models/FormModel.d.ts +0 -4
  73. package/.server/server/plugins/engine/models/FormModel.js +9 -15
  74. package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
  75. package/.server/server/plugins/engine/models/SummaryViewModel.js +2 -1
  76. package/.server/server/plugins/engine/models/SummaryViewModel.js.map +1 -1
  77. package/.server/server/plugins/engine/outputFormatters/human/v1.js +2 -1
  78. package/.server/server/plugins/engine/outputFormatters/human/v1.js.map +1 -1
  79. package/.server/server/plugins/engine/outputFormatters/machine/v2.d.ts +2 -0
  80. package/.server/server/plugins/engine/outputFormatters/machine/v2.js +2 -0
  81. package/.server/server/plugins/engine/outputFormatters/machine/v2.js.map +1 -1
  82. package/.server/server/plugins/engine/pageControllers/FileUploadPageController.d.ts +1 -1
  83. package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js +2 -2
  84. package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js.map +1 -1
  85. package/.server/server/plugins/engine/pageControllers/QuestionPageController.d.ts +1 -1
  86. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +2 -2
  87. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  88. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +6 -3
  89. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  90. package/.server/server/plugins/engine/pageControllers/helpers/submission.d.ts +1 -1
  91. package/.server/server/plugins/engine/pageControllers/helpers/submission.js +3 -3
  92. package/.server/server/plugins/engine/pageControllers/helpers/submission.js.map +1 -1
  93. package/.server/server/plugins/engine/types/schema.js +1 -0
  94. package/.server/server/plugins/engine/types/schema.js.map +1 -1
  95. package/.server/server/plugins/engine/types.d.ts +1 -0
  96. package/.server/server/plugins/engine/types.js.map +1 -1
  97. package/.server/server/plugins/engine/views/components/ukaddressfield.html +1 -1
  98. package/.server/server/plugins/nunjucks/filters/applyUrlParam.d.ts +9 -0
  99. package/.server/server/plugins/nunjucks/filters/applyUrlParam.js +17 -0
  100. package/.server/server/plugins/nunjucks/filters/applyUrlParam.js.map +1 -0
  101. package/.server/server/plugins/nunjucks/filters/applyUrlParam.test.js +17 -0
  102. package/.server/server/plugins/nunjucks/filters/applyUrlParam.test.js.map +1 -0
  103. package/.server/server/plugins/nunjucks/filters/field.d.ts +1 -1
  104. package/.server/server/plugins/nunjucks/filters/index.d.ts +1 -0
  105. package/.server/server/plugins/nunjucks/filters/index.js +1 -0
  106. package/.server/server/plugins/nunjucks/filters/index.js.map +1 -1
  107. package/.server/server/plugins/payment/helper.d.ts +11 -1
  108. package/.server/server/plugins/payment/helper.js +18 -2
  109. package/.server/server/plugins/payment/helper.js.map +1 -1
  110. package/.server/server/plugins/payment/helper.test.js +2 -1
  111. package/.server/server/plugins/payment/helper.test.js.map +1 -1
  112. package/.server/server/plugins/payment/service.d.ts +2 -1
  113. package/.server/server/plugins/payment/service.js +5 -2
  114. package/.server/server/plugins/payment/service.js.map +1 -1
  115. package/.server/server/plugins/payment/service.test.js +7 -3
  116. package/.server/server/plugins/payment/service.test.js.map +1 -1
  117. package/.server/server/plugins/payment/types.d.ts +4 -0
  118. package/.server/server/plugins/payment/types.js +1 -0
  119. package/.server/server/plugins/payment/types.js.map +1 -1
  120. package/.server/server/plugins/postcode-lookup/models/index.d.ts +247 -3
  121. package/.server/server/plugins/postcode-lookup/models/index.js +86 -33
  122. package/.server/server/plugins/postcode-lookup/models/index.js.map +1 -1
  123. package/.server/server/plugins/postcode-lookup/routes/index.d.ts +15 -1
  124. package/.server/server/plugins/postcode-lookup/routes/index.js +47 -15
  125. package/.server/server/plugins/postcode-lookup/routes/index.js.map +1 -1
  126. package/.server/server/plugins/postcode-lookup/routes/index.test.js +84 -0
  127. package/.server/server/plugins/postcode-lookup/routes/index.test.js.map +1 -0
  128. package/.server/server/plugins/postcode-lookup/types.d.ts +12 -0
  129. package/.server/server/plugins/postcode-lookup/types.js +6 -1
  130. package/.server/server/plugins/postcode-lookup/types.js.map +1 -1
  131. package/.server/server/plugins/postcode-lookup/views/postcode-lookup-details.html +3 -3
  132. package/package.json +7 -1
  133. package/src/client/javascripts/maps.js +5 -0
  134. package/src/client/javascripts/shared.js +1 -4
  135. package/src/client/stylesheets/maps.scss +4 -0
  136. package/src/client/stylesheets/shared.scss +1 -5
  137. package/src/server/constants.js +2 -1
  138. package/src/server/plugins/engine/components/ComponentCollection.ts +15 -9
  139. package/src/server/plugins/engine/components/DatePartsField.ts +16 -12
  140. package/src/server/plugins/engine/components/DeclarationField.ts +6 -4
  141. package/src/server/plugins/engine/components/EastingNorthingField.ts +156 -88
  142. package/src/server/plugins/engine/components/FormComponent.ts +4 -1
  143. package/src/server/plugins/engine/components/LatLongField.ts +129 -57
  144. package/src/server/plugins/engine/components/LocationFieldHelpers.ts +14 -11
  145. package/src/server/plugins/engine/components/MonthYearField.ts +16 -11
  146. package/src/server/plugins/engine/components/PaymentField.ts +9 -4
  147. package/src/server/plugins/engine/components/UkAddressField.ts +29 -6
  148. package/src/server/plugins/engine/components/YesNoField.ts +6 -5
  149. package/src/server/plugins/engine/components/helpers/components.ts +1 -2
  150. package/src/server/plugins/engine/components/helpers/index.ts +19 -0
  151. package/src/server/plugins/engine/helpers.ts +51 -5
  152. package/src/server/plugins/engine/i18n/createFormTranslator.ts +43 -12
  153. package/src/server/plugins/engine/i18n/extractBaseTranslations.ts +7 -1
  154. package/src/server/plugins/engine/i18n/form.ts +54 -0
  155. package/src/server/plugins/engine/i18n/languages.ts +19 -0
  156. package/src/server/plugins/engine/i18n/translations/cy.json +3 -1
  157. package/src/server/plugins/engine/i18n/translations/en-GB.json +3 -1
  158. package/src/server/plugins/engine/index.ts +6 -0
  159. package/src/server/plugins/engine/models/FormModel.ts +9 -15
  160. package/src/server/plugins/engine/models/SummaryViewModel.ts +2 -1
  161. package/src/server/plugins/engine/outputFormatters/human/v1.ts +2 -1
  162. package/src/server/plugins/engine/outputFormatters/machine/v2.ts +3 -0
  163. package/src/server/plugins/engine/pageControllers/FileUploadPageController.ts +2 -2
  164. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +2 -2
  165. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +9 -3
  166. package/src/server/plugins/engine/pageControllers/helpers/submission.ts +6 -3
  167. package/src/server/plugins/engine/types/schema.ts +1 -0
  168. package/src/server/plugins/engine/types.ts +1 -0
  169. package/src/server/plugins/engine/views/components/ukaddressfield.html +1 -1
  170. package/src/server/plugins/nunjucks/filters/applyUrlParam.js +17 -0
  171. package/src/server/plugins/nunjucks/filters/index.js +1 -0
  172. package/src/server/plugins/payment/helper.js +22 -2
  173. package/src/server/plugins/payment/service.js +6 -2
  174. package/src/server/plugins/payment/types.js +1 -0
  175. package/src/server/plugins/postcode-lookup/models/index.js +75 -43
  176. package/src/server/plugins/postcode-lookup/routes/index.js +73 -16
  177. package/src/server/plugins/postcode-lookup/types.js +6 -1
  178. package/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html +3 -3
@@ -63,6 +63,7 @@ export class PaymentField extends FormComponent {
63
63
  reference: joi.string().required(),
64
64
  amount: joi.number().required(),
65
65
  description: joi.string().required(),
66
+ descriptionInEng: joi.string().optional(),
66
67
  uuid: joi.string().uuid().required(),
67
68
  formId: joi.string().required(),
68
69
  isLivePayment: joi.boolean().required(),
@@ -239,8 +240,9 @@ export class PaymentField extends FormComponent {
239
240
  ): Promise<unknown> {
240
241
  const { options, name: componentName } = args.component
241
242
  const { model } = args.controller
242
- const language = resolveLanguage(request)
243
- const { t } = model.createTranslator(language)
243
+ const { language, t, tComponent } = model.createTranslator(
244
+ resolveLanguage(request)
245
+ )
244
246
  const state = await args.controller.getState(request)
245
247
  const { baseUrl } = getPluginOptions(request.server)
246
248
  const summaryUrl = `${baseUrl}/${model.basePath}/summary`
@@ -272,7 +274,8 @@ export class PaymentField extends FormComponent {
272
274
  return h.redirect(summaryUrl).code(StatusCodes.SEE_OTHER)
273
275
  }
274
276
 
275
- const description = options.description
277
+ const description =
278
+ tComponent(args.component, 'paymentDescription') || options.description
276
279
 
277
280
  const slug = `/${model.basePath}`
278
281
 
@@ -292,7 +295,7 @@ export class PaymentField extends FormComponent {
292
295
  payCallbackUrl,
293
296
  reference,
294
297
  isLivePayment,
295
- { formId, slug },
298
+ { formId, slug, language },
296
299
  prefilledEmail
297
300
  )
298
301
 
@@ -313,6 +316,7 @@ export class PaymentField extends FormComponent {
313
316
  reference,
314
317
  amount: resolvedAmount,
315
318
  description,
319
+ descriptionInEng: options.description,
316
320
  paymentId: payment.paymentId,
317
321
  componentName,
318
322
  returnUrl: summaryUrl,
@@ -449,6 +453,7 @@ export interface PaymentSessionData {
449
453
  reference: string
450
454
  amount: number
451
455
  description: string
456
+ descriptionInEng?: string
452
457
  paymentId: string
453
458
  componentName: string
454
459
  returnUrl: string
@@ -1,4 +1,9 @@
1
- import { ComponentType, type UkAddressFieldComponent } from '@defra/forms-model'
1
+ import {
2
+ ComponentType,
3
+ FormStatus,
4
+ type FormMetadata,
5
+ type UkAddressFieldComponent
6
+ } from '@defra/forms-model'
2
7
  import { type ObjectSchema } from 'joi'
3
8
  import lowerFirst from 'lodash/lowerFirst.js'
4
9
 
@@ -9,6 +14,8 @@ import {
9
14
  } from './FormComponent.js'
10
15
  import { TextField } from './TextField.js'
11
16
  import { type RenderContext } from './types.js'
17
+ import { getCachedPluginTranslator } from '../i18n/form.js'
18
+ import { getAvailableLanguages } from '../i18n/languages.js'
12
19
  import { type Translator } from '../i18n/types.js'
13
20
  import {
14
21
  type FormRequestPayload,
@@ -290,7 +297,6 @@ export class UkAddressField extends FormComponent {
290
297
  )
291
298
  }
292
299
 
293
- // eslint-disable-next-line @typescript-eslint/require-await
294
300
  static async dispatcher(
295
301
  request: FormRequestPayload,
296
302
  h: FormResponseToolkit,
@@ -299,14 +305,31 @@ export class UkAddressField extends FormComponent {
299
305
  const { controller, component } = args
300
306
  const language = resolveLanguage(request)
301
307
 
308
+ const availableLanguages = getAvailableLanguages(controller.model.def)
309
+ const languages = availableLanguages.length
310
+ ? { languages: availableLanguages }
311
+ : {}
312
+
313
+ const translator = await getCachedPluginTranslator(
314
+ request,
315
+ { id: controller.model.formId } as unknown as FormMetadata,
316
+ args.isLive ? FormStatus.Live : FormStatus.Draft,
317
+ language
318
+ )
319
+ const { tComponent, tForm } = translator
320
+
302
321
  return dispatch(request, h, {
303
- formName: controller.model.name,
322
+ formName: tForm('title'),
323
+ formId: controller.model.formId,
324
+ formStatus: args.isLive ? FormStatus.Live : FormStatus.Draft,
325
+ componentId: component.id ?? 'unknown',
304
326
  componentName: component.name,
305
- componentHint: component.hint,
306
- componentTitle: component.title || controller.title,
327
+ componentHint: tComponent(component, 'hint'),
328
+ componentTitle: tComponent(component, 'title') || controller.title,
307
329
  step: args.actionArgs.step,
308
330
  sourceUrl: args.sourceUrl,
309
- language
331
+ language,
332
+ ...languages
310
333
  })
311
334
  }
312
335
  }
@@ -4,7 +4,6 @@ import {
4
4
  yesNoListName,
5
5
  type YesNoFieldComponent
6
6
  } from '@defra/forms-model'
7
- import { type LanguageMessages } from 'joi'
8
7
 
9
8
  import { SelectionControlField } from './SelectionControlField.js'
10
9
  import { addClassOptionIfNone } from './helpers/index.js'
@@ -55,11 +54,13 @@ export class YesNoField extends SelectionControlField {
55
54
  this.options = options
56
55
  }
57
56
 
58
- getValidationMessagesOverride(translator: Translator): LanguageMessages {
57
+ getValidationMessagesOverride(translator: Translator) {
59
58
  const { selectYesNoRequired } = buildValidationMessages(translator.t)
60
- return convertToLanguageMessages({
61
- 'any.required': selectYesNoRequired
62
- })
59
+ return {
60
+ [this.name]: convertToLanguageMessages({
61
+ 'any.required': selectYesNoRequired
62
+ })
63
+ }
63
64
  }
64
65
 
65
66
  /**
@@ -347,8 +347,7 @@ export function getAnswerMarkdown(
347
347
  field instanceof Components.EastingNorthingField ||
348
348
  field instanceof Components.LatLongField
349
349
  ) {
350
- const contextValue = field.getContextValueFromState(state)
351
- answerEscaped = contextValue ? `${contextValue}\n` : ''
350
+ answerEscaped = answer ? `${answer}\n` : ''
352
351
  } else if (field instanceof Components.GeospatialField) {
353
352
  const features = field.getFormValueFromState(state)
354
353
  const value = field.getDisplayStringFromFormValue(features, translator)
@@ -2,6 +2,8 @@ import { type ComponentDef } from '@defra/forms-model'
2
2
  import joi, { type JoiExpression, type ReferenceOptions } from 'joi'
3
3
  import lowerFirst from 'lodash/lowerFirst.js'
4
4
 
5
+ import { type Translator } from '../../i18n/types.js'
6
+
5
7
  /**
6
8
  * Prevent Markdown formatting
7
9
  * @see {@link https://pandoc.org/chunkedhtml-demo/8.11-backslash-escapes.html}
@@ -66,3 +68,20 @@ export const lowerFirstExpressionOptions = {
66
68
  */
67
69
  export const createLowerFirstExpression = (template: string): JoiExpression =>
68
70
  joi.expression(template, lowerFirstExpressionOptions) as JoiExpression
71
+
72
+ /**
73
+ * Translate a component label, in precedence order of errorDescription, then shortDesciption, then title
74
+ * @param {ComponentDef} component
75
+ * @param {Translator} translator
76
+ */
77
+ export function getTranslatedLabel(
78
+ component: ComponentDef,
79
+ translator: Translator
80
+ ) {
81
+ const { tComponent } = translator
82
+ const translatedLabel =
83
+ tComponent(component, 'errorDescription') ||
84
+ tComponent(component, 'shortDescription') ||
85
+ tComponent(component, 'title')
86
+ return translatedLabel
87
+ }
@@ -8,13 +8,14 @@ import {
8
8
  } from '@defra/forms-model'
9
9
  import Boom from '@hapi/boom'
10
10
  import { type ResponseToolkit, type Server } from '@hapi/hapi'
11
- import { format, parseISO } from 'date-fns'
11
+ import { format, parseISO, type Locale } from 'date-fns'
12
+ import { cy, enGB } from 'date-fns/locale'
12
13
  import { StatusCodes } from 'http-status-codes'
13
14
  import { type Schema, type ValidationErrorItem } from 'joi'
14
15
  import { Liquid } from 'liquidjs'
15
16
 
16
17
  import { logger } from '../../common/helpers/logging/logger.js'
17
- import { FORM_VERSION_METADATA_KEY } from '../../constants.js'
18
+ import { CY, EN_GB, FORM_VERSION_METADATA_KEY } from '../../constants.js'
18
19
  import {
19
20
  getAnswer,
20
21
  type Field
@@ -303,16 +304,20 @@ export function checkEmailAddressForLiveFormSubmission(
303
304
  * @param [details] - provided by {@link Schema.validate}
304
305
  */
305
306
  export function getErrors(
307
+ language: string | undefined,
306
308
  details?: ValidationErrorItem[]
307
309
  ): FormSubmissionError[] | undefined {
308
310
  if (!details?.length) {
309
311
  return
310
312
  }
311
313
 
312
- return details.map(getError)
314
+ return details.map((detail) => getError(language, detail))
313
315
  }
314
316
 
315
- export function getError(detail: ValidationErrorItem): FormSubmissionError {
317
+ export function getError(
318
+ language: string | undefined,
319
+ detail: ValidationErrorItem
320
+ ): FormSubmissionError {
316
321
  const { context, message, path } = detail
317
322
 
318
323
  const name = context?.key ?? ''
@@ -320,7 +325,10 @@ export function getError(detail: ValidationErrorItem): FormSubmissionError {
320
325
 
321
326
  const text = message.replace(
322
327
  /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)/,
323
- (text) => format(parseISO(text), 'd MMMM yyyy')
328
+ (text) =>
329
+ format(parseISO(text), 'd MMMM yyyy', {
330
+ locale: toLanguageLocale(language)
331
+ })
324
332
  )
325
333
 
326
334
  return {
@@ -410,3 +418,41 @@ export function getFormVersion(
410
418
  | FormVersionMetadata
411
419
  | undefined
412
420
  }
421
+
422
+ const payLanguageCodes = {
423
+ [EN_GB]: 'en',
424
+ [CY]: 'cy'
425
+ } as Record<string, string>
426
+
427
+ const languageLocales = {
428
+ [EN_GB]: enGB,
429
+ [CY]: cy
430
+ } as Record<string, Locale>
431
+
432
+ const defaultLanguageCode = 'en'
433
+ const defaultLanguageLocale = enGB
434
+
435
+ /**
436
+ * Converts a BCP 47 language tag to the ISO 639-1 code GOV.UK Pay accepts.
437
+ * Defaults to English if the language is not supported.
438
+ * @param { string | undefined } language - BCP 47 language code
439
+ * @returns {string} language code required by GOV.UK Pay
440
+ */
441
+ export function toPayLanguageCode(language: string | undefined): string {
442
+ return payLanguageCodes[language ?? 'unknown'] ?? defaultLanguageCode
443
+ }
444
+
445
+ /**
446
+ * Converts a BCP 47 language tag to the date-fns Locale (for date formatting).
447
+ * Defaults to English if the language is not supported.
448
+ * @param { string | undefined } language - BCP 47 language code
449
+ * @returns {Locale} language locale
450
+ */
451
+ export function toLanguageLocale(language: string | undefined): Locale {
452
+ return languageLocales[language ?? 'unknown'] ?? defaultLanguageLocale
453
+ }
454
+
455
+ /**
456
+ * @import { FormsService } from '../../types.js'
457
+ * @import { Locale } from 'date-fns/locale'
458
+ */
@@ -1,30 +1,40 @@
1
1
  import {
2
2
  yesNoListNoItemId,
3
3
  yesNoListYesItemId,
4
- type FormDefinition
4
+ type FormDefinition,
5
+ type FormMetadata
5
6
  } from '@defra/forms-model'
6
7
  import { type i18n } from 'i18next'
7
8
 
9
+ import { CY } from '../../../constants.js'
8
10
  import { createTranslator } from './createTranslator.js'
9
11
  import { extractBaseTranslations } from './extractBaseTranslations.js'
10
12
  import { createFormI18nInstance } from './index.js'
11
- import {
12
- type FormDefinitionTranslations,
13
- type Translator
14
- } from './types.js'
13
+ import { type FormDefinitionTranslations } from './types.js'
15
14
 
16
15
  export function createFormTranslator(
17
- def: FormDefinition,
16
+ metadata: FormMetadata,
17
+ definition: FormDefinition | undefined,
18
18
  language: string
19
- ): Translator {
20
- const baseTranslations = extractBaseTranslations(def)
19
+ ) {
20
+ const baseTranslations = extractBaseTranslations(definition)
21
21
  const i18nInstance = createFormI18nInstance(baseTranslations)
22
- loadFormTranslations(def, i18nInstance)
22
+ loadFormTranslations(definition, i18nInstance)
23
+ extractMetadataBaseTranslations(metadata, i18nInstance)
24
+
25
+ const translator = createTranslator(i18nInstance, language)
23
26
 
24
- return createTranslator(i18nInstance, language)
27
+ return translator
25
28
  }
26
29
 
27
- export function loadFormTranslations(def: FormDefinition, i18nInstance: i18n) {
30
+ export function loadFormTranslations(
31
+ def: FormDefinition | undefined,
32
+ i18nInstance: i18n
33
+ ) {
34
+ if (!def) {
35
+ return
36
+ }
37
+
28
38
  const formTranslations = def.metadata?.translations as
29
39
  | FormDefinitionTranslations
30
40
  | undefined
@@ -34,7 +44,7 @@ export function loadFormTranslations(def: FormDefinition, i18nInstance: i18n) {
34
44
  i18nInstance.addResourceBundle(lng, 'form', resources, true, true)
35
45
 
36
46
  // Temporary workaround - until we develop a better solution
37
- if (lng === 'cy') {
47
+ if (lng === CY) {
38
48
  i18nInstance.addResource(
39
49
  lng,
40
50
  'form',
@@ -51,3 +61,24 @@ export function loadFormTranslations(def: FormDefinition, i18nInstance: i18n) {
51
61
  }
52
62
  }
53
63
  }
64
+
65
+ export function extractMetadataBaseTranslations(
66
+ metadata: FormMetadata | undefined,
67
+ i18nInstance: i18n
68
+ ) {
69
+ if (metadata) {
70
+ const translations = {
71
+ 'form.title': metadata.title,
72
+ 'form.contact.email.address': metadata.contact?.email?.address ?? '',
73
+ 'form.contact.email.responseTime':
74
+ metadata.contact?.email?.responseTime ?? '',
75
+ 'form.contact.online.url': metadata.contact?.online?.url ?? '',
76
+ 'form.contact.online.text': metadata.contact?.online?.text ?? '',
77
+ 'form.contact.phone': metadata.contact?.phone ?? '',
78
+ 'form.submissionGuidance': metadata.submissionGuidance ?? '',
79
+ 'form.privacyNoticeText': metadata.privacyNoticeText ?? '',
80
+ 'form.privacyNoticeUrl': metadata.privacyNoticeUrl ?? ''
81
+ }
82
+ i18nInstance.addResourceBundle('en-GB', 'form', translations, true, true)
83
+ }
84
+ }
@@ -7,13 +7,19 @@ import { type FormDefinitionTranslations } from './types.js'
7
7
 
8
8
  type BaseTranslations = FormDefinitionTranslations[string]
9
9
 
10
- export function extractBaseTranslations(def: FormDefinition): BaseTranslations {
10
+ export function extractBaseTranslations(
11
+ def: FormDefinition | undefined
12
+ ): BaseTranslations {
11
13
  const pages: BaseTranslations['pages'] = {}
12
14
  const components: BaseTranslations['components'] = {}
13
15
  const sections: BaseTranslations['sections'] = {}
14
16
  const listItems: BaseTranslations['listItems'] = {}
15
17
  const form: BaseTranslations['form'] = {}
16
18
 
19
+ if (!def) {
20
+ return { pages, components, sections, listItems, form }
21
+ }
22
+
17
23
  for (const page of def.pages) {
18
24
  if (page.id && page.title) {
19
25
  pages[page.id] = { title: page.title }
@@ -0,0 +1,54 @@
1
+ import { type FormMetadata, type FormStatus } from '@defra/forms-model'
2
+ import Boom from '@hapi/boom'
3
+ import { LRUCache } from 'lru-cache'
4
+
5
+ import { getPluginOptions } from '../helpers.js'
6
+ import { createFormTranslator } from './createFormTranslator.js'
7
+ import { type Translator } from './types.js'
8
+ import { type AnyFormRequest } from '../types.js'
9
+
10
+ const cache = new LRUCache({
11
+ max: 100,
12
+ ttl: 1000 * 60 * 15 // 15 minutes
13
+ })
14
+
15
+ /**
16
+ * Get translator for plugin, for the current form's metadata (as well as the plugin-specific boilerplate).
17
+ * This is an async call so we can read the form definition inside this call.
18
+ * See docs/features/multi-language.md for the language and translation flow overview.
19
+ * @param {AnyFormRequest} request
20
+ * @param {FormMetadata} metadata - the metadata of the form
21
+ * @param {FormStatus} status - the form status to use when retrieving the definition
22
+ * @param {string} language - the language to use for the translator
23
+ */
24
+ export async function getCachedPluginTranslator(
25
+ request: AnyFormRequest,
26
+ metadata: FormMetadata,
27
+ status: FormStatus,
28
+ language: string
29
+ ) {
30
+ const key = `${metadata.id}-${status}-${language}-plugin-meta`
31
+
32
+ if (cache.has(key)) {
33
+ return cache.get(key) as unknown as Translator
34
+ }
35
+
36
+ const { services } = getPluginOptions(request.server)
37
+
38
+ const definition = await services?.formsService.getFormDefinition(
39
+ metadata.id,
40
+ status
41
+ )
42
+
43
+ if (!definition) {
44
+ throw Boom.notFound(
45
+ `FormDefinition not found for form ${metadata.id} and status ${status}`
46
+ )
47
+ }
48
+
49
+ const translator = createFormTranslator(metadata, definition, language)
50
+
51
+ cache.set(key, translator)
52
+
53
+ return translator
54
+ }
@@ -0,0 +1,19 @@
1
+ import { type FormDefinition } from '@defra/forms-model'
2
+
3
+ /**
4
+ * Return a list of available languages.
5
+ * Empty array if only English, or an array of English and Welsh if translations have been defined.
6
+ * @param {FormDefinition} def
7
+ */
8
+ export function getAvailableLanguages(
9
+ def: FormDefinition
10
+ ): { name: string; code: string }[] {
11
+ // @ts-expect-error - dynamic property
12
+ if (def.metadata?.translations?.cy) {
13
+ return [
14
+ { name: 'English', code: 'en-GB' },
15
+ { name: 'Cymraeg', code: 'cy' }
16
+ ]
17
+ }
18
+ return []
19
+ }
@@ -29,7 +29,9 @@
29
29
  "continue": "Parhau",
30
30
  "saveAndExit": "Cadw a gadael",
31
31
  "draft": "ddrafft",
32
- "live": "fyw"
32
+ "live": "fyw",
33
+ "or": "neu",
34
+ "and": "a"
33
35
  },
34
36
 
35
37
  "pages": {
@@ -29,7 +29,9 @@
29
29
  "continue": "Continue",
30
30
  "saveAndExit": "Save and exit",
31
31
  "draft": "draft",
32
- "live": "live"
32
+ "live": "live",
33
+ "or": "or",
34
+ "and": "and"
33
35
  },
34
36
 
35
37
  "pages": {
@@ -22,6 +22,12 @@ export {
22
22
  resolveFormModel
23
23
  } from './form-context.js'
24
24
  export * from './form-availability.js'
25
+ export { getAvailableLanguages } from './i18n/languages.js'
26
+ export {
27
+ extractMetadataBaseTranslations,
28
+ loadFormTranslations
29
+ } from './i18n/createFormTranslator.js'
30
+ export { extractBaseTranslations } from './i18n/extractBaseTranslations.js'
25
31
 
26
32
  const globals = {
27
33
  checkComponentTemplates,
@@ -31,6 +31,7 @@ import { type i18n } from 'i18next'
31
31
  import joi from 'joi'
32
32
 
33
33
  import { logger } from '../../../common/helpers/logging/logger.js'
34
+ import { EN_GB } from '../../../constants.js'
34
35
  import { type ListFormComponent } from '../components/ListFormComponent.js'
35
36
  import {} from '../components/YesNoField.js'
36
37
  import {
@@ -47,6 +48,7 @@ import { loadFormTranslations } from '../i18n/createFormTranslator.js'
47
48
  import { createTranslator } from '../i18n/createTranslator.js'
48
49
  import { extractBaseTranslations } from '../i18n/extractBaseTranslations.js'
49
50
  import { createFormI18nInstance } from '../i18n/index.js'
51
+ import { getAvailableLanguages } from '../i18n/languages.js'
50
52
  import { type Translator } from '../i18n/types.js'
51
53
  import { type ExecutableCondition } from './types.js'
52
54
  import { type PageController } from '../pageControllers/PageController.js'
@@ -240,7 +242,12 @@ export class FormModel {
240
242
  }
241
243
 
242
244
  /** Returns a scoped translator pair for the given language. */
243
- createTranslator(language = 'en-GB'): Translator {
245
+ createTranslator(language = EN_GB): Translator {
246
+ // @ts-expect-error - dynamic language lookup
247
+ if (!this.def.metadata?.translations?.[language]) {
248
+ // If not translations defined in the FormDefinition, always default to English
249
+ language = EN_GB
250
+ }
244
251
  return createTranslator(this.i18nInstance, language)
245
252
  }
246
253
 
@@ -662,7 +669,7 @@ function validateFormState(
662
669
 
663
670
  // Add relevant state errors
664
671
  if (error) {
665
- const errorsState = error.details.map(getError)
672
+ const errorsState = error.details.map((item) => getError(EN_GB, item))
666
673
  return { ...context, errors: errors.concat(errorsState) }
667
674
  }
668
675
 
@@ -679,16 +686,3 @@ function getReferenceNumber(state: FormSubmissionState): string {
679
686
 
680
687
  return state.$$__referenceNumber
681
688
  }
682
-
683
- export function getAvailableLanguages(
684
- def: FormDefinition
685
- ): { name: string; code: string }[] {
686
- // @ts-expect-error - dynamic property
687
- if (def.metadata?.translations?.cy) {
688
- return [
689
- { name: 'English', code: 'en-GB' },
690
- { name: 'Cymraeg', code: 'cy' }
691
- ]
692
- }
693
- return []
694
- }
@@ -4,6 +4,7 @@ import {
4
4
  type Section
5
5
  } from '@defra/forms-model'
6
6
 
7
+ import { EN_GB } from '../../../constants.js'
7
8
  import { PaymentField } from '../components/PaymentField.js'
8
9
  import { type PaymentState } from '../components/PaymentField.types.js'
9
10
  import {
@@ -96,7 +97,7 @@ export class SummaryViewModel {
96
97
  .validate(this.context.relevantState, { ...opts, stripUnknown: true })
97
98
 
98
99
  // Format errors
99
- this.errors = result.error?.details.map(getError)
100
+ this.errors = result.error?.details.map((item) => getError(EN_GB, item))
100
101
  this.details = this.summaryDetails(request, sections, translator)
101
102
 
102
103
  // Format check answers
@@ -5,6 +5,7 @@ import {
5
5
  import { addDays, format as dateFormat } from 'date-fns'
6
6
 
7
7
  import { config } from '../../../../../config/index.js'
8
+ import { EN_GB } from '../../../../constants.js'
8
9
  import { getAnswer } from '../../components/helpers/components.js'
9
10
  import { escapeMarkdown } from '../../components/helpers/index.js'
10
11
  import { PaymentField } from '../../components/index.js'
@@ -116,7 +117,7 @@ function appendPaymentSection(paymentItems: DetailItem[], lines: string[]) {
116
117
 
117
118
  const formattedAmount = formatCurrency(paymentState.amount)
118
119
  const dateOfPayment = paymentState.preAuth?.createdAt
119
- ? formatPaymentDate(paymentState.preAuth.createdAt)
120
+ ? formatPaymentDate(paymentState.preAuth.createdAt, EN_GB)
120
121
  : ''
121
122
 
122
123
  lines.push(
@@ -28,6 +28,7 @@ interface PaymentOutput {
28
28
  reference: string
29
29
  amount: number
30
30
  description: string
31
+ descriptionInEng?: string
31
32
  createdAt: string
32
33
  }
33
34
 
@@ -93,6 +94,7 @@ export function format(
93
94
  * reference: 'REF-123',
94
95
  * amount: 10.00,
95
96
  * description: 'Application fee',
97
+ * descriptionInEng: 'Applicaiton fee',
96
98
  * createdAt: '2025-01-23T10:30:00.000Z'
97
99
  * }
98
100
  * }
@@ -204,6 +206,7 @@ function extractPayment(
204
206
  reference: paymentState.reference,
205
207
  amount: paymentState.amount,
206
208
  description: paymentState.description,
209
+ descriptionInEng: item.field.def.options.description,
207
210
  createdAt: paymentState.preAuth?.createdAt ?? ''
208
211
  }
209
212
  }
@@ -226,7 +226,7 @@ export class FileUploadPageController extends QuestionPageController {
226
226
  }
227
227
  }
228
228
 
229
- getErrors(details?: ValidationErrorItem[]) {
229
+ getErrors(details?: ValidationErrorItem[], language?: string) {
230
230
  const { fileUpload } = this
231
231
 
232
232
  if (details) {
@@ -239,7 +239,7 @@ export class FileUploadPageController extends QuestionPageController {
239
239
  if (!isUploadError || isUploadRootError) {
240
240
  // The error is for the root of the upload or another
241
241
  // field on the page so defer to the getError helper
242
- errors.push(getError(error))
242
+ errors.push(getError(language, error))
243
243
  } else {
244
244
  const { context, path, type } = error
245
245
 
@@ -364,8 +364,8 @@ export class QuestionPageController extends PageController {
364
364
  return this.collection.getStateFromValidForm(payload)
365
365
  }
366
366
 
367
- getErrors(details?: ValidationErrorItem[]) {
368
- return getErrors(details)
367
+ getErrors(details?: ValidationErrorItem[], language?: string) {
368
+ return getErrors(language, details)
369
369
  }
370
370
 
371
371
  async getState(request: AnyFormRequest) {