From 84c7b2c4040ef3ad6fbcff7c77568c1f10327ee2 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Mon, 15 Feb 2021 12:49:57 +1000 Subject: [PATCH] FEATURE: Relative time input for timers and bookmarks and promote auto-close after last post timer (#12063) This PR adds a new relative-time component, that is an input box with a SK dropdown of minutes, hours, days, and months which outputs the duration selected in minutes. This new component is used in the time shortcuts list (used by bookmarks and topic timers) as a new Relative Time shortcut. Also in this PR, I have made the "Auto-Close After Last Post" timer into a top level timer type in the UI, and removed the "based on last post" custom time shortcut. --- .gitignore | 1 + .../discourse/app/components/bookmark.js | 4 +- .../app/components/edit-topic-timer-form.js | 39 ++++----- .../discourse/app/components/relative-time.js | 87 +++++++++++++++++++ .../app/components/time-shortcut-picker.js | 18 +++- .../app/components/topic-timer-info.js | 13 +-- .../app/controllers/edit-topic-timer.js | 32 +++++-- .../discourse/app/lib/time-shortcut.js | 9 ++ .../components/edit-topic-timer-form.hbs | 6 +- .../templates/components/relative-time.hbs | 9 ++ .../components/time-shortcut-picker.hbs | 8 ++ .../tests/acceptance/topic-edit-timer-test.js | 10 ++- .../common/base/edit-topic-timer-modal.scss | 6 ++ .../stylesheets/common/components/_index.scss | 1 + .../common/components/relative-time.scss | 18 ++++ .../components/time-shortcut-picker.scss | 5 ++ config/locales/client.en.yml | 11 ++- 17 files changed, 233 insertions(+), 44 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/components/relative-time.js create mode 100644 app/assets/javascripts/discourse/app/templates/components/relative-time.hbs create mode 100644 app/assets/stylesheets/common/components/relative-time.scss diff --git a/.gitignore b/.gitignore index d0359a12ee6..0afee536eea 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,4 @@ dist copyright yarn-error.log +tags diff --git a/app/assets/javascripts/discourse/app/components/bookmark.js b/app/assets/javascripts/discourse/app/components/bookmark.js index 0340401b490..2d794dea073 100644 --- a/app/assets/javascripts/discourse/app/components/bookmark.js +++ b/app/assets/javascripts/discourse/app/components/bookmark.js @@ -404,7 +404,9 @@ export default Component.extend({ // if the type is custom, we need to wait for the user to click save, as // they could still be adjusting the date and time - if (type !== TIME_SHORTCUT_TYPES.CUSTOM) { + if ( + ![TIME_SHORTCUT_TYPES.CUSTOM, TIME_SHORTCUT_TYPES.RELATIVE].includes(type) + ) { return this.saveAndClose(); } }, diff --git a/app/assets/javascripts/discourse/app/components/edit-topic-timer-form.js b/app/assets/javascripts/discourse/app/components/edit-topic-timer-form.js index cbc8f744a40..4c35310ab86 100644 --- a/app/assets/javascripts/discourse/app/components/edit-topic-timer-form.js +++ b/app/assets/javascripts/discourse/app/components/edit-topic-timer-form.js @@ -1,5 +1,6 @@ import { BUMP_TYPE, + CLOSE_AFTER_LAST_POST_STATUS_TYPE, CLOSE_STATUS_TYPE, DELETE_REPLIES_TYPE, DELETE_STATUS_TYPE, @@ -19,13 +20,21 @@ export default Component.extend({ statusType: readOnly("topicTimer.status_type"), autoOpen: equal("statusType", OPEN_STATUS_TYPE), autoClose: equal("statusType", CLOSE_STATUS_TYPE), + autoCloseAfterLastPost: equal( + "statusType", + CLOSE_AFTER_LAST_POST_STATUS_TYPE + ), autoDelete: equal("statusType", DELETE_STATUS_TYPE), autoBump: equal("statusType", BUMP_TYPE), publishToCategory: equal("statusType", PUBLISH_TO_CATEGORY_STATUS_TYPE), autoDeleteReplies: equal("statusType", DELETE_REPLIES_TYPE), showTimeOnly: or("autoOpen", "autoDelete", "autoBump"), showFutureDateInput: or("showTimeOnly", "publishToCategory", "autoClose"), - useDuration: or("isBasedOnLastPost", "autoDeleteReplies"), + useDuration: or( + "isBasedOnLastPost", + "autoDeleteReplies", + "autoCloseAfterLastPost" + ), duration: null, @on("init") @@ -52,8 +61,8 @@ export default Component.extend({ } }, - @discourseComputed("includeBasedOnLastPost") - customTimeShortcutOptions(includeBasedOnLastPost) { + @discourseComputed() + customTimeShortcutOptions() { return [ { icon: "bed", @@ -83,14 +92,6 @@ export default Component.extend({ time: startOfDay(now().add(6, "months")), timeFormatKey: "dates.long_no_year", }, - { - icon: "far-clock", - id: "set_based_on_last_post", - label: "topic.auto_update_input.set_based_on_last_post", - time: null, - timeFormatted: "", - hidden: !includeBasedOnLastPost, - }, ]; }, @@ -100,8 +101,7 @@ export default Component.extend({ }, isCustom: equal("timerType", "custom"), - isBasedOnLastPost: equal("timerType", "set_based_on_last_post"), - includeBasedOnLastPost: equal("statusType", CLOSE_STATUS_TYPE), + isBasedOnLastPost: equal("statusType", "close_after_last_post"), @discourseComputed( "topicTimer.updateTime", @@ -183,19 +183,12 @@ export default Component.extend({ @action onTimeSelected(type, time) { - this.setProperties({ - "topicTimer.based_on_last_post": type === "set_based_on_last_post", - timerType: type, - }); + this.set("timerType", type); this.onChangeInput(type, time); }, @action - durationChanged(newDuration) { - if (this.durationType === "days") { - this.set("topicTimer.duration_minutes", newDuration * 60 * 24); - } else { - this.set("topicTimer.duration_minutes", newDuration * 60); - } + durationChanged(newDurationMins) { + this.set("topicTimer.duration_minutes", newDurationMins); }, }); diff --git a/app/assets/javascripts/discourse/app/components/relative-time.js b/app/assets/javascripts/discourse/app/components/relative-time.js new file mode 100644 index 00000000000..2527b09cf40 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/relative-time.js @@ -0,0 +1,87 @@ +import discourseComputed, { on } from "discourse-common/utils/decorators"; + +import Component from "@ember/component"; +import I18n from "I18n"; +import { action } from "@ember/object"; + +export default Component.extend({ + tagName: "", + selectedInterval: "mins", + durationMinutes: null, + duration: null, + + @on("init") + cloneDuration() { + let mins = this.durationMinutes; + if (mins >= 43800) { + this.setProperties({ + duration: Math.floor(mins / 30 / 60 / 24), + selectedInterval: "months", + }); + } else if (mins >= 1440) { + this.setProperties({ + duration: Math.floor(mins / 60 / 24), + selectedInterval: "days", + }); + } else if (mins >= 60) { + this.setProperties({ + duration: Math.floor(mins / 60), + selectedInterval: "hours", + }); + } else { + this.setProperties({ + duration: mins, + selectedInterval: "mins", + }); + } + }, + + @discourseComputed + intervals() { + return [ + { id: "mins", name: I18n.t("relative_time.minutes") }, + { id: "hours", name: I18n.t("relative_time.hours") }, + { id: "days", name: I18n.t("relative_time.days") }, + { id: "months", name: I18n.t("relative_time.months") }, + ]; + }, + + @discourseComputed("selectedInterval", "duration") + calculatedMinutes(interval, duration) { + duration = parseFloat(duration); + + let mins = 0; + + switch (interval) { + case "mins": + mins = duration; + break; + case "hours": + mins = duration * 60; + break; + case "days": + mins = duration * 60 * 24; + break; + case "months": + mins = duration * 60 * 24 * 30; // least accurate because of varying days in months + break; + } + + return mins; + }, + + @action + onChangeInterval(newInterval) { + this.set("selectedInterval", newInterval); + if (this.onChange) { + this.onChange(this.calculatedMinutes); + } + }, + + @action + onChangeDuration() { + if (this.onChange) { + this.onChange(this.calculatedMinutes); + } + }, +}); diff --git a/app/assets/javascripts/discourse/app/components/time-shortcut-picker.js b/app/assets/javascripts/discourse/app/components/time-shortcut-picker.js index ae3cb9b78c5..9df40934a21 100644 --- a/app/assets/javascripts/discourse/app/components/time-shortcut-picker.js +++ b/app/assets/javascripts/discourse/app/components/time-shortcut-picker.js @@ -169,6 +169,7 @@ export default Component.extend({ }, customDatetimeSelected: equal("selectedShortcut", TIME_SHORTCUT_TYPES.CUSTOM), + relativeTimeSelected: equal("selectedShortcut", TIME_SHORTCUT_TYPES.RELATIVE), customDatetimeFilled: and("customDate", "customTime"), @observes("customDate", "customTime") @@ -219,15 +220,26 @@ export default Component.extend({ } }); - let customOptionIndex = options.findIndex( - (opt) => opt.id === TIME_SHORTCUT_TYPES.CUSTOM + let relativeOptionIndex = options.findIndex( + (opt) => opt.id === TIME_SHORTCUT_TYPES.RELATIVE ); - options.splice(customOptionIndex, 0, ...customOptions); + options.splice(relativeOptionIndex, 0, ...customOptions); return options; }, + @action + relativeTimeChanged(relativeTimeMins) { + let dateTime = now(this.userTimezone).add(relativeTimeMins, "minutes"); + + this.set("selectedDatetime", dateTime); + + if (this.onTimeSelected) { + this.onTimeSelected(TIME_SHORTCUT_TYPES.RELATIVE, dateTime); + } + }, + @action selectShortcut(type) { if (this.options.filterBy("hidden").mapBy("id").includes(type)) { diff --git a/app/assets/javascripts/discourse/app/components/topic-timer-info.js b/app/assets/javascripts/discourse/app/components/topic-timer-info.js index a0c5809a993..ea3e1dc1a0b 100644 --- a/app/assets/javascripts/discourse/app/components/topic-timer-info.js +++ b/app/assets/javascripts/discourse/app/components/topic-timer-info.js @@ -67,7 +67,9 @@ export default Component.extend({ let options = { timeLeft: duration.humanize(true), - duration: moment.duration(durationMinutes, "minutes").humanize(), + duration: moment + .duration(durationMinutes, "minutes") + .humanize({ s: 60, m: 60, h: 24 }), }; const categoryId = this.categoryId; @@ -130,11 +132,10 @@ export default Component.extend({ if (statusType === "silent_close") { statusType = "close"; } - - if (this.basedOnLastPost) { - return `topic.status_update_notice.auto_${statusType}_based_on_last_post`; - } else { - return `topic.status_update_notice.auto_${statusType}`; + if (this.basedOnLastPost && statusType === "close") { + statusType = "close_after_last_post"; } + + return `topic.status_update_notice.auto_${statusType}`; }, }); diff --git a/app/assets/javascripts/discourse/app/controllers/edit-topic-timer.js b/app/assets/javascripts/discourse/app/controllers/edit-topic-timer.js index 1bff8157a5c..a60f541d4fd 100644 --- a/app/assets/javascripts/discourse/app/controllers/edit-topic-timer.js +++ b/app/assets/javascripts/discourse/app/controllers/edit-topic-timer.js @@ -9,6 +9,7 @@ import discourseComputed from "discourse-common/utils/decorators"; import { popupAjaxError } from "discourse/lib/ajax-error"; export const CLOSE_STATUS_TYPE = "close"; +export const CLOSE_AFTER_LAST_POST_STATUS_TYPE = "close_after_last_post"; export const OPEN_STATUS_TYPE = "open"; export const PUBLISH_TO_CATEGORY_STATUS_TYPE = "publish_to_category"; export const DELETE_STATUS_TYPE = "delete"; @@ -28,6 +29,14 @@ export default Controller.extend(ModalFunctionality, { closed ? "topic.temp_open.title" : "topic.auto_close.title" ), }, + { + id: CLOSE_AFTER_LAST_POST_STATUS_TYPE, + name: I18n.t( + closed + ? "topic.temp_open.title" + : "topic.auto_close_after_last_post.title" + ), + }, { id: OPEN_STATUS_TYPE, name: I18n.t( @@ -112,6 +121,13 @@ export default Controller.extend(ModalFunctionality, { if (!this.get("topicTimer.status_type")) { this.send("onChangeStatusType", this.defaultStatusType); } + + if ( + this.get("topicTimer.status_type") === CLOSE_STATUS_TYPE && + this.get("topicTimer.based_on_last_post") + ) { + this.send("onChangeStatusType", CLOSE_AFTER_LAST_POST_STATUS_TYPE); + } }, @discourseComputed("publicTimerTypes") @@ -121,10 +137,11 @@ export default Controller.extend(ModalFunctionality, { actions: { onChangeStatusType(value) { - if (value !== CLOSE_STATUS_TYPE) { - this.set("topicTimer.based_on_last_post", false); - } - this.set("topicTimer.status_type", value); + this.setProperties({ + "topicTimer.based_on_last_post": + CLOSE_AFTER_LAST_POST_STATUS_TYPE === value, + "topicTimer.status_type": value, + }); }, onChangeInput(_type, time) { @@ -168,10 +185,15 @@ export default Controller.extend(ModalFunctionality, { } } + let statusType = this.get("topicTimer.status_type"); + if (statusType === CLOSE_AFTER_LAST_POST_STATUS_TYPE) { + statusType = CLOSE_STATUS_TYPE; + } + this._setTimer( this.get("topicTimer.updateTime"), this.get("topicTimer.duration_minutes"), - this.get("topicTimer.status_type"), + statusType, this.get("topicTimer.based_on_last_post"), this.get("topicTimer.category_id") ); diff --git a/app/assets/javascripts/discourse/app/lib/time-shortcut.js b/app/assets/javascripts/discourse/app/lib/time-shortcut.js index 6c85d19fcc4..4691cea5bde 100644 --- a/app/assets/javascripts/discourse/app/lib/time-shortcut.js +++ b/app/assets/javascripts/discourse/app/lib/time-shortcut.js @@ -16,6 +16,7 @@ export const TIME_SHORTCUT_TYPES = { NEXT_WEEK: "next_week", NEXT_MONTH: "next_month", CUSTOM: "custom", + RELATIVE: "relative", LAST_CUSTOM: "last_custom", NONE: "none", START_OF_NEXT_BUSINESS_WEEK: "start_of_next_business_week", @@ -76,6 +77,14 @@ export function defaultShortcutOptions(timezone) { time: nextMonth(timezone), timeFormatted: nextMonth(timezone).format(I18n.t("dates.long_no_year")), }, + { + icon: "far-clock", + id: TIME_SHORTCUT_TYPES.RELATIVE, + label: "time_shortcut.relative", + time: null, + timeFormatted: null, + isRelativeTimeShortcut: true, + }, { icon: "calendar-alt", id: TIME_SHORTCUT_TYPES.CUSTOM, diff --git a/app/assets/javascripts/discourse/app/templates/components/edit-topic-timer-form.hbs b/app/assets/javascripts/discourse/app/templates/components/edit-topic-timer-form.hbs index e3f678f9688..17dafbfb846 100644 --- a/app/assets/javascripts/discourse/app/templates/components/edit-topic-timer-form.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/edit-topic-timer-form.hbs @@ -23,8 +23,8 @@ {{/if}} {{#if useDuration}}
- - {{text-field id="topic_timer_duration" class="topic-timer-duration" type="number" value=duration min="0.1" step="0.1" onChange=durationChanged}} + + {{relative-time onChange=(action "durationChanged") durationMinutes=(readonly topicTimer.duration_minutes)}}
{{/if}} {{#if willCloseImmediately}} @@ -34,7 +34,7 @@ {{/if}} {{#if showTopicStatusInfo}} -
+ {{/if}} {{/if}} + + {{#if option.isRelativeTimeShortcut}} + {{#if relativeTimeSelected}} +
+ {{relative-time onChange=(action "relativeTimeChanged")}} +
+ {{/if}} + {{/if}} {{/each}} {{/tap-tile-grid}} diff --git a/app/assets/javascripts/discourse/tests/acceptance/topic-edit-timer-test.js b/app/assets/javascripts/discourse/tests/acceptance/topic-edit-timer-test.js index a8fd8944ced..50f44af9a64 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/topic-edit-timer-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/topic-edit-timer-test.js @@ -63,8 +63,14 @@ acceptance("Topic - Edit timer", function (needs) { .trim(); assert.ok(regex2.test(html2)); - await click("#tap_tile_set_based_on_last_post"); - await fillIn("#topic_timer_duration", "2"); + const timerType = selectKit(".select-kit.timer-type"); + await timerType.expand(); + await timerType.selectRowByValue("close_after_last_post"); + + const interval = selectKit(".select-kit.relative-time-intervals"); + await interval.expand(); + await interval.selectRowByValue("hours"); + await fillIn(".relative-time-duration", "2"); const regex3 = /last post in the topic is already/g; const html3 = queryAll(".edit-topic-timer-modal .warning").html().trim(); diff --git a/app/assets/stylesheets/common/base/edit-topic-timer-modal.scss b/app/assets/stylesheets/common/base/edit-topic-timer-modal.scss index 11315c44982..f43d632b9c2 100644 --- a/app/assets/stylesheets/common/base/edit-topic-timer-modal.scss +++ b/app/assets/stylesheets/common/base/edit-topic-timer-modal.scss @@ -58,6 +58,12 @@ .pika-single { position: absolute !important; /* inline JS styles */ } + + .modal-topic-timer-info { + .topic-status-info { + border-top: 0; + } + } } // mobile styles diff --git a/app/assets/stylesheets/common/components/_index.scss b/app/assets/stylesheets/common/components/_index.scss index 79fee8ec7ab..dd37f9edbef 100644 --- a/app/assets/stylesheets/common/components/_index.scss +++ b/app/assets/stylesheets/common/components/_index.scss @@ -17,6 +17,7 @@ @import "ignored-user-list"; @import "keyboard_shortcuts"; @import "navs"; +@import "relative-time"; @import "share-and-invite-modal"; @import "svg"; @import "tap-tile"; diff --git a/app/assets/stylesheets/common/components/relative-time.scss b/app/assets/stylesheets/common/components/relative-time.scss new file mode 100644 index 00000000000..579a477f371 --- /dev/null +++ b/app/assets/stylesheets/common/components/relative-time.scss @@ -0,0 +1,18 @@ +.relative-time { + display: flex; + flex-wrap: wrap; + + input[type="text"] { + flex: 1; + } + + .select-kit { + flex: 1; + width: auto; + margin-left: 5px; + } + + &:last-child { + margin-right: auto; + } +} diff --git a/app/assets/stylesheets/common/components/time-shortcut-picker.scss b/app/assets/stylesheets/common/components/time-shortcut-picker.scss index 86713690554..eabf18307d0 100644 --- a/app/assets/stylesheets/common/components/time-shortcut-picker.scss +++ b/app/assets/stylesheets/common/components/time-shortcut-picker.scss @@ -9,6 +9,11 @@ margin-top: -0.5em; } + .caret-icon { + margin-top: 0; + padding: 0 0 0 5px; + } + .tap-tile-date-input, .tap-tile-time-input { display: flex; diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 27ba2562c83..81a96d6071b 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -575,6 +575,12 @@ en: title: "Why are you rejecting this user?" send_email: "Send rejection email" + relative_time: + minutes: "minute(s)" + hours: "hour(s)" + days: "day(s)" + months: "month(s)" + time_shortcut: later_today: "Later today" next_business_day: "Next business day" @@ -586,6 +592,7 @@ en: start_of_next_business_week_alt: "Next Monday" next_month: "Next month" custom: "Custom date and time" + relative: "Relative time" none: "None needed" last_custom: "Last" @@ -2463,6 +2470,8 @@ en: label: "Auto-close topic hours:" error: "Please enter a valid value." based_on_last_post: "Don't close until the last post in the topic is at least this old." + auto_close_after_last_post: + title: "Auto-Close Topic After Last Post" auto_delete: title: "Auto-Delete Topic" auto_bump: @@ -2476,7 +2485,7 @@ en: auto_open: "This topic will automatically open %{timeLeft}." auto_close: "This topic will automatically close %{timeLeft}." auto_publish_to_category: "This topic will be published to #%{categoryName} %{timeLeft}." - auto_close_based_on_last_post: "This topic will close %{duration} after the last reply." + auto_close_after_last_post: "This topic will close %{duration} after the last reply." auto_delete: "This topic will be automatically deleted %{timeLeft}." auto_bump: "This topic will be automatically bumped %{timeLeft}." auto_reminder: "You will be reminded about this topic %{timeLeft}."