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.
This commit is contained in:
Martin Brennan 2021-02-15 12:49:57 +10:00 committed by GitHub
parent ad3ec5809f
commit 84c7b2c404
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 233 additions and 44 deletions

1
.gitignore vendored
View File

@ -150,3 +150,4 @@ dist
copyright
yarn-error.log
tags

View File

@ -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();
}
},

View File

@ -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);
},
});

View File

@ -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);
}
},
});

View File

@ -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)) {

View File

@ -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}`;
},
});

View File

@ -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")
);

View File

@ -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,

View File

@ -23,8 +23,8 @@
{{/if}}
{{#if useDuration}}
<div class="controls">
<label class="control-label">{{durationLabel}}</label>
{{text-field id="topic_timer_duration" class="topic-timer-duration" type="number" value=duration min="0.1" step="0.1" onChange=durationChanged}}
<label class="control-label">Duration</label>
{{relative-time onChange=(action "durationChanged") durationMinutes=(readonly topicTimer.duration_minutes)}}
</div>
{{/if}}
{{#if willCloseImmediately}}
@ -34,7 +34,7 @@
</div>
{{/if}}
{{#if showTopicStatusInfo}}
<div class="alert alert-info">
<div class="alert alert-info modal-topic-timer-info">
{{topic-timer-info
statusType=statusType
executeAt=executeAt

View File

@ -0,0 +1,9 @@
<div class="relative-time">
{{input class="relative-time-duration" min="0.1" step="0.01" type="number" value=duration onChange=(action "onChangeDuration")}}
{{combo-box
content=intervals
value=selectedInterval
class="relative-time-intervals"
onChange=(action "onChangeInterval")
}}
</div>

View File

@ -25,5 +25,13 @@
</div>
{{/if}}
{{/if}}
{{#if option.isRelativeTimeShortcut}}
{{#if relativeTimeSelected}}
<div class="control-group custom-date-time-wrap">
{{relative-time onChange=(action "relativeTimeChanged")}}
</div>
{{/if}}
{{/if}}
{{/each}}
{{/tap-tile-grid}}

View File

@ -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();

View File

@ -58,6 +58,12 @@
.pika-single {
position: absolute !important; /* inline JS styles */
}
.modal-topic-timer-info {
.topic-status-info {
border-top: 0;
}
}
}
// mobile styles

View File

@ -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";

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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 <a href=%{categoryUrl}>#%{categoryName}</a> %{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}."