Skip to content

Commit

Permalink
Improve date and datetime field front-end validation and UX (CFM-504) (
Browse files Browse the repository at this point in the history
…#375)

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

* Further improve datetime field format validation on front-end (CFM-504)

* Add timezone labels to datetime fields (CFM-504)
  • Loading branch information
arlen authored Mar 25, 2026
1 parent 3f60755 commit a9fe59c
Show file tree
Hide file tree
Showing 5 changed files with 132 additions and 5 deletions.
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

0 comments on commit a9fe59c

Please sign in to comment.