From f2714156265add723c4bfe2c6677093420b1eb21 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Fri, 10 Jan 2020 14:25:59 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Enabled=20manual=20typing=20of=20po?= =?UTF-8?q?st=20publish=20dates=20(#1431)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ 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 Tab --- .../app/components/gh-date-time-picker.js | 145 +++++++++++++++++- ghost/admin/app/components/gh-publishmenu.js | 18 ++- .../components/gh-date-time-picker.hbs | 10 +- .../components/gh-post-settings-menu.hbs | 2 +- .../components/gh-publishmenu-draft.hbs | 2 + .../components/gh-publishmenu-scheduled.hbs | 2 + .../templates/components/gh-publishmenu.hbs | 4 +- 7 files changed, 170 insertions(+), 13 deletions(-) diff --git a/ghost/admin/app/components/gh-date-time-picker.js b/ghost/admin/app/components/gh-date-time-picker.js index ecfd711069..016c8ed927 100644 --- a/ghost/admin/app/components/gh-date-time-picker.js +++ b/ghost/admin/app/components/gh-date-time-picker.js @@ -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); } }); diff --git a/ghost/admin/app/components/gh-publishmenu.js b/ghost/admin/app/components/gh-publishmenu.js index 23c9f7062c..c2c2369409 100644 --- a/ghost/admin/app/components/gh-publishmenu.js +++ b/ghost/admin/app/components/gh-publishmenu.js @@ -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' && diff --git a/ghost/admin/app/templates/components/gh-date-time-picker.hbs b/ghost/admin/app/templates/components/gh-date-time-picker.hbs index ab5cbd68f0..f27d5b5a91 100644 --- a/ghost/admin/app/templates/components/gh-date-time-picker.hbs +++ b/ghost/admin/app/templates/components/gh-date-time-picker.hbs @@ -8,7 +8,14 @@ }} {{#dp.trigger tabindex="-1" data-test-date-time-picker-datepicker=true}}
- + {{svg-jar "calendar"}}
{{/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 > ({{this.timezone}}) diff --git a/ghost/admin/app/templates/components/gh-post-settings-menu.hbs b/ghost/admin/app/templates/components/gh-post-settings-menu.hbs index 083dec0da4..9c6350d6df 100644 --- a/ghost/admin/app/templates/components/gh-post-settings-menu.hbs +++ b/ghost/admin/app/templates/components/gh-post-settings-menu.hbs @@ -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)}}

Use the publish menu to re-schedule

diff --git a/ghost/admin/app/templates/components/gh-publishmenu-draft.hbs b/ghost/admin/app/templates/components/gh-publishmenu-draft.hbs index a59edb2750..c87af26f10 100644 --- a/ghost/admin/app/templates/components/gh-publishmenu-draft.hbs +++ b/ghost/admin/app/templates/components/gh-publishmenu-draft.hbs @@ -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") }}
Set automatic future publish date
diff --git a/ghost/admin/app/templates/components/gh-publishmenu-scheduled.hbs b/ghost/admin/app/templates/components/gh-publishmenu-scheduled.hbs index dfa887fdbc..e097161e96 100644 --- a/ghost/admin/app/templates/components/gh-publishmenu-scheduled.hbs +++ b/ghost/admin/app/templates/components/gh-publishmenu-scheduled.hbs @@ -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") }}
Set automatic future publish date
diff --git a/ghost/admin/app/templates/components/gh-publishmenu.hbs b/ghost/admin/app/templates/components/gh-publishmenu.hbs index afab58919e..2cc86bb1e6 100644 --- a/ghost/admin/app/templates/components/gh-publishmenu.hbs +++ b/ghost/admin/app/templates/components/gh-publishmenu.hbs @@ -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}}