FEATURE: Redesigned bookmark modal and menu (#23071)

Adds the new quick menu for bookmarking. When you bookmark
a post (chat message behaviour will come later) we show this new quick
menu and bookmark the item straight away.

You can then choose a reminder quick option, or choose Custom... to open
the old modal. If you click on an existing bookmark, we show the same quick menu
but with Edit and Delete options.

A later PR will introduce a new bookmark modal, but for now we
are using the old modal for Edit and Custom... options.
This commit is contained in:
Martin Brennan 2024-04-05 09:25:30 +10:00 committed by GitHub
parent 63594fa643
commit 67a8080e33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 825 additions and 135 deletions

View File

@ -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,

View File

@ -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);
}
}
<template>
<DMenu
{{didInsert this.setReminderShortcuts}}
@identifier="bookmark-menu"
@triggers={{array "click"}}
@arrow="true"
class={{concatClass
"bookmark widget-button btn-flat no-text btn-icon bookmark-menu__trigger"
(if this.existingBookmark "bookmarked")
(if this.existingBookmark.reminderAt "with-reminder")
}}
@title={{this.buttonTitle}}
@onClose={{this.onCloseMenu}}
@onShow={{this.onShowMenu}}
@onRegisterApi={{this.onRegisterApi}}
>
<:trigger>
{{#if this.existingBookmark.reminderAt}}
{{icon "discourse-bookmark-clock"}}
{{else}}
{{icon "bookmark"}}
{{/if}}
</:trigger>
<:content>
<div class="bookmark-menu__body">
{{#if this.showEditDeleteMenu}}
<ul class="bookmark-menu__actions">
<li class="bookmark-menu__row -edit" data-menu-option-id="edit">
<DButton
@icon="pencil-alt"
@label="edit"
@action={{this.onEditBookmark}}
@class="bookmark-menu__row-btn btn-flat"
/>
</li>
<li
class="bookmark-menu__row -remove"
role="button"
tabindex="0"
data-menu-option-id="delete"
>
<DButton
@icon="trash-alt"
@label="delete"
@action={{this.onRemoveBookmark}}
@class="bookmark-menu__row-btn btn-flat"
/>
</li>
</ul>
{{else}}
<span class="bookmark-menu__row-title">{{i18n
"bookmarks.also_set_reminder"
}}</span>
<ul class="bookmark-menu__actions">
{{#each this.reminderAtOptions as |option|}}
<li
class="bookmark-menu__row"
data-menu-option-id={{option.id}}
>
<DButton
@label={{option.label}}
@translatedTitle={{this.reminderShortcutTimeTitle option}}
@action={{fn this.onChooseReminderOption option}}
@class="bookmark-menu__row-btn btn-flat"
/>
</li>
{{/each}}
</ul>
{{/if}}
</div>
</:content>
</DMenu>
</template>
}

View File

@ -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}}
>

View File

@ -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);
});
}

View File

@ -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()
);
},

View File

@ -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()
),
};
},
});
}
});
},
};

View File

@ -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);
}
}

View File

@ -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,
};
}
}

View File

@ -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;
}
}
}

View File

@ -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,

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -155,7 +155,7 @@
href
{{on "click" this.editTopic}}
class="edit-topic"
title={{i18n "edit"}}
title={{i18n "edit_topic"}}
>{{d-icon "pencil-alt"}}</a>
{{/if}}

View File

@ -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`<BookmarkMenu @bookmarkManager={{@data.bookmarkManager}} />`
);

View File

@ -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

View File

