Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module.exports = {
clearSession: require('./clear-session'),
combineAndLoopFields: require('./combine-and-loop-fields'),
date: require('./date'),
time: require('./time'),
emailer: require('./emailer'),
homeOfficeCountries: require('./homeoffice-countries'),
notify: require('./notify'),
Expand Down
12 changes: 12 additions & 0 deletions components/time/fields.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use strict';

module.exports = key => ({
[`${key}-hour`]: {
label: 'Hour',
autocomplete: 'time-hour'
},
[`${key}-minute`]: {
label: 'Minute',
autocomplete: 'time-minute'
}
});
168 changes: 168 additions & 0 deletions components/time/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
'use strict';

const _ = require('lodash');
const path = require('path');
const getFields = require('./fields');

const TEMPLATE = path.resolve(__dirname, './templates/time.html');

// utility function taking the req.body, fields and key,
// returns a map of values in the format:
// {
// hour: '12',
// minute: '01',
// }

const getParts = (body, fields, key) =>
_.mapKeys(_.pick(body, Object.keys(fields)), (value, fieldKey) =>
fieldKey.replace(`${key}-`, '')
);

// accepts a time value in the format kk-mm and fields config,
// returns a map of key: value pairs for the intermedate fields
const getPartsFromTime = (time, fields) =>
time.split(':')
.slice()
.reduce((obj, value, index) => Object.assign({}, obj, {
[fields[index]]: value
}), {});

// preprend '0' if number is only a single digit
const pad = num => num !== '' && num.length < 2 ? `0${num}` : num;

const conditionalTranslate = (key, translate) => {
let result = translate(key);
if (result === key) {
result = null;
}
return result;
};

const getLegendClassName = field => field && field.legend && field.legend.className || '';
const getIsPageHeading = field => field && field.isPageHeading || '';

module.exports = (key, opts) => {
if (!key) {
throw new Error('Key must be passed to time component');
}
const options = opts || {};
const template = options.template ?
path.resolve(__dirname, options.template) :
TEMPLATE;
const fields = getFields(key);

options.validate = _.uniq(options.validate ? ['time'].concat(options.validate) : ['time']);

let hourOptional = !!options.hourOptional;
const minuteOptional = !!options.minuteOptional;

if (minuteOptional) {
hourOptional = true;
}

// take the 2 time parts, padding or defaulting
// if applicable, then create a time value in the
// format kk:mm. Save to req.body for processing
const preProcess = (req, res, next) => {
const parts = getParts(req.body, fields, key);
if (_.some(parts, part => part !== '')) {
if (hourOptional && parts.hour === '') {
parts.hour = '01';
} else {
parts.hour = pad(parts.hour);
}
if (minuteOptional && parts.minute === '') {
parts.minute = '00';
} else {
parts.minute = pad(parts.minute);
}
req.body[key] = `${parts.hour}:${parts.minute}`;
}
next();
};

const postProcess = (req, res, next) => {
const value = req.form.values[key];
if (value) {
req.form.values[key] = req.body[key];
}
next();
};
// if time field is included in errorValues, extend
// errorValues with the individual components
const preGetErrors = (req, res, next) => {
const errorValues = req.sessionModel.get('errorValues');
if (errorValues && errorValues[key]) {
req.sessionModel.set('errorValues',
Object.assign({}, errorValues, getPartsFromTime(errorValues[key], Object.keys(fields)))
);
}
next();
};

// if time field has any validation error, also add errors
// for the two child components. null type as we don't want to show
// duplicate messages
const postGetErrors = (req, res, next) => {
const errors = req.sessionModel.get('errors');
if (errors && errors[key]) {
Object.assign(req.form.errors, Object.keys(fields).reduce((obj, field) =>
Object.assign({}, obj, { [field]: { type: null } })
, {}));
}
next();
};

// if time value is set, split its parts and assign to req.form.values.
// This is extended with errorValues if they are present
const postGetValues = (req, res, next) => {
const time = req.form.values[key];
if (time) {
Object.assign(
req.form.values,
getPartsFromTime(time, Object.keys(fields)),
req.sessionModel.get('errorValues') || {}
);
}
next();
};

// render the template to a string, assign the html output
// to the time field in res.locals.fields
const preRender = (req, res, next) => {
Object.assign(req.form.options.fields, _.mapValues(fields, (v, k) => {
const rawKey = k.replace(`${key}-`, '');
const labelKey = `fields.${key}.parts.${rawKey}`;
const label = req.translate(labelKey);
return Object.assign({}, v, {
label: label === labelKey ? v.label : label
});
}));
const legend = conditionalTranslate(`fields.${key}.legend`, req.translate);
const hint = conditionalTranslate(`fields.${key}.hint`, req.translate);
const legendClassName = getLegendClassName(options);
const isPageHeading = getIsPageHeading(options);
const error = req.form.errors && req.form.errors[key];
res.render(template, { key, legend, legendClassName, isPageHeading, hint, error }, (err, html) => {
if (err) {
next(err);
} else {
const field = res.locals.fields.find(f => f.key === key);
Object.assign(field, { html });
next();
}
});
};

// return config extended with hooks
return Object.assign({}, options, {
hooks: {
'pre-process': preProcess,
'post-process': postProcess,
'pre-getErrors': preGetErrors,
'post-getErrors': postGetErrors,
'post-getValues': postGetValues,
'pre-render': preRender
}
});
};
20 changes: 20 additions & 0 deletions components/time/templates/time.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<div class="govuk-form-group {{#error}}govuk-form-group--error{{/error}}">
<fieldset id="{{key}}-group" class="govuk-fieldset{{#className}} {{className}}{{/className}}" role="group">
<legend class="govuk-fieldset__legend {{#isPageHeading}}govuk-fieldset__legend--l{{/isPageHeading}}{{#legendClassName}} {{legendClassName}}{{/legendClassName}}">
{{#isPageHeading}}<h1 class="govuk-fieldset__heading">{{/isPageHeading}}
{{legend}}
{{#isPageHeading}}</h1>{{/isPageHeading}}
</legend>
{{#hint}}
<span id="{{key}}-hint" class="govuk-hint">{{hint}}</span>
{{/hint}}
{{#error}}
<p id="{{key}}-error" class="govuk-error-message">
<span class="govuk-visually-hidden">Error:</span> {{error.message}}
</p>
{{/error}}
<div>
{{#input-time}}{{key}}{{/input-time}}
</div>
</fieldset>
</div>
5 changes: 5 additions & 0 deletions controller/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ module.exports = class Controller extends BaseController {
}
// eslint-disable-next-line brace-style
}
// get first field for time input control
else if (field && field.mixin === 'input-time') {
req.form.errors[key].errorLinkId = key + '-hour';
// eslint-disable-next-line brace-style
}
// get first field for date input control
else if (field && field.mixin === 'input-date') {
req.form.errors[key].errorLinkId = key + '-day';
Expand Down
13 changes: 13 additions & 0 deletions controller/validation/validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const emailValidator = require('./email');
// validator methods should return false (or falsy value) for *invalid* input
// and true (or truthy value) for *valid* input.
const dateFormat = 'YYYY-MM-DD';
const timeFormat = 'kk:mm';
let Validators;

module.exports = Validators = {
Expand Down Expand Up @@ -128,6 +129,18 @@ module.exports = Validators = {
return Validators.regex(value, /^\d{2}$/) && parseInt(value, 10) > 0 && parseInt(value, 10) < 32;
},

time(value) {
return value === '' || Validators.regex(value, /\d{2}\:\d{2}/) && moment(value, timeFormat).isValid();
},

'time-hour'(value) {
return Validators.regex(value, /^\d{2}$/) && parseInt(value, 10) >= 0 && parseInt(value, 10) < 24;
},

'time-minute'(value) {
return Validators.regex(value, /^\d{2}$/) && parseInt(value, 10) >= 0 && parseInt(value, 10) < 59;
},

// eslint-disable-next-line no-inline-comments, spaced-comment
before(value, date) {
// validator can also do before(value, [diff, unit][, diff, unit])
Expand Down
46 changes: 45 additions & 1 deletion frontend/template-mixins/mixins/template-mixins.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const PANELMIXIN = 'partials/mixins/panel';
const PARTIALS = [
'partials/forms/input-text-group',
'partials/forms/input-text-date',
'partials/forms/input-text-time',
'partials/forms/input-submit',
'partials/forms/select',
'partials/forms/checkbox',
Expand Down Expand Up @@ -213,6 +214,7 @@ module.exports = function (options) {
label: t(lKey),
labelClassName: labelClassName ? `govuk-label ${labelClassName}` : 'govuk-label',
formGroupClassName: classNames(field, 'formGroupClassName') || extension.formGroupClassName || 'govuk-form-group',
timeInputItemClassName: 'time-input__item',
hint: hint,
hintId: extension.hintId || (hint ? key + '-hint' : null),
error: this.errors && this.errors[key],
Expand All @@ -221,11 +223,12 @@ module.exports = function (options) {
required: required,
pattern: extension.pattern,
date: extension.date,
time: extension.time,
autocomplete: autocomplete,
child: field.child,
isPageHeading: field.isPageHeading,
attributes: field.attributes,
isPrefixOrSuffix: _.map(field.attributes, item => {if (item.prefix || item.suffix !== undefined) return true;}),
isPrefixOrSuffix: _.map(field.attributes, item => { if (item.prefix || item.suffix !== undefined) return true; }),
isMaxlengthOrMaxword: maxlength(field) || extension.maxlength || maxword(field) || extension.maxword,
renderChild: renderChild.bind(this)
});
Expand Down Expand Up @@ -468,6 +471,47 @@ module.exports = function (options) {
return parts.concat(monthPart, yearPart).join('\n');
};
}
},
'input-time': {
handler: function () {
/**
* props: '[value] [id]'
*/
return function (key) {
const field = Object.assign({}, this.options.fields[key] || options.fields[key]);
key = hoganRender(key, this);
// Exact unless there is a inexact property against the fields key.
const isExact = field.inexact !== true;

let autocomplete = field.autocomplete || {};
if (autocomplete === 'off') {
autocomplete = {
hour: 'off',
minute: 'off'
};
} else if (typeof autocomplete === 'string') {
autocomplete = {
hour: autocomplete + '-hour',
minute: autocomplete + '-minute'
};
}
const isThisRequired = field.validate ? field.validate.indexOf('required') > -1 : false;
const formGroupClassName = (field.formGroup && field.formGroup.className) ? field.formGroup.className : '';
const classNameHour = (field.controlsClass && field.controlsClass.hour) ? field.controlsClass.hour : 'govuk-input--width-2';
const classNameMinute = (field.controlsClass && field.controlsClass.minute) ? field.controlsClass.minute : 'govuk-input--width-2';

const parts = [];

if (isExact) {
const hourPart = compiled['partials/forms/input-text-time'].render(inputText.call(this, key + '-hour', { pattern: '[0-9]*', min: 1, max: 24, maxlength: 2, hintId: key + '-hint', time: true, autocomplete: autocomplete.hour, formGroupClassName, className: classNameHour, isThisRequired }));
parts.push(hourPart);
}

const minutePart = compiled['partials/forms/input-text-time'].render(inputText.call(this, key + '-minute', { pattern: '[0-9]*', min: 0, max: 59, maxlength: 2, hintId: key + '-hint', time: true, autocomplete: autocomplete.minute, formGroupClassName, className: classNameMinute, isThisRequired }));

return parts.concat(minutePart).join('\n');
};
}
}
};

Expand Down
37 changes: 37 additions & 0 deletions frontend/template-mixins/partials/forms/input-text-time.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<div class="{{timeInputItemClassName}}">
<div id="{{id}}-group" class="{{#formGroupClassName}} {{formGroupClassName}}{{/formGroupClassName}}">
<label for="{{id}}" class="{{labelClassName}}">
<span class="label-text">{{{label}}}</span>
</label>
{{#hint}}<span {{$hintId}}id="{{hintId}}" {{/hintId}}class="govuk-hint">{{hint}}</span>{{/hint}}
{{#renderChild}}{{/renderChild}}
{{#attributes}}
{{#prefix}}
<div class="govuk-input__prefix" aria-hidden="true">{{prefix}}</div>
{{/prefix}}
{{/attributes}}
<input
type="{{type}}"
name="{{id}}"
id="{{id}}"
class="govuk-input{{#className}} {{className}}{{/className}}{{#error}} govuk-input--error{{/error}}"
aria-required="{{required}}"
{{#value}} value="{{value}}"{{/value}}
{{#min}} min="{{min}}"{{/min}}
{{#max}} max="{{max}}"{{/max}}
{{#maxlength}} maxlength="{{maxlength}}"{{/maxlength}}
{{#pattern}} pattern="{{pattern}}"{{/pattern}}
{{#hintId}} aria-describedby="{{hintId}}"{{/hintId}}
{{#error}} aria-invalid="true"{{/error}}
{{#autocomplete}} autocomplete="{{autocomplete}}"{{/autocomplete}}
{{#attributes}}
{{attribute}}="{{value}}"
{{/attributes}}
>
{{#attributes}}
{{#suffix}}
<div class="govuk-input__prefix" aria-hidden="true">{{suffix}}</div>
{{/suffix}}
{{/attributes}}
</div>
</div>
5 changes: 5 additions & 0 deletions frontend/themes/gov-uk/styles/_time-input.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.time-input__item {
display: inline-block;
margin-right: 20px;
margin-bottom: 0;
}
1 change: 1 addition & 0 deletions frontend/themes/gov-uk/styles/govuk.scss
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ $path: "/public/images/" !default;
@import "check_your_answers";
@import "pdf";
@import "session-timeout-dialog";
@import "time-input";

// Modules
@import "modules/validation";
Expand Down
4 changes: 4 additions & 0 deletions frontend/toolkit/assets/javascript/form-focus.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ function formFocus() {
document.getElementById(getElementFromSummaryLink + '-day').focus();
}

if (document.getElementById(getElementFromSummaryLink + '-hour') && forms.length === 1 && editMode) {
document.getElementById(getElementFromSummaryLink + '-hour').focus();
}

if (forms.length > 0) {
labels = document.getElementsByTagName('label');
if (labels) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "hof",
"description": "A bootstrap for HOF projects",
"version": "22.1.1",
"version": "22.2.0",
"license": "MIT",
"main": "index.js",
"author": "HomeOffice",
Expand Down
Loading