DEV: Move Bookmark modal/component to use d-modal (#22532)

c.f. https://meta.discourse.org/t/converting-modals-from-legacy-controllers-to-new-dmodal-component-api/268057

This also converts the Bookmark component to a Glimmer
component.
This commit is contained in:
Martin Brennan 2023-07-17 10:14:17 +10:00 committed by GitHub
parent 9e8010df8b
commit 6459922993
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 844 additions and 1289 deletions

View File

@ -1,6 +1,7 @@
import Component from "@ember/component";
import { BookmarkFormData } from "discourse/lib/bookmark";
import BookmarkModal from "discourse/components/modal/bookmark";
import { action } from "@ember/object";
import { openBookmarkModal } from "discourse/controllers/bookmark";
import { ajax } from "discourse/lib/ajax";
import {
openLinkInNewTab,
@ -12,6 +13,7 @@ import { inject as service } from "@ember/service";
export default Component.extend({
dialog: service(),
modal: service(),
classNames: ["bookmark-list-wrapper"],
@action
@ -55,17 +57,20 @@ export default Component.extend({
@action
editBookmark(bookmark) {
openBookmarkModal(bookmark, {
onAfterSave: (savedData) => {
this.appEvents.trigger(
"bookmarks:changed",
savedData,
bookmark.attachedTo()
);
this.reload();
},
onAfterDelete: () => {
this.reload();
this.modal.show(BookmarkModal, {
model: {
bookmark: new BookmarkFormData(bookmark),
afterSave: (savedData) => {
this.appEvents.trigger(
"bookmarks:changed",
savedData,
bookmark.attachedTo()
);
this.reload();
},
afterDelete: () => {
this.reload();
},
},
});
},

View File

@ -1,94 +0,0 @@
<ConditionalLoadingSpinner @condition={{this.loading}}>
{{#if this.errorMessage}}
<div class="control-group">
<div class="controls">
<div class="alert alert-error">{{this.errorMessage}}</div>
</div>
</div>
{{/if}}
<div class="control-group bookmark-name-wrap">
<Input
id="bookmark-name"
@value={{this.model.name}}
name="bookmark-name"
class="bookmark-name"
@enter={{action "saveAndClose"}}
placeholder={{i18n "post.bookmarks.name_placeholder"}}
maxlength="100"
aria-label={{i18n "post.bookmarks.name_input_label"}}
/>
<DButton
@icon="cog"
@action={{action "toggleShowOptions"}}
@class="bookmark-options-button"
@ariaLabel="post.bookmarks.options"
@title="post.bookmarks.options"
/>
</div>
{{#if this.showOptions}}
<div class="bookmark-options-panel">
<label class="control-label" for="bookmark_auto_delete_preference">{{i18n
"bookmarks.auto_delete_preference.label"
}}</label>
<ComboBox
@content={{this.autoDeletePreferences}}
@value={{this.autoDeletePreference}}
@class="bookmark-option-selector"
@onChange={{action (mut this.autoDeletePreference)}}
/>
</div>
{{/if}}
{{#if this.showExistingReminderAt}}
<div class="alert alert-info existing-reminder-at-alert">
{{d-icon "far-clock"}}
<span>{{i18n
"bookmarks.reminders.existing_reminder"
at_date_time=this.existingReminderAtFormatted
}}</span>
</div>
{{/if}}
<div class="control-group">
<label class="control-label">
{{i18n "post.bookmarks.set_reminder"}}
</label>
{{#if this.userHasTimezoneSet}}
<TimeShortcutPicker
@timeShortcuts={{this.timeOptions}}
@prefilledDatetime={{this.prefilledDatetime}}
@onTimeSelected={{action "onTimeSelected"}}
@hiddenOptions={{this.hiddenTimeShortcutOptions}}
@customLabels={{this.customTimeShortcutLabels}}
@_itsatrap={{this._itsatrap}}
/>
{{else}}
<div class="alert alert-info">{{html-safe
(i18n "bookmarks.no_timezone" basePath=(base-path))
}}</div>
{{/if}}
</div>
<div class="modal-footer control-group">
<DButton
@id="save-bookmark"
@label="bookmarks.save"
@class="btn-primary"
@action={{action "saveAndClose"}}
/>
<DModalCancel @close={{action "closeWithoutSavingBookmark"}} />
{{#if this.showDelete}}
<DButton
@id="delete-bookmark"
@icon="trash-alt"
@class="delete-bookmark btn-danger"
@action={{action "delete"}}
@ariaLabel="post.bookmarks.actions.delete_bookmark.name"
@title="post.bookmarks.actions.delete_bookmark.name"
/>
{{/if}}
</div>
</ConditionalLoadingSpinner>

View File

@ -1,412 +0,0 @@
import { now, parseCustomDatetime, startOfDay } 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 ItsATrap from "@discourse/itsatrap";
import { Promise } from "rsvp";
import {
TIME_SHORTCUT_TYPES,
defaultTimeShortcuts,
} from "discourse/lib/time-shortcut";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import discourseComputed, { bind } from "discourse-common/utils/decorators";
import { formattedReminderTime } from "discourse/lib/bookmark";
import { and, notEmpty } from "@ember/object/computed";
import { popupAjaxError } from "discourse/lib/ajax-error";
import discourseLater from "discourse-common/lib/later";
import { inject as service } from "@ember/service";
const BOOKMARK_BINDINGS = {
enter: { handler: "saveAndClose" },
"d d": { handler: "delete" },
};
export default Component.extend({
dialog: service(),
tagName: "",
errorMessage: null,
selectedReminderType: null,
_closeWithoutSaving: null,
_savingBookmarkManually: null,
_saving: null,
_deleting: null,
_itsatrap: null,
postDetectedLocalDate: null,
postDetectedLocalTime: null,
postDetectedLocalTimezone: null,
prefilledDatetime: null,
userTimezone: null,
showOptions: null,
model: null,
afterSave: null,
init() {
this._super(...arguments);
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.user_option.timezone,
showOptions: false,
_itsatrap: new ItsATrap(),
autoDeletePreference:
this.model.autoDeletePreference ??
AUTO_DELETE_PREFERENCES.CLEAR_REMINDER,
});
this.registerOnCloseHandler(this._onModalClose);
this._bindKeyboardShortcuts();
if (this.editingExistingBookmark) {
this._initializeExistingBookmarkData();
}
this._loadPostLocalDates();
},
didInsertElement() {
this._super(...arguments);
discourseLater(() => {
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.model.id) {
this.set("showOptions", true);
} else {
document.getElementById("tap_tile_none").classList.add("active");
}
},
_initializeExistingBookmarkData() {
if (this.existingBookmarkHasReminder) {
this.set("prefilledDatetime", this.model.reminderAt);
let parsedDatetime = parseCustomDatetime(
this.prefilledDatetime,
null,
this.userTimezone
);
this.set("selectedDatetime", parsedDatetime);
}
},
_bindKeyboardShortcuts() {
KeyboardShortcuts.pause();
Object.keys(BOOKMARK_BINDINGS).forEach((shortcut) => {
this._itsatrap.bind(shortcut, () => {
let binding = BOOKMARK_BINDINGS[shortcut];
this.send(binding.handler);
return false;
});
});
},
_loadPostLocalDates() {
if (this.model.bookmarkableType !== "Post") {
return;
}
let postEl = document.querySelector(
`[data-post-id="${this.model.bookmarkableId}"]`
);
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"));
}
}
const data = {
reminder_at: reminderAtISO,
name: this.model.name,
id: this.model.id,
auto_delete_preference: this.autoDeletePreference,
};
data.bookmarkable_id = this.model.bookmarkableId;
data.bookmarkable_type = this.model.bookmarkableType;
if (this.editingExistingBookmark) {
return ajax(`/bookmarks/${this.model.id}`, {
type: "PUT",
data,
}).then((response) => {
this._executeAfterSave(response, reminderAtISO);
});
} else {
return ajax("/bookmarks", { type: "POST", data }).then((response) => {
this._executeAfterSave(response, reminderAtISO);
});
}
},
_executeAfterSave(response, reminderAtISO) {
if (!this.afterSave) {
return;
}
const data = {
reminder_at: reminderAtISO,
auto_delete_preference: this.autoDeletePreference,
id: this.model.id || response.id,
name: this.model.name,
};
data.bookmarkable_id = this.model.bookmarkableId;
data.bookmarkable_type = this.model.bookmarkableType;
this.afterSave(data);
},
_deleteBookmark() {
return ajax("/bookmarks/" + this.model.id, {
type: "DELETE",
}).then((response) => {
if (this.afterDelete) {
this.afterDelete(response.topic_bookmarked, this.model.id);
}
});
},
_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") {
this.dialog.alert(e);
} else {
popupAjaxError(e);
}
},
@bind
_onModalClose(closeOpts) {
// 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 ||
closeOpts.initiatedByCloseButton ||
closeOpts.initiatedByESC;
if (!this._closeWithoutSaving && !this._savingBookmarkManually) {
this._saveBookmark().catch((e) => this._handleSaveError(e));
}
if (this.onCloseWithoutSaving && this._closeWithoutSaving) {
this.onCloseWithoutSaving();
}
},
willDestroyElement() {
this._super(...arguments);
this._itsatrap?.destroy();
this.set("_itsatrap", null);
KeyboardShortcuts.unpause();
},
@discourseComputed("model.reminderAt")
showExistingReminderAt(reminderAt) {
return reminderAt && Date.parse(reminderAt) > new Date().getTime();
},
showDelete: notEmpty("model.id"),
userHasTimezoneSet: notEmpty("userTimezone"),
editingExistingBookmark: and("model", "model.id"),
existingBookmarkHasReminder: and("model", "model.id", "model.reminderAt"),
@discourseComputed("postDetectedLocalDate", "postDetectedLocalTime")
showPostLocalDate(postDetectedLocalDate, postDetectedLocalTime) {
if (!postDetectedLocalTime || !postDetectedLocalDate) {
return;
}
let postLocalDateTime = this._postLocalDate();
if (postLocalDateTime < now(this.userTimezone)) {
return;
}
return true;
},
@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("userTimezone")
timeOptions(userTimezone) {
const options = defaultTimeShortcuts(userTimezone);
if (this.showPostLocalDate) {
options.push({
icon: "globe-americas",
id: TIME_SHORTCUT_TYPES.POST_LOCAL_DATE,
label: "time_shortcut.post_local_date",
time: this._postLocalDate(),
timeFormatKey: "dates.long_no_year",
hidden: false,
});
}
return options;
},
@discourseComputed("existingBookmarkHasReminder")
customTimeShortcutLabels(existingBookmarkHasReminder) {
const labels = {};
if (existingBookmarkHasReminder) {
labels[TIME_SHORTCUT_TYPES.NONE] =
"bookmarks.remove_reminder_keep_bookmark";
}
return labels;
},
@discourseComputed("editingExistingBookmark", "existingBookmarkHasReminder")
hiddenTimeShortcutOptions(
editingExistingBookmark,
existingBookmarkHasReminder
) {
if (editingExistingBookmark && !existingBookmarkHasReminder) {
return [TIME_SHORTCUT_TYPES.NONE];
}
return [];
},
@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
toggleShowOptions() {
this.toggleProperty("showOptions");
},
@action
delete() {
if (!this.model.id) {
return;
}
this._deleting = true;
let deleteAction = () => {
this._closeWithoutSaving = true;
this._deleteBookmark()
.then(() => {
this._deleting = false;
this.closeModal();
})
.catch((e) => this._handleSaveError(e));
};
if (this.existingBookmarkHasReminder) {
this.dialog.deleteConfirm({
message: I18n.t("bookmarks.confirm_delete"),
didConfirm: () => 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 (
![TIME_SHORTCUT_TYPES.CUSTOM, TIME_SHORTCUT_TYPES.RELATIVE].includes(type)
) {
return this.saveAndClose();
}
},
@action
selectPostLocalDate(date) {
this.setProperties({
selectedReminderType: this.reminderTypes.POST_LOCAL_DATE,
postLocalDate: date,
});
return this.saveAndClose();
},
});

View File

@ -0,0 +1,100 @@
<DModal
@closeModal={{this.closingModal}}
@title={{this.modalTitle}}
@flash={{this.flash}}
@flashType="error"
id="bookmark-reminder-modal"
class="bookmark-reminder-modal bookmark-with-reminder"
data-bookmark-id={{this.bookmark.id}}
{{did-insert this.didInsert}}
>
<:body>
<div class="control-group bookmark-name-wrap">
<Input
id="bookmark-name"
@value={{this.bookmark.name}}
name="bookmark-name"
class="bookmark-name"
@enter={{action "saveAndClose"}}
placeholder={{i18n "post.bookmarks.name_placeholder"}}
aria-label={{i18n "post.bookmarks.name_input_label"}}
/>
<DButton
@icon="cog"
@action={{this.toggleShowOptions}}
class="bookmark-options-button"
@ariaLabel="post.bookmarks.options"
@title="post.bookmarks.options"
/>
</div>
{{#if this.showOptions}}
<div class="bookmark-options-panel">
<label
class="control-label"
for="bookmark_auto_delete_preference"
>{{i18n "bookmarks.auto_delete_preference.label"}}</label>
<ComboBox
@content={{this.autoDeletePreferences}}
@value={{this.bookmark.autoDeletePreference}}
@class="bookmark-option-selector"
@id="bookmark-auto-delete-preference"
@onChange={{action (mut this.bookmark.autoDeletePreference)}}
/>
</div>
{{/if}}
{{#if this.showExistingReminderAt}}
<div class="alert alert-info existing-reminder-at-alert">
{{d-icon "far-clock"}}
<span>{{i18n
"bookmarks.reminders.existing_reminder"
at_date_time=this.existingReminderAtFormatted
}}</span>
</div>
{{/if}}
<div class="control-group">
<label class="control-label">
{{i18n "post.bookmarks.set_reminder"}}
</label>
{{#if this.userHasTimezoneSet}}
<TimeShortcutPicker
@timeShortcuts={{this.timeOptions}}
@prefilledDatetime={{this.prefilledDatetime}}
@onTimeSelected={{action "onTimeSelected"}}
@hiddenOptions={{this.hiddenTimeShortcutOptions}}
@customLabels={{this.customTimeShortcutLabels}}
@_itsatrap={{this._itsatrap}}
/>
{{else}}
<div class="alert alert-info">{{html-safe
(i18n "bookmarks.no_timezone" basePath=(base-path))
}}</div>
{{/if}}
</div>
</:body>
<:footer>
<div class="control-group">
<DButton
id="save-bookmark"
@label="bookmarks.save"
class="btn-primary"
@action={{this.saveAndClose}}
/>
<DModalCancel @close={{action "closeWithoutSavingBookmark"}} />
{{#if this.showDelete}}
<DButton
id="delete-bookmark"
@icon="trash-alt"
class="delete-bookmark btn-danger"
@action={{this.delete}}
@ariaLabel="post.bookmarks.actions.delete_bookmark.name"
@title="post.bookmarks.actions.delete_bookmark.name"
/>
{{/if}}
</div>
</:footer>
</DModal>

View File

@ -0,0 +1,351 @@
import Component from "@glimmer/component";
import { extractError } from "discourse/lib/ajax-error";
import { sanitize } from "discourse/lib/text";
import { CLOSE_INITIATED_BY_CLICK_OUTSIDE } from "discourse/components/d-modal";
import { inject as service } from "@ember/service";
import { tracked } from "@glimmer/tracking";
import { now, parseCustomDatetime, startOfDay } from "discourse/lib/time-utils";
import { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark";
import I18n from "I18n";
import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts";
import ItsATrap from "@discourse/itsatrap";
import { Promise } from "rsvp";
import {
TIME_SHORTCUT_TYPES,
defaultTimeShortcuts,
} from "discourse/lib/time-shortcut";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { formattedReminderTime } from "discourse/lib/bookmark";
import { and, notEmpty } from "@ember/object/computed";
import discourseLater from "discourse-common/lib/later";
const BOOKMARK_BINDINGS = {
enter: { handler: "saveAndClose" },
"d d": { handler: "delete" },
};
export default class BookmarkModal extends Component {
@service dialog;
@service currentUser;
@service site;
@tracked postDetectedLocalDate = null;
@tracked postDetectedLocalTime = null;
@tracked postDetectedLocalTimezone = null;
@tracked prefilledDatetime = null;
@tracked flash = null;
@tracked userTimezone = this.currentUser.user_option.timezone;
@tracked showOptions = this.args.model.bookmark.id ? true : false;
@notEmpty("userTimezone")
userHasTimezoneSet;
@notEmpty("bookmark.id")
showDelete;
@notEmpty("bookmark.id")
editingExistingBookmark;
@and("bookmark.id", "bookmark.reminderAt")
existingBookmarkHasReminder;
@tracked _closeWithoutSaving = false;
@tracked _savingBookmarkManually = false;
@tracked _saving = false;
@tracked _deleting = false;
_itsatrap = new ItsATrap();
get bookmark() {
return this.args.model.bookmark;
}
get modalTitle() {
return I18n.t(this.bookmark.id ? "bookmarks.edit" : "bookmarks.create");
}
get autoDeletePreferences() {
return Object.keys(AUTO_DELETE_PREFERENCES).map((key) => {
return {
id: AUTO_DELETE_PREFERENCES[key],
name: I18n.t(`bookmarks.auto_delete_preference.${key.toLowerCase()}`),
};
});
}
get showExistingReminderAt() {
return (
this.bookmark.reminderAt &&
Date.parse(this.bookmark.reminderAt) > new Date().getTime()
);
}
get existingReminderAtFormatted() {
return formattedReminderTime(this.bookmark.reminderAt, this.userTimezone);
}
get timeOptions() {
const options = defaultTimeShortcuts(this.userTimezone);
if (this.showPostLocalDate) {
options.push({
icon: "globe-americas",
id: TIME_SHORTCUT_TYPES.POST_LOCAL_DATE,
label: "time_shortcut.post_local_date",
time: this.#parsedPostLocalDateTime(),
timeFormatKey: "dates.long_no_year",
hidden: false,
});
}
return options;
}
get showPostLocalDate() {
if (!this.postDetectedLocalTime || !this.postDetectedLocalDate) {
return false;
}
if (this.#parsedPostLocalDateTime() < now(this.userTimezone)) {
return false;
}
return true;
}
get hiddenTimeShortcutOptions() {
if (this.editingExistingBookmark && !this.existingBookmarkHasReminder) {
return [TIME_SHORTCUT_TYPES.NONE];
}
return [];
}
get customTimeShortcutLabels() {
const labels = {};
if (this.existingBookmarkHasReminder) {
labels[TIME_SHORTCUT_TYPES.NONE] =
"bookmarks.remove_reminder_keep_bookmark";
}
return labels;
}
willDestroy() {
this._itsatrap?.destroy();
this._itsatrap = null;
KeyboardShortcuts.unpause();
}
@action
didInsert() {
discourseLater(() => {
if (this.site.isMobileDevice) {
document.getElementById("bookmark-name").blur();
}
});
if (!this.args.model.bookmark.id) {
document.getElementById("tap_tile_none").classList.add("active");
}
this.#bindKeyboardShortcuts();
this.#initializeExistingBookmarkData();
this.#loadPostLocalDates();
}
@action
saveAndClose() {
this.flash = null;
if (this._saving || this._deleting) {
return;
}
this._saving = true;
this._savingBookmarkManually = true;
return this.#saveBookmark()
.then(() => this.args.closeModal())
.catch((error) => this.#handleSaveError(error))
.finally(() => {
this._saving = false;
});
}
@action
toggleShowOptions() {
this.showOptions = !this.showOptions;
}
@action
onTimeSelected(type, time) {
this.bookmark.selectedReminderType = type;
this.bookmark.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 (
![TIME_SHORTCUT_TYPES.CUSTOM, TIME_SHORTCUT_TYPES.RELATIVE].includes(type)
) {
return this.saveAndClose();
}
}
@action
closingModal(closeModalArgs) {
// If the user clicks outside the modal we save automatically for them,
// as long as they are not already saving manually or deleting the bookmark.
if (
closeModalArgs.initiatedBy === CLOSE_INITIATED_BY_CLICK_OUTSIDE &&
!this._closeWithoutSaving &&
!this._savingBookmarkManually
) {
this.#saveBookmark()
.catch((e) => this.#handleSaveError(e))
.then(() => {
this.args.closeModal(closeModalArgs);
});
} else {
this.args.closeModal(closeModalArgs);
}
}
@action
closeWithoutSavingBookmark() {
this._closeWithoutSaving = true;
this.args.closeModal({ closeWithoutSaving: this._closeWithoutSaving });
}
@action
delete() {
if (!this.bookmark.id) {
return;
}
this._deleting = true;
const deleteAction = () => {
this._closeWithoutSaving = true;
this.#deleteBookmark()
.then(() => {
this._deleting = false;
this.args.closeModal({
closeWithoutSaving: this._closeWithoutSaving,
});
})
.catch((error) => this.#handleSaveError(error));
};
if (this.existingBookmarkHasReminder) {
this.dialog.deleteConfirm({
message: I18n.t("bookmarks.confirm_delete"),
didConfirm: () => deleteAction(),
});
} else {
deleteAction();
}
}
#parsedPostLocalDateTime() {
let parsedPostLocalDate = parseCustomDatetime(
this.postDetectedLocalDate,
this.postDetectedLocalTime,
this.userTimezone,
this.postDetectedLocalTimezone
);
if (!this.postDetectedLocalTime) {
return startOfDay(parsedPostLocalDate);
}
return parsedPostLocalDate;
}
#saveBookmark() {
if (this.bookmark.selectedReminderType === TIME_SHORTCUT_TYPES.CUSTOM) {
if (!this.bookmark.reminderAtISO) {
return Promise.reject(I18n.t("bookmarks.invalid_custom_datetime"));
}
}
if (this.editingExistingBookmark) {
return ajax(`/bookmarks/${this.bookmark.id}`, {
type: "PUT",
data: this.bookmark.saveData,
}).then(() => {
this.args.model.afterSave?.(this.bookmark.saveData);
});
} else {
return ajax("/bookmarks", {
type: "POST",
data: this.bookmark.saveData,
}).then((response) => {
this.bookmark.id = response.id;
this.args.model.afterSave?.(this.bookmark.saveData);
});
}
}
#deleteBookmark() {
return ajax("/bookmarks/" + this.bookmark.id, {
type: "DELETE",
}).then((response) => {
this.args.model.afterDelete?.(
response.topic_bookmarked,
this.bookmark.id
);
});
}
#handleSaveError(error) {
this._savingBookmarkManually = false;
if (typeof error === "string") {
this.flash = sanitize(error);
} else {
this.flash = sanitize(extractError(error));
}
}
#bindKeyboardShortcuts() {
KeyboardShortcuts.pause();
Object.keys(BOOKMARK_BINDINGS).forEach((shortcut) => {
this._itsatrap.bind(shortcut, () => {
const binding = BOOKMARK_BINDINGS[shortcut];
this[binding.handler]();
return false;
});
});
}
#initializeExistingBookmarkData() {
if (!this.existingBookmarkHasReminder || !this.editingExistingBookmark) {
return;
}
this.prefilledDatetime = this.bookmark.reminderAt;
this.bookmark.selectedDatetime = parseCustomDatetime(
this.prefilledDatetime,
null,
this.userTimezone
);
}
// If we detect we are bookmarking a post which has local-date data
// in it, we can preload that date + time into the form to use as the
// bookmark reminder date + time.
#loadPostLocalDates() {
if (this.bookmark.bookmarkableType !== "Post") {
return;
}
const postEl = document.querySelector(
`[data-post-id="${this.bookmark.bookmarkableId}"]`
);
const localDateEl = postEl?.querySelector(".discourse-local-date");
if (localDateEl) {
this.postDetectedLocalDate = localDateEl.dataset.date;
this.postDetectedLocalTime = localDateEl.dataset.time;
this.postDetectedLocalTimezone = localDateEl.dataset.timezone;
}
}
}

View File

@ -1,80 +0,0 @@
import Controller from "@ember/controller";
import I18n from "I18n";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { action } from "@ember/object";
import { Promise } from "rsvp";
import showModal from "discourse/lib/show-modal";
export function openBookmarkModal(
bookmark,
callbacks = {
onCloseWithoutSaving: null,
onAfterSave: null,
onAfterDelete: null,
}
) {
return new Promise((resolve) => {
const model = {
id: bookmark.id,
reminderAt: bookmark.reminder_at,
autoDeletePreference: bookmark.auto_delete_preference,
name: bookmark.name,
};
model.bookmarkableId = bookmark.bookmarkable_id;
model.bookmarkableType = bookmark.bookmarkable_type;
let modalController = showModal("bookmark", {
model,
titleTranslated: I18n.t(
bookmark.id ? "bookmarks.edit" : "bookmarks.create"
),
modalClass: "bookmark-with-reminder",
});
modalController.setProperties({
onCloseWithoutSaving: () => {
if (callbacks.onCloseWithoutSaving) {
callbacks.onCloseWithoutSaving();
}
resolve();
},
afterSave: (savedData) => {
let resolveData;
if (callbacks.onAfterSave) {
resolveData = callbacks.onAfterSave(savedData);
}
resolve(resolveData);
},
afterDelete: (topicBookmarked, bookmarkId) => {
if (callbacks.onAfterDelete) {
callbacks.onAfterDelete(topicBookmarked, bookmarkId);
}
resolve();
},
});
});
}
export default Controller.extend(ModalFunctionality, {
onShow() {
this.setProperties({
model: this.model || {},
allowSave: true,
});
},
@action
registerOnCloseHandler(handlerFn) {
this.set("onCloseHandler", handlerFn);
},
/**
* We always want to save the bookmark unless the user specifically
* clicks the save or cancel button to mimic browser behaviour.
*/
onClose(opts = {}) {
if (this.onCloseHandler) {
this.onCloseHandler(opts);
}
},
});

View File

@ -10,6 +10,11 @@ import { resetCachedTopicList } from "discourse/lib/cached-topic-list";
import { isEmpty, isPresent } from "@ember/utils";
import { next, schedule } from "@ember/runloop";
import discourseLater from "discourse-common/lib/later";
import BookmarkModal from "discourse/components/modal/bookmark";
import {
CLOSE_INITIATED_BY_BUTTON,
CLOSE_INITIATED_BY_ESC,
} from "discourse/components/d-modal";
import Bookmark, { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark";
import Composer from "discourse/models/composer";
import EmberObject, { action } from "@ember/object";
@ -29,7 +34,7 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
import { inject as service } from "@ember/service";
import showModal from "discourse/lib/show-modal";
import { spinnerHTML } from "discourse/helpers/loading-spinner";
import { openBookmarkModal } from "discourse/controllers/bookmark";
import { BookmarkFormData } from "discourse/lib/bookmark";
let customPostMessageCallbacks = {};
@ -53,6 +58,7 @@ export default Controller.extend(bufferedProperty("model"), {
dialog: service(),
documentTitle: service(),
screenTrack: service(),
modal: service(),
multiSelect: false,
selectedPostIds: null,
@ -1272,43 +1278,60 @@ export default Controller.extend(bufferedProperty("model"), {
},
_modifyTopicBookmark(bookmark) {
return openBookmarkModal(bookmark, {
onAfterSave: (savedData) => {
this._syncBookmarks(savedData);
this.model.set("bookmarking", false);
this.model.set("bookmarked", true);
this.model.incrementProperty("bookmarksWereChanged");
this.appEvents.trigger(
"bookmarks:changed",
savedData,
bookmark.attachedTo()
);
},
onAfterDelete: (topicBookmarked, bookmarkId) => {
this.model.removeBookmark(bookmarkId);
this.modal.show(BookmarkModal, {
model: {
bookmark: new BookmarkFormData(bookmark),
afterSave: (savedData) => {
this._syncBookmarks(savedData);
this.model.set("bookmarking", false);
this.model.set("bookmarked", true);
this.model.incrementProperty("bookmarksWereChanged");
this.appEvents.trigger(
"bookmarks:changed",
savedData,
bookmark.attachedTo()
);
},
afterDelete: (topicBookmarked, bookmarkId) => {
this.model.removeBookmark(bookmarkId);
},
},
});
},
_modifyPostBookmark(bookmark, post) {
return openBookmarkModal(bookmark, {
onCloseWithoutSaving: () => {
post.appEvents.trigger("post-stream:refresh", {
id: bookmark.bookmarkable_id,
});
},
onAfterSave: (savedData) => {
this._syncBookmarks(savedData);
this.model.set("bookmarking", false);
post.createBookmark(savedData);
this.model.afterPostBookmarked(post, savedData);
return [post.id];
},
onAfterDelete: (topicBookmarked, bookmarkId) => {
this.model.removeBookmark(bookmarkId);
post.deleteBookmark(topicBookmarked);
},
});
this.modal
.show(BookmarkModal, {
model: {
bookmark: new BookmarkFormData(bookmark),
afterSave: (savedData) => {
this._syncBookmarks(savedData);
this.model.set("bookmarking", false);
post.createBookmark(savedData);
this.model.afterPostBookmarked(post, savedData);
return [post.id];
},
afterDelete: (topicBookmarked, bookmarkId) => {
this.model.removeBookmark(bookmarkId);
post.deleteBookmark(topicBookmarked);
},
},
})
.then((closeData) => {
if (!closeData) {
return;
}
if (
closeData.closeWithoutSaving ||
closeData.initiatedBy === CLOSE_INITIATED_BY_ESC ||
closeData.initiatedBy === CLOSE_INITIATED_BY_BUTTON
) {
post.appEvents.trigger("post-stream:refresh", {
id: bookmark.bookmarkable_id,
});
}
});
},
_syncBookmarks(data) {

View File

@ -1,4 +1,8 @@
import I18n from "I18n";
import { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark";
import { TIME_SHORTCUT_TYPES } from "discourse/lib/time-shortcut";
import { tracked } from "@glimmer/tracking";
export function formattedReminderTime(reminderAt, timezone) {
let reminderAtDate = moment.tz(reminderAt, timezone);
let formatted = reminderAtDate.format(I18n.t("dates.time"));
@ -16,3 +20,43 @@ export function formattedReminderTime(reminderAt, timezone) {
date_time: reminderAtDate.format(I18n.t("dates.long_with_year")),
});
}
export class BookmarkFormData {
@tracked selectedDatetime;
@tracked selectedReminderType = TIME_SHORTCUT_TYPES.NONE;
@tracked id;
@tracked reminderAt;
@tracked autoDeletePreference;
@tracked name;
@tracked bookmarkableId;
@tracked bookmarkableType;
constructor(bookmark) {
this.id = bookmark.id;
this.reminderAt = bookmark.reminder_at;
this.name = bookmark.name;
this.bookmarkableId = bookmark.bookmarkable_id;
this.bookmarkableType = bookmark.bookmarkable_type;
this.autoDeletePreference =
bookmark.auto_delete_preference ?? AUTO_DELETE_PREFERENCES.CLEAR_REMINDER;
}
get reminderAtISO() {
if (!this.selectedReminderType || !this.selectedDatetime) {
return;
}
return this.selectedDatetime.toISOString();
}
get saveData() {
return {
reminder_at: this.reminderAtISO,
name: this.name,
id: this.id,
auto_delete_preference: this.autoDeletePreference,
bookmarkable_id: this.bookmarkableId,
bookmarkable_type: this.bookmarkableType,
};
}
}

View File

@ -1,10 +0,0 @@
<DModalBody @id="bookmark-reminder-modal">
<Bookmark
@model={{this.model}}
@afterSave={{this.afterSave}}
@afterDelete={{this.afterDelete}}
@onCloseWithoutSaving={{this.onCloseWithoutSaving}}
@registerOnCloseHandler={{action "registerOnCloseHandler"}}
@closeModal={{action "closeModal"}}
/>
</DModalBody>

View File

@ -1,487 +0,0 @@
import {
acceptance,
exists,
loggedInUser,
query,
} from "discourse/tests/helpers/qunit-helpers";
import { click, fillIn, 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 { cloneJSON } from "discourse-common/lib/object";
async function openBookmarkModal(postNumber = 1) {
if (exists(`#post_${postNumber} button.show-more-actions`)) {
await click(`#post_${postNumber} button.show-more-actions`);
}
await click(`#post_${postNumber} button.bookmark`);
}
async function openEditBookmarkModal() {
await click(".topic-post:first-child button.bookmarked");
}
async function testTopicLevelBookmarkButtonIcon(assert, postNumber) {
const iconWithoutClock = "d-icon-bookmark";
const iconWithClock = "d-icon-discourse-bookmark-clock";
await visit("/t/internationalization-localization/280");
assert.ok(
query("#topic-footer-button-bookmark svg").classList.contains(
iconWithoutClock
),
"Shows an icon without a clock when there is no a bookmark"
);
await openBookmarkModal(postNumber);
await click("#save-bookmark");
assert.ok(
query("#topic-footer-button-bookmark svg").classList.contains(
iconWithoutClock
),
"Shows an icon without a clock when there is a bookmark without a reminder"
);
await openBookmarkModal(postNumber);
await click("#tap_tile_tomorrow");
assert.ok(
query("#topic-footer-button-bookmark svg").classList.contains(
iconWithClock
),
"Shows an icon with a clock when there is a bookmark with a reminder"
);
}
acceptance("Bookmarking", function (needs) {
needs.user();
const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]);
topicResponse.post_stream.posts[0].cooked += `<span data-date="2036-01-15" data-time="00:35:00" class="discourse-local-date cooked-date past" data-timezone="Europe/London">
<span>
<svg class="fa d-icon d-icon-globe-americas svg-icon" xmlns="http://www.w3.org/2000/svg">
<use href="#globe-americas"></use>
</svg>
<span class="relative-time">January 15, 2036 12:35 AM</span>
</span>
</span>`;
topicResponse.post_stream.posts[1].cooked += `<span data-date="2021-01-15" data-time="00:35:00" class="discourse-local-date cooked-date past" data-timezone="Europe/London">
<span>
<svg class="fa d-icon d-icon-globe-americas svg-icon" xmlns="http://www.w3.org/2000/svg">
<use href="#globe-americas"></use>
</svg>
<span class="relative-time">Today 10:30 AM</span>
</span>
</span>`;
needs.pretender((server, helper) => {
function handleRequest(request) {
const data = helper.parsePostData(request.requestBody);
if (data.bookmarkable_id === "398" && data.bookmarkable_type === "Post") {
return helper.response({ id: 1, success: "OK" });
} else if (data.bookmarkable_type === "Topic") {
return helper.response({ id: 3, success: "OK" });
} else if (
data.bookmarkable_id === "419" &&
data.bookmarkable_type === "Post"
) {
return helper.response({ id: 2, success: "OK" });
}
}
server.post("/bookmarks", handleRequest);
server.put("/bookmarks/1", handleRequest);
server.put("/bookmarks/2", handleRequest);
server.put("/bookmarks/3", handleRequest);
server.delete("/bookmarks/1", () =>
helper.response({ success: "OK", topic_bookmarked: false })
);
server.get("/t/280.json", () => helper.response(topicResponse));
});
test("Bookmarks modal opening", async function (assert) {
await visit("/t/internationalization-localization/280");
await openBookmarkModal();
assert.ok(
exists("#bookmark-reminder-modal"),
"it shows the bookmark modal"
);
assert.ok(
exists("#tap_tile_none.active"),
"it highlights the None option by default"
);
});
test("Bookmarks modal selecting reminder type", async function (assert) {
await visit("/t/internationalization-localization/280");
await openBookmarkModal();
await click("#tap_tile_tomorrow");
await openBookmarkModal();
await click("#tap_tile_start_of_next_business_week");
await openBookmarkModal();
await click("#tap_tile_next_month");
await openBookmarkModal();
await click("#tap_tile_custom");
assert.ok(exists("#tap_tile_custom.active"), "it selects custom");
assert.ok(exists(".tap-tile-date-input"), "it shows the custom date input");
assert.ok(exists(".tap-tile-time-input"), "it shows the custom time input");
await click("#save-bookmark");
});
test("Saving a bookmark with a reminder", async function (assert) {
await visit("/t/internationalization-localization/280");
await openBookmarkModal();
await fillIn("input#bookmark-name", "Check this out later");
await click("#tap_tile_tomorrow");
assert.ok(
exists(".topic-post:first-child button.bookmark.bookmarked"),
"it shows the bookmarked icon on the post"
);
assert.ok(
exists(
".topic-post:first-child button.bookmark.bookmarked > .d-icon-discourse-bookmark-clock"
),
"it shows the bookmark clock icon because of the reminder"
);
});
test("Opening the options panel and remembering the option", async function (assert) {
await visit("/t/internationalization-localization/280");
await openBookmarkModal();
assert.notOk(
exists(".bookmark-options-panel"),
"it should not open the options panel by default"
);
await click(".bookmark-options-button");
assert.ok(
exists(".bookmark-options-panel"),
"it should open the options panel"
);
await selectKit(".bookmark-option-selector").expand();
await selectKit(".bookmark-option-selector").selectRowByValue(1);
await click("#save-bookmark");
await openEditBookmarkModal();
assert.ok(
exists(".bookmark-options-panel"),
"it should reopen the options panel"
);
assert.strictEqual(
selectKit(".bookmark-option-selector").header().value(),
"1"
);
});
test("Saving a bookmark with no reminder or name", async function (assert) {
await visit("/t/internationalization-localization/280");
await openBookmarkModal();
await click("#save-bookmark");
assert.ok(
exists(".topic-post:first-child button.bookmark.bookmarked"),
"it shows the bookmarked icon on the post"
);
assert.notOk(
exists(
".topic-post:first-child button.bookmark.bookmarked > .d-icon-discourse-bookmark-clock"
),
"it shows the regular bookmark active icon"
);
});
test("Deleting a bookmark with a reminder", async function (assert) {
await visit("/t/internationalization-localization/280");
await openBookmarkModal();
await click("#tap_tile_tomorrow");
await openEditBookmarkModal();
assert.ok(
exists("#bookmark-reminder-modal"),
"it shows the bookmark modal"
);
await click("#delete-bookmark");
assert.ok(exists(".dialog-body"), "it asks for delete confirmation");
assert.ok(
query(".dialog-body").innerText.includes(
I18n.t("bookmarks.confirm_delete")
),
"it shows delete confirmation message"
);
await click(".dialog-footer .btn-danger");
assert.notOk(
exists(".topic-post:first-child button.bookmark.bookmarked"),
"it no longer shows the bookmarked icon on the post after bookmark is deleted"
);
});
test("Cancelling saving a bookmark", async function (assert) {
await visit("/t/internationalization-localization/280");
await openBookmarkModal();
await click(".d-modal-cancel");
assert.notOk(
exists(".topic-post:first-child button.bookmark.bookmarked"),
"it does not show the bookmarked icon on the post because it is not saved"
);
});
test("Editing a bookmark", async function (assert) {
await visit("/t/internationalization-localization/280");
let now = moment.tz(loggedInUser().user_option.timezone);
let tomorrow = now.add(1, "day").format("YYYY-MM-DD");
await openBookmarkModal();
await fillIn("input#bookmark-name", "Test name");
await click("#tap_tile_tomorrow");
await openEditBookmarkModal();
assert.strictEqual(
query("#bookmark-name").value,
"Test name",
"it should prefill the bookmark name"
);
assert.strictEqual(
query("#custom-date > input").value,
tomorrow,
"it should prefill the bookmark date"
);
assert.strictEqual(
query("#custom-time").value,
"08:00",
"it should prefill the bookmark time"
);
});
test("Using a post date for the reminder date", async function (assert) {
await visit("/t/internationalization-localization/280");
let postDate = moment.tz("2036-01-15", loggedInUser().user_option.timezone);
let postDateFormatted = postDate.format("YYYY-MM-DD");
await openBookmarkModal();
await fillIn("input#bookmark-name", "Test name");
await click("#tap_tile_post_local_date");
await openEditBookmarkModal();
assert.strictEqual(
query("#bookmark-name").value,
"Test name",
"it should prefill the bookmark name"
);
assert.strictEqual(
query("#custom-date > input").value,
postDateFormatted,
"it should prefill the bookmark date"
);
assert.strictEqual(
query("#custom-time").value,
"10:35",
"it should prefill the bookmark time"
);
});
test("Cannot use the post date for a reminder when the post date is in the past", async function (assert) {
await visit("/t/internationalization-localization/280");
await openBookmarkModal(2);
assert.notOk(
exists("#tap_tile_post_local_date"),
"it does not show the local date tile"
);
});
test("The topic level bookmark button deletes all bookmarks if several posts on the topic are bookmarked", async function (assert) {
const yesButton = ".dialog-footer .btn-primary";
const noButton = ".dialog-footer .btn-default";
await visit("/t/internationalization-localization/280");
await openBookmarkModal(1);
await click("#save-bookmark");
await openBookmarkModal(2);
await click("#save-bookmark");
assert.ok(
exists(".topic-post:first-child button.bookmark.bookmarked"),
"the first bookmark is added"
);
assert.ok(
exists(".topic-post:nth-child(3) button.bookmark.bookmarked"),
"the second bookmark is added"
);
// open the modal and cancel deleting
await click("#topic-footer-button-bookmark");
await click(noButton);
assert.ok(
exists(".topic-post:first-child button.bookmark.bookmarked"),
"the first bookmark isn't deleted"
);
assert.ok(
exists(".topic-post:nth-child(3) button.bookmark.bookmarked"),
"the second bookmark isn't deleted"
);
// open the modal and accept deleting
await click("#topic-footer-button-bookmark");
await click(yesButton);
assert.ok(
!exists(".topic-post:first-child button.bookmark.bookmarked"),
"the first bookmark is deleted"
);
assert.ok(
!exists(".topic-post:nth-child(3) button.bookmark.bookmarked"),
"the second bookmark is deleted"
);
});
test("The topic level bookmark button opens the edit modal if only the first post on the topic is bookmarked", async function (assert) {
await visit("/t/internationalization-localization/280");
await openBookmarkModal(1);
await click("#save-bookmark");
assert.strictEqual(
query("#topic-footer-button-bookmark").innerText,
I18n.t("bookmarked.edit_bookmark"),
"A topic level bookmark button has a label 'Edit Bookmark'"
);
await click("#topic-footer-button-bookmark");
assert.ok(
exists("div.modal.bookmark-with-reminder"),
"The edit modal is opened"
);
});
test("Creating and editing a topic level bookmark", async function (assert) {
await visit("/t/internationalization-localization/280");
await click("#topic-footer-button-bookmark");
await click("#save-bookmark");
assert.notOk(
exists(".topic-post:first-child button.bookmark.bookmarked"),
"the first post is not marked as being bookmarked"
);
assert.strictEqual(
query("#topic-footer-button-bookmark").innerText,
I18n.t("bookmarked.edit_bookmark"),
"A topic level bookmark button has a label 'Edit Bookmark'"
);
await click("#topic-footer-button-bookmark");
await fillIn("input#bookmark-name", "Test name");
await click("#tap_tile_tomorrow");
await click("#topic-footer-button-bookmark");
assert.strictEqual(
query("input#bookmark-name").value,
"Test name",
"The topic level bookmark editing preserves the values entered"
);
await click(".d-modal-cancel");
await openBookmarkModal(1);
await click("#save-bookmark");
assert.ok(
exists(".topic-post:first-child button.bookmark.bookmarked"),
"the first post is bookmarked independently of the topic level bookmark"
);
// deleting all bookmarks in the topic
assert.strictEqual(
query("#topic-footer-button-bookmark").innerText,
I18n.t("bookmarked.clear_bookmarks"),
"the footer button says Clear Bookmarks because there is more than one"
);
await click("#topic-footer-button-bookmark");
await click(".dialog-footer .btn-primary");
assert.ok(
!exists(".topic-post:first-child button.bookmark.bookmarked"),
"the first post bookmark is deleted"
);
assert.strictEqual(
query("#topic-footer-button-bookmark").innerText,
I18n.t("bookmarked.title"),
"the topic level bookmark is deleted"
);
});
test("Deleting a topic_level bookmark with a reminder", async function (assert) {
await visit("/t/internationalization-localization/280");
await click("#topic-footer-button-bookmark");
await click("#save-bookmark");
assert.strictEqual(
query("#topic-footer-button-bookmark").innerText,
I18n.t("bookmarked.edit_bookmark"),
"A topic level bookmark button has a label 'Edit Bookmark'"
);
await click("#topic-footer-button-bookmark");
await fillIn("input#bookmark-name", "Test name");
await click("#tap_tile_tomorrow");
await click("#topic-footer-button-bookmark");
await click("#delete-bookmark");
assert.ok(exists(".dialog-body"), "it asks for delete confirmation");
assert.ok(
query(".dialog-body").innerText.includes(
I18n.t("bookmarks.confirm_delete")
),
"it shows delete confirmation message"
);
await click(".dialog-footer .btn-danger");
assert.strictEqual(
query("#topic-footer-button-bookmark").innerText,
I18n.t("bookmarked.title"),
"A topic level bookmark button no longer says 'Edit Bookmark' after deletion"
);
});
test("The topic level bookmark button opens the edit modal if only one post in the post stream is bookmarked", async function (assert) {
await visit("/t/internationalization-localization/280");
await openBookmarkModal(2);
await click("#save-bookmark");
assert.strictEqual(
query("#topic-footer-button-bookmark").innerText,
I18n.t("bookmarked.edit_bookmark"),
"A topic level bookmark button has a label 'Edit Bookmark'"
);
await click("#topic-footer-button-bookmark");
assert.ok(
exists("div.modal.bookmark-with-reminder"),
"The edit modal is opened"
);
});
test("The topic level bookmark button shows an icon with a clock if there is a bookmark with a reminder on the first post", async function (assert) {
const postNumber = 1;
await testTopicLevelBookmarkButtonIcon(assert, postNumber);
});
test("The topic level bookmark button shows an icon with a clock if there is a bookmark with a reminder on the second post", async function (assert) {
const postNumber = 2;
await testTopicLevelBookmarkButtonIcon(assert, postNumber);
});
});

View File

@ -1,70 +0,0 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render } from "@ember/test-helpers";
import { exists } from "discourse/tests/helpers/qunit-helpers";
import { hbs } from "ember-cli-htmlbars";
module("Integration | Component | bookmark-alert", function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.setProperties({
model: {},
closeModal: () => {},
afterSave: () => {},
afterDelete: () => {},
registerOnCloseHandler: () => {},
onCloseWithoutSaving: () => {},
});
});
test("alert exists for reminder in the future", async function (assert) {
let name = "test";
let futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 10);
let reminderAt = futureDate.toISOString();
this.model = { id: 1, name, reminderAt };
await render(hbs`
<Bookmark
@model={{this.model}}
@afterSave={{this.afterSave}}
@afterDelete={{this.afterDelete}}
@onCloseWithoutSaving={{this.onCloseWithoutSaving}}
@registerOnCloseHandler={{this.registerOnCloseHandler}}
@closeModal={{this.closeModal}}
/>
`);
assert.ok(
exists(".existing-reminder-at-alert"),
"alert found for future reminder"
);
});
test("alert does not exist for reminder in the past", async function (assert) {
let name = "test";
let pastDate = new Date();
pastDate.setDate(pastDate.getDate() - 1);
let reminderAt = pastDate.toISOString();
this.model = { id: 1, name, reminderAt };
await render(hbs`
<Bookmark
@model={{this.model}}
@afterSave={{this.afterSave}}
@afterDelete={{this.afterDelete}}
@onCloseWithoutSaving={{this.onCloseWithoutSaving}}
@registerOnCloseHandler={{this.registerOnCloseHandler}}
@closeModal={{this.closeModal}}
/>
`);
assert.ok(
!exists(".existing-reminder-at-alert"),
"alert not found for past reminder"
);
});
});

View File

@ -1,49 +0,0 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render } from "@ember/test-helpers";
import { query } from "discourse/tests/helpers/qunit-helpers";
import { hbs } from "ember-cli-htmlbars";
import I18n from "I18n";
module("Integration | Component | bookmark", function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.setProperties({
model: {},
closeModal: () => {},
afterSave: () => {},
afterDelete: () => {},
registerOnCloseHandler: () => {},
onCloseWithoutSaving: () => {},
});
});
test("prefills the custom reminder type date and time", async function (assert) {
let name = "test";
let reminderAt = "2020-05-15T09:45:00";
this.model = { id: 1, autoDeletePreference: 0, name, reminderAt };
await render(hbs`
<Bookmark
@model={{this.model}}
@afterSave={{this.afterSave}}
@afterDelete={{this.afterDelete}}
@onCloseWithoutSaving={{this.onCloseWithoutSaving}}
@registerOnCloseHandler={{this.registerOnCloseHandler}}
@closeModal={{this.closeModal}}
/>
`);
assert.strictEqual(query("#bookmark-name").value, "test");
assert.strictEqual(
query("#custom-date > .date-picker").value,
"2020-05-15"
);
assert.strictEqual(query("#custom-time").value, "09:45");
assert.strictEqual(
query(".selected-name > .name").innerHTML.trim(),
I18n.t("bookmarks.auto_delete_preference.never")
);
});
});

View File

@ -3,16 +3,22 @@
box-sizing: border-box;
min-width: 310px;
}
.modal-footer {
margin: 0;
border-top: 0;
padding: 10px 0;
padding: 0 1em 1em 1em;
.delete-bookmark {
margin-left: auto;
margin-right: 0;
}
}
.modal-inner-container {
max-width: 375px;
}
.modal-body {
width: 375px;
box-sizing: border-box;

View File

@ -123,6 +123,10 @@ class Bookmark < ActiveRecord::Base
update!(reminder_last_sent_at: Time.zone.now, reminder_set_at: nil)
end
def reminder_at_in_zone(timezone)
self.reminder_at.in_time_zone(timezone)
end
scope :with_reminders, -> { where("reminder_at IS NOT NULL") }
scope :pending_reminders,

View File

@ -61,6 +61,10 @@ module SiteIconManager
WATCHED_SETTINGS = ICONS.keys + %i[logo logo_small]
def self.clear_cache!
@cache.clear
end
def self.ensure_optimized!
unless @disabled
ICONS.each do |name, info|

View File

@ -3,7 +3,8 @@ import { bind } from "discourse-common/utils/decorators";
import showModal from "discourse/lib/show-modal";
import ChatMessageFlag from "discourse/plugins/chat/discourse/lib/chat-message-flag";
import Bookmark from "discourse/models/bookmark";
import { openBookmarkModal } from "discourse/controllers/bookmark";
import BookmarkModal from "discourse/components/modal/bookmark";
import { BookmarkFormData } from "discourse/lib/bookmark";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
@ -42,6 +43,7 @@ export default class ChatMessageInteractor {
@service currentUser;
@service site;
@service router;
@service modal;
@service capabilities;
@tracked message = null;
@ -305,11 +307,17 @@ export default class ChatMessageInteractor {
@action
toggleBookmark() {
return openBookmarkModal(
this.message.bookmark ||
Bookmark.createFor(this.currentUser, "Chat::Message", this.message.id),
{
onAfterSave: (savedData) => {
this.modal.show(BookmarkModal, {
model: {
bookmark: new BookmarkFormData(
this.message.bookmark ||
Bookmark.createFor(
this.currentUser,
"Chat::Message",
this.message.id
)
),
afterSave: (savedData) => {
const bookmark = Bookmark.create(savedData);
this.message.bookmark = bookmark;
this.appEvents.trigger(
@ -318,11 +326,11 @@ export default class ChatMessageInteractor {
bookmark.attachedTo()
);
},
onAfterDelete: () => {
afterDelete: () => {
this.message.bookmark = null;
},
}
);
},
});
}
@action

View File

@ -2,19 +2,27 @@
describe "Local dates", type: :system do
fab!(:topic) { Fabricate(:topic) }
fab!(:user) { Fabricate(:user) }
fab!(:current_user) { Fabricate(:user) }
let(:year) { Time.zone.now.year + 1 }
let(:bookmark_modal) { PageObjects::Modals::Bookmark.new }
before { create_post(user: user, topic: topic, title: "Date range test post", raw: <<~RAW) }
First option: [date=2022-12-15 time=14:19:00 timezone="Asia/Singapore"]
Second option: [date=2022-12-15 time=01:20:00 timezone="Asia/Singapore"], or [date=2022-12-15 time=02:40:00 timezone="Asia/Singapore"]
Third option: [date-range from=2022-12-15T11:25:00 to=2022-12-16T00:26:00 timezone="Asia/Singapore"] or [date-range from=2022-12-22T11:57:00 to=2022-12-23T11:58:00 timezone="Asia/Singapore"]
before do
create_post(user: current_user, topic: topic, title: "Date range test post", raw: <<~RAW)
First option: [date=#{year}-12-15 time=14:19:00 timezone="Asia/Singapore"]
Second option: [date=#{year}-12-15 time=01:20:00 timezone="Asia/Singapore"], or [date=#{year}-12-15 time=02:40:00 timezone="Asia/Singapore"]
Third option: [date-range from=#{year}-12-15T11:25:00 to=#{year}-12-16T00:26:00 timezone="Asia/Singapore"] or [date-range from=#{year}-12-22T11:57:00 to=#{year}-12-23T11:58:00 timezone="Asia/Singapore"]
RAW
end
let(:topic_page) { PageObjects::Pages::Topic.new }
def formatted_date_for_year(month, day)
Date.parse("#{year}-#{month}-#{day}").strftime("%A, %B %-d, %Y")
end
it "renders local dates and date ranges correctly" do
using_browser_timezone("Asia/Singapore") do
sign_in user
sign_in current_user
topic_page.visit_topic(topic)
@ -27,19 +35,19 @@ describe "Local dates", type: :system do
post_dates[0].click
tippy_date = topic_page.find(".tippy-content .current .date-time")
expect(tippy_date).to have_text("Thursday, December 15, 2022\n2:19 PM", exact: true)
expect(tippy_date).to have_text("#{formatted_date_for_year(12, 15)}\n2:19 PM", exact: true)
# Two single dates in the same paragraph.
#
post_dates[1].click
tippy_date = topic_page.find(".tippy-content .current .date-time")
expect(tippy_date).to have_text("Thursday, December 15, 2022\n1:20 AM", exact: true)
expect(tippy_date).to have_text("#{formatted_date_for_year(12, 15)}\n1:20 AM", exact: true)
post_dates[2].click
tippy_date = topic_page.find(".tippy-content .current .date-time")
expect(tippy_date).to have_text("Thursday, December 15, 2022\n2:40 AM", exact: true)
expect(tippy_date).to have_text("#{formatted_date_for_year(12, 15)}\n2:40 AM", exact: true)
# Two date ranges in the same paragraph.
#
@ -47,7 +55,7 @@ describe "Local dates", type: :system do
tippy_date = topic_page.find(".tippy-content .current .date-time")
expect(tippy_date).to have_text(
"Thursday, December 15, 2022\n11:25 AM → 12:26 AM",
"#{formatted_date_for_year(12, 15)}\n11:25 AM → 12:26 AM",
exact: true,
)
@ -55,9 +63,38 @@ describe "Local dates", type: :system do
tippy_date = topic_page.find(".tippy-content .current .date-time")
expect(tippy_date).to have_text(
"Thursday, December 22, 2022 11:57 AM → Friday, December 23, 2022 11:58 AM",
"#{formatted_date_for_year(12, 22)} 11:57 AM → #{formatted_date_for_year(12, 23)} 11:58 AM",
exact: true,
)
end
end
describe "bookmarks" do
before do
current_user.user_option.update!(timezone: "Asia/Singapore")
sign_in(current_user)
end
it "can use the post local date for a bookmark preset" do
topic_page.visit_topic(topic)
topic_page.expand_post_actions(topic.first_post)
topic_page.click_post_action_button(topic.first_post, :bookmark)
bookmark_modal.select_preset_reminder(:post_local_date)
expect(topic_page).to have_post_bookmarked(topic.first_post)
bookmark = Bookmark.find_by(bookmarkable: topic.first_post, user: current_user)
expect(bookmark.reminder_at.to_s).to eq("#{year}-12-15 06:19:00 UTC")
end
it "does not allow using post dates in the past for a bookmark preset" do
topic.first_post.update!(
raw: 'First option: [date=1999-12-15 time=14:19:00 timezone="Asia/Singapore"]',
)
topic.first_post.rebake!
topic_page.visit_topic(topic)
topic_page.expand_post_actions(topic.first_post)
topic_page.click_post_action_button(topic.first_post, :bookmark)
expect(bookmark_modal).to be_open
expect(bookmark_modal).to have_no_preset(:post_local_date)
end
end
end

View File

@ -322,6 +322,9 @@ RSpec.configure do |config|
else
DB.exec "SELECT setval('uploads_id_seq', 1)"
end
# Prevents 500 errors for site setting URLs pointing to test.localhost in system specs.
SiteIconManager.clear_cache!
end
class TestLocalProcessProvider < SiteSettings::LocalProcessProvider

View File

@ -2,14 +2,18 @@
describe "Bookmarking posts and topics", type: :system do
fab!(:topic) { Fabricate(:topic) }
fab!(:user) { Fabricate(:user) }
fab!(:current_user) { Fabricate(:user) }
fab!(:post) { Fabricate(:post, topic: topic, raw: "This is some post to bookmark") }
fab!(:post2) { Fabricate(:post, topic: topic, raw: "Some interesting post content") }
fab!(:post_2) { Fabricate(:post, topic: topic, raw: "Some interesting post content") }
let(:timezone) { "Australia/Brisbane" }
let(:topic_page) { PageObjects::Pages::Topic.new }
let(:bookmark_modal) { PageObjects::Modals::Bookmark.new }
before { sign_in user }
before do
current_user.user_option.update!(timezone: timezone)
sign_in(current_user)
end
def visit_topic_and_open_bookmark_modal(post)
topic_page.visit_topic(topic)
@ -24,15 +28,15 @@ describe "Bookmarking posts and topics", type: :system do
bookmark_modal.save
expect(topic_page).to have_post_bookmarked(post)
bookmark = Bookmark.find_by(bookmarkable: post, user: user)
bookmark = Bookmark.find_by(bookmarkable: post, user: current_user)
expect(bookmark.name).to eq("something important")
expect(bookmark.reminder_at).to eq(nil)
visit_topic_and_open_bookmark_modal(post2)
visit_topic_and_open_bookmark_modal(post_2)
bookmark_modal.select_preset_reminder(:tomorrow)
expect(topic_page).to have_post_bookmarked(post2)
bookmark = Bookmark.find_by(bookmarkable: post2, user: user)
expect(topic_page).to have_post_bookmarked(post_2)
bookmark = Bookmark.find_by(bookmarkable: post_2, user: current_user)
expect(bookmark.reminder_at).not_to eq(nil)
expect(bookmark.reminder_set_at).not_to eq(nil)
end
@ -44,7 +48,7 @@ describe "Bookmarking posts and topics", type: :system do
bookmark_modal.cancel
expect(topic_page).to have_no_post_bookmarked(post)
expect(Bookmark.exists?(bookmarkable: post, user: user)).to eq(false)
expect(Bookmark.exists?(bookmarkable: post, user: current_user)).to eq(false)
end
it "creates a bookmark if the modal is closed by clicking outside the modal window" do
@ -56,22 +60,108 @@ describe "Bookmarking posts and topics", type: :system do
expect(topic_page).to have_post_bookmarked(post)
end
it "allows the topic to be bookmarked" do
topic_page.visit_topic(topic)
topic_page.click_topic_footer_button(:bookmark)
bookmark_modal.fill_name("something important")
it "allows choosing a different auto_delete_preference to the user preference and remembers it when reopening the modal" do
current_user.user_option.update!(
bookmark_auto_delete_preference: Bookmark.auto_delete_preferences[:on_owner_reply],
)
visit_topic_and_open_bookmark_modal(post_2)
bookmark_modal.open_options_panel
expect(bookmark_modal).to have_auto_delete_preference(
Bookmark.auto_delete_preferences[:on_owner_reply],
)
bookmark_modal.select_auto_delete_preference(Bookmark.auto_delete_preferences[:clear_reminder])
bookmark_modal.save
expect(topic_page).to have_post_bookmarked(post_2)
topic_page.click_post_action_button(post_2, :bookmark)
expect(bookmark_modal).to have_open_options_panel
expect(bookmark_modal).to have_auto_delete_preference(
Bookmark.auto_delete_preferences[:clear_reminder],
)
end
expect(topic_page).to have_topic_bookmarked
bookmark =
try_until_success { expect(Bookmark.exists?(bookmarkable: topic, user: user)).to eq(true) }
expect(bookmark).not_to eq(nil)
describe "topic level bookmarks" do
it "allows the topic to be bookmarked" do
topic_page.visit_topic(topic)
topic_page.click_topic_footer_button(:bookmark)
bookmark_modal.fill_name("something important")
bookmark_modal.save
expect(topic_page).to have_topic_bookmarked
expect(Bookmark.exists?(bookmarkable: topic, user: current_user)).to be_truthy
end
it "opens the edit bookmark modal from the topic bookmark button if one post is bookmarked" do
bookmark = Fabricate(:bookmark, bookmarkable: post_2, user: current_user)
topic_page.visit_topic(topic)
topic_page.click_topic_footer_button(:bookmark)
expect(bookmark_modal).to be_open
expect(bookmark_modal).to be_editing_id(bookmark.id)
end
it "clears all topic bookmarks from the topic bookmark button if more than one post is bookmarked" do
Fabricate(:bookmark, bookmarkable: post, user: current_user)
Fabricate(:bookmark, bookmarkable: post_2, user: current_user)
topic_page.visit_topic(topic)
topic_page.click_topic_footer_button(:bookmark)
dialog = PageObjects::Components::Dialog.new
expect(dialog).to have_content(I18n.t("js.bookmarks.confirm_clear"))
dialog.click_yes
expect(dialog).to be_closed
expect(Bookmark.where(user: current_user).count).to eq(0)
end
end
describe "editing existing bookmarks" do
fab!(:bookmark) do
Fabricate(
:bookmark,
bookmarkable: post_2,
user: current_user,
name: "test name",
reminder_at: 10.days.from_now,
)
end
it "prefills the name of the bookmark and the custom reminder date and time" do
topic_page.visit_topic(topic)
topic_page.click_post_action_button(post_2, :bookmark)
expect(bookmark_modal).to have_open_options_panel
expect(bookmark_modal.name.value).to eq("test name")
expect(bookmark_modal.existing_reminder_alert).to have_content(
bookmark_modal.existing_reminder_alert_message(bookmark),
)
expect(bookmark_modal.custom_date_picker.value).to eq(
bookmark.reminder_at_in_zone(timezone).strftime("%Y-%m-%d"),
)
expect(bookmark_modal.custom_time_picker.value).to eq(
bookmark.reminder_at_in_zone(timezone).strftime("%H:%M"),
)
expect(bookmark_modal).to have_active_preset("custom")
end
it "can delete the bookmark" do
topic_page.visit_topic(topic)
topic_page.click_post_action_button(post_2, :bookmark)
bookmark_modal.delete
bookmark_modal.confirm_delete
expect(topic_page).to have_no_post_bookmarked(post_2)
end
it "does not save edits when pressing cancel" do
topic_page.visit_topic(topic)
topic_page.click_post_action_button(post_2, :bookmark)
bookmark_modal.fill_name("something important")
bookmark_modal.cancel
topic_page.click_post_action_button(post_2, :bookmark)
expect(bookmark_modal.name.value).to eq("test name")
expect(bookmark.reload.name).to eq("test name")
end
end
context "when the user has a bookmark auto_delete_preference" do
before do
user.user_option.update!(
current_user.user_option.update!(
bookmark_auto_delete_preference: Bookmark.auto_delete_preferences[:on_owner_reply],
)
end
@ -82,7 +172,7 @@ describe "Bookmarking posts and topics", type: :system do
bookmark_modal.save
expect(topic_page).to have_post_bookmarked(post)
bookmark = Bookmark.find_by(bookmarkable: post, user: user)
bookmark = Bookmark.find_by(bookmarkable: post, user: current_user)
expect(bookmark.auto_delete_preference).to eq(
Bookmark.auto_delete_preferences[:on_owner_reply],
)
@ -94,7 +184,7 @@ describe "Bookmarking posts and topics", type: :system do
bookmark_modal.save
expect(topic_page).to have_post_bookmarked(post)
bookmark = Bookmark.find_by(bookmarkable: post, user: user)
bookmark = Bookmark.find_by(bookmarkable: post, user: current_user)
expect(bookmark.auto_delete_preference).to eq(
Bookmark.auto_delete_preferences[:on_owner_reply],
)

View File

@ -3,6 +3,14 @@
module PageObjects
module Components
class Dialog < PageObjects::Components::Base
def closed?
has_no_css?(".dialog-container")
end
def open?
has_css?(".dialog-container")
end
def has_content?(content)
find(".dialog-container").has_content?(content)
end

View File

@ -4,16 +4,90 @@ module PageObjects
module Modals
class Bookmark < PageObjects::Modals::Base
def fill_name(name)
fill_in "bookmark-name", with: name
fill_in("bookmark-name", with: name)
end
def name
find("#bookmark-name")
end
def select_preset_reminder(identifier)
find("#tap_tile_#{identifier}").click
end
def has_active_preset?(identifier)
has_css?("#tap_tile_#{identifier}.tap-tile.active")
end
def has_preset?(identifier)
has_css?("#tap_tile_#{identifier}")
end
def has_no_preset?(identifier)
has_no_css?("#tap_tile_#{identifier}")
end
def editing_id?(bookmark_id)
has_css?(".bookmark-reminder-modal[data-bookmark-id='#{bookmark_id}']")
end
def open_options_panel
find(".bookmark-options-button").click
end
def has_open_options_panel?
has_css?(".bookmark-options-panel")
end
def select_auto_delete_preference(preference)
select_kit = PageObjects::Components::SelectKit.new("#bookmark-auto-delete-preference")
select_kit.expand
select_kit.select_row_by_value(preference)
end
def has_auto_delete_preference?(preference)
select_kit = PageObjects::Components::SelectKit.new("#bookmark-auto-delete-preference")
select_kit.has_selected_value?(preference)
end
def custom_date_picker
find(".tap-tile-date-input #custom-date .date-picker")
end
def custom_time_picker
find(".tap-tile-time-input #custom-time")
end
def save
find("#save-bookmark").click
end
def delete
find("#delete-bookmark").click
end
def confirm_delete
find(".dialog-footer .btn-danger").click
end
def existing_reminder_alert
find(".existing-reminder-at-alert")
end
def existing_reminder_alert_message(bookmark)
I18n.t(
"js.bookmarks.reminders.existing_reminder",
at_date_time:
I18n.t(
"js.bookmarks.reminders.at_time",
date_time:
bookmark
.reminder_at_in_zone(bookmark.user.user_option&.timezone || "UTC")
.strftime("%b %-d, %Y %l:%M %P")
.gsub(" ", " "), # have to do this because %l adds padding before the hour but not in JS
),
)
end
end
end
end