@ -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`<DMenu @inline={{true}} @label="label" />`);
@ -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`<DMenu @inline={{true}} @onRegisterApi={{this.onRegisterApi}} />`
);
assert.ok(this.api instanceof DMenuInstance);
});
test("@onShow", async function (assert) {
this.test = false;
this.onShow = () => (this.test = true);
await render(hbs`<DMenu @inline={{true}} @onShow={{this.onShow}} />`);
await open();
assert.strictEqual(this.test, true);
});
test("@onClose", async function (assert) {
this.test = false;
this.onClose = () => (this.test = true);
await render(hbs`<DMenu @inline={{true}} @onClose={{this.onClose}} />`);
await open();
await close();
assert.strictEqual(this.test, true);
});
test("-expanded class", async function (assert) {
await render(hbs`<DMenu @inline={{true}} @label="label" />`);

View File

@ -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`<DTooltip @inline={{true}} @label="label" />`);
@ -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`<DTooltip @inline={{true}} @onRegisterApi={{this.onRegisterApi}} />`
);
assert.ok(this.api instanceof DTooltipInstance);
});
test("@onShow", async function (assert) {
this.test = false;
this.onShow = () => (this.test = true);
await render(hbs`<DTooltip @inline={{true}} @onShow={{this.onShow}} />`);
await hover();
assert.strictEqual(this.test, true);
});
test("@onClose", async function (assert) {
this.test = false;
this.onClose = () => (this.test = true);
await render(hbs`<DTooltip @inline={{true}} @onClose={{this.onClose}} />`);
await hover();
await close();
assert.strictEqual(this.test, true);
});
test("-expanded class", async function (assert) {
await render(hbs`<DTooltip @inline={{true}} @label="label" />`);
await render(hbs`<DTooltip @inline={{true}} @label="label" />`);
assert.dom(".fk-d-tooltip__trigger").doesNotHaveClass("-expanded");
@ -140,7 +178,7 @@ module("Integration | Component | FloatKit | d-tooltip", function (hooks) {
hbs`<DTooltip @inline={{true}} @label="label" @closeOnEscape={{true}} />`
);
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`<DTooltip @inline={{true}} @label="label" @closeOnEscape={{false}} />`
);
await hover();
await triggerKeyEvent(document.activeElement, "keydown", "Escape");
await close();
assert.dom(".fk-d-tooltip").exists();
});

View File

@ -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;
}
<template>
<DButton
class={{concatClass
"fk-d-menu__trigger"
(if this.menuInstance.expanded "-expanded")
(concat this.options.identifier "-trigger")
}}
id={{this.menuInstance.id}}
data-identifier={{this.options.identifier}}
@ -67,7 +83,7 @@ export default class DMenu extends Component {
@translatedTitle={{@title}}
@disabled={{@disabled}}
aria-expanded={{if this.menuInstance.expanded "true" "false"}}
{{this.registerTrigger}}
{{this.registerTrigger (this.allowedProperties)}}
...attributes
>
{{#if (has-block "trigger")}}
@ -79,7 +95,10 @@ export default class DMenu extends Component {
<DFloatBody
@instance={{this.menuInstance}}
@trapTab={{this.options.trapTab}}
@mainClass="fk-d-menu"
@mainClass={{concatClass
"fk-d-menu"
(concat this.options.identifier "-content")
}}
@innerClass="fk-d-menu__inner-content"
@role="dialog"
@inline={{this.options.inline}}

View File

@ -1,12 +1,14 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { getOwner } from "@ember/application";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { modifier } from "ember-modifier";
import { and } from "truth-helpers";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse-common/helpers/d-icon";
import DFloatBody from "float-kit/components/d-float-body";
import { TOOLTIP } from "float-kit/lib/constants";
import DTooltipInstance from "float-kit/lib/d-tooltip-instance";
export default class DTooltip extends Component {
@ -15,9 +17,9 @@ export default class DTooltip extends Component {
@tracked tooltipInstance = null;
registerTrigger = modifier((element) => {
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;
}
<template>
<span
class={{concatClass
@ -61,7 +75,7 @@ export default class DTooltip extends Component {
data-identifier={{this.options.identifier}}
data-trigger
aria-expanded={{if this.tooltipInstance.expanded "true" "false"}}
{{this.registerTrigger}}
{{this.registerTrigger (this.allowedProperties)}}
...attributes
>
<div class="fk-d-tooltip__trigger-container">

View File

@ -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",
};

View File

@ -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

View File

@ -2,6 +2,7 @@
@import "banner";
@import "bookmark-list";
@import "bookmark-modal";
@import "bookmark-menu";
@import "buttons";
@import "color-input";
@import "char-counter";

View File

@ -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;
}
}

View File

@ -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

View File

@ -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:

View File

@ -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. <b>After enabled,</b> manage the templates at <a href='%{base_path}/admin/customize/form-templates'>Customize / Templates</a>."
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."

View File

@ -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()
);
},

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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