diff --git a/app/assets/javascripts/discourse/app/components/bookmark-list.js b/app/assets/javascripts/discourse/app/components/bookmark-list.js index 82fd27f856e..9518a62b691 100644 --- a/app/assets/javascripts/discourse/app/components/bookmark-list.js +++ b/app/assets/javascripts/discourse/app/components/bookmark-list.js @@ -4,7 +4,7 @@ import { service } from "@ember/service"; import { Promise } from "rsvp"; import BookmarkModal from "discourse/components/modal/bookmark"; import { ajax } from "discourse/lib/ajax"; -import { BookmarkFormData } from "discourse/lib/bookmark"; +import { BookmarkFormData } from "discourse/lib/bookmark-form-data"; import { openLinkInNewTab, shouldOpenInNewTab, diff --git a/app/assets/javascripts/discourse/app/components/bookmark-menu.gjs b/app/assets/javascripts/discourse/app/components/bookmark-menu.gjs new file mode 100644 index 00000000000..6dcc129aa8d --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/bookmark-menu.gjs @@ -0,0 +1,250 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { array, fn } from "@ember/helper"; +import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import { inject as service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import BookmarkModal from "discourse/components/modal/bookmark"; +import concatClass from "discourse/helpers/concat-class"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { + TIME_SHORTCUT_TYPES, + timeShortcuts, +} from "discourse/lib/time-shortcut"; +import icon from "discourse-common/helpers/d-icon"; +import i18n from "discourse-common/helpers/i18n"; +import I18n from "discourse-i18n"; +import DMenu from "float-kit/components/d-menu"; + +export default class BookmarkMenu extends Component { + @service modal; + @service currentUser; + @service toasts; + @tracked quicksaved = false; + + bookmarkManager = this.args.bookmarkManager; + timezone = this.currentUser?.user_option?.timezone || moment.tz.guess(); + timeShortcuts = timeShortcuts(this.timezone); + + @action + setReminderShortcuts() { + this.reminderAtOptions = [ + this.timeShortcuts.twoHours(), + this.timeShortcuts.tomorrow(), + this.timeShortcuts.threeDays(), + ]; + + // So the label is a simple 'Custom...' + const custom = this.timeShortcuts.custom(); + custom.label = "time_shortcut.custom_short"; + this.reminderAtOptions.push(custom); + } + + get existingBookmark() { + return this.bookmarkManager.trackedBookmark.id + ? this.bookmarkManager.trackedBookmark + : null; + } + + get showEditDeleteMenu() { + return this.existingBookmark && !this.quicksaved; + } + + get buttonTitle() { + if (!this.existingBookmark) { + return I18n.t("bookmarks.not_bookmarked"); + } else { + if (this.existingBookmark.reminderAt) { + return I18n.t("bookmarks.created_with_reminder", { + date: this.existingBookmark.formattedReminder(this.timezone), + name: this.existingBookmark.name || "", + }); + } else { + return I18n.t("bookmarks.created", { + name: this.existingBookmark.name || "", + }); + } + } + } + + @action + reminderShortcutTimeTitle(option) { + if (!option.time) { + return ""; + } + return option.time.format(I18n.t(option.timeFormatKey)); + } + + @action + async onBookmark() { + try { + await this.bookmarkManager.create(); + // We show the menu with Edit/Delete options if the bokmark exists, + // so this "quicksave" will do nothing in that case. + // NOTE: Need a nicer way to handle this; otherwise as soon as you save + // a bookmark, it switches to the other Edit/Delete menu. + this.quicksaved = true; + this.toasts.success({ + duration: 3000, + data: { message: I18n.t("bookmarks.bookmarked_success") }, + }); + } catch (error) { + popupAjaxError(error); + } + } + + @action + onShowMenu() { + if (!this.existingBookmark) { + this.onBookmark(); + } + } + + @action + onRegisterApi(api) { + this.dMenu = api; + } + + @action + onEditBookmark() { + this._openBookmarkModal(); + } + + @action + onCloseMenu() { + this.quicksaved = false; + } + + @action + async onRemoveBookmark() { + try { + const response = await this.bookmarkManager.delete(); + this.bookmarkManager.afterDelete(response, this.existingBookmark.id); + this.toasts.success({ + duration: 3000, + data: { message: I18n.t("bookmarks.deleted_bookmark_success") }, + }); + } catch (error) { + popupAjaxError(error); + } finally { + this.dMenu.close(); + } + } + + @action + async onChooseReminderOption(option) { + if (option.id === TIME_SHORTCUT_TYPES.CUSTOM) { + this._openBookmarkModal(); + } else { + this.existingBookmark.selectedReminderType = option.id; + this.existingBookmark.selectedDatetime = option.time; + this.existingBookmark.reminderAt = option.time; + + try { + await this.bookmarkManager.save(); + this.toasts.success({ + duration: 3000, + data: { message: I18n.t("bookmarks.reminder_set_success") }, + }); + } catch (error) { + popupAjaxError(error); + } finally { + this.dMenu.close(); + } + } + } + + async _openBookmarkModal() { + try { + const closeData = await this.modal.show(BookmarkModal, { + model: { + bookmark: this.existingBookmark, + afterSave: (savedData) => { + return this.bookmarkManager.afterSave(savedData); + }, + afterDelete: (response, bookmarkId) => { + this.bookmarkManager.afterDelete(response, bookmarkId); + }, + }, + }); + this.bookmarkManager.afterModalClose(closeData); + } catch (error) { + popupAjaxError(error); + } + } + + + + <:trigger> + {{#if this.existingBookmark.reminderAt}} + {{icon "discourse-bookmark-clock"}} + {{else}} + {{icon "bookmark"}} + {{/if}} + + <:content> + + {{#if this.showEditDeleteMenu}} + + + + + + + + + {{else}} + {{i18n + "bookmarks.also_set_reminder" + }} + + {{#each this.reminderAtOptions as |option|}} + + + + {{/each}} + + {{/if}} + + + + +} diff --git a/app/assets/javascripts/discourse/app/components/modal/bookmark.hbs b/app/assets/javascripts/discourse/app/components/modal/bookmark.hbs index f0622164fb5..f0b499eefdc 100644 --- a/app/assets/javascripts/discourse/app/components/modal/bookmark.hbs +++ b/app/assets/javascripts/discourse/app/components/modal/bookmark.hbs @@ -4,7 +4,7 @@ @flash={{this.flash}} @flashType="error" id="bookmark-reminder-modal" - class="bookmark-reminder-modal bookmark-with-reminder" + class="bookmark-reminder-modal" data-bookmark-id={{this.bookmark.id}} {{did-insert this.didInsert}} > diff --git a/app/assets/javascripts/discourse/app/components/modal/bookmark.js b/app/assets/javascripts/discourse/app/components/modal/bookmark.js index acb4fe301b0..f0aba9ee0c4 100644 --- a/app/assets/javascripts/discourse/app/components/modal/bookmark.js +++ b/app/assets/javascripts/discourse/app/components/modal/bookmark.js @@ -6,7 +6,6 @@ import { service } from "@ember/service"; import ItsATrap from "@discourse/itsatrap"; import { Promise } from "rsvp"; import { CLOSE_INITIATED_BY_CLICK_OUTSIDE } from "discourse/components/d-modal"; -import { ajax } from "discourse/lib/ajax"; import { extractError } from "discourse/lib/ajax-error"; import { formattedReminderTime } from "discourse/lib/bookmark"; import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts"; @@ -29,6 +28,7 @@ export default class BookmarkModal extends Component { @service dialog; @service currentUser; @service site; + @service bookmarkApi; @tracked postDetectedLocalDate = null; @tracked postDetectedLocalTime = null; @@ -264,31 +264,19 @@ export default class BookmarkModal extends Component { } if (this.editingExistingBookmark) { - return ajax(`/bookmarks/${this.bookmark.id}`, { - type: "PUT", - data: this.bookmark.saveData, - }).then(() => { - this.args.model.afterSave?.(this.bookmark.saveData); + return this.bookmarkApi.update(this.bookmark).then(() => { + this.args.model.afterSave?.(this.bookmark); }); } else { - return ajax("/bookmarks", { - type: "POST", - data: this.bookmark.saveData, - }).then((response) => { - this.bookmark.id = response.id; - this.args.model.afterSave?.(this.bookmark.saveData); + return this.bookmarkApi.create(this.bookmark).then(() => { + this.args.model.afterSave?.(this.bookmark); }); } } #deleteBookmark() { - return ajax("/bookmarks/" + this.bookmark.id, { - type: "DELETE", - }).then((response) => { - this.args.model.afterDelete?.( - response.topic_bookmarked, - this.bookmark.id - ); + return this.bookmarkApi.delete(this.bookmark.id).then((response) => { + this.args.model.afterDelete?.(response, this.bookmark.id); }); } diff --git a/app/assets/javascripts/discourse/app/controllers/topic.js b/app/assets/javascripts/discourse/app/controllers/topic.js index f2f6bc08349..db720e7164b 100644 --- a/app/assets/javascripts/discourse/app/controllers/topic.js +++ b/app/assets/javascripts/discourse/app/controllers/topic.js @@ -17,7 +17,7 @@ import JumpToPost from "discourse/components/modal/jump-to-post"; import { spinnerHTML } from "discourse/helpers/loading-spinner"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; -import { BookmarkFormData } from "discourse/lib/bookmark"; +import { BookmarkFormData } from "discourse/lib/bookmark-form-data"; import { resetCachedTopicList } from "discourse/lib/cached-topic-list"; import { buildQuote } from "discourse/lib/quote"; import QuoteState from "discourse/lib/quote-state"; @@ -1281,14 +1281,14 @@ export default Controller.extend(bufferedProperty("model"), { this.modal.show(BookmarkModal, { model: { bookmark: new BookmarkFormData(bookmark), - afterSave: (savedData) => { - this._syncBookmarks(savedData); + afterSave: (bookmarkFormData) => { + this._syncBookmarks(bookmarkFormData.saveData); this.model.set("bookmarking", false); this.model.set("bookmarked", true); this.model.incrementProperty("bookmarksWereChanged"); this.appEvents.trigger( "bookmarks:changed", - savedData, + bookmarkFormData.saveData, bookmark.attachedTo() ); }, diff --git a/app/assets/javascripts/discourse/app/instance-initializers/bookmark-menu.js b/app/assets/javascripts/discourse/app/instance-initializers/bookmark-menu.js new file mode 100644 index 00000000000..30d4b28c9cf --- /dev/null +++ b/app/assets/javascripts/discourse/app/instance-initializers/bookmark-menu.js @@ -0,0 +1,28 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import PostBookmarkManager from "discourse/lib/post-bookmark-manager"; + +export default { + name: "discourse-bookmark-menu", + + initialize(container) { + const currentUser = container.lookup("service:current-user"); + + withPluginApi("0.10.1", (api) => { + if (currentUser) { + api.replacePostMenuButton("bookmark", { + name: "bookmark-menu-shim", + shouldRender: () => true, + buildAttrs: (widget) => { + return { + post: widget.findAncestorModel(), + bookmarkManager: new PostBookmarkManager( + container, + widget.findAncestorModel() + ), + }; + }, + }); + } + }); + }, +}; diff --git a/app/assets/javascripts/discourse/app/lib/bookmark-form-data.js b/app/assets/javascripts/discourse/app/lib/bookmark-form-data.js new file mode 100644 index 00000000000..0a5d61b82b4 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/bookmark-form-data.js @@ -0,0 +1,56 @@ +import { tracked } from "@glimmer/tracking"; +import { formattedReminderTime } from "discourse/lib/bookmark"; +import { TIME_SHORTCUT_TYPES } from "discourse/lib/time-shortcut"; +import { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark"; + +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 === TIME_SHORTCUT_TYPES.NONE) { + return null; + } + + if (!this.selectedReminderType || !this.selectedDatetime) { + if (this.reminderAt) { + return this.reminderAt.toISOString(); + } else { + return null; + } + } + + 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, + }; + } + + formattedReminder(timezone) { + return formattedReminderTime(this.reminderAt, timezone); + } +} diff --git a/app/assets/javascripts/discourse/app/lib/bookmark.js b/app/assets/javascripts/discourse/app/lib/bookmark.js index 3cb96c43149..c8f3d7283f4 100644 --- a/app/assets/javascripts/discourse/app/lib/bookmark.js +++ b/app/assets/javascripts/discourse/app/lib/bookmark.js @@ -1,6 +1,3 @@ -import { tracked } from "@glimmer/tracking"; -import { TIME_SHORTCUT_TYPES } from "discourse/lib/time-shortcut"; -import { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark"; import I18n from "discourse-i18n"; export function formattedReminderTime(reminderAt, timezone) { @@ -20,43 +17,3 @@ 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/lib/post-bookmark-manager.js b/app/assets/javascripts/discourse/app/lib/post-bookmark-manager.js new file mode 100644 index 00000000000..3491a964f66 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/post-bookmark-manager.js @@ -0,0 +1,102 @@ +import { tracked } from "@glimmer/tracking"; +import { setOwner } from "@ember/application"; +import { inject as controller } from "@ember/controller"; +import { inject as service } from "@ember/service"; +import { + CLOSE_INITIATED_BY_BUTTON, + CLOSE_INITIATED_BY_ESC, +} from "discourse/components/d-modal"; +import { BookmarkFormData } from "discourse/lib/bookmark-form-data"; +import Bookmark from "discourse/models/bookmark"; + +export default class PostBookmarkManager { + @service currentUser; + @service bookmarkApi; + @controller("topic") topicController; + @tracked trackedBookmark; + @tracked bookmarkModel; + + constructor(owner, post) { + setOwner(this, owner); + + this.model = post; + this.type = "Post"; + + this.bookmarkModel = + this.topicController.model?.bookmarks.find( + (bookmark) => + bookmark.bookmarkable_id === this.model.id && + bookmark.bookmarkable_type === this.type + ) || this.bookmarkApi.buildNewBookmark(this.type, this.model.id); + this.trackedBookmark = new BookmarkFormData(this.bookmarkModel); + } + + create() { + return this.bookmarkApi + .create(this.trackedBookmark) + .then((updatedBookmark) => { + this.trackedBookmark = updatedBookmark; + }); + } + + delete() { + return this.bookmarkApi.delete(this.trackedBookmark.id); + } + + save() { + return this.bookmarkApi.update(this.trackedBookmark); + } + + afterModalClose(closeData) { + if (!closeData) { + return; + } + + if ( + closeData.closeWithoutSaving || + closeData.initiatedBy === CLOSE_INITIATED_BY_ESC || + closeData.initiatedBy === CLOSE_INITIATED_BY_BUTTON + ) { + this.model.appEvents.trigger("post-stream:refresh", { + id: this.model.id, + }); + } + } + + afterSave(bookmarkFormData) { + this.trackedBookmark = bookmarkFormData; + this._syncBookmarks(bookmarkFormData.saveData); + this.topicController.model.set("bookmarking", false); + this.model.createBookmark(bookmarkFormData.saveData); + this.topicController.model.afterPostBookmarked( + this.model, + bookmarkFormData.saveData + ); + return [this.model.id]; + } + + afterDelete(deleteResponse, bookmarkId) { + this.topicController.model.removeBookmark(bookmarkId); + this.model.deleteBookmark(deleteResponse.topic_bookmarked); + this.bookmarkModel = this.bookmarkApi.buildNewBookmark( + this.type, + this.model.id + ); + this.trackedBookmark = new BookmarkFormData(this.bookmarkModel); + } + + _syncBookmarks(data) { + if (!this.topicController.bookmarks) { + this.topicController.set("bookmarks", []); + } + + const bookmark = this.topicController.bookmarks.findBy("id", data.id); + if (!bookmark) { + this.topicController.bookmarks.pushObject(Bookmark.create(data)); + } else { + bookmark.reminder_at = data.reminder_at; + bookmark.name = data.name; + bookmark.auto_delete_preference = data.auto_delete_preference; + } + } +} diff --git a/app/assets/javascripts/discourse/app/lib/time-shortcut.js b/app/assets/javascripts/discourse/app/lib/time-shortcut.js index 16c9ee6cb85..6922e47a5dd 100644 --- a/app/assets/javascripts/discourse/app/lib/time-shortcut.js +++ b/app/assets/javascripts/discourse/app/lib/time-shortcut.js @@ -1,5 +1,6 @@ import { fourMonths, + inNDays, LATER_TODAY_CUTOFF_HOUR, laterThisWeek, laterToday, @@ -126,6 +127,15 @@ export function timeShortcuts(timezone) { timeFormatKey: "dates.time_short_day", }; }, + threeDays() { + return { + id: "three_days", + icon: "angle-right", + label: "time_shortcut.three_days", + time: inNDays(timezone, 3), + timeFormatKey: "dates.time_short_day", + }; + }, laterThisWeek() { return { id: TIME_SHORTCUT_TYPES.LATER_THIS_WEEK, diff --git a/app/assets/javascripts/discourse/app/lib/time-utils.js b/app/assets/javascripts/discourse/app/lib/time-utils.js index 135e386d277..0836253b818 100644 --- a/app/assets/javascripts/discourse/app/lib/time-utils.js +++ b/app/assets/javascripts/discourse/app/lib/time-utils.js @@ -49,6 +49,10 @@ export function twoDays(timezone) { return startOfDay(now(timezone).add(2, "days")); } +export function inNDays(timezone, num) { + return startOfDay(now(timezone).add(num, "days")); +} + export function laterThisWeek(timezone) { return twoDays(timezone); } diff --git a/app/assets/javascripts/discourse/app/services/bookmark-api.js b/app/assets/javascripts/discourse/app/services/bookmark-api.js new file mode 100644 index 00000000000..afbfb2f4f61 --- /dev/null +++ b/app/assets/javascripts/discourse/app/services/bookmark-api.js @@ -0,0 +1,41 @@ +import Service, { inject as service } from "@ember/service"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import Bookmark from "discourse/models/bookmark"; + +export default class BookmarkApi extends Service { + @service currentUser; + + buildNewBookmark(bookmarkableType, bookmarkableId) { + return Bookmark.createFor( + this.currentUser, + bookmarkableType, + bookmarkableId + ); + } + + create(bookmarkFormData) { + return ajax("/bookmarks.json", { + method: "POST", + data: bookmarkFormData.saveData, + }) + .then((response) => { + bookmarkFormData.id = response.id; + return bookmarkFormData; + }) + .catch(popupAjaxError); + } + + delete(bookmarkId) { + return ajax(`/bookmarks/${bookmarkId}.json`, { + method: "DELETE", + }).catch(popupAjaxError); + } + + update(bookmarkFormData) { + return ajax(`/bookmarks/${bookmarkFormData.id}.json`, { + method: "PUT", + data: bookmarkFormData.saveData, + }).catch(popupAjaxError); + } +} diff --git a/app/assets/javascripts/discourse/app/templates/topic.hbs b/app/assets/javascripts/discourse/app/templates/topic.hbs index 196dd042787..c4e16168f34 100644 --- a/app/assets/javascripts/discourse/app/templates/topic.hbs +++ b/app/assets/javascripts/discourse/app/templates/topic.hbs @@ -155,7 +155,7 @@ href {{on "click" this.editTopic}} class="edit-topic" - title={{i18n "edit"}} + title={{i18n "edit_topic"}} >{{d-icon "pencil-alt"}} {{/if}} diff --git a/app/assets/javascripts/discourse/app/widgets/bookmark-menu.js b/app/assets/javascripts/discourse/app/widgets/bookmark-menu.js new file mode 100644 index 00000000000..edff13f595a --- /dev/null +++ b/app/assets/javascripts/discourse/app/widgets/bookmark-menu.js @@ -0,0 +1,8 @@ +import { hbs } from "ember-cli-htmlbars"; +import { registerWidgetShim } from "discourse/widgets/render-glimmer"; + +registerWidgetShim( + "bookmark-menu-shim", + "div.bookmark-menu-shim", + hbs`` +); diff --git a/app/assets/javascripts/discourse/app/widgets/post-menu.js b/app/assets/javascripts/discourse/app/widgets/post-menu.js index 031029b0960..352d74c9864 100644 --- a/app/assets/javascripts/discourse/app/widgets/post-menu.js +++ b/app/assets/javascripts/discourse/app/widgets/post-menu.js @@ -387,7 +387,7 @@ registerButton( return; } - let classNames = ["bookmark", "with-reminder"]; + let classNames = ["bookmark"]; let title = "bookmarks.not_bookmarked"; let titleOptions = { name: "" }; @@ -395,6 +395,8 @@ registerButton( classNames.push("bookmarked"); if (attrs.bookmarkReminderAt) { + classNames.push("with-reminder"); + let formattedReminder = formattedReminderTime( attrs.bookmarkReminderAt, currentUser.user_option.timezone diff --git a/app/assets/javascripts/discourse/tests/integration/components/float-kit/d-menu-test.js b/app/assets/javascripts/discourse/tests/integration/components/float-kit/d-menu-test.js index 77ae36b49c6..41622a22a0c 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/float-kit/d-menu-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/float-kit/d-menu-test.js @@ -9,6 +9,7 @@ import { hbs } from "ember-cli-htmlbars"; import { module, test } from "qunit"; import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import DDefaultToast from "float-kit/components/d-default-toast"; +import DMenuInstance from "float-kit/lib/d-menu-instance"; module("Integration | Component | FloatKit | d-menu", function (hooks) { setupRenderingTest(hooks); @@ -17,6 +18,10 @@ module("Integration | Component | FloatKit | d-menu", function (hooks) { await triggerEvent(".fk-d-menu__trigger", "click"); } + async function close() { + await triggerEvent(".fk-d-menu__trigger.-expanded", "click"); + } + test("@label", async function (assert) { await render(hbs``); @@ -38,6 +43,38 @@ module("Integration | Component | FloatKit | d-menu", function (hooks) { assert.dom(".fk-d-menu").hasText("content"); }); + test("@onRegisterApi", async function (assert) { + this.api = null; + this.onRegisterApi = (api) => (this.api = api); + + await render( + hbs`` + ); + + assert.ok(this.api instanceof DMenuInstance); + }); + + test("@onShow", async function (assert) { + this.test = false; + this.onShow = () => (this.test = true); + + await render(hbs``); + await open(); + + assert.strictEqual(this.test, true); + }); + + test("@onClose", async function (assert) { + this.test = false; + this.onClose = () => (this.test = true); + + await render(hbs``); + await open(); + await close(); + + assert.strictEqual(this.test, true); + }); + test("-expanded class", async function (assert) { await render(hbs``); diff --git a/app/assets/javascripts/discourse/tests/integration/components/float-kit/d-tooltip-test.js b/app/assets/javascripts/discourse/tests/integration/components/float-kit/d-tooltip-test.js index b7ff3ebe2b2..a2aeac55722 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/float-kit/d-tooltip-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/float-kit/d-tooltip-test.js @@ -9,6 +9,7 @@ import { hbs } from "ember-cli-htmlbars"; import { module, test } from "qunit"; import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import DDefaultToast from "float-kit/components/d-default-toast"; +import DTooltipInstance from "float-kit/lib/d-tooltip-instance"; module("Integration | Component | FloatKit | d-tooltip", function (hooks) { setupRenderingTest(hooks); @@ -17,6 +18,10 @@ module("Integration | Component | FloatKit | d-tooltip", function (hooks) { await triggerEvent(".fk-d-tooltip__trigger", "mousemove"); } + async function close() { + await triggerKeyEvent(document.activeElement, "keydown", "Escape"); + } + test("@label", async function (assert) { await render(hbs``); @@ -38,8 +43,41 @@ module("Integration | Component | FloatKit | d-tooltip", function (hooks) { assert.dom(".fk-d-tooltip").hasText("content"); }); + test("@onRegisterApi", async function (assert) { + this.api = null; + this.onRegisterApi = (api) => (this.api = api); + + await render( + hbs`` + ); + + assert.ok(this.api instanceof DTooltipInstance); + }); + + test("@onShow", async function (assert) { + this.test = false; + this.onShow = () => (this.test = true); + + await render(hbs``); + + await hover(); + + assert.strictEqual(this.test, true); + }); + + test("@onClose", async function (assert) { + this.test = false; + this.onClose = () => (this.test = true); + + await render(hbs``); + await hover(); + await close(); + + assert.strictEqual(this.test, true); + }); + test("-expanded class", async function (assert) { - await render(hbs``); + await render(hbs``); assert.dom(".fk-d-tooltip__trigger").doesNotHaveClass("-expanded"); @@ -140,7 +178,7 @@ module("Integration | Component | FloatKit | d-tooltip", function (hooks) { hbs`` ); await hover(); - await triggerKeyEvent(document.activeElement, "keydown", "Escape"); + await close(); assert.dom(".fk-d-tooltip").doesNotExist(); @@ -148,7 +186,7 @@ module("Integration | Component | FloatKit | d-tooltip", function (hooks) { hbs`` ); await hover(); - await triggerKeyEvent(document.activeElement, "keydown", "Escape"); + await close(); assert.dom(".fk-d-tooltip").exists(); }); diff --git a/app/assets/javascripts/float-kit/addon/components/d-menu.gjs b/app/assets/javascripts/float-kit/addon/components/d-menu.gjs index 9eb86d18804..d085aa458fe 100644 --- a/app/assets/javascripts/float-kit/addon/components/d-menu.gjs +++ b/app/assets/javascripts/float-kit/addon/components/d-menu.gjs @@ -1,11 +1,14 @@ import Component from "@glimmer/component"; import { tracked } from "@glimmer/tracking"; import { getOwner } from "@ember/application"; -import { service } from "@ember/service"; +import { concat } from "@ember/helper"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; import { modifier } from "ember-modifier"; import DButton from "discourse/components/d-button"; import concatClass from "discourse/helpers/concat-class"; import DFloatBody from "float-kit/components/d-float-body"; +import { MENU } from "float-kit/lib/constants"; import DMenuInstance from "float-kit/lib/d-menu-instance"; export default class DMenu extends Component { @@ -13,9 +16,9 @@ export default class DMenu extends Component { @tracked menuInstance = null; - registerTrigger = modifier((element) => { + registerTrigger = modifier((element, [properties]) => { const options = { - ...this.args, + ...properties, ...{ autoUpdate: true, listeners: true, @@ -28,6 +31,8 @@ export default class DMenu extends Component { this.menuInstance = instance; + this.options.onRegisterApi?.(this.menuInstance); + return () => { instance.destroy(); @@ -52,11 +57,22 @@ export default class DMenu extends Component { }; } + @action + allowedProperties() { + const properties = {}; + Object.keys(MENU.options).forEach((key) => { + const value = MENU.options[key]; + properties[key] = this.args[key] ?? value; + }); + return properties; + } + {{#if (has-block "trigger")}} @@ -79,7 +95,10 @@ export default class DMenu extends Component { { + registerTrigger = modifier((element, [properties]) => { const options = { - ...this.args, + ...properties, ...{ listeners: true, beforeTrigger: (instance) => { @@ -30,6 +32,8 @@ export default class DTooltip extends Component { this.tooltipInstance = instance; + this.options.onRegisterApi?.(instance); + return () => { instance.destroy(); @@ -50,6 +54,16 @@ export default class DTooltip extends Component { }; } + @action + allowedProperties() { + const properties = {}; + Object.keys(TOOLTIP.options).forEach((key) => { + const value = TOOLTIP.options[key]; + properties[key] = this.args[key] ?? value; + }); + return properties; + } + diff --git a/app/assets/javascripts/float-kit/addon/lib/constants.js b/app/assets/javascripts/float-kit/addon/lib/constants.js index bf7ed2f3de6..54d2de1d701 100644 --- a/app/assets/javascripts/float-kit/addon/lib/constants.js +++ b/app/assets/javascripts/float-kit/addon/lib/constants.js @@ -35,6 +35,9 @@ export const TOOLTIP = { fallbackPlacements: FLOAT_UI_PLACEMENTS, autoUpdate: true, trapTab: true, + onClose: null, + onShow: null, + onRegisterApi: null, }, portalOutletId: "d-tooltip-portal-outlet", }; @@ -62,6 +65,9 @@ export const MENU = { autoUpdate: true, trapTab: true, extraClassName: null, + onClose: null, + onShow: null, + onRegisterApi: null, }, portalOutletId: "d-menu-portal-outlet", }; diff --git a/app/assets/javascripts/float-kit/addon/lib/float-kit-instance.js b/app/assets/javascripts/float-kit/addon/lib/float-kit-instance.js index 4a4c0132e38..bebcbbeb174 100644 --- a/app/assets/javascripts/float-kit/addon/lib/float-kit-instance.js +++ b/app/assets/javascripts/float-kit/addon/lib/float-kit-instance.js @@ -1,6 +1,6 @@ import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; -import { cancel } from "@ember/runloop"; +import { cancel, next } from "@ember/runloop"; import { makeArray } from "discourse-common/lib/helpers"; import discourseLater from "discourse-common/lib/later"; import { bind } from "discourse-common/utils/decorators"; @@ -22,11 +22,19 @@ export default class FloatKitInstance { @action show() { this.expanded = true; + + next(() => { + this.options.onShow?.(); + }); } @action close() { this.expanded = false; + + next(() => { + this.options.onClose?.(); + }); } @action diff --git a/app/assets/stylesheets/common/components/_index.scss b/app/assets/stylesheets/common/components/_index.scss index b151bc5cfbd..0eb66bf6beb 100644 --- a/app/assets/stylesheets/common/components/_index.scss +++ b/app/assets/stylesheets/common/components/_index.scss @@ -2,6 +2,7 @@ @import "banner"; @import "bookmark-list"; @import "bookmark-modal"; +@import "bookmark-menu"; @import "buttons"; @import "color-input"; @import "char-counter"; diff --git a/app/assets/stylesheets/common/components/bookmark-menu.scss b/app/assets/stylesheets/common/components/bookmark-menu.scss new file mode 100644 index 00000000000..63445a14056 --- /dev/null +++ b/app/assets/stylesheets/common/components/bookmark-menu.scss @@ -0,0 +1,91 @@ +.bookmark-menu-content { + .bookmark-menu__body { + background: var(--secondary); + list-style: none; + display: flex; + flex-direction: column; + color: var(--primary); + + .bookmark-menu__actions { + margin: 0; + padding: 0; + list-style: none; + } + } + + .bookmark-menu { + &__text { + display: flex; + align-items: left; + } + + &__row { + border-bottom: 1px solid var(--primary-low); + width: 100%; + display: flex; + align-items: left; + + &:hover, + &:focus { + background: var(--tertiary-very-low); + } + + &-title { + font-size: var(--font-down-1); + padding: 0.75rem; + border-bottom: 1px solid var(--primary-low); + } + + &-btn { + margin: 0; + padding: 0.75rem; + width: 100%; + text-align: left; + justify-content: left; + + .d-icon { + color: var(--primary); + } + + .d-button-label { + color: var(--primary); + font-size: var(--font-down-1); + } + &:hover, + &:focus { + background: var(--tertiary-very-low); + } + } + + &.-edit { + .d-icon { + margin-right: 5px; + } + } + + &.-remove { + .d-icon { + color: var(--danger); + } + + &:hover, + &:focus { + background: var(--danger-low); + } + } + + &:last-child { + border-bottom: none; + } + + &.-no-reminder { + border-bottom: 2px solid var(--primary-low); + } + } + } + + .bookmark-menu__row-title { + font-weight: 900; + padding: 0.75rem; + } +} diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb index 6060be35937..b2b7cbce532 100644 --- a/app/serializers/current_user_serializer.rb +++ b/app/serializers/current_user_serializer.rb @@ -73,7 +73,6 @@ class CurrentUserSerializer < BasicUserSerializer :sidebar_sections, :new_new_view_enabled?, :use_experimental_topic_bulk_actions?, - :use_experimental_topic_bulk_actions?, :use_admin_sidebar, :glimmer_header_enabled?, :can_view_raw_email diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 08be962fbed..1d7d1b6437c 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -259,7 +259,8 @@ en: us_west_2: "US West (Oregon)" clear_input: "Clear input" - edit: "edit the title and category of this topic" + edit: "Edit" + edit_topic: "edit the title and category of this topic" expand: "Expand" not_implemented: "That feature hasn't been implemented yet, sorry!" no_value: "No" @@ -353,6 +354,10 @@ en: unbookmark_with_reminder: "Click to remove all bookmarks and reminders in this topic" bookmarks: + also_set_reminder: "Also set a reminder?" + bookmarked_success: "Bookmarked!" + deleted_bookmark_success: "Bookmark deleted!" + reminder_set_success: "Reminder has beeen set!" created: "You've bookmarked this post. %{name}" created_generic: "You've bookmarked this. %{name}" create: "Create bookmark" @@ -375,8 +380,11 @@ en: when_reminder_sent: "Delete bookmark" on_owner_reply: "Delete bookmark, once I reply" clear_reminder: "Keep bookmark and clear reminder" + after_reminder_label: "After reminding you we should..." + after_reminder_checkbox: "Set this as default for all future bookmark reminders" search_placeholder: "Search bookmarks by name, topic title, or post content" search: "Search" + bookmark: "Bookmark" reminders: today_with_time: "today at %{time}" tomorrow_with_time: "tomorrow at %{time}" @@ -700,6 +708,7 @@ en: in_two_hours: "In two hours" later_today: "Later today" two_days: "Two days" + three_days: "In three days" next_business_day: "Next business day" tomorrow: "Tomorrow" post_local_date: "Date in post" @@ -721,6 +730,7 @@ en: never: "Never" last_custom: "Last custom datetime" custom: "Custom date and time" + custom_short: "Custom..." select_timeframe: "Select a timeframe" user_action: diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 683909ff1dd..f97b7749210 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -2617,8 +2617,8 @@ en: enable_custom_sidebar_sections: "EXPERIMENTAL: Enable custom sidebar sections" experimental_topics_filter: "EXPERIMENTAL: Enables the experimental topics filter route at /filter" enable_experimental_lightbox: "EXPERIMENTAL: Replace the default image lightbox with the revamped design." + enable_experimental_bookmark_redesign_groups: "EXPERIMENTAL: Show a quick access menu for bookmarks on posts and a new redesigned modal" experimental_glimmer_header_groups: "EXPERIMENTAL: Render the site header as glimmer components." - experimental_form_templates: "EXPERIMENTAL: Enable the form templates feature. After enabled, manage the templates at Customize / Templates." admin_sidebar_enabled_groups: "EXPERIMENTAL: Enable sidebar navigation for the admin UI for the specified groups, which replaces the top-level admin navigation buttons." lazy_load_categories_groups: "EXPERIMENTAL: Lazy load category information only for users of these groups. This improves performance on sites with many categories." 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 2836fb0b146..b9a73e5e233 100644 --- a/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js @@ -5,7 +5,7 @@ import { service } from "@ember/service"; import BookmarkModal from "discourse/components/modal/bookmark"; import FlagModal from "discourse/components/modal/flag"; import { popupAjaxError } from "discourse/lib/ajax-error"; -import { BookmarkFormData } from "discourse/lib/bookmark"; +import { BookmarkFormData } from "discourse/lib/bookmark-form-data"; import { clipboardCopy } from "discourse/lib/utilities"; import Bookmark from "discourse/models/bookmark"; import getURL from "discourse-common/lib/get-url"; @@ -337,12 +337,12 @@ export default class ChatMessageInteractor { this.message.id ) ), - afterSave: (savedData) => { - const bookmark = Bookmark.create(savedData); + afterSave: (bookmarkFormData) => { + const bookmark = Bookmark.create(bookmarkFormData.saveData); this.message.bookmark = bookmark; this.appEvents.trigger( "bookmarks:changed", - savedData, + bookmarkFormData.saveData, bookmark.attachedTo() ); }, 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 5b2e5353873..87812415c57 100644 --- a/plugins/discourse-local-dates/spec/system/local_dates_spec.rb +++ b/plugins/discourse-local-dates/spec/system/local_dates_spec.rb @@ -6,6 +6,7 @@ describe "Local dates", type: :system do let(:year) { Time.zone.now.year + 1 } let(:month) { Time.zone.now.month } let(:bookmark_modal) { PageObjects::Modals::Bookmark.new } + let(:bookmark_menu) { PageObjects::Components::BookmarkMenu.new } let(:composer) { PageObjects::Components::Composer.new } let(:insert_datetime_modal) { PageObjects::Modals::InsertDateTime.new } @@ -163,6 +164,7 @@ describe "Local dates", type: :system 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_menu.click_menu_option("custom") 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) @@ -177,6 +179,7 @@ describe "Local dates", type: :system 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_menu.click_menu_option("custom") expect(bookmark_modal).to be_open expect(bookmark_modal).to have_no_preset(:post_local_date) end diff --git a/spec/system/bookmarks_spec.rb b/spec/system/bookmarks_spec.rb index b38ef338f7d..d41f562972c 100644 --- a/spec/system/bookmarks_spec.rb +++ b/spec/system/bookmarks_spec.rb @@ -9,63 +9,61 @@ describe "Bookmarking posts and topics", type: :system do let(:timezone) { "Australia/Brisbane" } let(:topic_page) { PageObjects::Pages::Topic.new } let(:bookmark_modal) { PageObjects::Modals::Bookmark.new } + let(:bookmark_menu) { PageObjects::Components::BookmarkMenu.new } before do current_user.user_option.update!(timezone: timezone) sign_in(current_user) end - def visit_topic_and_open_bookmark_modal(post) + def visit_topic_and_open_bookmark_menu(post, expand_actions: true) topic_page.visit_topic(topic) - topic_page.expand_post_actions(post) + + topic_page.expand_post_actions(post) if expand_actions + topic_page.click_post_action_button(post, :bookmark) end - it "allows the user to create bookmarks with and without reminders" do - visit_topic_and_open_bookmark_modal(post) - - bookmark_modal.fill_name("something important") - bookmark_modal.save + it "creates a bookmark on the post as soon as the bookmark button is clicked" do + visit_topic_and_open_bookmark_menu(post) + expect(bookmark_menu).to be_open + expect(page).to have_content(I18n.t("js.bookmarks.bookmarked_success")) expect(topic_page).to have_post_bookmarked(post) - bookmark = Bookmark.find_by(bookmarkable: post, user: current_user) - expect(bookmark.name).to eq("something important") - expect(bookmark.reminder_at).to eq(nil) + expect(Bookmark.find_by(bookmarkable: post, user: current_user)).to be_truthy + end - visit_topic_and_open_bookmark_modal(post_2) + it "updates the created bookmark with a selected reminder option from the bookmark menu" do + visit_topic_and_open_bookmark_menu(post) + expect(bookmark_menu).to be_open + expect(page).to have_content(I18n.t("js.bookmarks.bookmarked_success")) + + bookmark_menu.click_menu_option("tomorrow") + expect(page).to have_content(I18n.t("js.bookmarks.reminder_set_success")) + expect(Bookmark.find_by(bookmarkable: post, user: current_user).reminder_at).not_to be_blank + end + + it "can set a reminder from the bookmark modal using the custom bookmark menu option" do + visit_topic_and_open_bookmark_menu(post) + bookmark_menu.click_menu_option("custom") bookmark_modal.select_preset_reminder(:tomorrow) - 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 - - it "does not create a bookmark if the modal is closed with the cancel button" do - visit_topic_and_open_bookmark_modal(post) - - bookmark_modal.fill_name("something important") - bookmark_modal.cancel - - expect(topic_page).to have_no_post_bookmarked(post) - 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 - visit_topic_and_open_bookmark_modal(post) - - bookmark_modal.fill_name("something important") - bookmark_modal.click_outside - expect(topic_page).to have_post_bookmarked(post) + expect(Bookmark.find_by(bookmarkable: post, user: current_user).reminder_at).not_to be_blank 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) + visit_topic_and_open_bookmark_menu(post_2) + bookmark_menu.click_menu_option("custom") + expect(bookmark_modal).to be_open + + # TODO (martin) Not sure why, but I need to click this twice for the panel to open :/ bookmark_modal.open_options_panel + bookmark_modal.open_options_panel + expect(bookmark_modal).to have_auto_delete_preference( Bookmark.auto_delete_preferences[:on_owner_reply], ) @@ -73,6 +71,7 @@ describe "Bookmarking posts and topics", type: :system do bookmark_modal.save expect(topic_page).to have_post_bookmarked(post_2) topic_page.click_post_action_button(post_2, :bookmark) + bookmark_menu.click_menu_option("edit") expect(bookmark_modal).to have_open_options_panel expect(bookmark_modal).to have_auto_delete_preference( Bookmark.auto_delete_preferences[:clear_reminder], @@ -125,8 +124,8 @@ describe "Bookmarking posts and topics", type: :system do 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) + visit_topic_and_open_bookmark_menu(post_2, expand_actions: false) + bookmark_menu.click_menu_option("edit") 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( @@ -142,20 +141,27 @@ describe "Bookmarking posts and topics", type: :system do end it "can delete the bookmark" do - topic_page.visit_topic(topic) - topic_page.click_post_action_button(post_2, :bookmark) + visit_topic_and_open_bookmark_menu(post_2, expand_actions: false) + bookmark_menu.click_menu_option("edit") bookmark_modal.delete bookmark_modal.confirm_delete expect(topic_page).to have_no_post_bookmarked(post_2) end + it "can delete the bookmark from within the menu" do + visit_topic_and_open_bookmark_menu(post_2, expand_actions: false) + bookmark_menu.click_menu_option("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) + visit_topic_and_open_bookmark_menu(post_2, expand_actions: false) + bookmark_menu.click_menu_option("edit") 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") + bookmark_menu.click_menu_option("edit") + expect(bookmark_modal.name.value).to eq("something important") expect(bookmark.reload.name).to eq("test name") end end diff --git a/spec/system/page_objects/components/bookmark_menu.rb b/spec/system/page_objects/components/bookmark_menu.rb new file mode 100644 index 00000000000..c0114c86cd2 --- /dev/null +++ b/spec/system/page_objects/components/bookmark_menu.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module PageObjects + module Components + class BookmarkMenu < PageObjects::Components::Base + def click_menu_option(option_id) + find(".bookmark-menu__row[data-menu-option-id='#{option_id}']").click + end + + def open? + has_css?(".bookmark-menu__body") + end + end + end +end diff --git a/spec/system/page_objects/pages/topic.rb b/spec/system/page_objects/pages/topic.rb index eb0654e7000..760ed826345 100644 --- a/spec/system/page_objects/pages/topic.rb +++ b/spec/system/page_objects/pages/topic.rb @@ -83,7 +83,7 @@ module PageObjects def click_post_action_button(post, button) case button when :bookmark - post_by_number(post).find(".bookmark.with-reminder").click + post_by_number(post).find(".bookmark").click when :reply post_by_number(post).find(".post-controls .reply").click when :flag @@ -240,10 +240,7 @@ module PageObjects def is_post_bookmarked(post, bookmarked:) within post_by_number(post) do - page.public_send( - bookmarked ? :has_css? : :has_no_css?, - ".bookmark.with-reminder.bookmarked", - ) + page.public_send(bookmarked ? :has_css? : :has_no_css?, ".bookmark.bookmarked") end end end