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:
parent
9e8010df8b
commit
6459922993
|
@ -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,8 +57,10 @@ export default Component.extend({
|
|||
|
||||
@action
|
||||
editBookmark(bookmark) {
|
||||
openBookmarkModal(bookmark, {
|
||||
onAfterSave: (savedData) => {
|
||||
this.modal.show(BookmarkModal, {
|
||||
model: {
|
||||
bookmark: new BookmarkFormData(bookmark),
|
||||
afterSave: (savedData) => {
|
||||
this.appEvents.trigger(
|
||||
"bookmarks:changed",
|
||||
savedData,
|
||||
|
@ -64,9 +68,10 @@ export default Component.extend({
|
|||
);
|
||||
this.reload();
|
||||
},
|
||||
onAfterDelete: () => {
|
||||
afterDelete: () => {
|
||||
this.reload();
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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();
|
||||
},
|
||||
});
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
|
@ -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,8 +1278,10 @@ export default Controller.extend(bufferedProperty("model"), {
|
|||
},
|
||||
|
||||
_modifyTopicBookmark(bookmark) {
|
||||
return openBookmarkModal(bookmark, {
|
||||
onAfterSave: (savedData) => {
|
||||
this.modal.show(BookmarkModal, {
|
||||
model: {
|
||||
bookmark: new BookmarkFormData(bookmark),
|
||||
afterSave: (savedData) => {
|
||||
this._syncBookmarks(savedData);
|
||||
this.model.set("bookmarking", false);
|
||||
this.model.set("bookmarked", true);
|
||||
|
@ -1284,30 +1292,45 @@ export default Controller.extend(bufferedProperty("model"), {
|
|||
bookmark.attachedTo()
|
||||
);
|
||||
},
|
||||
onAfterDelete: (topicBookmarked, bookmarkId) => {
|
||||
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.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];
|
||||
},
|
||||
onAfterDelete: (topicBookmarked, bookmarkId) => {
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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")
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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|
|
||||
|
|
|
@ -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.modal.show(BookmarkModal, {
|
||||
model: {
|
||||
bookmark: new BookmarkFormData(
|
||||
this.message.bookmark ||
|
||||
Bookmark.createFor(this.currentUser, "Chat::Message", this.message.id),
|
||||
{
|
||||
onAfterSave: (savedData) => {
|
||||
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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,6 +60,26 @@ describe "Bookmarking posts and topics", type: :system do
|
|||
expect(topic_page).to have_post_bookmarked(post)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
@ -64,14 +88,80 @@ describe "Bookmarking posts and topics", type: :system do
|
|||
bookmark_modal.save
|
||||
|
||||
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)
|
||||
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],
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue