widget/date/datepicker-extended.js

import Widget from '../../js/widget';
import support from '../../js/support';
import $ from 'jquery';
import types from '../../js/types';
import { isNumber, getPasteData } from '../../js/utils';
import 'bootstrap-datepicker';
import '../../js/dropdown.jquery';

/**
 * Extends eternicode's bootstrap-datepicker without changing the original.
 * https://github.com/eternicode/bootstrap-datepicker
 *
 * @augments Widget
 */
class DatepickerExtended extends Widget {
    /**
     * @type {string}
     */
    static get selector() {
        return '.question input[type="date"]';
    }

    /**
     * @type {boolean}
     */
    static condition() {
        return !support.touch || !support.inputTypes.date;
    }

    _init() {
        this.settings = ( this.props.appearances.includes( 'year' ) ) ? {
            format: 'yyyy',
            startView: 'decade',
            minViewMode: 'years'
        } : ( this.props.appearances.includes( 'month-year' ) ) ? {
            format: 'yyyy-mm',
            startView: 'year',
            minViewMode: 'months'
        } : {
            format: 'yyyy-mm-dd',
            startView: 'month',
            minViewMode: 'days'
        };

        this.$fakeDateI = this._createFakeDateInput( this.settings.format );

        this._setChangeHandler( this.$fakeDateI );
        this._setFocusHandler( this.$fakeDateI );
        this._setResetHandler( this.$fakeDateI );

        this.enable();
        this.value = this.element.value;

        // It is much easier to first enable and disable, and not as bad it seems, since readonly will become dynamic eventually.
        if ( this.props.readonly ) {
            this.disable();
        }
    }

    /**
     * Creates fake date input elements
     *
     * @param {string} format - The date format
     * @return {jQuery} The jQuery-wrapped fake date input element
     */
    _createFakeDateInput( format ) {
        const $dateI = $( this.element );
        const $fakeDate = $( `<div class="widget date"><input class="ignore input-small" type="text" placeholder="${format}" /></div>` )
            .append( this.resetButtonHtml );
        const $fakeDateI = $fakeDate.find( 'input' );

        $dateI.hide().before( $fakeDate );

        return $fakeDateI;
    }

    /**
     * Copy manual changes that were not detected by bootstrap-datepicker (one without pressing Enter) to original date input field
     *
     * @param {jQuery} $fakeDateI - Fake date input element
     */
    _setChangeHandler( $fakeDateI ) {
        const settings = this.settings;

        $fakeDateI.on( 'change paste', e => {
            let convertedValue = '';
            let value = e.type === 'paste' ? getPasteData( e ) : this.value;

            if ( value.length > 0 ) {
                // Note: types.date.convert considers numbers to be a number of days since the epoch
                // as this is what the XPath evaluator may return.
                // For user-entered input, we want to consider a Number value to be incorrect, expect for year input.
                if ( isNumber( value ) && settings.format !== 'yyyy' ) {
                    convertedValue = '';
                } else {
                    value = this._toActualDate( value );
                    convertedValue = types.date.convert( value );
                }
            }

            $fakeDateI.val( this._toDisplayDate( convertedValue ) ).datepicker( 'update' );

            // Here we have to do something unusual to prevent native inputs from automatically
            // changing 2012-12-32 into 2013-01-01
            // convertedValue is '' for invalid 2012-12-32
            if ( convertedValue === '' && e.type === 'paste' ) {
                e.stopImmediatePropagation();
            }

            // Avoid triggering unnecessary change events as they mess up sensitive custom applications (OC)
            if ( this.originalInputValue !== convertedValue ) {
                this.originalInputValue = convertedValue;
            }

            return false;
        } );
    }

    /**
     * Reset button handler
     *
     * @param {jQuery} $fakeDateI - Fake date input element
     */
    _setResetHandler( $fakeDateI ) {
        $fakeDateI.next( '.btn-reset' ).on( 'click', () => {
            if ( this.originalInputValue ) {
                this.value = '';
            }
        } );
    }

    /**
     * Handler for focus events.
     * These events on the original input are used to check whether to display the 'required' message
     *
     * @param {jQuery} $fakeDateI - Fake date input element
     */
    _setFocusHandler( $fakeDateI ) {
        // Handle focus on original input (goTo functionality)
        $( this.element ).on( 'applyfocus', () => {
            $fakeDateI[ 0 ].focus();
        } );
    }

    /**
     * @param {string} [date] - date
     * @return {string} the actual date
     */
    _toActualDate( date = '' ) {
        date = date.trim();

        return date && this.settings.format === 'yyyy' && date.length < 5 ? `${date}-01-01` : ( date && this.settings.format === 'yyyy-mm' && date.length < 8 ? `${date}-01` : date );
    }

    /**
     * @param {string} [date] - date
     * @return {string} the display date
     */
    _toDisplayDate( date = '' ) {
        date = date.trim();

        return date && this.settings.format === 'yyyy' ? date.substring( 0, 4 ) : ( this.settings.format === 'yyyy-mm' ? date.substring( 0, 7 ) : date );
    }

    disable() {
        this.$fakeDateI.datepicker( 'destroy' );
        this.$fakeDateI.prop( 'disabled', true );
        this.$fakeDateI.next( '.btn-reset' ).prop( 'disabled', true );
    }

    enable() {
        this.$fakeDateI.datepicker( {
            format: this.settings.format,
            autoclose: true,
            todayHighlight: true,
            startView: this.settings.startView,
            minViewMode: this.settings.minViewMode,
            forceParse: false
        } );
        this.$fakeDateI.prop( 'disabled', false );
        this.$fakeDateI.next( '.btn-reset' ).prop( 'disabled', false );
    }

    update() {
        this.value = this.element.value;
    }

    /**
     * @type {string}
     */
    get displayedValue() {
        return this.question.querySelector( '.widget input' ).value;
    }

    /**
     * @type {string}
     */
    get value() {
        return this._toActualDate( this.displayedValue );
    }

    set value( date ) {
        if ( this.$fakeDateI[ 0 ].disabled ) {
            this.$fakeDateI[ 0 ].value = this._toDisplayDate( date );
        } else {
            this.$fakeDateI.datepicker( 'setDate', this._toDisplayDate( date ) );
        }
    }

}

export default DatepickerExtended;