DEV: Add time shortcut picker component and libs and refactor bookmark modal controller into component which uses time shortcut picker (#11802)

This PR moves all of the time picking functionality from the bookmark modal and controller into a reusable time-shortcut-picker component, which will be used for the topic timer UI revamp. All of the utility JS for getting dates like tomorrow/next week/next month etc. have also been moved into a separate utility lib.

The time-shortcut-picker has a couple of options that can be passed in:

* prefilledDatetime - The date and time to parse and prefill into the custom date and time section, useful for editing interfaces.
* onTimeSelected (callback) - Called when one of the time shortcuts is clicked, and passes the type of the shortcut (e.g. tomorrow) and the datetime selected.
* additionalOptionsToShow - An array of option ids to show (by default `later_today` and `later_this_week` are hidden)
* hiddenOptions - An array of option ids to hide
* customOptions - An array of custom options to display (e.g. the option to select a post date for the bookmarks modal). The options should have the below properties:
    * id
    * icon
    * label (I18n key)
    * time (moment datetime object)
    * timeFormatted
    * hidden

The other major work in this PR is moving all of the bookmark functionality out of the bookmark modal controller and into its own component, where it makes more sense to be able to access elements on the page via `document`. Tests have been added to accompany this move, and existing acceptance tests for bookmark are all passing.
This commit is contained in:
Martin Brennan 2021-02-01 09:03:41 +10:00 committed by GitHub
parent e242e0b13c
commit 3e3f3f7b7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1232 additions and 1022 deletions

View File

@ -1,57 +0,0 @@
import Component from "@ember/component";
import I18n from "I18n";
import { action } from "@ember/object";
import { getOwner } from "discourse-common/lib/get-owner";
import { or } from "@ember/object/computed";
export default Component.extend({
tagName: "",
init() {
this._super(...arguments);
this.loadLocalDates();
},
get postLocalDateFormatted() {
return this.postLocalDate().format(I18n.t("dates.long_no_year"));
},
showPostLocalDate: or("postDetectedLocalDate", "postDetectedLocalTime"),
loadLocalDates() {
let postEl = document.querySelector(`[data-post-id="${this.postId}"]`);
let localDateEl = null;
if (postEl) {
localDateEl = postEl.querySelector(".discourse-local-date");
}
this.setProperties({
postDetectedLocalDate: localDateEl ? localDateEl.dataset.date : null,
postDetectedLocalTime: localDateEl ? localDateEl.dataset.time : null,
postDetectedLocalTimezone: localDateEl
? localDateEl.dataset.timezone
: null,
});
},
postLocalDate() {
const bookmarkController = getOwner(this).lookup("controller:bookmark");
let parsedPostLocalDate = bookmarkController._parseCustomDateTime(
this.postDetectedLocalDate,
this.postDetectedLocalTime,
this.postDetectedLocalTimezone
);
if (!this.postDetectedLocalTime) {
return bookmarkController.startOfDay(parsedPostLocalDate);
}
return parsedPostLocalDate;
},
@action
setReminder() {
return this.onChange(this.postLocalDate());
},
});

View File

@ -0,0 +1,416 @@
import {
LATER_TODAY_CUTOFF_HOUR,
MOMENT_THURSDAY,
laterToday,
now,
parseCustomDatetime,
startOfDay,
tomorrow,
} from "discourse/lib/time-utils";
import { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark";
import Component from "@ember/component";
import I18n from "I18n";
import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts";
import { Promise } from "rsvp";
import { TIME_SHORTCUT_TYPES } from "discourse/lib/time-shortcut";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import bootbox from "bootbox";
import discourseComputed, { on } from "discourse-common/utils/decorators";
import { formattedReminderTime } from "discourse/lib/bookmark";
import { and, notEmpty, or } from "@ember/object/computed";
import { popupAjaxError } from "discourse/lib/ajax-error";
// global shortcuts that interfere with these modal shortcuts, they are rebound when the
// modal is closed
//
// d deletePost
const GLOBAL_SHORTCUTS_TO_PAUSE = ["d"];
const BOOKMARK_BINDINGS = {
enter: { handler: "saveAndClose" },
"d d": { handler: "delete" },
};
export default Component.extend({
tagName: "",
errorMessage: null,
selectedReminderType: null,
_closeWithoutSaving: null,
_savingBookmarkManually: null,
_saving: null,
_deleting: null,
postDetectedLocalDate: null,
postDetectedLocalTime: null,
postDetectedLocalTimezone: null,
prefilledDatetime: null,
userTimezone: null,
showOptions: null,
model: null,
afterSave: null,
@on("init")
_setup() {
this.setProperties({
errorMessage: null,
selectedReminderType: TIME_SHORTCUT_TYPES.NONE,
_closeWithoutSaving: false,
_savingBookmarkManually: false,
_saving: false,
_deleting: false,
postDetectedLocalDate: null,
postDetectedLocalTime: null,
postDetectedLocalTimezone: null,
prefilledDatetime: null,
userTimezone: this.currentUser.resolvedTimezone(this.currentUser),
showOptions: false,
});
this.registerOnCloseHandler(this._onModalClose.bind(this));
this._loadBookmarkOptions();
this._bindKeyboardShortcuts();
if (this.editingExistingBookmark) {
this._initializeExistingBookmarkData();
}
this._loadPostLocalDates();
},
@on("didInsertElement")
_prepareUI() {
if (this.site.isMobileDevice) {
document.getElementById("bookmark-name").blur();
}
// we want to make sure the options panel opens so the user
// knows they have set these options previously.
if (this.autoDeletePreference) {
this.toggleOptionsPanel();
}
},
_initializeExistingBookmarkData() {
if (this.existingBookmarkHasReminder) {
this.set("prefilledDatetime", this.model.reminderAt);
let parsedDatetime = parseCustomDatetime(
this.prefilledDatetime,
null,
this.userTimezone
);
this.set("selectedDatetime", parsedDatetime);
}
},
_loadBookmarkOptions() {
this.set(
"autoDeletePreference",
this.model.autoDeletePreference || this._preferredDeleteOption() || 0
);
},
_preferredDeleteOption() {
let preferred = localStorage.bookmarkDeleteOption;
if (preferred && preferred !== "") {
preferred = parseInt(preferred, 10);
}
return preferred;
},
_bindKeyboardShortcuts() {
KeyboardShortcuts.pause(GLOBAL_SHORTCUTS_TO_PAUSE);
Object.keys(BOOKMARK_BINDINGS).forEach((shortcut) => {
KeyboardShortcuts.addShortcut(shortcut, () => {
let binding = BOOKMARK_BINDINGS[shortcut];
if (binding.args) {
return this.send(binding.handler, ...binding.args);
}
this.send(binding.handler);
});
});
},
_unbindKeyboardShortcuts() {
KeyboardShortcuts.unbind(BOOKMARK_BINDINGS);
},
_restoreGlobalShortcuts() {
KeyboardShortcuts.unpause(GLOBAL_SHORTCUTS_TO_PAUSE);
},
_loadPostLocalDates() {
let postEl = document.querySelector(
`[data-post-id="${this.model.postId}"]`
);
let localDateEl;
if (postEl) {
localDateEl = postEl.querySelector(".discourse-local-date");
}
if (localDateEl) {
this.setProperties({
postDetectedLocalDate: localDateEl.dataset.date,
postDetectedLocalTime: localDateEl.dataset.time,
postDetectedLocalTimezone: localDateEl.dataset.timezone,
});
}
},
_saveBookmark() {
let reminderAt;
if (this.selectedReminderType) {
reminderAt = this.selectedDatetime;
}
const reminderAtISO = reminderAt ? reminderAt.toISOString() : null;
if (this.selectedReminderType === TIME_SHORTCUT_TYPES.CUSTOM) {
if (!reminderAt) {
return Promise.reject(I18n.t("bookmarks.invalid_custom_datetime"));
}
}
localStorage.bookmarkDeleteOption = this.autoDeletePreference;
let reminderType;
if (this.selectedReminderType === TIME_SHORTCUT_TYPES.NONE) {
reminderType = null;
} else if (
this.selectedReminderType === TIME_SHORTCUT_TYPES.LAST_CUSTOM ||
this.selectedReminderType === TIME_SHORTCUT_TYPES.POST_LOCAL_DATE
) {
reminderType = TIME_SHORTCUT_TYPES.CUSTOM;
} else {
reminderType = this.selectedReminderType;
}
const data = {
reminder_type: reminderType,
reminder_at: reminderAtISO,
name: this.model.name,
post_id: this.model.postId,
id: this.model.id,
auto_delete_preference: this.autoDeletePreference,
};
if (this.editingExistingBookmark) {
return ajax(`/bookmarks/${this.model.id}`, {
type: "PUT",
data,
}).then(() => {
this._executeAfterSave(reminderAtISO);
});
} else {
return ajax("/bookmarks", { type: "POST", data }).then((response) => {
this.set("model.id", response.id);
this._executeAfterSave(reminderAtISO);
});
}
},
_executeAfterSave(reminderAtISO) {
if (!this.afterSave) {
return;
}
this.afterSave({
reminderAt: reminderAtISO,
reminderType: this.selectedReminderType,
autoDeletePreference: this.autoDeletePreference,
id: this.model.id,
name: this.model.name,
});
},
_deleteBookmark() {
return ajax("/bookmarks/" + this.model.id, {
type: "DELETE",
}).then((response) => {
if (this.afterDelete) {
this.afterDelete(response.topic_bookmarked);
}
});
},
_postLocalDate() {
let parsedPostLocalDate = parseCustomDatetime(
this.postDetectedLocalDate,
this.postDetectedLocalTime,
this.userTimezone,
this.postDetectedLocalTimezone
);
if (!this.postDetectedLocalTime) {
return startOfDay(parsedPostLocalDate);
}
return parsedPostLocalDate;
},
_handleSaveError(e) {
this._savingBookmarkManually = false;
if (typeof e === "string") {
bootbox.alert(e);
} else {
popupAjaxError(e);
}
},
_onModalClose(initiatedByCloseButton) {
// we want to close without saving if the user already saved
// manually or deleted the bookmark, as well as when the modal
// is just closed with the X button
this._closeWithoutSaving =
this._closeWithoutSaving || initiatedByCloseButton;
this._unbindKeyboardShortcuts();
this._restoreGlobalShortcuts();
if (!this._closeWithoutSaving && !this._savingBookmarkManually) {
this._saveBookmark().catch((e) => this._handleSaveError(e));
}
if (this.onCloseWithoutSaving && this._closeWithoutSaving) {
this.onCloseWithoutSaving();
}
},
showExistingReminderAt: notEmpty("model.reminderAt"),
showDelete: notEmpty("model.id"),
userHasTimezoneSet: notEmpty("userTimezone"),
showPostLocalDate: or("postDetectedLocalDate", "postDetectedLocalTime"),
editingExistingBookmark: and("model", "model.id"),
existingBookmarkHasReminder: and("model", "model.reminderAt"),
@discourseComputed()
autoDeletePreferences: () => {
return Object.keys(AUTO_DELETE_PREFERENCES).map((key) => {
return {
id: AUTO_DELETE_PREFERENCES[key],
name: I18n.t(`bookmarks.auto_delete_preference.${key.toLowerCase()}`),
};
});
},
@discourseComputed()
customTimeShortcutOptions() {
let customOptions = [];
if (this.showPostLocalDate) {
customOptions.push({
icon: "globe-americas",
id: TIME_SHORTCUT_TYPES.POST_LOCAL_DATE,
label: "bookmarks.reminders.post_local_date",
time: this._postLocalDate(),
timeFormatted: this._postLocalDate().format(
I18n.t("dates.long_no_year")
),
hidden: false,
});
}
return customOptions;
},
@discourseComputed()
additionalTimeShortcutOptions() {
let additional = [];
let later = laterToday(this.userTimezone);
if (
!later.isSame(tomorrow(this.userTimezone), "date") &&
now(this.userTimezone).hour() < LATER_TODAY_CUTOFF_HOUR
) {
additional.push(TIME_SHORTCUT_TYPES.LATER_TODAY);
}
if (now(this.userTimezone).day() < MOMENT_THURSDAY) {
additional.push(TIME_SHORTCUT_TYPES.LATER_THIS_WEEK);
}
return additional;
},
@discourseComputed("model.reminderAt")
existingReminderAtFormatted(existingReminderAt) {
return formattedReminderTime(existingReminderAt, this.userTimezone);
},
@action
saveAndClose() {
if (this._saving || this._deleting) {
return;
}
this._saving = true;
this._savingBookmarkManually = true;
return this._saveBookmark()
.then(() => this.closeModal())
.catch((e) => this._handleSaveError(e))
.finally(() => (this._saving = false));
},
@action
toggleOptionsPanel() {
if (this.showOptions) {
$(".bookmark-options-panel").slideUp("fast");
} else {
$(".bookmark-options-panel").slideDown("fast");
}
this.toggleProperty("showOptions");
},
@action
delete() {
this._deleting = true;
let deleteAction = () => {
this._closeWithoutSaving = true;
this._deleteBookmark()
.then(() => {
this._deleting = false;
this.closeModal();
})
.catch((e) => this._handleSaveError(e));
};
if (this.existingBookmarkHasReminder) {
bootbox.confirm(I18n.t("bookmarks.confirm_delete"), (result) => {
if (result) {
deleteAction();
}
});
} else {
deleteAction();
}
},
@action
closeWithoutSavingBookmark() {
this._closeWithoutSaving = true;
this.closeModal();
},
@action
onTimeSelected(type, time) {
this.setProperties({ selectedReminderType: type, selectedDatetime: time });
// 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) {
return this.saveAndClose();
}
},
@action
selectPostLocalDate(date) {
this.setProperties({
selectedReminderType: this.reminderTypes.POST_LOCAL_DATE,
postLocalDate: date,
});
return this.saveAndClose();
},
});

View File

@ -0,0 +1,242 @@
import {
START_OF_DAY_HOUR,
laterToday,
now,
parseCustomDatetime,
} from "discourse/lib/time-utils";
import {
TIME_SHORTCUT_TYPES,
defaultShortcutOptions,
} from "discourse/lib/time-shortcut";
import discourseComputed, {
observes,
on,
} from "discourse-common/utils/decorators";
import Component from "@ember/component";
import I18n from "I18n";
import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts";
import { action } from "@ember/object";
import { and, equal } from "@ember/object/computed";
// global shortcuts that interfere with these modal shortcuts, they are rebound when the
// component is destroyed
//
// c createTopic
// r replyToPost
// l toggle like
// t replyAsNewTopic
const GLOBAL_SHORTCUTS_TO_PAUSE = ["c", "r", "l", "t"];
const BINDINGS = {
"l t": {
handler: "selectShortcut",
args: [TIME_SHORTCUT_TYPES.LATER_TODAY],
},
"l w": {
handler: "selectShortcut",
args: [TIME_SHORTCUT_TYPES.LATER_THIS_WEEK],
},
"n d": {
handler: "selectShortcut",
args: [TIME_SHORTCUT_TYPES.TOMORROW],
},
"n w": {
handler: "selectShortcut",
args: [TIME_SHORTCUT_TYPES.NEXT_WEEK],
},
"n b w": {
handler: "selectShortcut",
args: [TIME_SHORTCUT_TYPES.START_OF_NEXT_BUSINESS_WEEK],
},
"n m": {
handler: "selectShortcut",
args: [TIME_SHORTCUT_TYPES.NEXT_MONTH],
},
"c r": { handler: "selectShortcut", args: [TIME_SHORTCUT_TYPES.CUSTOM] },
"n r": { handler: "selectShortcut", args: [TIME_SHORTCUT_TYPES.NONE] },
};
export default Component.extend({
tagName: "",
userTimezone: null,
onTimeSelected: null,
selectedShortcut: null,
selectedTime: null,
selectedDate: null,
selectedDatetime: null,
prefilledDatetime: null,
additionalOptionsToShow: null,
hiddenOptions: null,
customOptions: null,
lastCustomDate: null,
lastCustomTime: null,
parsedLastCustomDatetime: null,
customDate: null,
customTime: null,
defaultCustomReminderTime: `0${START_OF_DAY_HOUR}:00`,
@on("init")
_setupPicker() {
this.setProperties({
customTime: this.defaultCustomReminderTime,
userTimezone: this.currentUser.resolvedTimezone(this.currentUser),
additionalOptionsToShow: this.additionalOptionsToShow || [],
hiddenOptions: this.hiddenOptions || [],
customOptions: this.customOptions || [],
});
if (this.prefilledDatetime) {
let parsedDatetime = parseCustomDatetime(
this.prefilledDatetime,
null,
this.userTimezone
);
if (parsedDatetime.isSame(laterToday())) {
return this.set("selectedShortcut", TIME_SHORTCUT_TYPES.LATER_TODAY);
}
this.setProperties({
customDate: parsedDatetime.format("YYYY-MM-DD"),
customTime: parsedDatetime.format("HH:mm"),
selectedShortcut: TIME_SHORTCUT_TYPES.CUSTOM,
});
}
this._bindKeyboardShortcuts();
this._loadLastUsedCustomDatetime();
},
@on("willDestroyElement")
_resetKeyboardShortcuts() {
KeyboardShortcuts.unbind(BINDINGS);
KeyboardShortcuts.unpause(GLOBAL_SHORTCUTS_TO_PAUSE);
},
_loadLastUsedCustomDatetime() {
let lastTime = localStorage.lastCustomTime;
let lastDate = localStorage.lastCustomDate;
if (lastTime && lastDate) {
let parsed = parseCustomDatetime(lastDate, lastTime, this.userTimezone);
if (parsed < now(this.userTimezone)) {
return;
}
this.setProperties({
lastCustomDate: lastDate,
lastCustomTime: lastTime,
parsedLastCustomDatetime: parsed,
});
}
},
_bindKeyboardShortcuts() {
KeyboardShortcuts.pause(GLOBAL_SHORTCUTS_TO_PAUSE);
Object.keys(BINDINGS).forEach((shortcut) => {
KeyboardShortcuts.addShortcut(shortcut, () => {
let binding = BINDINGS[shortcut];
if (binding.args) {
return this.send(binding.handler, ...binding.args);
}
this.send(binding.handler);
});
});
},
customDatetimeSelected: equal("selectedShortcut", TIME_SHORTCUT_TYPES.CUSTOM),
customDatetimeFilled: and("customDate", "customTime"),
@observes("customDate", "customTime")
customDatetimeChanged() {
if (!this.customDatetimeFilled) {
return;
}
this.selectShortcut(TIME_SHORTCUT_TYPES.CUSTOM);
},
@discourseComputed(
"additionalOptionsToShow",
"hiddenOptions",
"customOptions",
"userTimezone"
)
options(additionalOptionsToShow, hiddenOptions, customOptions, userTimezone) {
let options = defaultShortcutOptions(userTimezone);
if (additionalOptionsToShow.length > 0) {
options.forEach((opt) => {
if (additionalOptionsToShow.includes(opt.id)) {
opt.hidden = false;
}
});
}
if (hiddenOptions.length > 0) {
options.forEach((opt) => {
if (hiddenOptions.includes(opt.id)) {
opt.hidden = true;
}
});
}
if (this.lastCustomDate && this.lastCustomTime) {
let lastCustom = options.findBy("id", TIME_SHORTCUT_TYPES.LAST_CUSTOM);
lastCustom.time = this.parsedLastCustomDatetime;
lastCustom.timeFormatted = this.parsedLastCustomDatetime.format(
I18n.t("dates.long_no_year")
);
lastCustom.hidden = false;
}
let customOptionIndex = options.findIndex(
(opt) => opt.id === TIME_SHORTCUT_TYPES.CUSTOM
);
options.splice(customOptionIndex, 0, ...customOptions);
return options;
},
@action
selectShortcut(type) {
if (this.options.filterBy("hidden").mapBy("id").includes(type)) {
return;
}
let dateTime = null;
if (type === TIME_SHORTCUT_TYPES.CUSTOM) {
this.set("customTime", this.customTime || this.defaultCustomReminderTime);
const customDatetime = parseCustomDatetime(
this.customDate,
this.customTime,
this.userTimezone
);
if (customDatetime.isValid()) {
dateTime = customDatetime;
localStorage.lastCustomTime = this.customTime;
localStorage.lastCustomDate = this.customDate;
}
} else {
dateTime = this.options.findBy("id", type).time;
}
this.setProperties({
selectedShortcut: type,
selectedDatetime: dateTime,
});
if (this.onTimeSelected) {
this.onTimeSelected(type, dateTime);
}
},
});

View File

@ -1,105 +1,18 @@
import { REMINDER_TYPES, formattedReminderTime } from "discourse/lib/bookmark";
import { isEmpty, isPresent } from "@ember/utils";
import { next, schedule } from "@ember/runloop";
import { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark";
import Controller from "@ember/controller";
import I18n from "I18n";
import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { Promise } from "rsvp";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { and } from "@ember/object/computed";
import bootbox from "bootbox";
import discourseComputed from "discourse-common/utils/decorators";
import { popupAjaxError } from "discourse/lib/ajax-error";
// global shortcuts that interfere with these modal shortcuts, they are rebound when the
// modal is closed
//
// c createTopic
// r replyToPost
// l toggle like
// d deletePost
// t replyAsNewTopic
const GLOBAL_SHORTCUTS_TO_PAUSE = ["c", "r", "l", "d", "t"];
const START_OF_DAY_HOUR = 8;
const LATER_TODAY_CUTOFF_HOUR = 17;
const LATER_TODAY_MAX_HOUR = 18;
const MOMENT_MONDAY = 1;
const MOMENT_THURSDAY = 4;
const BOOKMARK_BINDINGS = {
enter: { handler: "saveAndClose" },
"l t": { handler: "selectReminderType", args: [REMINDER_TYPES.LATER_TODAY] },
"l w": {
handler: "selectReminderType",
args: [REMINDER_TYPES.LATER_THIS_WEEK],
},
"n b d": {
handler: "selectReminderType",
args: [REMINDER_TYPES.NEXT_BUSINESS_DAY],
},
"n d": { handler: "selectReminderType", args: [REMINDER_TYPES.TOMORROW] },
"n w": { handler: "selectReminderType", args: [REMINDER_TYPES.NEXT_WEEK] },
"n b w": {
handler: "selectReminderType",
args: [REMINDER_TYPES.START_OF_NEXT_BUSINESS_WEEK],
},
"n m": { handler: "selectReminderType", args: [REMINDER_TYPES.NEXT_MONTH] },
"c r": { handler: "selectReminderType", args: [REMINDER_TYPES.CUSTOM] },
"n r": { handler: "selectReminderType", args: [REMINDER_TYPES.NONE] },
"d d": { handler: "delete" },
};
export default Controller.extend(ModalFunctionality, {
loading: false,
errorMessage: null,
selectedReminderType: null,
_closeWithoutSaving: false,
_savingBookmarkManually: false,
onCloseWithoutSaving: null,
customReminderDate: null,
customReminderTime: null,
lastCustomReminderDate: null,
lastCustomReminderTime: null,
postLocalDate: null,
mouseTrap: null,
userTimezone: null,
showOptions: false,
onShow() {
this.setProperties({
errorMessage: null,
selectedReminderType: REMINDER_TYPES.NONE,
_closeWithoutSaving: false,
_savingBookmarkManually: false,
customReminderDate: null,
customReminderTime: this._defaultCustomReminderTime(),
lastCustomReminderDate: null,
lastCustomReminderTime: null,
postDetectedLocalDate: null,
postDetectedLocalTime: null,
postDetectedLocalTimezone: null,
userTimezone: this.currentUser.resolvedTimezone(this.currentUser),
showOptions: false,
model: this.model || {},
allowSave: true,
});
},
this._loadBookmarkOptions();
this._bindKeyboardShortcuts();
this._loadLastUsedCustomReminderDatetime();
if (this._editingExistingBookmark()) {
this._initializeExistingBookmarkData();
}
this.loadLocalDates();
schedule("afterRender", () => {
if (this.site.isMobileDevice) {
document.getElementById("bookmark-name").blur();
}
});
@action
registerOnCloseHandler(handlerFn) {
this.set("onCloseHandler", handlerFn);
},
/**
@ -107,473 +20,8 @@ export default Controller.extend(ModalFunctionality, {
* clicks the save or cancel button to mimic browser behaviour.
*/
onClose(opts = {}) {
if (opts.initiatedByCloseButton) {
this._closeWithoutSaving = true;
if (this.onCloseHandler) {
this.onCloseHandler(opts.initiatedByCloseButton);
}
this._unbindKeyboardShortcuts();
this._restoreGlobalShortcuts();
if (!this._closeWithoutSaving && !this._savingBookmarkManually) {
this._saveBookmark().catch((e) => this._handleSaveError(e));
}
if (this.onCloseWithoutSaving && this._closeWithoutSaving) {
this.onCloseWithoutSaving();
}
},
_initializeExistingBookmarkData() {
if (this._existingBookmarkHasReminder()) {
let parsedReminderAt = this._parseCustomDateTime(this.model.reminderAt);
if (parsedReminderAt.isSame(this.laterToday())) {
return this.set("selectedReminderType", REMINDER_TYPES.LATER_TODAY);
}
this.setProperties({
customReminderDate: parsedReminderAt.format("YYYY-MM-DD"),
customReminderTime: parsedReminderAt.format("HH:mm"),
selectedReminderType: REMINDER_TYPES.CUSTOM,
});
}
},
_editingExistingBookmark() {
return isPresent(this.model) && isPresent(this.model.id);
},
_existingBookmarkHasReminder() {
return isPresent(this.model) && isPresent(this.model.reminderAt);
},
_loadBookmarkOptions() {
this.set(
"autoDeletePreference",
this.model.autoDeletePreference || this._preferredDeleteOption() || 0
);
// we want to make sure the options panel opens so the user
// knows they have set these options previously. run next otherwise
// the modal is not visible when it tries to slide down the options
if (this.autoDeletePreference) {
next(() => this.toggleOptionsPanel());
}
},
_preferredDeleteOption() {
let preferred = localStorage.bookmarkDeleteOption;
if (preferred && preferred !== "") {
preferred = parseInt(preferred, 10);
}
return preferred;
},
_loadLastUsedCustomReminderDatetime() {
let lastTime = localStorage.lastCustomBookmarkReminderTime;
let lastDate = localStorage.lastCustomBookmarkReminderDate;
if (lastTime && lastDate) {
let parsed = this._parseCustomDateTime(lastDate, lastTime);
// can't set reminders in the past
if (parsed < this.now()) {
return;
}
this.setProperties({
lastCustomReminderDate: lastDate,
lastCustomReminderTime: lastTime,
parsedLastCustomReminderDatetime: parsed,
});
}
},
_bindKeyboardShortcuts() {
KeyboardShortcuts.pause(GLOBAL_SHORTCUTS_TO_PAUSE);
Object.keys(BOOKMARK_BINDINGS).forEach((shortcut) => {
KeyboardShortcuts.addShortcut(shortcut, () => {
let binding = BOOKMARK_BINDINGS[shortcut];
if (binding.args) {
return this.send(binding.handler, ...binding.args);
}
this.send(binding.handler);
});
});
},
_unbindKeyboardShortcuts() {
KeyboardShortcuts.unbind(BOOKMARK_BINDINGS);
},
_restoreGlobalShortcuts() {
KeyboardShortcuts.unpause(GLOBAL_SHORTCUTS_TO_PAUSE);
},
@discourseComputed("model.reminderAt")
showExistingReminderAt(existingReminderAt) {
return isPresent(existingReminderAt);
},
@discourseComputed("model.id")
showDelete(id) {
return isPresent(id);
},
@discourseComputed("selectedReminderType")
customDateTimeSelected(selectedReminderType) {
return selectedReminderType === REMINDER_TYPES.CUSTOM;
},
@discourseComputed()
reminderTypes: () => {
return REMINDER_TYPES;
},
@discourseComputed()
autoDeletePreferences: () => {
return Object.keys(AUTO_DELETE_PREFERENCES).map((key) => {
return {
id: AUTO_DELETE_PREFERENCES[key],
name: I18n.t(`bookmarks.auto_delete_preference.${key.toLowerCase()}`),
};
});
},
showLastCustom: and("lastCustomReminderTime", "lastCustomReminderDate"),
get showLaterToday() {
let later = this.laterToday();
return (
!later.isSame(this.tomorrow(), "date") &&
this.now().hour() < LATER_TODAY_CUTOFF_HOUR
);
},
get showLaterThisWeek() {
return this.now().day() < MOMENT_THURSDAY;
},
@discourseComputed("parsedLastCustomReminderDatetime")
lastCustomFormatted(parsedLastCustomReminderDatetime) {
return parsedLastCustomReminderDatetime.format(
I18n.t("dates.long_no_year")
);
},
@discourseComputed("model.reminderAt")
existingReminderAtFormatted(existingReminderAt) {
return formattedReminderTime(existingReminderAt, this.userTimezone);
},
get startNextBusinessWeekLabel() {
if (this.now().day() === MOMENT_MONDAY) {
return I18n.t("bookmarks.reminders.start_of_next_business_week_alt");
}
return I18n.t("bookmarks.reminders.start_of_next_business_week");
},
get startNextBusinessWeekFormatted() {
return this.nextWeek()
.day(MOMENT_MONDAY)
.format(I18n.t("dates.long_no_year"));
},
get laterTodayFormatted() {
return this.laterToday().format(I18n.t("dates.time"));
},
get tomorrowFormatted() {
return this.tomorrow().format(I18n.t("dates.time_short_day"));
},
get nextWeekFormatted() {
return this.nextWeek().format(I18n.t("dates.long_no_year"));
},
get laterThisWeekFormatted() {
return this.laterThisWeek().format(I18n.t("dates.time_short_day"));
},
get nextMonthFormatted() {
return this.nextMonth().format(I18n.t("dates.long_no_year"));
},
loadLocalDates() {
let postEl = document.querySelector(
`[data-post-id="${this.model.postId}"]`
);
let localDateEl = null;
if (postEl) {
localDateEl = postEl.querySelector(".discourse-local-date");
}
if (localDateEl) {
this.setProperties({
postDetectedLocalDate: localDateEl.dataset.date,
postDetectedLocalTime: localDateEl.dataset.time,
postDetectedLocalTimezone: localDateEl.dataset.timezone,
});
}
},
@discourseComputed("userTimezone")
userHasTimezoneSet(userTimezone) {
return !isEmpty(userTimezone);
},
_saveBookmark() {
const reminderAt = this._reminderAt();
const reminderAtISO = reminderAt ? reminderAt.toISOString() : null;
if (this.selectedReminderType === REMINDER_TYPES.CUSTOM) {
if (!reminderAt) {
return Promise.reject(I18n.t("bookmarks.invalid_custom_datetime"));
}
localStorage.lastCustomBookmarkReminderTime = this.customReminderTime;
localStorage.lastCustomBookmarkReminderDate = this.customReminderDate;
}
localStorage.bookmarkDeleteOption = this.autoDeletePreference;
let reminderType;
if (this.selectedReminderType === REMINDER_TYPES.NONE) {
reminderType = null;
} else if (
this.selectedReminderType === REMINDER_TYPES.LAST_CUSTOM ||
this.selectedReminderType === REMINDER_TYPES.POST_LOCAL_DATE
) {
reminderType = REMINDER_TYPES.CUSTOM;
} else {
reminderType = this.selectedReminderType;
}
const data = {
reminder_type: reminderType,
reminder_at: reminderAtISO,
name: this.model.name,
post_id: this.model.postId,
id: this.model.id,
auto_delete_preference: this.autoDeletePreference,
};
if (this._editingExistingBookmark()) {
return ajax("/bookmarks/" + this.model.id, {
type: "PUT",
data,
}).then(() => {
if (this.afterSave) {
this.afterSave({
reminderAt: reminderAtISO,
reminderType: this.selectedReminderType,
autoDeletePreference: this.autoDeletePreference,
id: this.model.id,
name: this.model.name,
});
}
});
} else {
return ajax("/bookmarks", { type: "POST", data }).then((response) => {
if (this.afterSave) {
this.afterSave({
reminderAt: reminderAtISO,
reminderType: this.selectedReminderType,
autoDeletePreference: this.autoDeletePreference,
id: response.id,
name: this.model.name,
});
}
});
}
},
_deleteBookmark() {
return ajax("/bookmarks/" + this.model.id, {
type: "DELETE",
}).then((response) => {
if (this.afterDelete) {
this.afterDelete(response.topic_bookmarked);
}
});
},
_parseCustomDateTime(date, time, parseTimezone = this.userTimezone) {
let dateTime = isPresent(time) ? date + " " + time : date;
let parsed = moment.tz(dateTime, parseTimezone);
if (parseTimezone !== this.userTimezone) {
parsed = parsed.tz(this.userTimezone);
}
return parsed;
},
_defaultCustomReminderTime() {
return `0${START_OF_DAY_HOUR}:00`;
},
_reminderAt() {
if (!this.selectedReminderType) {
return;
}
switch (this.selectedReminderType) {
case REMINDER_TYPES.LATER_TODAY:
return this.laterToday();
case REMINDER_TYPES.NEXT_BUSINESS_DAY:
return this.nextBusinessDay();
case REMINDER_TYPES.TOMORROW:
return this.tomorrow();
case REMINDER_TYPES.NEXT_WEEK:
return this.nextWeek();
case REMINDER_TYPES.START_OF_NEXT_BUSINESS_WEEK:
return this.nextWeek().day(MOMENT_MONDAY);
case REMINDER_TYPES.LATER_THIS_WEEK:
return this.laterThisWeek();
case REMINDER_TYPES.NEXT_MONTH:
return this.nextMonth();
case REMINDER_TYPES.CUSTOM:
this.set(
"customReminderTime",
this.customReminderTime || this._defaultCustomReminderTime()
);
const customDateTime = this._parseCustomDateTime(
this.customReminderDate,
this.customReminderTime
);
if (!customDateTime.isValid()) {
this.setProperties({
customReminderTime: null,
customReminderDate: null,
});
return;
}
return customDateTime;
case REMINDER_TYPES.LAST_CUSTOM:
return this.parsedLastCustomReminderDatetime;
case REMINDER_TYPES.POST_LOCAL_DATE:
return this.postLocalDate;
}
},
nextWeek() {
return this.startOfDay(this.now().add(7, "days"));
},
nextMonth() {
return this.startOfDay(this.now().add(1, "month"));
},
tomorrow() {
return this.startOfDay(this.now().add(1, "day"));
},
startOfDay(momentDate) {
return momentDate.hour(START_OF_DAY_HOUR).startOf("hour");
},
now() {
return moment.tz(this.userTimezone);
},
laterToday() {
let later = this.now().add(3, "hours");
if (later.hour() >= LATER_TODAY_MAX_HOUR) {
return later.hour(LATER_TODAY_MAX_HOUR).startOf("hour");
}
return later.minutes() < 30
? later.startOf("hour")
: later.add(30, "minutes").startOf("hour");
},
laterThisWeek() {
if (!this.showLaterThisWeek) {
return;
}
return this.startOfDay(this.now().add(2, "days"));
},
_handleSaveError(e) {
this._savingBookmarkManually = false;
if (typeof e === "string") {
bootbox.alert(e);
} else {
popupAjaxError(e);
}
},
@action
toggleOptionsPanel() {
if (this.showOptions) {
$(".bookmark-options-panel").slideUp("fast");
} else {
$(".bookmark-options-panel").slideDown("fast");
}
this.toggleProperty("showOptions");
},
@action
saveAndClose() {
if (this._saving || this._deleting) {
return;
}
this._saving = true;
this._savingBookmarkManually = true;
return this._saveBookmark()
.then(() => this.send("closeModal"))
.catch((e) => this._handleSaveError(e))
.finally(() => (this._saving = false));
},
@action
delete() {
this._deleting = true;
let deleteAction = () => {
this._closeWithoutSaving = true;
this._deleteBookmark()
.then(() => {
this._deleting = false;
this.send("closeModal");
})
.catch((e) => this._handleSaveError(e));
};
if (this._existingBookmarkHasReminder()) {
bootbox.confirm(I18n.t("bookmarks.confirm_delete"), (result) => {
if (result) {
deleteAction();
}
});
} else {
deleteAction();
}
},
@action
closeWithoutSavingBookmark() {
this._closeWithoutSaving = true;
this.send("closeModal");
},
@action
selectReminderType(type) {
if (type === REMINDER_TYPES.LATER_TODAY && !this.showLaterToday) {
return;
}
this.set("selectedReminderType", type);
if (type !== REMINDER_TYPES.CUSTOM) {
return this.saveAndClose();
}
},
@action
selectPostLocalDate(date) {
this.setProperties({
selectedReminderType: this.reminderTypes.POST_LOCAL_DATE,
postLocalDate: date,
});
return this.saveAndClose();
},
});

View File

@ -16,17 +16,3 @@ export function formattedReminderTime(reminderAt, timezone) {
date_time: reminderAtDate.format(I18n.t("dates.long_with_year")),
});
}
export const REMINDER_TYPES = {
LATER_TODAY: "later_today",
NEXT_BUSINESS_DAY: "next_business_day",
TOMORROW: "tomorrow",
NEXT_WEEK: "next_week",
NEXT_MONTH: "next_month",
CUSTOM: "custom",
LAST_CUSTOM: "last_custom",
NONE: "none",
START_OF_NEXT_BUSINESS_WEEK: "start_of_next_business_week",
LATER_THIS_WEEK: "later_this_week",
POST_LOCAL_DATE: "post_local_date",
};

View File

@ -124,7 +124,15 @@ export default {
this.container = null;
},
isTornDown() {
return this.keyTrapper == null || this.container == null;
},
bindKey(key, binding = null) {
if (this.isTornDown()) {
return;
}
if (!binding) {
binding = DEFAULT_BINDINGS[key];
}
@ -152,11 +160,21 @@ export default {
// for cases when you want to disable global keyboard shortcuts
// so that you can override them (e.g. inside a modal)
pause(combinations) {
if (this.isTornDown()) {
return;
}
combinations.forEach((combo) => this.keyTrapper.unbind(combo));
},
// restore global shortcuts that you have paused
unpause(combinations) {
if (this.isTornDown()) {
return;
}
// if the keytrapper has already been torn down this will error
if (this.keyTrapper == null) {
return;
}
combinations.forEach((combo) => this.bindKey(combo));
},

View File

@ -0,0 +1,103 @@
import {
MOMENT_MONDAY,
laterThisWeek,
laterToday,
nextBusinessWeekStart,
nextMonth,
nextWeek,
now,
tomorrow,
} from "discourse/lib/time-utils";
import I18n from "I18n";
export const TIME_SHORTCUT_TYPES = {
LATER_TODAY: "later_today",
TOMORROW: "tomorrow",
NEXT_WEEK: "next_week",
NEXT_MONTH: "next_month",
CUSTOM: "custom",
LAST_CUSTOM: "last_custom",
NONE: "none",
START_OF_NEXT_BUSINESS_WEEK: "start_of_next_business_week",
LATER_THIS_WEEK: "later_this_week",
POST_LOCAL_DATE: "post_local_date",
};
export function defaultShortcutOptions(timezone) {
return [
{
icon: "angle-right",
id: TIME_SHORTCUT_TYPES.LATER_TODAY,
label: "time_shortcut.later_today",
time: laterToday(timezone),
timeFormatted: laterToday(timezone).format(I18n.t("dates.time")),
hidden: true,
},
{
icon: "far-sun",
id: TIME_SHORTCUT_TYPES.TOMORROW,
label: "time_shortcut.tomorrow",
time: tomorrow(timezone),
timeFormatted: tomorrow(timezone).format(I18n.t("dates.time_short_day")),
},
{
icon: "angle-double-right",
id: TIME_SHORTCUT_TYPES.LATER_THIS_WEEK,
label: "time_shortcut.later_this_week",
time: laterThisWeek(timezone),
timeFormatted: laterThisWeek(timezone).format(
I18n.t("dates.time_short_day")
),
hidden: true,
},
{
icon: "briefcase",
id: TIME_SHORTCUT_TYPES.START_OF_NEXT_BUSINESS_WEEK,
label:
now(timezone).day() === MOMENT_MONDAY
? "time_shortcut.start_of_next_business_week_alt"
: "time_shortcut.start_of_next_business_week",
time: nextBusinessWeekStart(timezone),
timeFormatted: nextBusinessWeekStart(timezone).format(
I18n.t("dates.long_no_year")
),
},
{
icon: "far-clock",
id: TIME_SHORTCUT_TYPES.NEXT_WEEK,
label: "time_shortcut.next_week",
time: nextWeek(timezone),
timeFormatted: nextWeek(timezone).format(I18n.t("dates.long_no_year")),
},
{
icon: "far-calendar-plus",
id: TIME_SHORTCUT_TYPES.NEXT_MONTH,
label: "time_shortcut.next_month",
time: nextMonth(timezone),
timeFormatted: nextMonth(timezone).format(I18n.t("dates.long_no_year")),
},
{
icon: "calendar-alt",
id: TIME_SHORTCUT_TYPES.CUSTOM,
label: "time_shortcut.custom",
time: null,
timeFormatted: null,
isCustomTimeShortcut: true,
},
{
icon: "undo",
id: TIME_SHORTCUT_TYPES.LAST_CUSTOM,
label: "time_shortcut.last_custom",
time: null,
timeFormatted: null,
hidden: true,
},
{
icon: "ban",
id: TIME_SHORTCUT_TYPES.NONE,
label: "time_shortcut.none",
time: null,
timeFormatted: null,
},
];
}

View File

@ -0,0 +1,63 @@
import { isPresent } from "@ember/utils";
export const START_OF_DAY_HOUR = 8;
export const LATER_TODAY_CUTOFF_HOUR = 17;
export const LATER_TODAY_MAX_HOUR = 18;
export const MOMENT_MONDAY = 1;
export const MOMENT_THURSDAY = 4;
export function now(timezone) {
return moment.tz(timezone);
}
export function startOfDay(momentDate, startOfDayHour = START_OF_DAY_HOUR) {
return momentDate.hour(startOfDayHour).startOf("hour");
}
export function tomorrow(timezone) {
return startOfDay(now(timezone).add(1, "day"));
}
export function laterToday(timezone) {
let later = now(timezone).add(3, "hours");
if (later.hour() >= LATER_TODAY_MAX_HOUR) {
return later.hour(LATER_TODAY_MAX_HOUR).startOf("hour");
}
return later.minutes() < 30
? later.startOf("hour")
: later.add(30, "minutes").startOf("hour");
}
export function laterThisWeek(timezone) {
return startOfDay(now(timezone).add(2, "days"));
}
export function nextWeek(timezone) {
return startOfDay(now(timezone).add(7, "days"));
}
export function nextMonth(timezone) {
return startOfDay(now(timezone).add(1, "month"));
}
export function nextBusinessWeekStart(timezone) {
return nextWeek(timezone).day(MOMENT_MONDAY);
}
export function parseCustomDatetime(
date,
time,
currentTimezone,
parseTimezone = null
) {
let dateTime = isPresent(time) ? `${date} ${time}` : date;
parseTimezone = parseTimezone || currentTimezone;
let parsed = moment.tz(dateTime, parseTimezone);
if (parseTimezone !== currentTimezone) {
parsed = parsed.tz(currentTimezone);
}
return parsed;
}

View File

@ -1,6 +0,0 @@
{{#if showPostLocalDate}}
{{#tap-tile icon="globe-americas" tileId=tileId activeTile=activeTile onChange=(action "setReminder")}}
<div class="tap-tile-title">{{i18n "bookmarks.reminders.post_local_date"}}</div>
<div class="tap-tile-date">{{postLocalDateFormatted}}</div>
{{/tap-tile}}
{{/if}}

View File

@ -0,0 +1,53 @@
{{#conditional-loading-spinner condition=loading}}
{{#if errorMessage}}
<div class="control-group">
<div class="controls">
<div class="alert alert-error">{{errorMessage}}</div>
</div>
</div>
{{/if}}
<div class="control-group bookmark-name-wrap">
{{input id="bookmark-name" value=model.name name="bookmark-name" class="bookmark-name" enter=(action "saveAndClose") placeholder=(i18n "post.bookmarks.name_placeholder") maxlength="100"}}
{{d-button icon="cog" action=(action "toggleOptionsPanel") class="bookmark-options-button"}}
</div>
<div class="bookmark-options-panel">
<label class="control-label" for="bookmark_auto_delete_preference">{{i18n "bookmarks.auto_delete_preference.label"}}</label>
{{combo-box
content=autoDeletePreferences
value=autoDeletePreference
class="bookmark-option-selector"
onChange=(action (mut autoDeletePreference))
}}
</div>
{{#if showExistingReminderAt }}
<div class="alert alert-info existing-reminder-at-alert">
{{d-icon "far-clock"}}
<span>{{i18n "bookmarks.reminders.existing_reminder" at_date_time=existingReminderAtFormatted}}</span>
</div>
{{/if}}
<div class="control-group">
<label class="control-label" for="set_reminder">
{{i18n "post.bookmarks.set_reminder"}}
</label>
{{#if userHasTimezoneSet}}
{{time-shortcut-picker prefilledDatetime=prefilledDatetime onTimeSelected=(action "onTimeSelected") customOptions=customTimeShortcutOptions additionalOptionsToShow=additionalTimeShortcutOptions}}
{{else}}
<div class="alert alert-info">{{html-safe (i18n "bookmarks.no_timezone" basePath=(base-path))}}</div>
{{/if}}
</div>
<div class="control-group">
{{d-button id="save-bookmark" label="bookmarks.save" class="btn-primary" action=(action "saveAndClose")}}
{{d-modal-cancel close=(action "closeWithoutSavingBookmark")}}
{{#if showDelete}}
<div class="pull-right">
{{d-button id="delete-bookmark" icon="trash-alt" class="btn-danger" action=(action "delete")}}
</div>
{{/if}}
</div>
{{/conditional-loading-spinner}}

View File

@ -0,0 +1,29 @@
{{#tap-tile-grid activeTile=selectedShortcut as |grid|}}
{{#each options as |option|}}
{{#unless option.hidden}}
{{#tap-tile icon=option.icon tileId=option.id activeTile=grid.activeTile onChange=(action "selectShortcut")}}
<div class="tap-tile-title">{{i18n option.label}}</div>
<div class="tap-tile-date">{{option.timeFormatted}}</div>
{{/tap-tile}}
{{/unless}}
{{#if option.isCustomTimeShortcut}}
{{#if customDatetimeSelected}}
<div class="control-group custom-date-time-wrap">
<div class="tap-tile-date-input">
{{d-icon "calendar-alt"}}
{{date-picker-future
value=customDate
onSelect=(action (mut customDate))
id="custom-date"
}}
</div>
<div class="tap-tile-time-input">
{{d-icon "far-clock"}}
{{input placeholder="--:--" id="custom-time" type="time" class="time-input" value=customTime}}
</div>
</div>
{{/if}}
{{/if}}
{{/each}}
{{/tap-tile-grid}}

View File

@ -1,117 +1,8 @@
{{#d-modal-body id="bookmark-reminder-modal"}}
{{#conditional-loading-spinner condition=loading}}
{{#if errorMessage}}
<div class="control-group">
<div class="controls">
<div class="alert alert-error">{{errorMessage}}</div>
</div>
</div>
{{/if}}
<div class="control-group bookmark-name-wrap">
{{input id="bookmark-name" value=model.name name="bookmark-name" class="bookmark-name" enter=(action "saveAndClose") placeholder=(i18n "post.bookmarks.name_placeholder") maxlength="100"}}
{{d-button icon="cog" action=(action "toggleOptionsPanel") class="bookmark-options-button"}}
</div>
<div class="bookmark-options-panel">
<label class="control-label" for="bookmark_auto_delete_preference">{{i18n "bookmarks.auto_delete_preference.label"}}</label>
{{combo-box
content=autoDeletePreferences
value=autoDeletePreference
class="bookmark-option-selector"
onChange=(action (mut autoDeletePreference))
}}
</div>
{{#if showExistingReminderAt }}
<div class="alert alert-info existing-reminder-at-alert">
{{d-icon "far-clock"}}
<span>{{i18n "bookmarks.reminders.existing_reminder" at_date_time=existingReminderAtFormatted}}</span>
</div>
{{/if}}
<div class="control-group">
<label class="control-label" for="set_reminder">
{{i18n "post.bookmarks.set_reminder"}}
</label>
{{#if userHasTimezoneSet}}
{{#tap-tile-grid activeTile=selectedReminderType as |grid|}}
{{#if showLaterToday}}
{{#tap-tile icon="angle-right" tileId=reminderTypes.LATER_TODAY activeTile=grid.activeTile onChange=(action "selectReminderType")}}
<div class="tap-tile-title">{{i18n "bookmarks.reminders.later_today"}}</div>
<div class="tap-tile-date">{{laterTodayFormatted}}</div>
{{/tap-tile}}
{{/if}}
{{#tap-tile icon="far-sun" tileId=reminderTypes.TOMORROW activeTile=grid.activeTile onChange=(action "selectReminderType")}}
<div class="tap-tile-title">{{i18n "bookmarks.reminders.tomorrow"}}</div>
<div class="tap-tile-date">{{tomorrowFormatted}}</div>
{{/tap-tile}}
{{#if showLaterThisWeek}}
{{#tap-tile icon="angle-double-right" tileId=reminderTypes.LATER_THIS_WEEK activeTile=grid.activeTile onChange=(action "selectReminderType")}}
<div class="tap-tile-title">{{i18n "bookmarks.reminders.later_this_week"}}</div>
<div class="tap-tile-date">{{laterThisWeekFormatted}}</div>
{{/tap-tile}}
{{/if}}
{{#tap-tile icon="briefcase" tileId=reminderTypes.START_OF_NEXT_BUSINESS_WEEK activeTile=grid.activeTile onChange=(action "selectReminderType")}}
<div class="tap-tile-title">{{startNextBusinessWeekLabel}}</div>
<div class="tap-tile-date">{{startNextBusinessWeekFormatted}}</div>
{{/tap-tile}}
{{#tap-tile icon="far-clock" tileId=reminderTypes.NEXT_WEEK activeTile=grid.activeTile onChange=(action "selectReminderType")}}
<div class="tap-tile-title">{{i18n "bookmarks.reminders.next_week"}}</div>
<div class="tap-tile-date">{{nextWeekFormatted}}</div>
{{/tap-tile}}
{{#tap-tile icon="far-calendar-plus" tileId=reminderTypes.NEXT_MONTH activeTile=grid.activeTile onChange=(action "selectReminderType")}}
<div class="tap-tile-title">{{i18n "bookmarks.reminders.next_month"}}</div>
<div class="tap-tile-date">{{nextMonthFormatted}}</div>
{{/tap-tile}}
{{bookmark-local-date postId=model.postId tileId=reminderTypes.POST_LOCAL_DATE activeTile=grid.activeTile onChange=(action "selectPostLocalDate")}}
{{#tap-tile icon="calendar-alt" tileId=reminderTypes.CUSTOM activeTile=grid.activeTile onChange=(action "selectReminderType")}}
<div class="tap-tile-title">{{i18n "bookmarks.reminders.custom"}}</div>
{{/tap-tile}}
{{#if customDateTimeSelected}}
<div class="control-group custom-date-time-wrap">
<div class="tap-tile-date-input">
{{d-icon "calendar-alt"}}
{{date-picker-future
value=customReminderDate
onSelect=(action (mut customReminderDate))
id="bookmark-custom-date"
}}
</div>
<div class="tap-tile-time-input">
{{d-icon "far-clock"}}
{{input placeholder="--:--" id="bookmark-custom-time" type="time" class="time-input" value=customReminderTime}}
</div>
</div>
{{/if}}
{{#if showLastCustom}}
{{#tap-tile icon="undo" tileId=reminderTypes.LAST_CUSTOM activeTile=grid.activeTile onChange=(action "selectReminderType")}}
<div class="tap-tile-title">{{i18n "bookmarks.reminders.last_custom"}}</div>
<div class="tap-tile-date">{{lastCustomFormatted}}</div>
{{/tap-tile}}
{{/if}}
{{#tap-tile icon="ban" tileId=reminderTypes.NONE activeTile=grid.activeTile onChange=(action "selectReminderType")}}
<div class="tap-tile-title">{{i18n "bookmarks.reminders.none"}}</div>
{{/tap-tile}}
{{/tap-tile-grid}}
{{else}}
<div class="alert alert-info">{{html-safe (i18n "bookmarks.no_timezone" basePath=(base-path))}}</div>
{{/if}}
</div>
<div class="control-group">
{{d-button id="save-bookmark" label="bookmarks.save" class="btn-primary" action=(action "saveAndClose")}}
{{d-modal-cancel close=(action "closeWithoutSavingBookmark")}}
{{#if showDelete}}
<div class="pull-right">
{{d-button id="delete-bookmark" icon="trash-alt" class="btn-danger" action=(action "delete")}}
</div>
{{/if}}
</div>
{{/conditional-loading-spinner}}
{{bookmark model=model
afterSave=afterSave
afterDelete=afterDelete
onCloseWithoutSaving=onCloseWithoutSaving
registerOnCloseHandler=(action "registerOnCloseHandler")
closeModal=(action "closeModal")}}
{{/d-modal-body}}

View File

@ -4,11 +4,12 @@ import {
loggedInUser,
queryAll,
} from "discourse/tests/helpers/qunit-helpers";
import { click, fillIn, visit } from "@ember/test-helpers";
import { click, fillIn, getApplication, visit } from "@ember/test-helpers";
import I18n from "I18n";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { test } from "qunit";
import topicFixtures from "discourse/tests/fixtures/topic";
import KeyboardShortcutInitializer from "discourse/initializers/keyboard-shortcuts";
async function openBookmarkModal() {
if (exists(".topic-post:first-child button.show-more-actions")) {
@ -25,7 +26,10 @@ acceptance("Bookmarking", function (needs) {
needs.user();
let steps = [];
needs.hooks.beforeEach(() => (steps = []));
needs.hooks.beforeEach(function () {
KeyboardShortcutInitializer.initialize(getApplication());
steps = [];
});
const topicResponse = topicFixtures["/t/280/1.json"];
topicResponse.post_stream.posts[0].cooked += `<span data-date="2021-01-15" data-time="00:35:00" class="discourse-local-date cooked-date past" data-timezone="Europe/London">
@ -206,12 +210,12 @@ acceptance("Bookmarking", function (needs) {
"it should prefill the bookmark name"
);
assert.equal(
queryAll("#bookmark-custom-date > input").val(),
queryAll("#custom-date > input").val(),
tomorrow,
"it should prefill the bookmark date"
);
assert.equal(
queryAll("#bookmark-custom-time").val(),
queryAll("#custom-time").val(),
"08:00",
"it should prefill the bookmark time"
);
@ -236,12 +240,12 @@ acceptance("Bookmarking", function (needs) {
"it should prefill the bookmark name"
);
assert.equal(
queryAll("#bookmark-custom-date > input").val(),
queryAll("#custom-date > input").val(),
postDateFormatted,
"it should prefill the bookmark date"
);
assert.equal(
queryAll("#bookmark-custom-time").val(),
queryAll("#custom-time").val(),
"10:35",
"it should prefill the bookmark time"
);

View File

@ -63,7 +63,10 @@ export default function (name, opts) {
const store = createStore();
if (!opts.anonymous) {
const currentUser = User.create({ username: "eviltrout" });
const currentUser = User.create({
username: "eviltrout",
timezone: "Australia/Brisbane",
});
this.currentUser = currentUser;
this.registry.register("current-user:main", this.currentUser, {
instantiate: false,

View File

@ -0,0 +1,160 @@
import componentTest, {
setupRenderingTest,
} from "discourse/tests/helpers/component-test";
import {
discourseModule,
fakeTime,
query,
} from "discourse/tests/helpers/qunit-helpers";
import KeyboardShortcutInitializer from "discourse/initializers/keyboard-shortcuts";
import { getApplication } from "@ember/test-helpers";
import sinon from "sinon";
let clock = null;
function mockMomentTz(dateString, timezone) {
clock = fakeTime(dateString, timezone, true);
}
discourseModule("Integration | Component | bookmark", function (hooks) {
setupRenderingTest(hooks);
let template =
'{{bookmark model=model afterSave=afterSave afterDelete=afterDelete onCloseWithoutSaving=onCloseWithoutSaving registerOnCloseHandler=(action "registerOnCloseHandler") closeModal=(action "closeModal")}}';
hooks.beforeEach(function () {
KeyboardShortcutInitializer.initialize(getApplication());
this.actions.registerOnCloseHandler = () => {};
this.actions.closeModal = () => {};
this.setProperties({
model: {},
afterSave: () => {},
afterDelete: () => {},
onCloseWithoutSaving: () => {},
});
});
hooks.afterEach(function () {
if (clock) {
clock.restore();
}
sinon.restore();
});
componentTest("show later this week option if today is < Thursday", {
template,
skip: true,
beforeEach() {
mockMomentTz("2019-12-10T08:00:00", this.currentUser._timezone);
},
test(assert) {
assert.ok(exists("#tap_tile_later_this_week"), "it has later this week");
},
});
componentTest(
"does not show later this week option if today is >= Thursday",
{
template,
skip: true,
beforeEach() {
mockMomentTz("2019-12-13T08:00:00", this.currentUser._timezone);
},
test(assert) {
assert.notOk(
exists("#tap_tile_later_this_week"),
"it does not have later this week"
);
},
}
);
componentTest("later today does not show if later today is tomorrow", {
template,
skip: true,
beforeEach() {
mockMomentTz("2019-12-11T22:00:00", this.currentUser._timezone);
},
test(assert) {
assert.notOk(
exists("#tap_tile_later_today"),
"it does not have later today"
);
},
});
componentTest("later today shows if it is after 5pm but before 6pm", {
template,
skip: true,
beforeEach() {
mockMomentTz("2019-12-11T14:30:00", this.currentUser._timezone);
},
test(assert) {
assert.ok(exists("#tap_tile_later_today"), "it does have later today");
},
});
componentTest("later today does not show if it is after 5pm", {
template,
skip: true,
beforeEach() {
mockMomentTz("2019-12-11T17:00:00", this.currentUser._timezone);
},
test(assert) {
assert.notOk(
exists("#tap_tile_later_today"),
"it does not have later today"
);
},
});
componentTest("later today does show if it is before the end of the day", {
template,
skip: true,
beforeEach() {
mockMomentTz("2019-12-11T13:00:00", this.currentUser._timezone);
},
test(assert) {
assert.ok(exists("#tap_tile_later_today"), "it does have later today");
},
});
componentTest("prefills the custom reminder type date and time", {
template,
skip: true,
beforeEach() {
let name = "test";
let reminderAt = "2020-05-15T09:45:00";
this.model = { id: 1, name, reminderAt };
},
test(assert) {
assert.equal(query("#bookmark-name").value, "test");
assert.equal(query("#custom-date > .date-picker").value, "2020-05-15");
assert.equal(query("#custom-time").value, "09:45");
},
});
componentTest("defaults to 08:00 for custom time", {
template,
skip: true,
async test(assert) {
await click("#tap_tile_custom");
assert.equal(query("#custom-time").value, "08:00");
},
});
});

View File

@ -1,263 +0,0 @@
import {
discourseModule,
fakeTime,
logIn,
} from "discourse/tests/helpers/qunit-helpers";
import KeyboardShortcutInitializer from "discourse/initializers/keyboard-shortcuts";
import { REMINDER_TYPES } from "discourse/lib/bookmark";
import User from "discourse/models/user";
import { getApplication } from "@ember/test-helpers";
import sinon from "sinon";
import { test } from "qunit";
let BookmarkController;
function mockMomentTz(dateString) {
fakeTime(dateString, BookmarkController.userTimezone);
}
discourseModule("Unit | Controller | bookmark", function (hooks) {
hooks.beforeEach(function () {
logIn();
KeyboardShortcutInitializer.initialize(getApplication());
BookmarkController = this.owner.lookup("controller:bookmark");
BookmarkController.setProperties({
currentUser: User.current(),
site: { isMobileDevice: false },
});
BookmarkController.onShow();
});
hooks.afterEach(function () {
sinon.restore();
});
test("showLaterToday when later today is tomorrow do not show", function (assert) {
mockMomentTz("2019-12-11T22:00:00");
assert.equal(BookmarkController.get("showLaterToday"), false);
});
test("showLaterToday when later today is after 5pm but before 6pm", function (assert) {
mockMomentTz("2019-12-11T15:00:00");
assert.equal(BookmarkController.get("showLaterToday"), true);
});
test("showLaterToday when now is after the cutoff time (5pm)", function (assert) {
mockMomentTz("2019-12-11T17:00:00");
assert.equal(BookmarkController.get("showLaterToday"), false);
});
test("showLaterToday when later today is before the end of the day, show", function (assert) {
mockMomentTz("2019-12-11T10:00:00");
assert.equal(BookmarkController.get("showLaterToday"), true);
});
test("nextWeek gets next week correctly", function (assert) {
mockMomentTz("2019-12-11T08:00:00");
assert.equal(
BookmarkController.nextWeek().format("YYYY-MM-DD"),
"2019-12-18"
);
});
test("nextMonth gets next month correctly", function (assert) {
mockMomentTz("2019-12-11T08:00:00");
assert.equal(
BookmarkController.nextMonth().format("YYYY-MM-DD"),
"2020-01-11"
);
});
test("laterThisWeek gets 2 days from now", function (assert) {
mockMomentTz("2019-12-10T08:00:00");
assert.equal(
BookmarkController.laterThisWeek().format("YYYY-MM-DD"),
"2019-12-12"
);
});
test("laterThisWeek returns null if we are at Thursday already", function (assert) {
mockMomentTz("2019-12-12T08:00:00");
assert.equal(BookmarkController.laterThisWeek(), null);
});
test("showLaterThisWeek returns true if < Thursday", function (assert) {
mockMomentTz("2019-12-10T08:00:00");
assert.equal(BookmarkController.showLaterThisWeek, true);
});
test("showLaterThisWeek returns false if > Thursday", function (assert) {
mockMomentTz("2019-12-12T08:00:00");
assert.equal(BookmarkController.showLaterThisWeek, false);
});
test("tomorrow gets tomorrow correctly", function (assert) {
mockMomentTz("2019-12-11T08:00:00");
assert.equal(
BookmarkController.tomorrow().format("YYYY-MM-DD"),
"2019-12-12"
);
});
test("startOfDay changes the time of the provided date to 8:00am correctly", function (assert) {
let dt = moment.tz(
"2019-12-11T11:37:16",
BookmarkController.currentUser.resolvedTimezone(
BookmarkController.currentUser
)
);
assert.equal(
BookmarkController.startOfDay(dt).format("YYYY-MM-DD HH:mm:ss"),
"2019-12-11 08:00:00"
);
});
test("laterToday gets 3 hours from now and if before half-past, it rounds down", function (assert) {
mockMomentTz("2019-12-11T08:13:00");
assert.equal(
BookmarkController.laterToday().format("YYYY-MM-DD HH:mm:ss"),
"2019-12-11 11:00:00"
);
});
test("laterToday gets 3 hours from now and if after half-past, it rounds up to the next hour", function (assert) {
mockMomentTz("2019-12-11T08:43:00");
assert.equal(
BookmarkController.laterToday().format("YYYY-MM-DD HH:mm:ss"),
"2019-12-11 12:00:00"
);
});
test("laterToday is capped to 6pm. later today at 3pm = 6pm, 3:30pm = 6pm, 4pm = 6pm, 4:59pm = 6pm", function (assert) {
mockMomentTz("2019-12-11T15:00:00");
assert.equal(
BookmarkController.laterToday().format("YYYY-MM-DD HH:mm:ss"),
"2019-12-11 18:00:00",
"3pm should max to 6pm"
);
mockMomentTz("2019-12-11T15:31:00");
assert.equal(
BookmarkController.laterToday().format("YYYY-MM-DD HH:mm:ss"),
"2019-12-11 18:00:00",
"3:30pm should max to 6pm"
);
mockMomentTz("2019-12-11T16:00:00");
assert.equal(
BookmarkController.laterToday().format("YYYY-MM-DD HH:mm:ss"),
"2019-12-11 18:00:00",
"4pm should max to 6pm"
);
mockMomentTz("2019-12-11T16:59:00");
assert.equal(
BookmarkController.laterToday().format("YYYY-MM-DD HH:mm:ss"),
"2019-12-11 18:00:00",
"4:59pm should max to 6pm"
);
});
test("showLaterToday returns false if >= 5PM", function (assert) {
mockMomentTz("2019-12-11T17:00:01");
assert.equal(BookmarkController.showLaterToday, false);
});
test("showLaterToday returns false if >= 5PM", function (assert) {
mockMomentTz("2019-12-11T17:00:01");
assert.equal(BookmarkController.showLaterToday, false);
});
test("reminderAt - custom - defaults to 8:00am if the time is not selected", function (assert) {
BookmarkController.customReminderDate = "2028-12-12";
BookmarkController.selectedReminderType =
BookmarkController.reminderTypes.CUSTOM;
const reminderAt = BookmarkController._reminderAt();
assert.equal(BookmarkController.customReminderTime, "08:00");
assert.equal(
reminderAt.toString(),
moment
.tz(
"2028-12-12 08:00",
BookmarkController.currentUser.resolvedTimezone(
BookmarkController.currentUser
)
)
.toString(),
"the custom date and time are parsed correctly with default time"
);
});
test("loadLastUsedCustomReminderDatetime fills the custom reminder date + time if present in localStorage", function (assert) {
mockMomentTz("2019-12-11T08:00:00");
localStorage.lastCustomBookmarkReminderDate = "2019-12-12";
localStorage.lastCustomBookmarkReminderTime = "08:00";
BookmarkController._loadLastUsedCustomReminderDatetime();
assert.equal(BookmarkController.lastCustomReminderDate, "2019-12-12");
assert.equal(BookmarkController.lastCustomReminderTime, "08:00");
});
test("loadLastUsedCustomReminderDatetime does not fills the custom reminder date + time if the datetime in localStorage is < now", function (assert) {
mockMomentTz("2019-12-11T08:00:00");
localStorage.lastCustomBookmarkReminderDate = "2019-12-11";
localStorage.lastCustomBookmarkReminderTime = "07:00";
BookmarkController._loadLastUsedCustomReminderDatetime();
assert.equal(BookmarkController.lastCustomReminderDate, null);
assert.equal(BookmarkController.lastCustomReminderTime, null);
});
test("user timezone updates when the modal is shown", function (assert) {
User.current().changeTimezone(null);
let stub = sinon.stub(moment.tz, "guess").returns("Europe/Moscow");
BookmarkController.onShow();
assert.equal(BookmarkController.userHasTimezoneSet, true);
assert.equal(
BookmarkController.userTimezone,
"Europe/Moscow",
"the user does not have their timezone set and a timezone is guessed"
);
User.current().changeTimezone("Australia/Brisbane");
BookmarkController.onShow();
assert.equal(BookmarkController.userHasTimezoneSet, true);
assert.equal(
BookmarkController.userTimezone,
"Australia/Brisbane",
"the user does their timezone set"
);
stub.restore();
});
test("opening the modal with an existing bookmark with reminder at prefills the custom reminder type", function (assert) {
let name = "test";
let reminderAt = "2020-05-15T09:45:00";
BookmarkController.model = { id: 1, name: name, reminderAt: reminderAt };
BookmarkController.onShow();
assert.equal(
BookmarkController.selectedReminderType,
REMINDER_TYPES.CUSTOM
);
assert.equal(BookmarkController.customReminderDate, "2020-05-15");
assert.equal(BookmarkController.customReminderTime, "09:45");
assert.equal(BookmarkController.model.name, name);
});
});

View File

@ -0,0 +1,107 @@
import {
discourseModule,
fakeTime,
} from "discourse/tests/helpers/qunit-helpers";
import {
laterThisWeek,
laterToday,
nextMonth,
nextWeek,
startOfDay,
tomorrow,
} from "discourse/lib/time-utils";
import { test } from "qunit";
const timezone = "Australia/Brisbane";
function mockMomentTz(dateString) {
fakeTime(dateString, timezone);
}
discourseModule("Unit | lib | timeUtils", function () {
test("nextWeek gets next week correctly", function (assert) {
mockMomentTz("2019-12-11T08:00:00");
assert.equal(nextWeek(timezone).format("YYYY-MM-DD"), "2019-12-18");
});
test("nextMonth gets next month correctly", function (assert) {
mockMomentTz("2019-12-11T08:00:00");
assert.equal(nextMonth(timezone).format("YYYY-MM-DD"), "2020-01-11");
});
test("laterThisWeek gets 2 days from now", function (assert) {
mockMomentTz("2019-12-10T08:00:00");
assert.equal(laterThisWeek(timezone).format("YYYY-MM-DD"), "2019-12-12");
});
test("tomorrow gets tomorrow correctly", function (assert) {
mockMomentTz("2019-12-11T08:00:00");
assert.equal(tomorrow(timezone).format("YYYY-MM-DD"), "2019-12-12");
});
test("startOfDay changes the time of the provided date to 8:00am correctly", function (assert) {
let dt = moment.tz("2019-12-11T11:37:16", timezone);
assert.equal(
startOfDay(dt).format("YYYY-MM-DD HH:mm:ss"),
"2019-12-11 08:00:00"
);
});
test("laterToday gets 3 hours from now and if before half-past, it rounds down", function (assert) {
mockMomentTz("2019-12-11T08:13:00");
assert.equal(
laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"),
"2019-12-11 11:00:00"
);
});
test("laterToday gets 3 hours from now and if after half-past, it rounds up to the next hour", function (assert) {
mockMomentTz("2019-12-11T08:43:00");
assert.equal(
laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"),
"2019-12-11 12:00:00"
);
});
test("laterToday is capped to 6pm. later today at 3pm = 6pm, 3:30pm = 6pm, 4pm = 6pm, 4:59pm = 6pm", function (assert) {
mockMomentTz("2019-12-11T15:00:00");
assert.equal(
laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"),
"2019-12-11 18:00:00",
"3pm should max to 6pm"
);
mockMomentTz("2019-12-11T15:31:00");
assert.equal(
laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"),
"2019-12-11 18:00:00",
"3:30pm should max to 6pm"
);
mockMomentTz("2019-12-11T16:00:00");
assert.equal(
laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"),
"2019-12-11 18:00:00",
"4pm should max to 6pm"
);
mockMomentTz("2019-12-11T16:59:00");
assert.equal(
laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"),
"2019-12-11 18:00:00",
"4:59pm should max to 6pm"
);
});
});

View File

@ -35,7 +35,6 @@
padding: 1em 1em 0.5em;
border: 1px solid var(--primary-low);
border-top: none;
margin-top: -0.667em;
background: var(--primary-very-low);
.d-icon {
padding: 0 0.75em 0 0;

View File

@ -572,6 +572,20 @@ en:
title: "Why are you rejecting this user?"
send_email: "Send rejection email"
time_shortcut:
later_today: "Later today"
next_business_day: "Next business day"
tomorrow: "Tomorrow"
next_week: "Next week"
post_local_date: "Date in post"
later_this_week: "Later this week"
start_of_next_business_week: "Monday"
start_of_next_business_week_alt: "Next Monday"
next_month: "Next month"
custom: "Custom date and time"
none: "None needed"
last_custom: "Last"
user_action:
user_posted_topic: "<a href='%{userUrl}'>%{user}</a> posted <a href='%{topicUrl}'>the topic</a>"
you_posted_topic: "<a href='%{userUrl}'>You</a> posted <a href='%{topicUrl}'>the topic</a>"