From b9526082224b33d0410b442be03c5417e700ecb5 Mon Sep 17 00:00:00 2001 From: Arlen Johnson Date: Thu, 19 Mar 2026 10:27:15 -0400 Subject: [PATCH 1/3] Improve date and datetime field front-end validation and UX (CFM-504) --- app/src/View/Helper/FieldHelper.php | 8 +-- app/templates/element/datePicker.php | 35 ++++++++++++- app/webroot/css/co-base.css | 7 +++ app/webroot/js/comanage/comanage.js | 52 +++++++++++++++++++ .../datepicker/cm-datetimepicker.js | 24 +++++++++ 5 files changed, 121 insertions(+), 5 deletions(-) diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index 6a09398f0..8d50b4f41 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -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) { @@ -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); diff --git a/app/templates/element/datePicker.php b/app/templates/element/datePicker.php index 6eda46363..247f7fdcf 100644 --- a/app/templates/element/datePicker.php +++ b/app/templates/element/datePicker.php @@ -66,7 +66,7 @@ pm: "", choosetime: "" } - } + }; }, components: { CmDateTimePicker @@ -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); } @@ -105,3 +105,34 @@ app.mount("#-container");
+ + +
+ +
+ diff --git a/app/webroot/css/co-base.css b/app/webroot/css/co-base.css index c07b7a294..95da8a241 100644 --- a/app/webroot/css/co-base.css +++ b/app/webroot/css/co-base.css @@ -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; } @@ -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); diff --git a/app/webroot/js/comanage/comanage.js b/app/webroot/js/comanage/comanage.js index 41af5426e..6026b5213 100644 --- a/app/webroot/js/comanage/comanage.js +++ b/app/webroot/js/comanage/comanage.js @@ -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; + } + } +} diff --git a/app/webroot/js/comanage/components/datepicker/cm-datetimepicker.js b/app/webroot/js/comanage/components/datepicker/cm-datetimepicker.js index c0a4eabf3..4d3b79b0e 100644 --- a/app/webroot/js/comanage/components/datepicker/cm-datetimepicker.js +++ b/app/webroot/js/comanage/components/datepicker/cm-datetimepicker.js @@ -71,6 +71,7 @@ export default { dateField.value = dateTime.join(' '); } } + this.clearExternalValidationMessages(this.target); }, formatDate(date) { let formattedDate = date.getFullYear(); @@ -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(); @@ -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() { From c81745363dba0a98b9834057427ad8992ea5f2a5 Mon Sep 17 00:00:00 2001 From: Arlen Johnson Date: Thu, 19 Mar 2026 12:27:47 -0400 Subject: [PATCH 2/3] Further improve datetime field format validation on front-end (CFM-504) --- app/templates/element/datePicker.php | 2 +- app/webroot/js/comanage/comanage.js | 78 ++++++++++++++-------------- 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/app/templates/element/datePicker.php b/app/templates/element/datePicker.php index 247f7fdcf..9dcdc5239 100644 --- a/app/templates/element/datePicker.php +++ b/app/templates/element/datePicker.php @@ -128,7 +128,7 @@ // Validate on blur for handling keyboard input. $('#').blur(function() { const regExPattern = $(this).attr('pattern'); - $(this).val(validateDate($(this).val(), '', regExPattern, '')); + $(this).val(validateDateFormat($(this).val(), '', regExPattern, '')); }); // Hide warning messages on focus. $('#').on('focus', function() { diff --git a/app/webroot/js/comanage/comanage.js b/app/webroot/js/comanage/comanage.js index 6026b5213..a47e6635e 100644 --- a/app/webroot/js/comanage/comanage.js +++ b/app/webroot/js/comanage/comanage.js @@ -428,51 +428,53 @@ 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 +// 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) { +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])$/; - - // 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. + + 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; - } 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; + } + + // 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; +} \ No newline at end of file From 0441d0db7ad28d6ef7bf5f7c4b249d2304e65c51 Mon Sep 17 00:00:00 2001 From: Arlen Johnson Date: Thu, 19 Mar 2026 20:17:19 -0400 Subject: [PATCH 3/3] Add timezone labels to datetime fields (CFM-504) --- app/templates/element/datePicker.php | 4 ++++ app/webroot/css/co-base.css | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/app/templates/element/datePicker.php b/app/templates/element/datePicker.php index 9dcdc5239..a1f40bb0a 100644 --- a/app/templates/element/datePicker.php +++ b/app/templates/element/datePicker.php @@ -106,6 +106,10 @@
+ +
getName() ?>
+ +