hof 23.0.2-vite-sourcemap-beta → 23.0.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 (40) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +96 -5
  3. package/build/tasks/vite/index.js +6 -7
  4. package/build/tasks/vite/vite.config.js +1 -27
  5. package/components/amount-with-unit-select/fields.js +15 -0
  6. package/components/amount-with-unit-select/hooks.js +168 -0
  7. package/components/amount-with-unit-select/index.js +107 -0
  8. package/components/amount-with-unit-select/templates/amount-with-unit-select.html +20 -0
  9. package/components/amount-with-unit-select/utils.js +197 -0
  10. package/components/amount-with-unit-select/validation.js +175 -0
  11. package/components/index.js +1 -0
  12. package/controller/controller.js +5 -3
  13. package/controller/validation/index.js +1 -1
  14. package/controller/validation/validators.js +0 -1
  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/sessions.js +18 -7
  22. package/package.json +9 -4
  23. package/sandbox/apps/sandbox/fields.js +18 -1
  24. package/sandbox/apps/sandbox/index.js +4 -0
  25. package/sandbox/apps/sandbox/sections/summary-data-sections.js +7 -1
  26. package/sandbox/apps/sandbox/translations/src/en/fields.json +10 -0
  27. package/sandbox/apps/sandbox/translations/src/en/pages.json +3 -0
  28. package/sandbox/apps/sandbox/translations/src/en/validation.json +12 -0
  29. package/sandbox/server.js +0 -8
  30. package/utilities/autofill/index.js +169 -145
  31. package/frontend/govuk-template/govuk_template_generated.html +0 -118
  32. package/sandbox/apps/sandbox/translations/en/default.json +0 -245
  33. package/sandbox/public/css/app.css +0 -11708
  34. package/sandbox/public/css/app.css.map +0 -1
  35. package/sandbox/public/images/govuk-logo.svg +0 -25
  36. package/sandbox/public/images/icons/icon-caret-left.png +0 -0
  37. package/sandbox/public/images/icons/icon-complete.png +0 -0
  38. package/sandbox/public/images/icons/icon-cross-remove-sign.png +0 -0
  39. package/sandbox/public/js/bundle.js +0 -60
  40. package/sandbox/public/js/bundle.js.map +0 -1
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+
3
+ const _ = require('lodash');
4
+
5
+ /**
6
+ * Translates a field property (given its key and a translation function),
7
+ * returning null if there is no translation (enabling conditional statements to branch if there is no translation).
8
+ * @param {string} key - The key of the field property to translate
9
+ * (E.G. 'fields.amountWithUnitSelect-amount.label' - references the amount field label).
10
+ * @param {function(string): string} translate - The translation function to apply.
11
+ * @returns {string|null} Returns the translation or null if there is no translation.
12
+ */
13
+ const conditionalTranslate = (key, translate) => {
14
+ let result = translate(key);
15
+ if (result === key) {
16
+ result = null;
17
+ }
18
+ return result;
19
+ };
20
+
21
+ /**
22
+ * Gets the classname specified for the field's legend.
23
+ * Defaults to an empty string if not specified.
24
+ * @param {object} field - The field with (potentially) the legend classname.
25
+ * @returns {string} The classname specified for the field's legend text.
26
+ */
27
+ const getLegendClassName = field =>
28
+ field?.legend?.className || '';
29
+
30
+ /**
31
+ * Gets a boolean determining if the field's heading has been set to be the page heading.
32
+ * @param {Object} field - The field with the heading to (potentially) make the page title.
33
+ * @returns {string} A boolean that indicates if the field's heading is also the page heading.
34
+ */
35
+ const getIsPageHeading = field =>
36
+ field?.isPageHeading || '';
37
+
38
+ /**
39
+ * Creates a new object with the component's assigned 'amount' and 'unit' values.
40
+ * It does this by looking for the 'amountWithUnitSelect-amount' and 'amountWithUnitSelect-unit' fields in req.body
41
+ * and making a new object that copies those 2 fields and values, and removes the 'amountWithUnitSelect-' suffix.
42
+ * @param {Object} body - The submitted request's body (req.body) containing K:V pairs
43
+ * (E.G. 'amountWithUnitSelect-amount : 12').
44
+ * @param {Object} fields - The set of fields relevant to this component (I.E. fields defined in ./fields.js).
45
+ * @param {string} key - The grouped child fields' parent name/key ('amountWithUnitSelect' in this case).
46
+ * @returns {{ amount: string, unit: string }} Returns a map of values in the format { amount: '1', unit: 'litres' }.
47
+ */
48
+ const getParts = (body, fields, key) =>
49
+ _.mapKeys(_.pick(body, Object.keys(fields)), (value, fieldKey) =>
50
+ fieldKey.replace(`${key}-`, '')
51
+ );
52
+
53
+ /**
54
+ * Splits the AmountWithUnitSelect value (usually in the format '[Amount]-[Unit]') by the last hyphen in the text
55
+ * into an Array with 2 elements (the amount and unit - the value before and after the hyphen respectively).
56
+ * Returns an empty string for each element if in an unexpected format.
57
+ * @param {string} amountWithUnitSelectVal - The amountWithUnitSelect value (E.G. '1-Litre').
58
+ * @returns {string[]} Returns an array in format [amount, unit].
59
+ */
60
+ const getAmountWithUnitSelectValues = amountWithUnitSelectVal => {
61
+ const splitPointIndex = typeof amountWithUnitSelectVal === 'string' ?
62
+ amountWithUnitSelectVal.lastIndexOf('-') :
63
+ -1;
64
+
65
+ return splitPointIndex === -1 ?
66
+ ['', ''] :
67
+ [amountWithUnitSelectVal.substring(0, splitPointIndex), amountWithUnitSelectVal.substring(splitPointIndex + 1)];
68
+ };
69
+
70
+ /**
71
+ * Returns a map of the component's fields' keys and their assigned values.
72
+ * (E.G. If amountWithUnitSelectVal = 1-L,
73
+ * it returns { amountWithUnitSelect-amount: '1', amountWithUnitSelect-unit: 'L' }).
74
+ * @param {string} amountWithUnitSelectVal - AmountWithUnitSelect value in the format '[Amount]-[Unit]'.
75
+ * @param {Object} fields - The component's child field definitions and configurations.
76
+ * @returns {amountWithUnitSelect-amount: string, amountWithUnitSelect-unit: string} Returns a map of K:V pairs
77
+ * for each child field.
78
+ */
79
+ const getPartsFromAmountWithUnitSelect = (amountWithUnitSelectVal, fields) =>
80
+ getAmountWithUnitSelectValues(amountWithUnitSelectVal).reduce(
81
+ (obj, value, index) => Object.assign({},
82
+ obj,
83
+ {[fields[index]]: value }),
84
+ {});
85
+
86
+ /**
87
+ * Translates the labels of the child fields of the AmountWithUnitSelect component.
88
+ * Depending on which exists, labels are set in the following order of precedence:
89
+ * 1. A specific child-component translation (I.E. amountWithUnitSelect-amount.label).
90
+ * 2. A parent-component translation for the child field (I.E. amountWithUnitSelect.amountLabel).
91
+ * 3. A specific child-component (non-translated) label configuration.
92
+ * 4. A parent-component (non-translated) label configuration for the child field.
93
+ * @param {Object} req - The form's request object.
94
+ * @param {Object} fields - The component's child field definitions and configurations.
95
+ * @param {string} pKey - The parent component's key (E.G. 'amountWithUnitSelect').
96
+ * @param {string[]} keys - The list of child components (by keys) to translate (E.G. ['amount', 'unit']).
97
+ */
98
+ const translateLabels = (req, fields, pKey, keys) => {
99
+ keys.forEach(key => {
100
+ fields[`${pKey}-${key}`].label =
101
+ conditionalTranslate(`fields.${pKey}-${key}.label`, req.translate) ||
102
+ conditionalTranslate(`fields.${pKey}.${key}Label`, req.translate) ||
103
+ fields[`${pKey}-${key}`].label ||
104
+ req.form.options.fields[`${pKey}`]?.[`${key}Label`];
105
+ });
106
+ };
107
+
108
+ /**
109
+ * Adds the component's child fields to the request's form options fields (req.form.options.fields).
110
+ * @param {Object} req - The form's request object.
111
+ * @param {Object} fields - The component's child field definitions and configurations.
112
+ * @param {string} key - The parent component's key (E.G. 'amountWithUnitSelect').
113
+ */
114
+ const addChildFieldsToRequestForm = (req, fields, key) => {
115
+ Object.assign(req.form.options.fields, _.mapValues(fields, (v, k) => {
116
+ const rawKey = k.replace(`${key}-`, '');
117
+ const labelKey = `fields.${key}.parts.${rawKey}`;
118
+ const label = req.translate(labelKey);
119
+
120
+ return Object.assign({}, v, {
121
+ label: label === labelKey ? v.label : label
122
+ });
123
+ }));
124
+ };
125
+
126
+ /**
127
+ * Sets a default null option for a select component if undefined.
128
+ * The default null option has a label of 'Select...' and a value of an empty string.
129
+ * @param {Object[]} options - The dropdown menu options.
130
+ * @returns {Object[]} Returns the options with the null/default option resolved.
131
+ */
132
+ const resolveNullOption = options => {
133
+ const nullOptionLabel = options.find(opt => opt.null !== undefined && Object.keys(opt).length === 1);
134
+ const nonNullOptions = options.filter(opt => opt.null === undefined || Object.keys(opt).length !== 1);
135
+
136
+ return [{label: (nullOptionLabel !== undefined ? nullOptionLabel.null : 'Select...'), value: ''}]
137
+ .concat(nonNullOptions);
138
+ };
139
+
140
+ /**
141
+ * Translates the unit field's/component's options (dropdown menu options) for the AmountWithUnitSelect component.
142
+ * Also resolves the null/default select option's label and value.
143
+ * @param {Object} req - The form's request object.
144
+ * @param {Object} fields - The component's child field definitions and configurations.
145
+ * @param {Object} options - The component's configuration options.
146
+ * @param {String} key - The parent component's key (E.G. 'amountWithUnitSelect').
147
+ */
148
+ const translateUnitOptions = (req, fields, options, key) => {
149
+ // sets unit options as either translations (if they exist) or default untranslated options
150
+ const optionsToDisplay = conditionalTranslate(`fields.${key}-unit.options`, req.translate) ||
151
+ conditionalTranslate(`fields.${key}.options`, req.translate) ||
152
+ options.options;
153
+
154
+ // resolves the null/default select option
155
+ fields[`${key}-unit`].options = resolveNullOption(optionsToDisplay);
156
+ };
157
+
158
+ /**
159
+ * Constructs an object with field data required to render the AmountWithUnitSelect component.
160
+ * @param {Object} req - The form's request object.
161
+ * @param {Object} fields - The component's child field definitions and configurations.
162
+ * @param {Object} options - The component's configuration options.
163
+ * @param {string} key - The parent component's key.
164
+ * @returns {Object} Returns an object with field data required to render the component.
165
+ */
166
+ const constructFieldToRender = (req, fields, options, key) => {
167
+ addChildFieldsToRequestForm(req, fields, key);
168
+
169
+ const reqForm = req.form;
170
+ const legend = conditionalTranslate(`fields.${key}.legend`, req.translate) ||
171
+ reqForm.options.fields[`${key}`]?.legend;
172
+ const hint = conditionalTranslate(`fields.${key}.hint`, req.translate) ||
173
+ reqForm.options.fields[`${key}`]?.hint;
174
+ const legendClassName = getLegendClassName(options);
175
+ const isPageHeading = getIsPageHeading(options);
176
+ const error = reqForm.errors && (
177
+ (reqForm.errors[key]?.type && reqForm.errors[key]) ||
178
+ reqForm.errors[`${key}-amount`]?.type && reqForm.errors[`${key}-amount`] ||
179
+ reqForm.errors[`${key}-unit`]?.type && reqForm.errors[`${key}-unit`]
180
+ );
181
+
182
+ return { key, legend, legendClassName, isPageHeading, hint, error };
183
+ };
184
+
185
+ module.exports = {
186
+ conditionalTranslate,
187
+ getLegendClassName,
188
+ getIsPageHeading,
189
+ getParts,
190
+ getAmountWithUnitSelectValues,
191
+ getPartsFromAmountWithUnitSelect,
192
+ translateLabels,
193
+ addChildFieldsToRequestForm,
194
+ resolveNullOption,
195
+ translateUnitOptions,
196
+ constructFieldToRender
197
+ };
@@ -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'),
8
9
  emailer: require('./emailer'),
