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 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
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
39 changes: 37 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,38 @@
app.mount("#<?= $pickerId ?>-container");
</script>
<div id="<?= $pickerId ?>-container" class="datepicker-container"></div>

<?php if($pickerType != "dateonly"): ?>
<div class="cm-tz"><?= $vv_tz->getName() ?></div>
<?php endif; ?>

<?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(validateDateFormat($(this).val(), '<?= $pickerType ?>', regExPattern, '<?= $pickerTarget ?>'));
});
// Hide warning messages on focus.
$('#<?= $pickerTarget ?>').on('focus', function() {
$('#<?= $pickerTarget ?>-msg').hide();
$('#<?= $pickerTarget ?>').removeClass('invalid');
});
</script>
12 changes: 12 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 @@ -1861,6 +1865,11 @@ ul.form-list .cm-time-picker-vals li {
.cm-time-picker-colon {
padding: 0 1em;
}
.cm-tz {
font-size: 0.9em;
margin-left: 0.5em;
color: var(--cmg-color-txt-soft);
}
/* Autocomplete */
.cm-autocomplete-panel {
overflow-y: auto;
Expand Down Expand Up @@ -2349,6 +2358,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
54 changes: 54 additions & 0 deletions app/webroot/js/comanage/comanage.js
Original file line number Diff line number Diff line change
Expand Up @@ -424,3 +424,57 @@ 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 validateDateFormat(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])$/;

const showInvalid = () => {
$('#' + fieldId + '-msg').show();
$('#' + fieldId).addClass('invalid');
};

const expandCompactYmd = (compactYmd) =>
compactYmd.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3');

// empty is always OK
if(dateValTrimmed === '') {
return dateValTrimmed;
}

// already matches the field pattern, accept as-is
if(regexDateField.test(dateValTrimmed)) {
return dateValTrimmed;
}

const isDateOnly = (dateType === 'dateonly');

// compact date (yyyymmdd) can be auto-corrected
if(regexDateOnlyCompact.test(dateValTrimmed)) {
const expanded = expandCompactYmd(dateValTrimmed);

if(isDateOnly) {
return expanded;
}

const endTime = (dateType === 'throughtime') ? ' 23:59:59' : ' 00:00:00';
return expanded + endTime;
}

// yyyy-mm-dd can be accepted for datetime fields (append time)
if(!isDateOnly && regexDateOnly.test(dateValTrimmed)) {
const endTime = (dateType === 'throughtime') ? ' 23:59:59' : ' 00:00:00';
return dateValTrimmed + endTime;
}

// anything else is invalid
showInvalid();
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