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.
- package/CHANGELOG.md +47 -1
- package/README.md +96 -5
- package/build/tasks/vite/index.js +6 -7
- package/build/tasks/vite/vite.config.js +1 -27
- package/components/amount-with-unit-select/fields.js +15 -0
- package/components/amount-with-unit-select/hooks.js +168 -0
- package/components/amount-with-unit-select/index.js +107 -0
- package/components/amount-with-unit-select/templates/amount-with-unit-select.html +20 -0
- package/components/amount-with-unit-select/utils.js +197 -0
- package/components/amount-with-unit-select/validation.js +175 -0
- package/components/index.js +1 -0
- package/controller/controller.js +5 -3
- package/controller/validation/index.js +1 -1
- package/controller/validation/validators.js +0 -1
- package/frontend/template-mixins/mixins/template-mixins.js +55 -5
- package/frontend/template-mixins/partials/forms/grouped-inputs-select.html +13 -0
- package/frontend/template-mixins/partials/forms/grouped-inputs-text.html +37 -0
- package/frontend/themes/gov-uk/styles/_grouped-input.scss +5 -0
- package/frontend/themes/gov-uk/styles/govuk.scss +1 -0
- package/frontend/toolkit/assets/javascript/form-focus.js +4 -0
- package/lib/sessions.js +18 -7
- package/package.json +9 -4
- package/sandbox/apps/sandbox/fields.js +18 -1
- package/sandbox/apps/sandbox/index.js +4 -0
- package/sandbox/apps/sandbox/sections/summary-data-sections.js +7 -1
- package/sandbox/apps/sandbox/translations/src/en/fields.json +10 -0
- package/sandbox/apps/sandbox/translations/src/en/pages.json +3 -0
- package/sandbox/apps/sandbox/translations/src/en/validation.json +12 -0
- package/sandbox/server.js +0 -8
- package/utilities/autofill/index.js +169 -145
- package/frontend/govuk-template/govuk_template_generated.html +0 -118
- package/sandbox/apps/sandbox/translations/en/default.json +0 -245
- package/sandbox/public/css/app.css +0 -11708
- package/sandbox/public/css/app.css.map +0 -1
- package/sandbox/public/images/govuk-logo.svg +0 -25
- package/sandbox/public/images/icons/icon-caret-left.png +0 -0
- package/sandbox/public/images/icons/icon-complete.png +0 -0
- package/sandbox/public/images/icons/icon-cross-remove-sign.png +0 -0
- package/sandbox/public/js/bundle.js +0 -60
- 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
|
+
};
|
package/components/index.js
CHANGED
|
@@ -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'),
|
package/controller/controller.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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.' +
|
|
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.' +
|
|
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>
|
|
@@ -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
|
-
|
|
11
|
-
|
|
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(
|
|
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
|
-
|
|
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')(
|
|
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:
|
|
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:
|
|
88
|
+
secret: sessionSecret,
|
|
78
89
|
saveUninitialized: true,
|
|
79
90
|
resave: true
|
|
80
91
|
}, config.session);
|