hof 22.11.1 → 22.11.6-unit-of-measure-beta.3

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 (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +78 -0
  3. package/components/amount-with-unit-select/amount-with-unit-select.js +107 -0
  4. package/components/amount-with-unit-select/fields.js +15 -0
  5. package/components/amount-with-unit-select/hooks.js +168 -0
  6. package/components/amount-with-unit-select/templates/amount-with-unit-select.html +20 -0
  7. package/components/amount-with-unit-select/utils.js +197 -0
  8. package/components/amount-with-unit-select/validation.js +175 -0
  9. package/components/index.js +1 -0
  10. package/config/hof-defaults.js +1 -1
  11. package/controller/controller.js +5 -3
  12. package/controller/validation/index.js +1 -1
  13. package/controller/validation/validators.js +0 -1
  14. package/frontend/govuk-template/govuk_template_generated.html +102 -0
  15. package/frontend/template-mixins/mixins/template-mixins.js +55 -5
  16. package/frontend/template-mixins/partials/forms/grouped-inputs-select.html +13 -0
  17. package/frontend/template-mixins/partials/forms/grouped-inputs-text.html +37 -0
  18. package/frontend/themes/gov-uk/styles/_grouped-input.scss +5 -0
  19. package/frontend/themes/gov-uk/styles/govuk.scss +1 -0
  20. package/frontend/toolkit/assets/javascript/form-focus.js +4 -0
  21. package/lib/encryption.js +43 -17
  22. package/lib/sessions.js +5 -2
  23. package/lib/settings.js +1 -1
  24. package/package.json +2 -3
  25. package/sandbox/apps/sandbox/fields.js +18 -1
  26. package/sandbox/apps/sandbox/index.js +4 -0
  27. package/sandbox/apps/sandbox/sections/summary-data-sections.js +7 -1
  28. package/sandbox/apps/sandbox/translations/en/default.json +278 -0
  29. package/sandbox/apps/sandbox/translations/src/en/fields.json +10 -0
  30. package/sandbox/apps/sandbox/translations/src/en/pages.json +3 -0
  31. package/sandbox/apps/sandbox/translations/src/en/validation.json +12 -0
  32. package/sandbox/package.json +1 -1
  33. package/sandbox/public/css/app.css +10042 -0
  34. package/sandbox/public/images/icons/icon-caret-left.png +0 -0
  35. package/sandbox/public/images/icons/icon-complete.png +0 -0
  36. package/sandbox/public/images/icons/icon-cross-remove-sign.png +0 -0
  37. package/sandbox/public/js/bundle.js +36724 -0
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ const _ = require('lodash');
4
+ const controller = require('../../controller/controller').prototype;
5
+ const utils = require('./utils');
6
+
7
+ /**
8
+ * Validates the value is a string consisting of two hyphen-separated values.
9
+ * Can be passed to the list of field validators to run as a custom validator.
10
+ * @param {string} value - The amountWithUnitSelect value to validate (E.G. '1-Litre').
11
+ * @returns {boolean} Returns true if the value is in the expected format, false otherwise.
12
+ */
13
+ const isTwoHyphenSeparatedValues = value => {
14
+ if (typeof value !== 'string' || value.indexOf('-') === -1) {
15
+ return false;
16
+ }
17
+ const selectValue = [value.split('-').pop()];
18
+ return Array.isArray(selectValue) && selectValue.length;
19
+ };
20
+
21
+ /**
22
+ * Creates a custom 'equal' validator for a select component.
23
+ * @param {Object[]} options - The select component options.
24
+ * @returns {Object[]} Returns a custom 'equal' validator object for the select component.
25
+ */
26
+ const createCustomEqualValidator = options => [{
27
+ type: 'equal',
28
+ arguments: _.map(options, opt =>
29
+ typeof opt === 'string' ? opt : opt.value)
30
+ }];
31
+
32
+ /**
33
+ * Adds a validator to a field's validate array, ensuring no duplicates.
34
+ * @param {Object} field - The field to add the validator to.
35
+ * @param {Object|Object[]|string|string[]|function(string): boolean|(function(string): boolean)[]} newValidator -
36
+ * The validator to add.
37
+ */
38
+ const addValidator = (field, newValidator) => {
39
+ field.validate = _.uniq(field.validate.concat(newValidator));
40
+ };
41
+
42
+ /**
43
+ * Adds the 'groupedFieldsWithOptions' property to the specified field.
44
+ * This property prevents the 'equal' validator being applied to the parent component by default,
45
+ * and enables it to separately be added to the unit child component instead.
46
+ * @param {Object} field - The field to add the property to.
47
+ */
48
+ const addGroupedFieldsWithOptionsProperty = field => {
49
+ field.groupedFieldsWithOptions = true;
50
+ };
51
+
52
+ /**
53
+ * Resolves configurations related to making the amount and/or unit fields optional (E.G. amountOptional, unitOptional).
54
+ * @param {Object[]} parentField - The parent component's field definition and configuration.
55
+ * @param {Object[]} childFields - The child component's field definitions and configurations.
56
+ * @param {Array} validators - The list of validators assigned to the parent component.
57
+ * @param {string} key - The parent component's key.
58
+ */
59
+ const resolveOptionalFields = (parentField, childFields, validators, key) => {
60
+ // adds existing required validators from parent component to the child components
61
+ // and resolves configurations that determine if the child components should be optional
62
+ (validators?.indexOf('required') !== -1 || parentField[key]?.amountOptional !== 'true') &&
63
+ addValidator(childFields[`${key}-amount`], 'required');
64
+ (validators?.indexOf('required') !== -1 || parentField[key]?.unitOptional !== 'true') &&
65
+ addValidator(childFields[`${key}-unit`], 'required');
66
+ };
67
+
68
+ /**
69
+ * Propagates the child component's (amount and unit) field data and values into the form request to enable validation.
70
+ * @param {Object} formReq - The form's request object.
71
+ * @param {Object[]} fields - The child components' definitions and configurations.
72
+ * @param {string} key - The parent component's key.
73
+ */
74
+ const propagateChildFieldValidation = (formReq, fields, key) => {
75
+ // adds child component field definitions to the form request
76
+ Object.assign(formReq.options.fields,
77
+ { [`${key}-amount`]: fields[`${key}-amount`] },
78
+ { [`${key}-unit`]: fields[`${key}-unit`] }
79
+ );
80
+ // splits and assigns the component's values to the form request
81
+ const amountWithUnitSelectValues = utils.getAmountWithUnitSelectValues(formReq.values[key]);
82
+ Object.assign(formReq.values,
83
+ { [`${key}-amount`]: amountWithUnitSelectValues[0] },
84
+ { [`${key}-unit`]: amountWithUnitSelectValues[1] }
85
+ );
86
+ };
87
+
88
+ /**
89
+ * Moves validators, that are not the 'required' or 'equal' type, from the parent component to
90
+ * the 'amount' child component,
91
+ * ensuring all other validators are applied to the amount field only.
92
+ * @param {Object} formReqFields - The fields in the form's request object (req.form.options.fields).
93
+ * @param {Object[]} fields - The child components' definitions and configurations.
94
+ * @param {string} key - The parent component's key.
95
+ */
96
+ const moveExcessValidatorToChildComponent = (formReqFields, fields, key) => {
97
+ _.remove(formReqFields[key]?.validate, validator => {
98
+ if (!((typeof validator === 'object' &&
99
+ (validator.type === 'equal' ||
100
+ validator.type === 'required')) ||
101
+ (typeof validator === 'string' &&
102
+ (validator === 'equal' ||
103
+ validator === 'required')))) {
104
+ if (formReqFields[`${key}-amount`] === null) {
105
+ Object.assign(formReqFields, {
106
+ [`${key}-amount`]: fields[`${key}-amount`]
107
+ });
108
+ }
109
+ if (!formReqFields[`${key}-amount`]?.validate?.includes(validator)) {
110
+ formReqFields[`${key}-amount`].validate.push(validator);
111
+ }
112
+ return true;
113
+ }
114
+ return false;
115
+ });
116
+ };
117
+
118
+ /**
119
+ * Creates and adds a validation error for a child component into the form request's errors list.
120
+ * @param {Object} req - The request object given to the component.
121
+ * @param {Object} res - The response object given to the component.
122
+ * @param {Object[]} errors - The validation errors recorded in the session model.
123
+ * @param {string} pKey - The parent component's key.
124
+ * @param {string} key - The child component's key.
125
+ */
126
+ const addValidationError = (req, res, errors, pKey, key) => {
127
+ // manually creates and adds an error object
128
+ req.form.errors[`${pKey}-${key}`] = {
129
+ errorLinkId: `${pKey}-${key}`,
130
+ key: errors[`${pKey}-${key}`]?.key || `${key}-${key}`,
131
+ type: errors[`${pKey}-${key}`]?.type || null
132
+ };
133
+ // ensure the error message is processed and translated by the controller
134
+ req.form.errors[`${pKey}-${key}`].message =
135
+ controller.getErrorMessage(req.form.errors[`${pKey}-${key}`], req, res) ||
136
+ controller.getErrorMessage({
137
+ errorLinkId: `${pKey}-${key}`,
138
+ key: `${pKey}`,
139
+ type: errors[`${pKey}-${key}`]?.type || null
140
+ }, req, res);
141
+ };
142
+
143
+ /**
144
+ * Inserts child component validation errors into the form request if there is no parent component error.
145
+ * Only one error from the component is added to the request's error list in the order of the parent, amount,
146
+ * and then unit component.
147
+ * @param {Object} req - The request object given to the component.
148
+ * @param {Object} res - The response object given to the component.
149
+ * @param {string} pKey - The parent component's key.
150
+ * @param {Object[]} errors - The validation errors recorded in the session model.
151
+ */
152
+ const insertChildValidationErrors = (req, res, pKey, errors) => {
153
+ let key;
154
+ if (errors && !errors[pKey] && req?.form?.errors) {
155
+ if (errors[`${pKey}-amount`] && req.form.errors[`${pKey}-amount`]) {
156
+ key = 'amount';
157
+ } else if (errors[`${pKey}-unit`] && req.form.errors[`${pKey}-unit`]) {
158
+ key = 'unit';
159
+ }
160
+ }
161
+ // if there are not parent or child errors, no errors are added
162
+ key && addValidationError(req, res, errors, pKey, key);
163
+ };
164
+
165
+ module.exports = {
166
+ isTwoHyphenSeparatedValues,
167
+ createCustomEqualValidator,
168
+ addValidator,
169
+ addGroupedFieldsWithOptionsProperty,
170
+ resolveOptionalFields,
171
+ propagateChildFieldValidation,
172
+ moveExcessValidatorToChildComponent,
173
+ addValidationError,
174
+ insertChildValidationErrors
175
+ };
@@ -5,6 +5,7 @@ module.exports = {
5
5
  clearSession: require('./clear-session'),
6
6
  combineAndLoopFields: require('./combine-and-loop-fields'),
7
7
  date: require('./date'),
8
+ amountWithUnitSelect: require('./amount-with-unit-select/amount-with-unit-select'),
8
9
  emailer: require('./emailer'),
9
10
  homeOfficeCountries: require('./homeoffice-countries'),
10
11
  notify: require('./notify'),
@@ -51,7 +51,7 @@ const defaults = {
51
51
  },
52
52
  session: {
53
53
  ttl: process.env.SESSION_TTL || 1800,
54
- secret: process.env.SESSION_SECRET || 'changethis',
54
+ secret: process.env.SESSION_SECRET,
55
55
  name: process.env.SESSION_NAME || 'hod.sid',
56
56
  sanitiseInputs: false
57
57
  },
@@ -200,10 +200,12 @@ module.exports = class Controller extends BaseController {
200
200
  req.form.errors[key].errorLinkId = key + '-' + field.options[0];
201
201
  }
202
202
  // eslint-disable-next-line brace-style
203
- }
204
- // get first field for date input control
205
- else if (field && field.mixin === 'input-date') {
203
+ } else if (field && field.mixin === 'input-date') {
204
+ // get first field for date input control
206
205
  req.form.errors[key].errorLinkId = key + '-day';
206
+ } else if (field && field.mixin === 'input-amount-with-unit-select') {
207
+ // get first field for amount-unit input control
208
+ req.form.errors[key].errorLinkId = key + '-amount';
207
209
  } else {
208
210
  req.form.errors[key].errorLinkId = key;
209
211
  }
@@ -47,7 +47,7 @@ function validate(fields) {
47
47
  fields[key].validate = [fields[key].validate];
48
48
  }
49
49
 
50
- if (fields[key].options) {
50
+ if (fields[key].options && !fields[key].groupedFieldsWithOptions) {
51
51
  fields[key].validate = fields[key].validate || [];
52
52
  fields[key].validate.push({
53
53
  type: 'equal',
@@ -177,5 +177,4 @@ module.exports = Validators = {
177
177
  // eslint-disable-next-line max-len
178
178
  return value === '' || Validators.regex(value, /^(([GIR] ?0[A]{2})|((([A-Z][0-9]{1,2})|(([A-Z][A-HJ-Y][0-9]{1,2})|(([A-Z][0-9][A-Z])|([A-Z][A-HJ-Y][0-9]?[A-Z])))) ?[0-9][A-Z]{2}))$/i);
179
179
  }
180
-
181
180
  };
@@ -0,0 +1,102 @@
1
+
2
+ <!DOCTYPE html>
3
+ <!--[if lt IE 9]><html class="lte-ie8" lang="{{htmlLang}}"><![endif]-->
4
+ <!--[if gt IE 8]><!--><html lang="{{htmlLang}}" class="govuk-template"><!--<![endif]-->
5
+ <head>
6
+ <meta charset="utf-8" />
7
+ <title>{{$pageTitle}}{{/pageTitle}}</title>
8
+ {{$head}}{{/head}}
9
+
10
+ <link rel="shortcut icon" sizes="16x16 32x32 48x48" href="{{govukAssetPath}}images/favicon.ico" type="image/x-icon">
11
+ <link rel="mask-icon" href="{{govukAssetPath}}images/govuk-mask-icon.svg" color="#0b0c0c">
12
+ <link rel="apple-touch-icon" sizes="180x180" href="{{govukAssetPath}}images/govuk-apple-touch-icon-180x180.png">
13
+ <link rel="apple-touch-icon" sizes="167x167" href="{{govukAssetPath}}images/govuk-apple-touch-icon-167x167.png">
14
+ <link rel="apple-touch-icon" sizes="152x152" href="{{govukAssetPath}}images/govuk-apple-touch-icon-152x152.png">
15
+ <link rel="apple-touch-icon" href="{{govukAssetPath}}images/govuk-apple-touch-icon.png">
16
+
17
+
18
+ <meta name="theme-color" content="#0b0c0c" />
19
+
20
+ <meta name="viewport" content="width=device-width, initial-scale=1">
21
+
22
+
23
+ <meta property="og:image" content="{{govukAssetPath}}images/opengraph-image.png">
24
+ </head>
25
+
26
+ <body class="{{$bodyClasses}}{{/bodyClasses}} govuk-template__body js-enabled" >
27
+ <script {{#nonce}}nonce="{{nonce}}"{{/nonce}}>document.body.className = ((document.body.className) ? document.body.className + ' js-enabled' : 'js-enabled');</script>
28
+
29
+
30
+
31
+ <div id="global-cookie-message" class="gem-c-cookie-banner govuk-clearfix" data-module="cookie-banner" role="region" aria-label="cookie banner" data-nosnippet="">
32
+ {{$cookieMessage}}{{/cookieMessage}}
33
+ </div>
34
+
35
+ {{$bodyStart}}{{/bodyStart}}
36
+
37
+ <header role="banner" id="govuk-header" class="{{$headerClass}}{{/headerClass}}">
38
+ <div class="govuk-header__container govuk-width-container">
39
+
40
+ <div class="govuk-header__logo">
41
+ <a href="{{$homepageUrl}}https://www.gov.uk{{/homepageUrl}}" title="{{$logoLinkTitle}}Go to the GOV.UK homepage{{/logoLinkTitle}}" id="logo" class="govuk-header__link govuk-header__link--homepage" target="_blank" data-module="track-click" data-track-category="homeLinkClicked" data-track-action="homeHeader">
42
+ <span class="govuk-header__logotype">
43
+ <!--[if gt IE 8]><!-->
44
+ <svg aria-hidden="true" focusable="false" class="govuk-header__logotype-crown" xmlns="http://www.w3.org/2000/svg\" viewBox="0 0 32 30" height="30" width="32">
45
+ <path fill="currentColor" fill-rule="evenodd" d="M22.6 10.4c-1 .4-2-.1-2.4-1-.4-.9.1-2 1-2.4.9-.4 2 .1 2.4 1s-.1 2-1 2.4m-5.9 6.7c-.9.4-2-.1-2.4-1-.4-.9.1-2 1-2.4.9-.4 2 .1 2.4 1s-.1 2-1 2.4m10.8-3.7c-1 .4-2-.1-2.4-1-.4-.9.1-2 1-2.4.9-.4 2 .1 2.4 1s0 2-1 2.4m3.3 4.8c-1 .4-2-.1-2.4-1-.4-.9.1-2 1-2.4.9-.4 2 .1 2.4 1s-.1 2-1 2.4M17 4.7l2.3 1.2V2.5l-2.3.7-.2-.2.9-3h-3.4l.9 3-.2.2c-.1.1-2.3-.7-2.3-.7v3.4L15 4.7c.1.1.1.2.2.2l-1.3 4c-.1.2-.1.4-.1.6 0 1.1.8 2 1.9 2.2h.7c1-.2 1.9-1.1 1.9-2.1 0-.2 0-.4-.1-.6l-1.3-4c-.1-.2 0-.2.1-.3m-7.6 5.7c.9.4 2-.1 2.4-1 .4-.9-.1-2-1-2.4-.9-.4-2 .1-2.4 1s0 2 1 2.4m-5 3c.9.4 2-.1 2.4-1 .4-.9-.1-2-1-2.4-.9-.4-2 .1-2.4 1s.1 2 1 2.4m-3.2 4.8c.9.4 2-.1 2.4-1 .4-.9-.1-2-1-2.4-.9-.4-2 .1-2.4 1s0 2 1 2.4m14.8 11c4.4 0 8.6.3 12.3.8 1.1-4.5 2.4-7 3.7-8.8l-2.5-.9c.2 1.3.3 1.9 0 2.7-.4-.4-.8-1.1-1.1-2.3l-1.2 4c.7-.5 1.3-.8 2-.9-1.1 2.5-2.6 3.1-3.5 3-1.1-.2-1.7-1.2-1.5-2.1.3-1.2 1.5-1.5 2.1-.1 1.1-2.3-.8-3-2-2.3 1.9-1.9 2.1-3.5.6-5.6-2.1 1.6-2.1 3.2-1.2 5.5-1.2-1.4-3.2-.6-2.5 1.6.9-1.4 2.1-.5 1.9.8-.2 1.1-1.7 2.1-3.5 1.9-2.7-.2-2.9-2.1-2.9-3.6.7-.1 1.9.5 2.9 1.9l.4-4.3c-1.1 1.1-2.1 1.4-3.2 1.4.4-1.2 2.1-3 2.1-3h-5.4s1.7 1.9 2.1 3c-1.1 0-2.1-.2-3.2-1.4l.4 4.3c1-1.4 2.2-2 2.9-1.9-.1 1.5-.2 3.4-2.9 3.6-1.9.2-3.4-.8-3.5-1.9-.2-1.3 1-2.2 1.9-.8.7-2.3-1.2-3-2.5-1.6.9-2.2.9-3.9-1.2-5.5-1.5 2-1.3 3.7.6 5.6-1.2-.7-3.1 0-2 2.3.6-1.4 1.8-1.1 2.1.1.2.9-.3 1.9-1.5 2.1-.9.2-2.4-.5-3.5-3 .6 0 1.2.3 2 .9l-1.2-4c-.3 1.1-.7 1.9-1.1 2.3-.3-.8-.2-1.4 0-2.7l-2.9.9C1.3 23 2.6 25.5 3.7 30c3.7-.5 7.9-.8 12.3-.8"></path>
46
+ </svg>
47
+ <!--<![endif]-->
48
+ <!--[if IE 8]>
49
+ <img src="{{govukAssetPath}}images/govuk-logotype-tudor-crown.png" class="govuk-header__logotype-crown-fallback-image" width="32" height="30" alt="">
50
+ <![endif]-->
51
+ </span>
52
+ <span class="govuk-header__logotype-text">
53
+ {{$globalHeaderText}}GOV.UK{{/globalHeaderText}}
54
+ </span>
55
+ </a>
56
+ </div>
57
+ {{$insideHeader}}{{/insideHeader}}
58
+
59
+ {{$propositionHeader}}{{/propositionHeader}}
60
+ </div>
61
+ </header>
62
+
63
+
64
+ {{$afterHeader}}{{/afterHeader}}
65
+
66
+
67
+ {{$main}}{{/main}}
68
+
69
+ <footer class="govuk-footer" id="footer" role="contentinfo">
70
+
71
+ <div class="govuk-width-container">
72
+ {{$footerTop}}{{/footerTop}}
73
+
74
+ <div class="govuk-footer__meta">
75
+ <div class="govuk-footer__meta-item govuk-footer__meta-item--grow">
76
+ <h2 class="govuk-visually-hidden">Support links</h2>
77
+ {{$footerSupportLinks}}{{/footerSupportLinks}}
78
+
79
+ <svg aria-hidden="true" focusable="false" class="govuk-footer__licence-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 483.2 195.7" height="17" width="41">
80
+ <path fill="currentColor" d="M421.5 142.8V.1l-50.7 32.3v161.1h112.4v-50.7zm-122.3-9.6A47.12 47.12 0 0 1 221 97.8c0-26 21.1-47.1 47.1-47.1 16.7 0 31.4 8.7 39.7 21.8l42.7-27.2A97.63 97.63 0 0 0 268.1 0c-36.5 0-68.3 20.1-85.1 49.7A98 98 0 0 0 97.8 0C43.9 0 0 43.9 0 97.8s43.9 97.8 97.8 97.8c36.5 0 68.3-20.1 85.1-49.7a97.76 97.76 0 0 0 149.6 25.4l19.4 22.2h3v-87.8h-80l24.3 27.5zM97.8 145c-26 0-47.1-21.1-47.1-47.1s21.1-47.1 47.1-47.1 47.2 21 47.2 47S123.8 145 97.8 145"></path>
81
+ </svg>
82
+
83
+ <span class="govuk-footer__licence-description">{{$licenceMessage}}All content is available under the <a href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/" id="open-government-licence" class="govuk-footer__link" target="_blank" rel="license">Open Government Licence v3.0</a>, except where otherwise stated{{/licenceMessage}}</span>
84
+ </div>
85
+
86
+ <div class="govuk-footer__meta-item">
87
+ <a class="govuk-footer__link govuk-footer__copyright-logo" id="copyright-logo" target="_blank" href="https://www.nationalarchives.gov.uk/information-management/re-using-public-sector-information/uk-government-licensing-framework/crown-copyright/">{{$crownCopyrightMessage}}© Crown copyright{{/crownCopyrightMessage}}</a>
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </footer>
92
+
93
+ <div id="global-app-error" class="app-error hidden"></div>
94
+
95
+
96
+ {{$bodyEnd}}{{/bodyEnd}}
97
+
98
+
99
+ <script {{#nonce}}nonce="{{nonce}}"{{/nonce}}>if (typeof window.GOVUK === 'undefined') document.body.className = document.body.className.replace('js-enabled', '');</script>
100
+
101
+ </body>
102
+ </html>
@@ -14,6 +14,8 @@ const PARTIALS = [
14
14
  'partials/forms/input-text-group',
15
15
  'partials/forms/input-text-date',
16
16
  'partials/forms/input-submit',
17
+ 'partials/forms/grouped-inputs-select',
18
+ 'partials/forms/grouped-inputs-text',
17
19
  'partials/forms/select',
18
20
  'partials/forms/checkbox',
19
21
  'partials/forms/textarea-group',
@@ -214,6 +216,7 @@ module.exports = function (options) {
214
216
  labelClassName: labelClassName ? `govuk-label ${labelClassName}` : 'govuk-label',
215
217
  formGroupClassName: classNames(field, 'formGroupClassName') || extension.formGroupClassName || 'govuk-form-group',
216
218
  hint: hint,
219
+ amountWithUnitSelectItemClassName: 'grouped-inputs__item',
217
220
  hintId: extension.hintId || (hint ? key + '-hint' : null),
218
221
  error: this.errors && this.errors[key],
219
222
  maxlengthAttribute: field.maxlengthAttribute === true,
@@ -222,6 +225,7 @@ module.exports = function (options) {
222
225
  required: required,
223
226
  pattern: extension.pattern,
224
227
  date: extension.date,
228
+ amountWithUnitSelect: extension.amountWithUnitSelect,
225
229
  autocomplete: autocomplete,
226
230
  child: field.child,
227
231
  isPageHeading: field.isPageHeading,
@@ -232,7 +236,7 @@ module.exports = function (options) {
232
236
  });
233
237
  }
234
238
 
235
- function optionGroup(key, opts) {
239
+ function optionGroup(key, opts, pKey = key) {
236
240
  opts = opts || {};
237
241
  const field = Object.assign({}, this.options.fields[key] || options.fields[key]);
238
242
  const legend = field.legend;
@@ -248,6 +252,7 @@ module.exports = function (options) {
248
252
  legendValue = legend.value;
249
253
  }
250
254
  }
255
+
251
256
  return {
252
257
  key: key,
253
258
  error: this.errors && this.errors[key],
@@ -270,15 +275,16 @@ module.exports = function (options) {
270
275
 
271
276
  if (typeof obj === 'string') {
272
277
  value = obj;
273
- label = 'fields.' + key + '.options.' + obj + '.label';
274
- optionHint = 'fields.' + key + '.options.' + obj + '.hint';
278
+ // pKey - optional param that demotes parent key for group components - set to key param val by default
279
+ label = 'fields.' + pKey + '.options.' + obj + '.label';
280
+ optionHint = 'fields.' + pKey + '.options.' + obj + '.hint';
275
281
  } else {
276
282
  value = obj.value;
277
- label = obj.label || 'fields.' + key + '.options.' + obj.value + '.label';
283
+ label = obj.label || 'fields.' + pKey + '.options.' + obj.value + '.label';
278
284
  toggle = obj.toggle;
279
285
  child = obj.child;
280
286
  useHintText = obj.useHintText;
281
- optionHint = obj.hint || 'fields.' + key + '.options.' + obj.value + '.hint';
287
+ optionHint = obj.hint || 'fields.' + pKey + '.options.' + obj.value + '.hint';
282
288
  }
283
289
 
284
290
  if (this.values && this.values[key] !== undefined) {
@@ -469,6 +475,50 @@ module.exports = function (options) {
469
475
  return parts.concat(monthPart, yearPart).join('\n');
470
476
  };
471
477
  }
478
+ },
479
+ 'input-amount-with-unit-select': {
480
+ handler: function () {
481
+ return function (key) {
482
+ key = (key === '{{key}}' || key === '' || key === undefined) ? hoganRender(key, this) : key;
483
+ const field = Object.assign({}, this.options.fields[key] || options.fields[key]);
484
+
485
+ let autocomplete = field.autocomplete || 'off';
486
+ if (autocomplete === 'off') {
487
+ autocomplete = { amount: 'off'};
488
+ } else if (typeof autocomplete === 'string') {
489
+ autocomplete = { amount: autocomplete + '-amount' };
490
+ }
491
+
492
+ const formGroupClassName = (field.formGroup && field.formGroup.className) ? field.formGroup.className : '';
493
+ const classNameAmount = (field.controlsClass && field.controlsClass.amount) ? field.controlsClass.amount : 'govuk-input--width-3';
494
+ const classNameUnit = (field.controlsClass && field.controlsClass.unit) ? field.controlsClass.unit : 'govuk-input--width-5';
495
+
496
+ const parts = [];
497
+
498
+ // basically does the '_.each(mixins, function (mixin, name)' part manually (which renders the HTML
499
+ // for both child components and looks for a 'renderWith' and optional 'Options' method to use)
500
+ const amountPart = compiled['partials/forms/grouped-inputs-text']
501
+ .render(inputText.call(this,
502
+ key + '-amount', {
503
+ formGroupClassName,
504
+ autocomplete: autocomplete.amount,
505
+ className: classNameAmount,
506
+ amountWithUnitSelect: true }
507
+ ));
508
+
509
+ const unitPart = compiled['partials/forms/grouped-inputs-select']
510
+ .render(inputText.call(this, key + '-unit',
511
+ optionGroup.call(this,
512
+ key + '-unit', {
513
+ formGroupClassName,
514
+ className: classNameUnit,
515
+ amountWithUnitSelect: true },
516
+ key
517
+ )));
518
+
519
+ return parts.concat(amountPart, unitPart).join('\n');
520
+ };
521
+ }
472
522
  }
473
523
  };
474
524
 
@@ -0,0 +1,13 @@
1
+ <div class="{{amountWithUnitSelectItemClassName}}">
2
+ <div id="{{id}}-group" class="{{#compound}} form-group-compound{{/compound}}{{#formGroupClassName}} {{formGroupClassName}}{{/formGroupClassName}}">
3
+ <label for="{{id}}" class="{{labelClassName}}">
4
+ <span class="label-text">{{{label}}}</span>
5
+ </label>
6
+ {{#hint}}<div {{$hintId}}id="{{hintId}}" {{/hintId}}class="govuk-hint">{{{hint}}}</div>{{/hint}}
7
+ <select id="{{id}}" class="govuk-select{{#className}} {{className}}{{/className}}{{#error}} govuk-select--error{{/error}}" name="{{id}}" aria-required="{{required}}">
8
+ {{#options}}
9
+ <option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
10
+ {{/options}}
11
+ </select>
12
+ </div>
13
+ </div>
@@ -0,0 +1,37 @@
1
+ <div class="{{amountWithUnitSelectItemClassName}}">
2
+ <div id="{{id}}-group" class="{{#formGroupClassName}} {{formGroupClassName}}{{/formGroupClassName}}">
3
+ <label for="{{id}}" class="{{labelClassName}}">
4
+ <span class="label-text">{{{label}}}</span>
5
+ </label>
6
+ {{#hint}}<span {{$hintId}}id="{{hintId}}" {{/hintId}}class="govuk-hint">{{{hint}}}</span>{{/hint}}
7
+ {{#renderChild}}{{/renderChild}}
8
+ {{#attributes}}
9
+ {{#prefix}}
10
+ <div class="govuk-input__prefix" aria-hidden="true">{{prefix}}</div>
11
+ {{/prefix}}
12
+ {{/attributes}}
13
+ <input
14
+ type="{{type}}"
15
+ name="{{id}}"
16
+ id="{{id}}"
17
+ class="govuk-input{{#className}} {{className}}{{/className}}{{#error}} govuk-input--error{{/error}}"
18
+ aria-required="{{required}}"
19
+ {{#value}} value="{{value}}"{{/value}}
20
+ {{#min}} min="{{min}}"{{/min}}
21
+ {{#max}} max="{{max}}"{{/max}}
22
+ {{#maxlength}} maxlength="{{maxlength}}"{{/maxlength}}
23
+ {{#pattern}} pattern="{{pattern}}"{{/pattern}}
24
+ {{#hintId}} aria-describedby="{{hintId}}"{{/hintId}}
25
+ {{#error}} aria-invalid="true"{{/error}}
26
+ {{#autocomplete}} autocomplete="{{autocomplete}}"{{/autocomplete}}
27
+ {{#attributes}}
28
+ {{attribute}}="{{value}}"
29
+ {{/attributes}}
30
+ >
31
+ {{#attributes}}
32
+ {{#suffix}}
33
+ <div class="govuk-input__prefix" aria-hidden="true">{{suffix}}</div>
34
+ {{/suffix}}
35
+ {{/attributes}}
36
+ </div>
37
+ </div>
@@ -0,0 +1,5 @@
1
+ .grouped-inputs__item {
2
+ display: inline-block;
3
+ margin-right: 20px;
4
+ margin-bottom: 0;
5
+ }
@@ -27,6 +27,7 @@ $path: "/public/images/" !default;
27
27
  @import "check_your_answers";
28
28
  @import "pdf";
29
29
  @import "session-timeout-dialog";
30
+ @import "grouped-input";
30
31
 
31
32
  // Modules
32
33
  @import "modules/validation";
@@ -85,6 +85,10 @@ function formFocus() {
85
85
  document.getElementById(getElementFromSummaryLink + '-day').focus();
86
86
  }
87
87
 
88
+ if (document.getElementById(getElementFromSummaryLink + '-amount') && forms.length === 1 && editMode) {
89
+ document.getElementById(getElementFromSummaryLink + '-amount').focus();
90
+ }
91
+
88
92
  if (forms.length > 0) {
89
93
  labels = document.getElementsByTagName('label');
90
94
  if (labels) {
package/lib/encryption.js CHANGED
@@ -1,23 +1,49 @@
1
- /* eslint-disable */
2
1
  'use strict';
3
2
 
4
- const crypto = require('crypto');
3
+ const crypto = require('node:crypto');
5
4
  const algorithm = 'aes-256-cbc';
5
+ const ivLength = 16;
6
6
 
7
- module.exports = password => ({
8
-
9
- encrypt: text => {
10
- const cipher = crypto.createCipher(algorithm, password);
11
- let crypted = cipher.update(text, 'utf8', 'hex');
12
- crypted += cipher.final('hex');
13
- return crypted;
14
- },
15
-
16
- decrypt: text => {
17
- const decipher = crypto.createDecipher(algorithm, password);
18
- let dec = decipher.update(text, 'hex', 'utf8');
19
- dec += decipher.final('utf8');
20
- return dec;
7
+ /**
8
+ * Creates an encryption utility with AES-256-CBC algorithm.
9
+ * Provides encrypt and decrypt methods that use a random IV for each encryption operation.
10
+ *
11
+ * @module encryption
12
+ * @param {string|Buffer} secret - Must be exactly 32 bytes
13
+ * @returns {Object} Encryption utility object
14
+ * @throws {Error} If secret is not exactly 32 bytes
15
+ */
16
+ module.exports = secret => {
17
+ const encryptionKey = Buffer.from(secret, 'utf8');
18
+ if (encryptionKey.byteLength !== 32) {
19
+ throw new Error(`Encryption secret must be exactly 32 bytes. Provided: ${encryptionKey.byteLength} bytes.`);
21
20
  }
22
21
 
23
- });
22
+ return {
23
+ encrypt: text => {
24
+ try {
25
+ const iv = crypto.randomBytes(ivLength);
26
+ const cipher = crypto.createCipheriv(algorithm, encryptionKey, iv);
27
+ let encrypted = cipher.update(text, 'utf8');
28
+ encrypted = Buffer.concat([encrypted, cipher.final()]);
29
+ return iv.toString('hex') + ':' + encrypted.toString('hex');
30
+ } catch (error) {
31
+ throw new Error(`Encryption failed: ${error.message}`);
32
+ }
33
+ },
34
+
35
+ decrypt: text => {
36
+ try {
37
+ const textParts = text.split(':');
38
+ const iv = Buffer.from(textParts.shift(), 'hex');
39
+ const encryptedText = Buffer.from(textParts.join(':'), 'hex');
40
+ const decipher = crypto.createDecipheriv(algorithm, encryptionKey, iv);
41
+ let decrypted = decipher.update(encryptedText);
42
+ decrypted = Buffer.concat([decrypted, decipher.final()]);
43
+ return decrypted.toString('utf8');
44
+ } catch (error) {
45
+ throw new Error(`Decryption failed: ${error.message}`);
46
+ }
47
+ }
48
+ };
49
+ };
package/lib/sessions.js CHANGED
@@ -10,8 +10,11 @@ const secureHttps = config => config.protocol === 'https' || config.env === 'pro
10
10
  module.exports = (app, config) => {
11
11
  const logger = config.logger || console;
12
12
 
13
- if (config.env === 'production' && config.session.secret === 'changethis') {
14
- throw new Error('Session secret must be set to a unique random string for production environments');
13
+ const secretBuffer = Buffer.from(config.session.secret, 'utf8');
14
+ if (secretBuffer.byteLength !== 32) {
15
+ throw new Error(
16
+ `Session secret must be exactly 32 bytes. Current: ${secretBuffer.byteLength} bytes.`
17
+ );
15
18
  }
16
19
 
17
20
  app.use(cookieParser(config.session.secret, {
package/lib/settings.js CHANGED
@@ -42,7 +42,7 @@ module.exports = async (app, config) => {
42
42
  viewsArray.slice().reverse().forEach(view => {
43
43
  const customViewPath = path.resolve(config.root, view);
44
44
  try {
45
- fs.accessSync(customViewPath, fs.F_OK);
45
+ fs.accessSync(customViewPath, fs.constants.F_OK);
46
46
  } catch (err) {
47
47
  throw new Error(`Cannot find views at ${customViewPath}`);
48
48
  }
package/package.json CHANGED
@@ -1,13 +1,12 @@
1
1
  {
2
2
  "name": "hof",
3
3
  "description": "A bootstrap for HOF projects",
4
- "version": "22.11.1",
4
+ "version": "22.11.6-unit-of-measure-beta.3",
5
5
  "license": "MIT",
6
6
  "main": "index.js",
7
7
  "author": "HomeOffice",
8
8
  "engines": {
9
- "node": ">=10.22.1",
10
- "npm": ">=6.14.0"
9
+ "node": ">=14.0.0"
11
10
  },
12
11
  "bin": {
13
12
  "hof-build": "./bin/hof-build",