9
10
  homeOfficeCountries: require('./homeoffice-countries'),
10
11
  notify: require('./notify'),
@@ -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
  };
@@ -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) {
@@ -470,6 +476,50 @@ module.exports = function (options) {
470
476
  return parts.concat(monthPart, yearPart).join('\n');
471
477
  };
472
478
  }
479
+ },
480
+ 'input-amount-with-unit-select': {
481
+ handler: function () {
482
+ return function (key) {
483
+ key = (key === '{{key}}' || key === '' || key === undefined) ? hoganRender(key, this) : key;
484
+ const field = Object.assign({}, this.options.fields[key] || options.fields[key]);
485
+
486
+ let autocomplete = field.autocomplete || 'off';
487
+ if (autocomplete === 'off') {
488
+ autocomplete = { amount: 'off'};
489
+ } else if (typeof autocomplete === 'string') {
490
+ autocomplete = { amount: autocomplete + '-amount' };
491
+ }
492
+
493
+ const formGroupClassName = (field.formGroup && field.formGroup.className) ? field.formGroup.className : '';
494
+ const classNameAmount = (field.controlsClass && field.controlsClass.amount) ? field.controlsClass.amount : 'govuk-input--width-3';
495
+ const classNameUnit = (field.controlsClass && field.controlsClass.unit) ? field.controlsClass.unit : 'govuk-input--width-5';
496
+
497
+ const parts = [];
498
+
499
+ // basically does the '_.each(mixins, function (mixin, name)' part manually (which renders the HTML
500
+ // for both child components and looks for a 'renderWith' and optional 'Options' method to use)
501
+ const amountPart = compiled['partials/forms/grouped-inputs-text']
502
+ .render(inputText.call(this,
503
+ key + '-amount', {
504
+ formGroupClassName,
505
+ autocomplete: autocomplete.amount,
506
+ className: classNameAmount,
507
+ amountWithUnitSelect: true }
508
+ ));
509
+
510
+ const unitPart = compiled['partials/forms/grouped-inputs-select']
511
+ .render(inputText.call(this, key + '-unit',
512
+ optionGroup.call(this,
513
+ key + '-unit', {
514
+ formGroupClassName,
515
+ className: classNameUnit,
516
+ amountWithUnitSelect: true },
517
+ key
518
+ )));
519
+
520
+ return parts.concat(amountPart, unitPart).join('\n');
521
+ };
522
+ }
473
523
  }
