mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-25 20:03:12 +03:00
✨ Enabled manual typing of post publish dates (#1431)
* ✨ Enabled manual typing of post publish dates closes https://github.com/TryGhost/Ghost/issues/9256 - stores the typed date internally to the component until - Enter is pressed whilst focused - Ctrl/Cmd+S is pressed whilst focused - the input loses focus - shows an error message if the typed date is not in the correct format or is invalid - stops Ctrl/Cmd+S propagating if the typed date is not in the correct format or is invalid - as long as the date is valid it calls the `setDate` action when the input loses focus, Ctrl/Cmd+S or Enter is pressed - prevents publish being triggered in the publish menu if an invalid date is entered - resets any invalid state in the PSM when it's closed - manages focus when using <kbd>Tab</kbd>
This commit is contained in:
parent
704cbcfb4d
commit
f271415626
@ -1,35 +1,56 @@
|
||||
import Component from '@ember/component';
|
||||
import moment from 'moment';
|
||||
import {computed} from '@ember/object';
|
||||
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';
|
||||
|
||||
const DATE_FORMAT = 'YYYY-MM-DD';
|
||||
|
||||
export default Component.extend({
|
||||
settings: service(),
|
||||
|
||||
tagName: '',
|
||||
|
||||
date: '',
|
||||
dateFormat: DATE_FORMAT,
|
||||
time: '',
|
||||
errors: null,
|
||||
dateErrorProperty: null,
|
||||
timeErrorProperty: null,
|
||||
isActive: true,
|
||||
|
||||
_time: '',
|
||||
_previousTime: '',
|
||||
_minDate: null,
|
||||
_maxDate: null,
|
||||
_scratchDate: null,
|
||||
_scratchDateError: null,
|
||||
|
||||
// actions
|
||||
setTypedDateError() {},
|
||||
|
||||
blogTimezone: reads('settings.activeTimezone'),
|
||||
hasError: or('dateError', 'timeError'),
|
||||
|
||||
dateValue: computed('_date', '_scratchDate', function () {
|
||||
if (this._scratchDate !== null) {
|
||||
return this._scratchDate;
|
||||
} else {
|
||||
return moment(this._date).format(DATE_FORMAT);
|
||||
}
|
||||
}),
|
||||
|
||||
timezone: computed('blogTimezone', function () {
|
||||
let blogTimezone = this.blogTimezone;
|
||||
return moment.utc().tz(blogTimezone).format('z');
|
||||
}),
|
||||
|
||||
dateError: computed('errors.[]', 'dateErrorProperty', function () {
|
||||
dateError: computed('errors.[]', 'dateErrorProperty', '_scratchDateError', function () {
|
||||
if (this._scratchDateError) {
|
||||
return this._scratchDateError;
|
||||
}
|
||||
|
||||
let errors = this.errors;
|
||||
let property = this.dateErrorProperty;
|
||||
|
||||
@ -64,8 +85,21 @@ export default Component.extend({
|
||||
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'));
|
||||
this.set('_time', moment(this._date).format('HH:mm'));
|
||||
} else {
|
||||
this.set('_time', this.time);
|
||||
}
|
||||
@ -73,22 +107,26 @@ export default Component.extend({
|
||||
|
||||
// unless min/max date is at midnight moment will diable that day
|
||||
if (minDate === 'now') {
|
||||
this.set('_minDate', moment(moment().format('YYYY-MM-DD')));
|
||||
this.set('_minDate', moment(moment().format(DATE_FORMAT)));
|
||||
} else if (!isBlank(minDate)) {
|
||||
this.set('_minDate', moment(moment(minDate).format('YYYY-MM-DD')));
|
||||
this.set('_minDate', moment(moment(minDate).format(DATE_FORMAT)));
|
||||
} else {
|
||||
this.set('_minDate', null);
|
||||
}
|
||||
|
||||
if (maxDate === 'now') {
|
||||
this.set('_maxDate', moment(moment().format('YYYY-MM-DD')));
|
||||
this.set('_maxDate', moment(moment().format(DATE_FORMAT)));
|
||||
} else if (!isBlank(maxDate)) {
|
||||
this.set('_maxDate', moment(moment(maxDate).format('YYYY-MM-DD')));
|
||||
this.set('_maxDate', moment(moment(maxDate).format(DATE_FORMAT)));
|
||||
} else {
|
||||
this.set('_maxDate', null);
|
||||
}
|
||||
},
|
||||
|
||||
willDestroyElement() {
|
||||
this.setTypedDateError(null);
|
||||
},
|
||||
|
||||
actions: {
|
||||
// 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
|
||||
@ -116,5 +154,98 @@ export default Component.extend({
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
registerTimeInput: action(function (elem) {
|
||||
this._timeInputElem = elem;
|
||||
}),
|
||||
|
||||
onDateInput: action(function (datepicker, event) {
|
||||
datepicker.actions.close();
|
||||
this.set('_scratchDate', event.target.value);
|
||||
}),
|
||||
|
||||
onDateBlur: action(function (event) {
|
||||
// make sure we're not doing anything just because the calendar dropdown
|
||||
// is opened and clicked
|
||||
if (event.target.value === moment(this._date).format('YYYY-MM-DD')) {
|
||||
this._resetScratchDate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.target.value) {
|
||||
this._resetScratchDate();
|
||||
} else {
|
||||
this._setDate(event.target.value);
|
||||
}
|
||||
}),
|
||||
|
||||
onDateKeydown: action(function (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(dateStr, DATE_FORMAT);
|
||||
if (!date.isValid()) {
|
||||
this._setScratchDateError('Invalid date');
|
||||
return false;
|
||||
}
|
||||
|
||||
this.send('setDate', date.toDate());
|
||||
this._resetScratchDate();
|
||||
return true;
|
||||
},
|
||||
|
||||
_setScratchDateError(error) {
|
||||
this.set('_scratchDateError', error);
|
||||
this.setTypedDateError(error);
|
||||
}
|
||||
});
|
||||
|
@ -12,14 +12,15 @@ const CONFIRM_EMAIL_MAX_POLL_LENGTH = 15 * 1000;
|
||||
export default Component.extend({
|
||||
clock: service(),
|
||||
|
||||
backgroundTask: null,
|
||||
classNames: 'gh-publishmenu',
|
||||
displayState: 'draft',
|
||||
post: null,
|
||||
postStatus: 'draft',
|
||||
saveTask: null,
|
||||
runningText: null,
|
||||
backgroundTask: null,
|
||||
saveTask: null,
|
||||
sendEmailWhenPublished: false,
|
||||
typedDateError: null,
|
||||
|
||||
_publishedAtBlogTZ: null,
|
||||
_previousStatus: null,
|
||||
@ -257,7 +258,18 @@ export default Component.extend({
|
||||
}),
|
||||
|
||||
save: task(function* ({dropdown} = {}) {
|
||||
let {post, sendEmailWhenPublished, sendEmailConfirmed, saveType} = this;
|
||||
let {
|
||||
post,
|
||||
sendEmailWhenPublished,
|
||||
sendEmailConfirmed,
|
||||
saveType,
|
||||
typedDateError
|
||||
} = this;
|
||||
|
||||
// don't allow save if an invalid schedule date is present
|
||||
if (typedDateError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
post.status === 'draft' &&
|
||||
|
@ -8,7 +8,14 @@
|
||||
}}
|
||||
{{#dp.trigger tabindex="-1" data-test-date-time-picker-datepicker=true}}
|
||||
<div class="gh-date-time-picker-date {{if this.dateError "error"}}">
|
||||
<input type="text" readonly value={{moment-format this._date "YYYY-MM-DD"}} disabled={{this.disabled}} data-test-date-time-picker-date-input>
|
||||
<input type="text"
|
||||
placeholder={{this.dateFormat}}
|
||||
value={{readonly this.dateValue}}
|
||||
disabled={{this.disabled}}
|
||||
{{on "input" (fn this.onDateInput dp)}}
|
||||
{{on "blur" this.onDateBlur}}
|
||||
{{on "keydown" (fn this.onDateKeydown dp)}}
|
||||
data-test-date-time-picker-date-input>
|
||||
{{svg-jar "calendar"}}
|
||||
</div>
|
||||
{{/dp.trigger}}
|
||||
@ -25,6 +32,7 @@
|
||||
disabled={{this.disabled}}
|
||||
oninput={{action (mut this._time) value="target.value"}}
|
||||
onblur={{action "setTime" this._time}}
|
||||
{{did-insert this.registerTimeInput}}
|
||||
data-test-date-time-picker-time-input
|
||||
>
|
||||
<small class="gh-date-time-picker-timezone">({{this.timezone}})</small>
|
||||
|
@ -61,7 +61,7 @@
|
||||
timeErrorProperty="publishedAtBlogTime"
|
||||
maxDate='now'
|
||||
disabled=this.post.isScheduled
|
||||
static=true
|
||||
isActive=(and this.showSettingsMenu (not this.isViewingSubview))
|
||||
}}
|
||||
{{#unless (or this.post.isDraft this.post.isPublished this.post.pastScheduledTime)}}
|
||||
<p>Use the publish menu to re-schedule</p>
|
||||
|
@ -17,10 +17,12 @@
|
||||
time=this.post.publishedAtBlogTime
|
||||
setDate=(action "setDate")
|
||||
setTime=(action "setTime")
|
||||
setTypedDateError=this.setTypedDateError
|
||||
errors=this.post.errors
|
||||
dateErrorProperty="publishedAtBlogDate"
|
||||
timeErrorProperty="publishedAtBlogTime"
|
||||
minDate=this._minDate
|
||||
isActive=(eq this.saveType "schedule")
|
||||
}}
|
||||
<div class="gh-publishmenu-radio-desc">Set automatic future publish date</div>
|
||||
</div>
|
||||
|
@ -17,10 +17,12 @@
|
||||
time=this.post.publishedAtBlogTime
|
||||
setDate=(action "setDate")
|
||||
setTime=(action "setTime")
|
||||
setTypedDateError=this.setTypedDateError
|
||||
errors=this.post.errors
|
||||
dateErrorProperty="publishedAtBlogDate"
|
||||
timeErrorProperty="publishedAtBlogTime"
|
||||
minDate=this._minDate
|
||||
isActive=(eq this.saveType "schedule")
|
||||
}}
|
||||
<div class="gh-publishmenu-radio-desc">Set automatic future publish date</div>
|
||||
</div>
|
||||
|
@ -17,13 +17,15 @@
|
||||
saveType=this.saveType
|
||||
isClosing=this.isClosing
|
||||
memberCount=this.memberCount
|
||||
setSaveType=(action "setSaveType")}}
|
||||
setSaveType=(action "setSaveType")
|
||||
setTypedDateError=(action (mut this.typedDateError))}}
|
||||
|
||||
{{else}}
|
||||
{{gh-publishmenu-draft
|
||||
post=this.post
|
||||
saveType=this.saveType
|
||||
setSaveType=(action "setSaveType")
|
||||
setTypedDateError=(action (mut this.typedDateError))
|
||||
backgroundTask=this.backgroundTask
|
||||
memberCount=this.memberCount
|
||||
sendEmailWhenPublished=this.sendEmailWhenPublished}}
|
||||
|
Loading…
Reference in New Issue
Block a user