fradrive/frontend/src/utils/form/datepicker.js

330 lines
12 KiB
JavaScript

import datetime from 'tail.datetime';
import './datepicker.css';
import { Utility } from '../../core/utility';
import moment from 'moment';
import * as defer from 'lodash.defer';
const KEYCODE_ESCAPE = 27;
const Z_INDEX_MODAL = 9999;
// should be the same as ATTR_SUBMIT_LOCKED in async-table util
// TODO move to global config
const ATTR_DATEPICKER_OPEN = 'submit-locked';
// INTERNAL (Uni2work specific) formats for formatting dates and/or times
const FORM_DATE_FORMAT = {
'date': moment.HTML5_FMT.DATE,
'time': moment.HTML5_FMT.TIME_SECONDS,
'datetime-local': moment.HTML5_FMT.DATETIME_LOCAL_SECONDS,
};
// FANCY (tail.datetime specific) formats for displaying dates and/or times
const FORM_DATE_FORMAT_DATE_DT = 'dd.mm.YYYY';
const FORM_DATE_FORMAT_TIME_DT = 'HH:ii:ss';
// FANCY (moment specific) formats for displaying dates and/or times
const FORM_DATE_FORMAT_DATE_MOMENT = 'DD.MM.YYYY';
const FORM_DATE_FORMAT_TIME_MOMENT = 'HH:mm:ss';
const FORM_DATE_FORMAT_MOMENT = {
'date': FORM_DATE_FORMAT_DATE_MOMENT,
'time': FORM_DATE_FORMAT_TIME_MOMENT,
'datetime-local': `${FORM_DATE_FORMAT_DATE_MOMENT} ${FORM_DATE_FORMAT_TIME_MOMENT}`,
};
const DATEPICKER_UTIL_SELECTOR = 'input[type="date"], input[type="time"], input[type="datetime-local"]';
const DATEPICKER_INITIALIZED_CLASS = 'datepicker--initialized';
const DATEPICKER_OPEN_CLASS = 'calendar-open';
const DATEPICKER_CONFIG = {
'global': {
// set default time to 00:00:00
timeHours: 0,
timeMinutes: 0,
timeSeconds: 0,
weekStart: 1,
dateFormat: FORM_DATE_FORMAT_DATE_DT,
timeFormat: FORM_DATE_FORMAT_TIME_DT,
// prevent the instance from closing when selecting a date before selecting a time
stayOpen: true,
// hide the close button (we handle closing the datepicker manually by clicking outside)
closeButton: false,
// disable the decades view because nobody will ever need it (i.e. cap the switch to the more relevant year view)
viewDecades: false,
},
'datetime-local': {},
'date': {
// disable date picker
timeFormat: false,
},
'time': {
// disable time picker
dateFormat: false,
},
};
@Utility({
selector: DATEPICKER_UTIL_SELECTOR,
})
export class Datepicker {
// singleton Map that maps a formID to a Map of Datepicker objects
static datepickerCollections;
datepickerInstance;
_element;
elementType;
initialValue;
_locale;
_unloadIsDueToSubmit = false;
constructor(element) {
if (!element) {
throw new Error('Datepicker utility needs to be passed an element!');
}
if (element.classList.contains(DATEPICKER_INITIALIZED_CLASS)) {
return false;
}
this._locale = window.App.i18n.getDatetimeLocale();
// initialize datepickerCollections singleton if not already done
if (!Datepicker.datepickerCollections) {
Datepicker.datepickerCollections = new Map();
}
this._element = element;
// store the previously set type to select the input format
this.elementType = this._element.getAttribute('type');
// store initial value prior to changing type
this.initialValue = this._element.value || this._element.getAttribute('value');
// manually set the type attribute to text because datepicker handles displaying the date
this._element.setAttribute('type', 'text');
// get all relevant config options for this datepicker type
const datepickerGlobalConfig = DATEPICKER_CONFIG['global'];
const datepickerConfig = DATEPICKER_CONFIG[this.elementType];
// additional position config (optional data-datepicker-position attribute in html) that can specialize the global config
const datepickerPosition = this._element.dataset.datepickerPosition;
if (datepickerPosition) {
datepickerGlobalConfig.position = datepickerPosition;
}
if (!datepickerConfig || !FORM_DATE_FORMAT[this.elementType]) {
throw new Error('Datepicker utility called on unsupported element!');
}
// FIXME dirty hack below; fix tail.datetime instead
// get date object from internal format before datetime does nasty things with it
let parsedMomentDate = moment(this.initialValue, [ FORM_DATE_FORMAT[this.elementType], FORM_DATE_FORMAT_MOMENT[this.elementType] ], true);
if (parsedMomentDate && parsedMomentDate.isValid()) {
parsedMomentDate = parsedMomentDate.toDate();
} else {
parsedMomentDate = undefined;
}
// initialize tail.datetime (datepicker) instance and let it do weird stuff with the element value
this.datepickerInstance = datetime(this._element, { ...datepickerGlobalConfig, ...datepickerConfig, locale: this._locale });
// reset date to something sane
if (parsedMomentDate)
this.datepickerInstance.selectDate(parsedMomentDate);
// insert the datepicker element (dt) after the form
this._element.form.parentNode.insertBefore(this.datepickerInstance.dt, this._element.form.nextSibling);
// if the input element is in any open modal, increase the z-index of the datepicker
if (this._element.closest('.modal--open')) {
this.datepickerInstance.dt.style.zIndex = Z_INDEX_MODAL;
}
// register this datepicker instance with the formID of the given element in the datepicker collection
const formID = this._element.form.id;
const elemID = this._element.id;
if (!Datepicker.datepickerCollections.has(formID)) {
// insert a new key value pair if the formID key is not there already
Datepicker.datepickerCollections.set(formID, new Map([[elemID, this]]));
} else {
// otherwise, insert this instance into the Map
Datepicker.datepickerCollections.get(formID).set(elemID, this);
}
// mark the form input element as initialized
this._element.classList.add(DATEPICKER_INITIALIZED_CLASS);
}
start() {
const setDatepickerDate = () => {
// try to parse the current input element value with fancy and internal format string
const parsedMomentDate = moment(this._element.value, FORM_DATE_FORMAT_MOMENT[this.elementType]);
const parsedMomentDateInternal = moment(this._element.value, FORM_DATE_FORMAT[this.elementType]);
// only set the datepicker date if the input is either in valid fancy format or in valid internal format
if (parsedMomentDate.isValid()) {
this.datepickerInstance.selectDate(parsedMomentDate.toDate());
} else if (parsedMomentDateInternal.isValid()) {
this.datepickerInstance.selectDate(parsedMomentDateInternal.toDate());
}
// reregister change event to prevent event loop
this._element.addEventListener('change', setDatepickerDate, { once: true });
};
// change the selected date in the tail.datetime instance if the value of the input element is changed
this._element.addEventListener('change', setDatepickerDate, { once: true });
// create a mutation observer that observes the datepicker instance class and sets
// the datepicker-open DOM attribute of the input element if the datepicker has been opened
const datepickerInstanceObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (!mutation.oldValue.includes(DATEPICKER_OPEN_CLASS) && this.datepickerInstance.dt.getAttribute('class').includes(DATEPICKER_OPEN_CLASS)) {
this._element.setAttribute(ATTR_DATEPICKER_OPEN, true);
break;
}
}
});
datepickerInstanceObserver.observe(this.datepickerInstance.dt, {
attributes: true,
attributeFilter: ['class'],
attributeOldValue: true,
});
// close the instance on focusout of any element if another input is focussed that is neither the timepicker nor _element
window.addEventListener('focusout', event => {
const hasFocus = event.relatedTarget !== null;
const focussedIsNotTimepicker = !this.datepickerInstance.dt.contains(event.relatedTarget);
const focussedIsNotElement = event.relatedTarget !== this._element;
const focussedIsInDocument = window.document.contains(event.relatedTarget);
if (hasFocus && focussedIsNotTimepicker && focussedIsNotElement && focussedIsInDocument)
this.closeDatepickerInstance();
});
// close the instance on click on any element outside of the datepicker (except the input element itself)
window.addEventListener('click', event => {
const targetIsOutside = !this.datepickerInstance.dt.contains(event.target)
&& event.target !== this.datepickerInstance.dt;
const targetIsInDocument = window.document.contains(event.target);
const targetIsNotElement = event.target !== this._element;
if (targetIsOutside && targetIsInDocument && targetIsNotElement)
this.closeDatepickerInstance();
});
// close the instance on escape keydown events
this._element.addEventListener('keydown', event => {
if (event.keyCode === KEYCODE_ESCAPE) {
this.closeDatepickerInstance();
}
});
// format the date value of the form input element of this datepicker before form submission
this._element.form.addEventListener('submit', this._submitHandler.bind(this));
}
destroy() {
this.datepickerInstance.remove();
}
// DATEPICKER INSTANCE CONTROL
/**
* Closes the datepicker instance, releasing the lock on the input element.
*/
closeDatepickerInstance() {
if (!this._element.datepicker-open) {
throw new Error('Cannot close already closed datepicker instance!');
}
this._element.setAttribute(ATTR_DATEPICKER_OPEN, false);
this.datepickerInstance.close();
}
// FORMAT METHODS
/**
* Formats the value of this input element from datepicker format (i.e. DATEPICKER_CONFIG.dateFormat + " " + datetime.defaults.timeFormat) to Uni2work internal date format (i.e. FORM_DATE_FORMAT) required for form submission
* @param {*} toFancy optional target format switch (boolean value; default is false). If set to a truthy value, formats the element value to fancy instead of internal date format.
*/
formatElementValue(toFancy) {
if (this._element.value) {
this._element.value = this.unformat(toFancy);
}
}
_submitHandler() {
this._unloadIsDueToSubmit = true;
this.formatElementValue(false);
defer(() => { // Restore state after event loop is settled
this._unloadIsDueToSubmit = false;
this.formatElementValue(true);
});
}
/**
* Returns a datestring in internal format from the current state of the input element value.
* @param {*} toFancy Format date from internal to fancy or vice versa. When omitted, toFancy is falsy and results in fancy -> internal
*/
unformat(toFancy) {
const formatIn = toFancy ? FORM_DATE_FORMAT[this.elementType] : FORM_DATE_FORMAT_MOMENT[this.elementType];
const formatOut = toFancy ? FORM_DATE_FORMAT_MOMENT[this.elementType] : FORM_DATE_FORMAT[this.elementType];
return reformatDateString(this._element.value, formatIn, formatOut);
}
/**
* Takes a Form and a FormData and returns a new FormData with all dates formatted to uni2work date format. This function will not change the value of the date input elements.
* @param {*} form Form for which all dates will be formatted in the FormData
* @param {*} formData Initial FormData
*/
static unformatAll(form, formData) {
// only proceed if there are any datepickers and if both form and formData are defined
if (Datepicker.datepickerCollections && form && formData) {
// if the form has no id, assign one randomly
if (!form.id) {
form.id = `f${Math.floor(Math.random() * 100000)}`;
}
const formId = form.id;
if (Datepicker.datepickerCollections.has(formId)) {
const datepickerInstances = Datepicker.datepickerCollections.get(formId);
datepickerInstances.forEach(instance => {
formData.set(instance._element.name, instance.unformat());
});
}
}
// return the (possibly changed) FormData
return formData;
}
}
// HELPER FUNCTIONS
/**
* Takes a string representation of a date, an input ('previous') format and a desired output format and returns a reformatted date string.
* If the date string is not valid (i.e. cannot be parsed with the given input format string), returns the original date string;
* @param {*} dateStr string representation of a date (needs to be in format formatIn)
* @param {*} formatIn input format string
* @param {*} formatOut format string of the desired output date string
*/
function reformatDateString(dateStr, formatIn, formatOut) {
const parsedMomentDate = moment(dateStr, [formatIn, formatOut]);
return parsedMomentDate.isValid() ? parsedMomentDate.format(formatOut) : dateStr;
}