import angular from 'angular' import { DateTime } from 'luxon' import Pikaday from 'pikaday' import { recursivelySetPristine } from '../form-validation-utils/form-validation-utils' import type { TranslateFactory } from '../translate/translate-factory' interface PikadayInstance { hide: () => void } // Wrapper directive for pikaday datepicker component // This 3rd party component has an optional dependency on moment.js that we no longer use angular.module('app').directive('mflyDatePicker', [ 'dateTimeZoneFactory', 'translateFactory', function mflyDatePicker( dateTimeZoneFactory, translateFactory: TranslateFactory, ) { return { restrict: 'E', scope: { inputId: '@', isDisabled: '=', isRequired: '@', format: '@', dateLuxon: '=', onDateChange: '&', onOpen: '&' }, template: require('./datepicker.html'), link($scope: ScopeKeyValuePair, element: ng.IAugmentedJQuery) { // object to hold the text input model $scope.dateInputValue = { date: '' } // if a "small" format is required we need to set it here const dateStringFormat = ($scope.format === 'short') ? 'd-MMM-yyyy' : 'd-MMMM-yyyy' // The Pikaday component does some odd formatting at times // This flag helps avoid this anomaly when it occurs let preventPikadayUpdate = false // locate the input for pikaday component field const inputElement = element.find('input') // locate div to trigger the opening of the picker // we want the wrapping div, not the input so that the cal icon will also open const wrapperDiv = element.find('div') // Pikaday takes ownership of whatever node it is handed as `field`: it stamps its // own formatted value onto `field.value` and listens for `change` on it. Handing it // the wrapping div meant the text input's native `change` event bubbled up into // Pikaday, which then re-parsed the *stale* value it had previously stamped there // and restored the date the user had just erased or retyped. // Give Pikaday a detached input that it owns outright, and keep the wrapping div as // the `trigger` - that is what drives open-on-click and the popup positioning, so // the cal icon still opens the picker. const pikadayField = document.createElement('input') // set up default options for the pikaday component const pikadayOptions = { field: pikadayField, trigger: wrapperDiv[0], // Pikaday binds a document level keydown handler while the popup is open. Inside // a free text input that hijacks the arrow keys - they shift the selected date // and fire onSelect instead of moving the caret. We do our own keyboard handling. keyboardInput: false, firstDay: 1, format: dateStringFormat.toUpperCase(), // Pikaday expects uppercase format yearRange: 10, setDefaultDate: true, defaultDate: '', // Handler for when the popup is opening - triggered when the input or calendar icon is clicked onOpen(this: PikadayInstance) { if ($scope.isDisabled) { // input is disabled, prevent the picker from opening this.hide() } else { // callback function to parent dateTimePicker directive $scope.onOpen() } }, // Handler for when a date is 'selected' with the picker // selection happens on enter, blur, and by using the popup calendars onSelect() { // update the bound date luxon - convert from JS Date to DateTime const selectedDate = picker.getDate() // Safely handle the digest cycle if ($scope.$root.$$phase !== '$digest' && $scope.$root.$$phase !== '$apply') { $scope.$apply(() => { $scope.updateDate(selectedDate) }) } else { $scope.updateDate(selectedDate) } } } // Handle for the Pikaday component const picker = new Pikaday(pikadayOptions) // Pikaday appends its popup to the body, so it outlives this directive unless we // tear it down explicitly $scope.$on('$destroy', function() { picker.destroy() }) $scope.updateDate = function(newDate) { $scope.dateLuxon = newDate ? DateTime.fromJSDate(newDate) : null // fire a change in the parent directive for the api $scope.onDateChange({ newDate: $scope.dateLuxon }) // Set the form control as dirty and valid when date changes $scope.form?.validation?.mflyDatepicker?.$setDirty() $scope.form?.validation?.mflyDatepicker?.$setValidity('invalidDate', true) } $scope.$watch('dateLuxon', function(newValue: any, oldValue) { if (newValue === oldValue) { // i.e., initializing if ($scope.dateLuxon) { // true = do not trigger onSelect. Rendering an existing value is not a // user change - letting onSelect fire here pushed a re-formatted date // back into the model and dirtied the form just for showing the control. picker.setDate($scope.dateLuxon.toJSDate(), true) $scope.dateInputValue.date = $scope.dateLuxon.toFormat(dateStringFormat) } recursivelySetPristine($scope.form.validation) return } // In case we are "clearing out the date" we should ensure that the datepicker text input is cleared. if (!newValue) { $scope.dateInputValue.date = '' // Also, we want to make the date picker selection and default // month view both return to the current date. picker.setDate('', true) // true = do not trigger onSelect picker.gotoToday() return } // Otherwise, it may be a legit change. For example, if the model was // programmatically changed from somewhere outside the component. picker.setDate(newValue.toJSDate(), true) // true = do not trigger onSelect // Also update the input value to match our format $scope.dateInputValue.date = newValue.toFormat(dateStringFormat) }) // watch necessary to style preceding add-on element (CSS has no prev sibling selector) $scope.$watch('form.validation.mflyDatepicker', function(newValue) { if (newValue) { $scope.$watch('form.validation.mflyDatepicker.$valid', function(newVal) { $scope.invalidAddOn = !newVal }) $scope.$watch('form.validation.mflyDatepicker.$dirty', function(newVal) { $scope.dirtyAddOn = newVal }) } }) $scope.setPikadayLuxon = function() { // Try parsing with any valid format, just like in validateDate let tempDateTime for (const format of dateTimeZoneFactory.validDateFormats) { tempDateTime = DateTime.fromFormat($scope.dateInputValue.date, format, { zone: 'UTC' }) if (tempDateTime.isValid) { break } } // The pikaday will oddly format 2 digit years to 0003 // The year is correct in the dateTime, but you can see the odd string when formatted // To prevent we will set the dateTime manually if the user is not backspacing/deleting if (!preventPikadayUpdate && tempDateTime && tempDateTime.isValid) { // Set the date without triggering onSelect to avoid circular updates picker.setDate(tempDateTime.toJSDate(), true) // Manually update the dateLuxon to trigger reformatting const updateDateTime = () => { $scope.dateLuxon = tempDateTime $scope.onDateChange({ newDate: tempDateTime }) } // Safely handle the digest cycle if ($scope.$root.$$phase !== '$digest' && $scope.$root.$$phase !== '$apply') { $scope.$apply(updateDateTime) } else { updateDateTime() } } else { preventPikadayUpdate = false } } // called every time the text input is changed manually $scope.validateDate = function() { // pikaday interrupts the normal binding - manually update scope $scope.dateInputValue.date = angular.element(inputElement[0]).val() let isValid = true if ($scope.dateInputValue.date && $scope.dateInputValue.date.length > 0) { // attempt to create a date from value using any valid format isValid = false for (const format of dateTimeZoneFactory.validDateFormats) { const parsed = DateTime.fromFormat($scope.dateInputValue.date, format, { zone: 'UTC' }) if (parsed.isValid) { isValid = true $scope.updateDate(parsed.toJSDate()) break } } if (isValid) { $scope.setPikadayLuxon() } } else if ($scope.dateInputValue.date.length === 0) { // clear the scope luxon $scope.dateLuxon = null // clearing the date is a change like any other $scope.form?.validation?.mflyDatepicker?.$setDirty() // update the parent component $scope.onDateChange({ newDate: null }) } else { isValid = false } $scope.form.validation.mflyDatepicker.$setValidity('invalidDate', isValid) } $scope.inputKeyDown = function($event) { if ($event.which === 8 || $event.which === 46) { // we set this when the user is backspacing/deleting so that we don't // validate a 2 digit year and update the control when a user is deleting a 4-digit year preventPikadayUpdate = true } } $scope.onBlur = function() { // Validate the date $scope.validateDate() // Hide the Pikaday calendar popup picker.hide() // Update focus state $scope.focusAddOn = false } $scope.requiredText = translateFactory.instant('JSUI.REQUIRED') $scope.invalidDateText = translateFactory.instant('JSUI.INVALID_DATE') } } }])