From a9fe59c9481ac56c857cdec37ce2127a016c3ff0 Mon Sep 17 00:00:00 2001 From: Arlen Johnson Date: Wed, 25 Mar 2026 06:16:02 -0400 Subject: [PATCH] Improve date and datetime field front-end validation and UX (CFM-504) (#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) --- app/src/View/Helper/FieldHelper.php | 8 +-- app/templates/element/datePicker.php | 39 +++++++++++++- app/webroot/css/co-base.css | 12 +++++ app/webroot/js/comanage/comanage.js | 54 +++++++++++++++++++ .../datepicker/cm-datetimepicker.js | 24 +++++++++ 5 files changed, 132 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..a1f40bb0a 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,38 @@ app.mount("#-container");
+ + +
getName() ?>
+ + + +
+ +
+ diff --git a/app/webroot/css/co-base.css b/app/webroot/css/co-base.css index c07b7a294..7580fafcb 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; } @@ -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; @@ -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); diff --git a/app/webroot/js/comanage/comanage.js b/app/webroot/js/comanage/comanage.js index 41af5426e..a47e6635e 100644 --- a/app/webroot/js/comanage/comanage.js +++ b/app/webroot/js/comanage/comanage.js @@ -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; +} \ No newline at end of file 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() {