mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-25 11:55:03 +03:00
07cb542b97
fixes https://github.com/TryGhost/Team/issues/1946 Problem: - When running the admin tests in a timezone that is later than UTC, the tests failed. Causes: - Some tests needed some adjustements - The DateTimePicker did not always use the correct timezone. - Test models createdAt times sometimes depended on the timezone of the test runner Solution: - All the input DateTimePicker gets should be processed in the blog's timezone. - Make sure that all communication (properties, setters, minDate...) with `PowerDatepicker` happens in the local timezone. When setting, convert that date to the blog timezone and use that as the real value.
298 lines
9.2 KiB
JavaScript
298 lines
9.2 KiB
JavaScript
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);
|
|
}
|
|
}
|