Skip to content

Improve date and datetime field front-end validation and UX (CFM-504) #375

Merged
merged 3 commits into from
Mar 25, 2026
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 5 additions & 3 deletions app/src/View/Helper/FieldHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,9 @@ public function dateField(string $fieldName,
// Initialize
$dateFormat = $dateType === DateTypeEnum::DateOnly ? 'yyyy-MM-dd' : 'yyyy-MM-dd HH:mm:ss';
$dateTitle = $dateType === DateTypeEnum::DateOnly ? 'datepicker.enterDate' : 'datepicker.enterDateTime';
$datePattern = $dateType === DateTypeEnum::DateOnly ? '\d{4}-\d{2}-\d{2}' : '\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}';
$datePattern = $dateType === DateTypeEnum::DateOnly ?
'\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])' :
'\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]) (0\d|1\d|2[0-3]):(0\d|[1-5]\d):(0\d|[1-5]\d)';
$queryParams = $this->getView()->getRequest()->getQueryParams();

$date_object = match(true) {
Expand Down Expand Up @@ -332,8 +334,8 @@ public function dateField(string $fieldName,
default => $dateType
};

// Append the timezone to the label
$coptions['class'] = 'form-control datepicker';
// Set the attributes for the field
$coptions['class'] = 'form-control datepicker ' . $pickerType;
$coptions['placeholder'] = $dateFormat;
$coptions['pattern'] = $datePattern;
$coptions['title'] = __d('field', $dateTitle);
Expand Down
35 changes: 33 additions & 2 deletions app/templates/element/datePicker.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
pm: "<?= __d('field', 'datepicker.pm') ?>",
choosetime: "<?= __d('field', 'datepicker.chooseTime') ?>"
}
}
};
},
components: {
CmDateTimePicker
Expand All @@ -90,7 +90,7 @@
// is registered and destroyed as the component is mounted and unmounted.
app.directive("clickout", {
mounted(el, binding, vnode) {
el.clickOutEvent = function (event) {
el.clickOutEvent = function(event) {
if (!(el === event.target || el.contains(event.target))) {
binding.value(event, el);
}
Expand All @@ -105,3 +105,34 @@
app.mount("#<?= $pickerId ?>-container");
</script>
<div id="<?= $pickerId ?>-container" class="datepicker-container"></div>

<?php
/** DATE-TIME KEYBOARD HANDLING
* Registry's date and time pickers will automatically set the correct format for date and time.
* The date fields also check their validity against a pattern attribute on form submission.
* To improve UX when a user enters a date manually with the keyboard, the field can also
* check validity against the pattern on blur and "autocorrect" the value when possible.
* There are four types of date fields held in $pickerType described by DateTypeEnum (in parentheses):
* date-time (standard), date only (dateonly), valid from (fromtime), and valid through (throughtime).
* Valid from and standard date-time fields should behave the same way: time should default to 00:00:00 but
* can be set explicitly. Valid through needs special treatment for its end-time which defaults to 23:59:59
* but can also be set explicitly. The script below handles keyboard interaction independent of the
* VueJS widgets because we are acting on a simple text input field that is not directly part of the
* Vue components (for accessibility).
*/
?>
<div id="<?= $pickerTarget ?>-msg" class="datepicker-message invalid-feedback">
<?= $pickerType == 'dateonly' ? __d('field','datepicker.enterDate') : __d('field','datepicker.enterDateTime') ?>
</div>
<script>
// Validate on blur for handling keyboard input.
$('#<?= $pickerTarget ?>').blur(function() {
const regExPattern = $(this).attr('pattern');
$(this).val(validateDate($(this).val(), '<?= $pickerType ?>', regExPattern, '<?= $pickerTarget ?>'));
});
// Hide warning messages on focus.
$('#<?= $pickerTarget ?>').on('focus', function() {
$('#<?= $pickerTarget ?>-msg').hide();
$('#<?= $pickerTarget ?>').removeClass('invalid');
});
</script>
7 changes: 7 additions & 0 deletions app/webroot/css/co-base.css
Original file line number Diff line number Diff line change
Expand Up @@ -1785,8 +1785,12 @@ ul.fields li.fields-datepicker {
ul.fields li.fields-datepicker .field-info {
width: auto;
display: flex;
flex-wrap: wrap;
align-items: center;
}
ul.fields li.fields-datepicker .datepicker-message {
flex-basis: 100%;
}
ul.form-list input.datepicker {
width: auto;
}
Expand Down Expand Up @@ -2349,6 +2353,9 @@ address {
.invalid-feedback {
font-size: 1em;
}
input[type='text'].invalid {
border: 1px solid var(--cmg-color-highlight-001) !important;
}
.warn-level-a,
.warn-level-a td {
background-color: var(--cmg-color-highlight-012);
Expand Down
52 changes: 52 additions & 0 deletions app/webroot/js/comanage/comanage.js
Original file line number Diff line number Diff line change
Expand Up @@ -424,3 +424,55 @@ function setApplicationState(value, elem, reload= false) {
}
});
}

// Validate a date and attempt autocompletion by type. This is used primarily with keyboard input.
// dateVal - (string) date value from date input field
// dateType - (string) type as defined in DateTypeEnum: standard, dateonly, fromtime, throughtime
// regExPattern - (string) the regular expression pattern held in the field's pattern attribute
// messageContainerId - (string) the ID of the field message container (for warnings to the user)
function validateDate(dateVal,dateType,regExPattern,fieldId) {
const dateValTrimmed = dateVal.trim();
const regexDateField = new RegExp(regExPattern);
const regexDateOnly = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
const regexDateOnlyCompact = /^\d{4}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])$/;

// If the field is empty or the value matches the pattern attribute, it's good. Just return the value.
if(dateValTrimmed === '' || regexDateField.test(dateValTrimmed)) {
return dateValTrimmed;
}

// Otherwise, let's do some validation and autocompletion.
if(dateType === 'dateonly') {
// If the date has been entered as yyyymmdd, we'll try and correct it.
if(regexDateOnlyCompact.test(dateValTrimmed)) {
return dateValTrimmed.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3');
}
// Otherwise, they've entered an invalid value.
// Let's tell them, and leave the field alone. It will get stopped on submit.
$('#' + fieldId + '-msg').show();
$('#' + fieldId).addClass('invalid');
return dateValTrimmed;
} else {
// We're validating a datetime field.
let endTime = ' 00:00:00'; // default end time for fromtime and standard
if(dateType === 'throughtime') {
endTime = ' 23:59:59'; // default end time for throughtime
}
// Determine if they've entered a valid date.
if(regexDateOnly.test(dateValTrimmed)) {
// They've entered just the date as yyyy-mm-dd. Add the time value.
return dateValTrimmed + endTime;
} else if(regexDateOnlyCompact.test(dateValTrimmed)) {
// This looks like a compact date in the form of yyyymmdd.
// Convert it to the correct format and add the time value.
let dateValExpanded = dateValTrimmed.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3');
return dateValExpanded + endTime;
} else {
// They've entered an invalid value.
// Tell them, and leave the field alone. It will get stopped on submit.
$('#' + fieldId + '-msg').show();
$('#' + fieldId).addClass('invalid');
return dateValTrimmed;
}
}
}
24 changes: 24 additions & 0 deletions app/webroot/js/comanage/components/datepicker/cm-datetimepicker.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export default {
dateField.value = dateTime.join(' ');
}
}
this.clearExternalValidationMessages(this.target);
},
formatDate(date) {
let formattedDate = date.getFullYear();
Expand Down Expand Up @@ -116,8 +117,25 @@ export default {
if(type == 'minute') {
time[1] = val;
}
// Do some basic validation
let minutesAndSecondsRegex = /^(0\d|[1-5]\d)$/;
if(time[1] === undefined) {
// The minutes are missing (including the colon). Restore them.
time.push('00');
} else if(!minutesAndSecondsRegex.test(time[1])) {
// The minutes are invalid. Set them to zero.
time[1] = '00';
}
if(time[2] === undefined) {
// The seconds are missing (including the colon). Restore them.
time.push('00');
} else if(!minutesAndSecondsRegex.test(time[2])) {
// The seconds are invalid. Set them to zero.
time[2] = '00';
}
dateTime[1] = time.join(':');
dateField.value = dateTime.join(' ');
this.clearExternalValidationMessages(this.target);
},
formatToday() {
const today = new Date();
Expand All @@ -127,6 +145,12 @@ export default {
today.setSeconds(0);
const formattedToday = this.formatDate(today);
return formattedToday.split(' ');
},
clearExternalValidationMessages(fieldId) {
// Turn off validation messages that may have been revealed on the input field
// which is outside of this component. (We'll use jQuery for simplicity.)
$('#' + fieldId + '-msg').hide();
$('#' + fieldId).removeClass('invalid');
}
},
mounted() {
Expand Down