TryGhost/Ghost

View on GitHub
ghost/admin/app/components/gh-date-time-picker.js

Summary

Maintainability
C
1 day
Test Coverage
import Component from '@ember/component';
import classic from 'ember-classic-decorator';
import moment from 'moment-timezone';
import {action, computed} from '@ember/object';
import {isBlank, isEmpty} from '@ember/utils';
import {or, reads} from '@ember/object/computed';
import {inject as service} from '@ember/service';
import {tagName} from '@ember-decorators/component';

const DATE_FORMAT = 'YYYY-MM-DD';

@classic
@tagName('')
export default class GhDateTimePicker extends Component {
    @service settings;

    date = '';
    dateFormat = DATE_FORMAT;
    time = '';
    errors = null;
    dateErrorProperty = null;
    timeErrorProperty = null;
    isActive = true;
    _time = '';
    // _date is always a moment object in the blog's timezone
    _previousTime = '';
    _minDate = null; // Always set to a Date object
    _maxDate = null; // Always set to a Date object
    _scratchDate = null;
    _scratchDateError = null;

    // actions
    setTypedDateError() {}

    get renderInPlaceWithFallback() {
        return this.renderInPlace === undefined ? true : this.renderInPlace;
    }

    @reads('settings.timezone')
        blogTimezone;

    @or('dateError', 'timeError')
        hasError;

    @computed('_date', '_scratchDate')
    get dateValue() {
        if (this._scratchDate !== null) {
            return this._scratchDate;
        } else {
            return this._date?.format(DATE_FORMAT);
        }
    }
    
    @computed('_date')
    get localDateValue() {
        // Convert the selected date to a new date in the local timezone, purely to please PowerDatepicker
        return new Date(this._date.format(DATE_FORMAT));
    }

    @computed('blogTimezone')
    get timezone() {
        let blogTimezone = this.blogTimezone;
        return moment.utc().tz(blogTimezone).format('z');
    }

    @computed('errors.[]', 'dateErrorProperty', '_scratchDateError')
    get dateError() {
        if (this._scratchDateError) {
            return this._scratchDateError;
        }

        let errors = this.errors;
        let property = this.dateErrorProperty;

        if (errors && !isEmpty(errors.errorsFor(property))) {
            return errors.errorsFor(property).get('firstObject').message;
        }

        return '';
    }

    @computed('errors.[]', 'timeErrorProperty')
    get timeError() {
        let errors = this.errors;
        let property = this.timeErrorProperty;

        if (errors && !isEmpty(errors.errorsFor(property))) {
            return errors.errorsFor(property).get('firstObject').message;
        }

        return '';
    }

    didReceiveAttrs() {
        super.didReceiveAttrs(...arguments);

        let date = this.date;
        let time = this.time;
        let minDate = this.minDate;
        let maxDate = this.maxDate;
        let blogTimezone = this.blogTimezone;

        if (!isBlank(date)) {
            // Note: input date as a string is expected to be in the blog's timezone
            this.set('_date', moment.tz(date, blogTimezone));
        } else {
            this.set('_date', moment().tz(blogTimezone));
        }

        // reset scratch date if the component becomes inactive
        // (eg, PSM is closed, or save type is changed away from scheduled)
        if (!this.isActive && this._lastIsActive) {
            this._resetScratchDate();
        }
        this._lastIsActive = this.isActive;

        // reset scratch date if date is changed externally
        if ((date && date.valueOf()) !== (this._lastDate && this._lastDate.valueOf())) {
            this._resetScratchDate();
        }
        this._lastDate = this.date;

        if (isBlank(time)) {
            this.set('_time', this._date.format('HH:mm'));
        } else {
            this.set('_time', this.time);
        }
        this.set('_previousTime', this._time);

        // unless min/max date is at midnight moment will disable that day
        if (minDate === 'now') {
            this.set('_minDate', moment(moment().tz(blogTimezone).format(DATE_FORMAT)).toDate());
        } else if (!isBlank(minDate)) {
            this.set('_minDate', moment(moment.tz(minDate, blogTimezone).format(DATE_FORMAT)).toDate());
        } else {
            this.set('_minDate', null);
        }

        if (maxDate === 'now') {
            this.set('_maxDate', moment(moment().tz(blogTimezone).format(DATE_FORMAT)).toDate());
        } else if (!isBlank(maxDate)) {
            this.set('_maxDate', moment(moment.tz(maxDate, blogTimezone).format(DATE_FORMAT)).toDate());
        } else {
            this.set('_maxDate', null);
        }
    }

    willDestroyElement() {
        super.willDestroyElement(...arguments);
        this.setTypedDateError(null);
    }

    // if date or time is set and the other property is blank set that too
    // so that we don't get "can't be blank" errors
    @action
    setDateInternal(date) {
        if (date !== this._date) {
            this.setDate(date);

            if (isBlank(this.time)) {
                this.setTime(this._time);
            }
        }
    }

    /** 
     * This method is called by `PowerDatepicker` when a user selected a date. It is constructed like
     * The difference here is that the Date object that is passed contains the date, but only when viewed in the local timezone.
     * This timezone can differ between the timezone of the blog. We need to convert the date to a new date in the blog's timezone on the same day that was selected.
     * Example: new Date('2000-01-01') -> a user selected 2000-01-01. In the blog timezone, this could be 1999-12-31 23:00, which is wrong.
    */
    @action
    setLocalDate(date) {
        // Convert to a date string in the local timezone (moment is in local timezone by default)
        const dateString = moment(date).format(DATE_FORMAT);
        this._setDate(dateString);
    }

    @action
    setTimeInternal(time, event) {
        if (time.match(/^\d:\d\d$/)) {
            time = `0${time}`;
        }

        if (time !== this._previousTime) {
            this.setTime(time, event);
            this.set('_previousTime', time);

            if (isBlank(this.date)) {
                this.setDate(this._date.toDate());
            }
        }
    }

    @action
    updateTimeValue(event) {
        this.set('_time', event.target.value);
    }

    @action
    registerTimeInput(elem) {
        this._timeInputElem = elem;
    }

    @action
    onDateInput(datepicker, event) {
        let skipFocus = true;
        datepicker.actions.close(event, skipFocus);
        this.set('_scratchDate', event.target.value);
    }

    @action
    onDateBlur(event) {
        // make sure we're not doing anything just because the calendar dropdown
        // is opened and clicked
        if (event.target.value === this._date.format('YYYY-MM-DD')) {
            this._resetScratchDate();
            return;
        }

        if (!event.target.value) {
            this._resetScratchDate();
        } else {
            this._setDate(event.target.value);
        }
    }

    @action
    onDateKeydown(datepicker, event) {
        if (event.key === 'Escape') {
            this._resetScratchDate();
        }

        if (event.key === 'Enter') {
            this._setDate(event.target.value);
            event.preventDefault();
            event.stopImmediatePropagation();
            datepicker.actions.close();
        }

        // close the dropdown and manually focus the time input if necessary
        // so that keyboard focus behaves as expected
        if (event.key === 'Tab' && datepicker.isOpen) {
            datepicker.actions.close();

            // manual focus is required because the dropdown is rendered in place
            // and the default browser behaviour will move focus to the dropdown
            // which is then removed from the DOM making it look like focus has
            // disappeared. Shift+Tab is fine because the DOM is not changing in
            // that direction
            if (!event.shiftKey && this._timeInputElem) {
                event.preventDefault();
                this._timeInputElem.focus();
                this._timeInputElem.select();
            }
        }

        // capture a Ctrl/Cmd+S combo to make sure that the model value is updated
        // before the save occurs or we abort the save if the value is invalid
        if (event.key === 's' && (event.ctrlKey || event.metaKey)) {
            let wasValid = this._setDate(event.target.value);
            if (!wasValid) {
                event.stopImmediatePropagation();
                event.preventDefault();
            }
        }
    }

    // internal methods

    _resetScratchDate() {
        this.set('_scratchDate', null);
        this._setScratchDateError(null);
    }

    _setDate(dateStr) {
        if (!dateStr.match(/^\d\d\d\d-\d\d-\d\d$/)) {
            this._setScratchDateError('Invalid date format, must be YYYY-MM-DD');
            return false;
        }

        let date = moment.tz(dateStr, DATE_FORMAT, this.blogTimezone);
        if (!date.isValid()) {
            this._setScratchDateError('Invalid date');
            return false;
        }

        this.setDateInternal(date.toDate());
        this._resetScratchDate();
        return true;
    }

    _setScratchDateError(error) {
        this.set('_scratchDateError', error);
        this.setTypedDateError(error);
    }
}