From 645992299368aa63388a4a182f1a26a568329c88 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Mon, 17 Jul 2023 10:14:17 +1000 Subject: [PATCH] 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. --- .../discourse/app/components/bookmark-list.js | 29 +- .../discourse/app/components/bookmark.hbs | 94 ---- .../discourse/app/components/bookmark.js | 412 --------------- .../app/components/modal/bookmark.hbs | 100 ++++ .../app/components/modal/bookmark.js | 351 +++++++++++++ .../discourse/app/controllers/bookmark.js | 80 --- .../discourse/app/controllers/topic.js | 89 ++-- .../javascripts/discourse/app/lib/bookmark.js | 44 ++ .../app/templates/modal/bookmark.hbs | 10 - .../tests/acceptance/bookmarks-test.js | 487 ------------------ .../components/bookmark-alert-test.js | 70 --- .../integration/components/bookmark-test.js | 49 -- .../common/components/bookmark-modal.scss | 8 +- app/models/bookmark.rb | 4 + lib/site_icon_manager.rb | 4 + .../discourse/lib/chat-message-interactor.js | 26 +- .../spec/system/local_dates_spec.rb | 59 ++- spec/rails_helper.rb | 3 + spec/system/bookmarks_spec.rb | 130 ++++- spec/system/page_objects/components/dialog.rb | 8 + spec/system/page_objects/modals/bookmark.rb | 76 ++- 21 files changed, 844 insertions(+), 1289 deletions(-) delete mode 100644 app/assets/javascripts/discourse/app/components/bookmark.hbs delete mode 100644 app/assets/javascripts/discourse/app/components/bookmark.js create mode 100644 app/assets/javascripts/discourse/app/components/modal/bookmark.hbs create mode 100644 app/assets/javascripts/discourse/app/components/modal/bookmark.js delete mode 100644 app/assets/javascripts/discourse/app/controllers/bookmark.js delete mode 100644 app/assets/javascripts/discourse/app/templates/modal/bookmark.hbs delete mode 100644 app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js delete mode 100644 app/assets/javascripts/discourse/tests/integration/components/bookmark-alert-test.js delete mode 100644 app/assets/javascripts/discourse/tests/integration/components/bookmark-test.js diff --git a/app/assets/javascripts/discourse/app/components/bookmark-list.js b/app/assets/javascripts/discourse/app/components/bookmark-list.js index f13a4282aa5..2765b5cc056 100644 --- a/app/assets/javascripts/discourse/app/components/bookmark-list.js +++ b/app/assets/javascripts/discourse/app/components/bookmark-list.js @@ -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(); + }, }, }); }, diff --git a/app/assets/javascripts/discourse/app/components/bookmark.hbs b/app/assets/javascripts/discourse/app/components/bookmark.hbs deleted file mode 100644 index 68f01e42e22..00000000000 --- a/app/assets/javascripts/discourse/app/components/bookmark.hbs +++ /dev/null @@ -1,94 +0,0 @@ - - {{#if this.errorMessage}} -
-
-
{{this.errorMessage}}
-
-
- {{/if}} - -
- - -
- - {{#if this.showOptions}} -
- - -
- {{/if}} - - {{#if this.showExistingReminderAt}} -
- {{d-icon "far-clock"}} - {{i18n - "bookmarks.reminders.existing_reminder" - at_date_time=this.existingReminderAtFormatted - }} -
- {{/if}} - -
- - - {{#if this.userHasTimezoneSet}} - - {{else}} -
{{html-safe - (i18n "bookmarks.no_timezone" basePath=(base-path)) - }}
- {{/if}} -
- - -
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/bookmark.js b/app/assets/javascripts/discourse/app/components/bookmark.js deleted file mode 100644 index 7132f03a5cc..00000000000 --- a/app/assets/javascripts/discourse/app/components/bookmark.js +++ /dev/null @@ -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(); - }, -}); diff --git a/app/assets/javascripts/discourse/app/components/modal/bookmark.hbs b/app/assets/javascripts/discourse/app/components/modal/bookmark.hbs new file mode 100644 index 00000000000..8ec0aa1f2c4 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/bookmark.hbs @@ -0,0 +1,100 @@ + + <:body> +
+ + +
+ + {{#if this.showOptions}} +
+ + +
+ {{/if}} + + {{#if this.showExistingReminderAt}} +
+ {{d-icon "far-clock"}} + {{i18n + "bookmarks.reminders.existing_reminder" + at_date_time=this.existingReminderAtFormatted + }} +
+ {{/if}} + +
+ + + {{#if this.userHasTimezoneSet}} + + {{else}} +
{{html-safe + (i18n "bookmarks.no_timezone" basePath=(base-path)) + }}
+ {{/if}} +
+ + + <:footer> +
+ + + {{#if this.showDelete}} + + {{/if}} +
+ +
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/modal/bookmark.js b/app/assets/javascripts/discourse/app/components/modal/bookmark.js new file mode 100644 index 00000000000..62334a58dbe --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/bookmark.js @@ -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; + } + } +} diff --git a/app/assets/javascripts/discourse/app/controllers/bookmark.js b/app/assets/javascripts/discourse/app/controllers/bookmark.js deleted file mode 100644 index c05fcda47ad..00000000000 --- a/app/assets/javascripts/discourse/app/controllers/bookmark.js +++ /dev/null @@ -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); - } - }, -}); diff --git a/app/assets/javascripts/discourse/app/controllers/topic.js b/app/assets/javascripts/discourse/app/controllers/topic.js index 7b2b6ec9232..a5c62883319 100644 --- a/app/assets/javascripts/discourse/app/controllers/topic.js +++ b/app/assets/javascripts/discourse/app/controllers/topic.js @@ -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) { diff --git a/app/assets/javascripts/discourse/app/lib/bookmark.js b/app/assets/javascripts/discourse/app/lib/bookmark.js index 3ed24cfb111..367ed8f8170 100644 --- a/app/assets/javascripts/discourse/app/lib/bookmark.js +++ b/app/assets/javascripts/discourse/app/lib/bookmark.js @@ -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, + }; + } +} diff --git a/app/assets/javascripts/discourse/app/templates/modal/bookmark.hbs b/app/assets/javascripts/discourse/app/templates/modal/bookmark.hbs deleted file mode 100644 index c73b473949d..00000000000 --- a/app/assets/javascripts/discourse/app/templates/modal/bookmark.hbs +++ /dev/null @@ -1,10 +0,0 @@ - - - \ No newline at end of file diff --git a/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js b/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js deleted file mode 100644 index 7cafec78bd8..00000000000 --- a/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js +++ /dev/null @@ -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 += ` - - - - - January 15, 2036 12:35 AM - -`; - - topicResponse.post_stream.posts[1].cooked += ` - - - - - Today 10:30 AM - -`; - - 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); - }); -}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/bookmark-alert-test.js b/app/assets/javascripts/discourse/tests/integration/components/bookmark-alert-test.js deleted file mode 100644 index f90e5415ab4..00000000000 --- a/app/assets/javascripts/discourse/tests/integration/components/bookmark-alert-test.js +++ /dev/null @@ -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` - - `); - - 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` - - `); - - assert.ok( - !exists(".existing-reminder-at-alert"), - "alert not found for past reminder" - ); - }); -}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/bookmark-test.js b/app/assets/javascripts/discourse/tests/integration/components/bookmark-test.js deleted file mode 100644 index 2d254feba00..00000000000 --- a/app/assets/javascripts/discourse/tests/integration/components/bookmark-test.js +++ /dev/null @@ -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` - - `); - - 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") - ); - }); -}); diff --git a/app/assets/stylesheets/common/components/bookmark-modal.scss b/app/assets/stylesheets/common/components/bookmark-modal.scss index fc350a83889..5e8317f8a73 100644 --- a/app/assets/stylesheets/common/components/bookmark-modal.scss +++ b/app/assets/stylesheets/common/components/bookmark-modal.scss @@ -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; diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb index d8b21670888..239824566ba 100644 --- a/app/models/bookmark.rb +++ b/app/models/bookmark.rb @@ -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, diff --git a/lib/site_icon_manager.rb b/lib/site_icon_manager.rb index 198d1c7faaa..2ecfec40521 100644 --- a/lib/site_icon_manager.rb +++ b/lib/site_icon_manager.rb @@ -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| diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js b/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js index ff65d117948..dbb2cecfc5c 100644 --- a/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js @@ -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 diff --git a/plugins/discourse-local-dates/spec/system/local_dates_spec.rb b/plugins/discourse-local-dates/spec/system/local_dates_spec.rb index a1a6d10ad45..ac8ad5080ad 100644 --- a/plugins/discourse-local-dates/spec/system/local_dates_spec.rb +++ b/plugins/discourse-local-dates/spec/system/local_dates_spec.rb @@ -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 diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 20010b4db94..e90205c8ad8 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -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 diff --git a/spec/system/bookmarks_spec.rb b/spec/system/bookmarks_spec.rb index 1b558ec00bc..a16eb786b1d 100644 --- a/spec/system/bookmarks_spec.rb +++ b/spec/system/bookmarks_spec.rb @@ -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], ) diff --git a/spec/system/page_objects/components/dialog.rb b/spec/system/page_objects/components/dialog.rb index d5f9b7b067b..ae5e7ea3e08 100644 --- a/spec/system/page_objects/components/dialog.rb +++ b/spec/system/page_objects/components/dialog.rb @@ -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 diff --git a/spec/system/page_objects/modals/bookmark.rb b/spec/system/page_objects/modals/bookmark.rb index c9d3ac6c946..1b4f26a7262 100644 --- a/spec/system/page_objects/modals/bookmark.rb +++ b/spec/system/page_objects/modals/bookmark.rb @@ -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