474
524
  };
475
525
 
@@ -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: "/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/sessions.js CHANGED
@@ -7,17 +7,28 @@ const cookieParser = require('cookie-parser');
7
7
 
8
8
  const secureHttps = config => config.protocol === 'https' || config.env === 'production';
9
9
 
10
- module.exports = (app, config) => {
11
- const logger = config.logger || console;
10
+ const validateSessionSecret = secret => {
11
+ if (!secret || !String(secret).trim()) {
12
+ throw new Error(
13
+ 'Session secret is required. Set the SESSION_SECRET environment variable to a 32-byte value.'
14
+ );
15
+ }
12
16
 
13
- const secretBuffer = Buffer.from(config.session.secret, 'utf8');
17
+ const secretBuffer = Buffer.from(secret, 'utf8');
14
18
  if (secretBuffer.byteLength !== 32) {
15
19
  throw new Error(
16
20
  `Session secret must be exactly 32 bytes. Current: ${secretBuffer.byteLength} bytes.`
17
21
  );
18
22
  }
19
23
 
20
- app.use(cookieParser(config.session.secret, {
24
+ return secret;
25
+ };
26
+
27
+ module.exports = (app, config) => {
28
+ const logger = config.logger || console;
29
+ const sessionSecret = validateSessionSecret(config.session.secret);
30
+
31
+ app.use(cookieParser(sessionSecret, {
21
32
  path: '/',
22
33
  httpOnly: true,
23
34
  secure: secureHttps(config)
@@ -32,7 +43,7 @@ module.exports = (app, config) => {
32
43
  }));
33
44
  }
34
45
 
35
- const encryption = require('./encryption')(config.session.secret);
46
+ const encryption = require('./encryption')(sessionSecret);
36
47
  const RedisStore = connectRedis(session);
37
48
  const client = redis.createClient(config.redis);
38
49
 
@@ -57,7 +68,7 @@ module.exports = (app, config) => {
57
68
  const store = new RedisStore({
58
69
  client: client,
59
70
  ttl: config.session.ttl,
60
- secret: config.session.secret,
71
+ secret: sessionSecret,
61
72
  serializer: {
62
73
  parse: data => JSON.parse(encryption.decrypt(data)),
63
74
  stringify: data => encryption.encrypt(JSON.stringify(data))
@@ -74,7 +85,7 @@ module.exports = (app, config) => {
74
85
  sameSite: config.cookie?.sameSite === 'lax' ? config.cookie?.sameSite : 'strict',
75
86
  httpOnly: true
76
87
  },
77
- secret: config.session.secret,
88
+ secret: sessionSecret,
78
89
  saveUninitialized: true,
79
90
  resave: true
80
91
  }, config.session);